diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index ab3a9887ee..a2188ff433 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Preserve accepted HyperLiquid Scale orders when part of a batch is rejected ([#9989](https://github.com/MetaMask/core/pull/9989)) + ## [14.0.0] ### Added diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index 529788c04e..1c9cac54d8 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -23,6 +23,10 @@ export const PERPS_EVENT_PROPERTY = { ORDER_SIZE: 'order_size', MARGIN_USED: 'margin_used', ORDER_TYPE: 'order_type', // lowercase per dashboard + SCALE_ORDER_COUNT: 'scale_order_count', + SCALE_RANGE_PCT: 'scale_range_pct', + SCALE_SKEW: 'scale_skew', + REDUCE_ONLY: 'reduce_only', ORDER_TIMESTAMP: 'order_timestamp', LIMIT_PRICE: 'limit_price', FEES: 'fees', @@ -478,6 +482,8 @@ export const PERPS_EVENT_VALUE = { SLIPPAGE_CONFIG_OPENED: 'slippage_config_opened', SLIPPAGE_CONFIG_CHANGED: 'slippage_config_changed', SLIPPAGE_LIMIT_BLOCKED_ORDER: 'slippage_limit_blocked_order', + SCALE_CONFIG_CHANGED: 'scale_config_changed', + SCALE_VALIDATION_ERROR_SHOWN: 'scale_validation_error_shown', // Auto Close TP/SL RoE sign toggle TPSL_ROE_SIGN_TOGGLED: 'tpsl_roe_sign_toggled', // Discovery analytics @@ -587,6 +593,10 @@ export const PERPS_EVENT_VALUE = { SETTING_TYPE: { LEVERAGE: 'leverage', SLIPPAGE: 'slippage', + SCALE_START_PRICE: 'start_price', + SCALE_END_PRICE: 'end_price', + SCALE_TOTAL_ORDERS: 'total_orders', + SCALE_SIZE_SKEW: 'size_skew', }, SCREEN_NAME: { CONNECTION_ERROR: 'connection_error', diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 942182cebc..fcf5727318 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -199,6 +199,7 @@ export type { TPSLTrackingData, OrderParams, OrderResult, + ScaleOrderChild, ChaseOrder, ChaseOrderMaxDistanceReached, ChaseOrderStatus, diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index 82eb8d0cda..30097f4d2c 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -5,6 +5,7 @@ import type { InfoClient, UserAbstractionResponse, } from '@nktkas/hyperliquid'; +import { HyperliquidError } from '@nktkas/hyperliquid'; import { BigNumber } from 'bignumber.js'; import { v4 as uuidv4 } from 'uuid'; @@ -119,6 +120,7 @@ import type { OrderFill, OrderParams, OrderResult, + ScaleOrderChild, PerpsMarketData, DirectProviderOrderCapabilities, Position, @@ -251,6 +253,185 @@ const HISTORICAL_ORDER_TYPE_BY_DETAILED_TYPE = { const isStatusObject = (status: unknown): status is Record => typeof status === 'object' && status !== null; +type ScaleBulkOrderResponse = { + status: 'ok'; + response: { + type: 'order'; + data: { statuses: unknown[] }; + }; +}; + +type ScaleBulkOrderStatus = + | { kind: 'accepted'; state: 'resting'; orderId: string } + | { + kind: 'accepted'; + state: 'filled'; + orderId: string; + averagePrice: string; + filledSize: string; + } + | { kind: 'accepted'; state: 'waitingForFill' | 'waitingForTrigger' } + | { kind: 'error'; error: string }; + +/** + * Parse every status the HyperLiquid bulk order API can return for a Scale + * rung. Unknown or malformed statuses are not exchange rejections. + * + * @param status - One status from a bulk order response. + * @returns The classified status, or undefined when it is malformed. + */ +const parseScaleBulkOrderStatus = ( + status: unknown, +): ScaleBulkOrderStatus | undefined => { + if (status === 'waitingForFill' || status === 'waitingForTrigger') { + return { kind: 'accepted', state: status }; + } + + if (!isStatusObject(status) || Object.keys(status).length !== 1) { + return undefined; + } + + if (hasProperty(status, 'error')) { + return typeof status.error === 'string' + ? { kind: 'error', error: status.error } + : undefined; + } + + if (hasProperty(status, 'resting')) { + const order = status.resting; + if ( + isStatusObject(order) && + typeof order.oid === 'number' && + Number.isSafeInteger(order.oid) && + order.oid >= 0 + ) { + return { + kind: 'accepted', + state: 'resting', + orderId: order.oid.toString(), + }; + } + } + + if (hasProperty(status, 'filled')) { + const order = status.filled; + if ( + isStatusObject(order) && + typeof order.oid === 'number' && + Number.isSafeInteger(order.oid) && + order.oid >= 0 && + typeof order.avgPx === 'string' && + new BigNumber(order.avgPx).isFinite() && + new BigNumber(order.avgPx).gt(0) && + typeof order.totalSz === 'string' && + new BigNumber(order.totalSz).isFinite() && + new BigNumber(order.totalSz).gt(0) + ) { + return { + kind: 'accepted', + state: 'filled', + orderId: order.oid.toString(), + averagePrice: order.avgPx, + filledSize: order.totalSz, + }; + } + } + + return undefined; +}; + +/** + * Recover the bulk order response that the pinned SDK wraps in an + * `ApiRequestError` when any rung is rejected. + * + * @param error - The SDK error thrown by `ExchangeClient.order`. + * @param expectedStatusCount - Number of Scale rungs submitted. + * @returns The complete bulk order response, or undefined for any other error. + */ +const getScaleBulkOrderResponseFromError = ( + error: unknown, + expectedStatusCount: number, +): ScaleBulkOrderResponse | undefined => { + if ( + !(error instanceof HyperliquidError) || + error.name !== 'ApiRequestError' || + !hasProperty(error, 'response') + ) { + return undefined; + } + + const result = error.response; + if (!isStatusObject(result) || result.status !== 'ok') { + return undefined; + } + + const { response } = result; + if (!isStatusObject(response) || response.type !== 'order') { + return undefined; + } + + const { data } = response; + if (!isStatusObject(data)) { + return undefined; + } + + const { statuses } = data; + if (!Array.isArray(statuses) || statuses.length !== expectedStatusCount) { + return undefined; + } + + const parsedStatuses = statuses.map(parseScaleBulkOrderStatus); + if ( + parsedStatuses.some((status) => status === undefined) || + !parsedStatuses.some((status) => status?.kind === 'accepted') || + !parsedStatuses.some((status) => status?.kind === 'error') + ) { + return undefined; + } + + return result as ScaleBulkOrderResponse; +}; + +/** + * Read a complete cancel response from an SDK `ApiRequestError`. + * + * The SDK throws when any cancel entry is an error, including the benign + * already-gone response that confirms an order is no longer live. + * + * @param error - Error thrown by an exchange cancel method. + * @param expectedStatusCount - Number of cancel requests submitted. + * @returns The per-request statuses, or undefined for another error shape. + */ +const getCancelStatusesFromError = ( + error: unknown, + expectedStatusCount: number, +): unknown[] | undefined => { + if ( + !(error instanceof HyperliquidError) || + error.name !== 'ApiRequestError' || + !hasProperty(error, 'response') + ) { + return undefined; + } + + const result = error.response; + if (!isStatusObject(result) || result.status !== 'ok') { + return undefined; + } + const { response } = result; + if ( + !isStatusObject(response) || + response.type !== 'cancel' || + !isStatusObject(response.data) || + !Array.isArray(response.data.statuses) || + response.data.statuses.length !== expectedStatusCount + ) { + return undefined; + } + + return response.data.statuses; +}; + /** * Exchange messages that mean a cancel was refused because the order is not on * the book any more. @@ -358,6 +539,8 @@ type HyperLiquidTwapSliceFillEntry = Awaited< type ExchangeCancelRequest = { a: number; o: number }; +type ExchangeCancelByCloidRequest = { asset: number; cloid: Hex }; + type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; @@ -741,6 +924,7 @@ type ScaleOrderIdentity = { type ScaleOrderGroup = { symbol: string; orderIds: string[]; + clientOrderIds: Hex[]; }; /** @@ -5400,10 +5584,12 @@ export class HyperLiquidProvider implements PerpsProvider { throw error; } + const resultFilledSize = new BigNumber(result.filledSize ?? 0); const hasVenueExposure = result.success === true || result.orderId !== undefined || - (result.childOrderIds?.length ?? 0) > 0; + (result.childOrderIds?.length ?? 0) > 0 || + (resultFilledSize.isFinite() && resultFilledSize.gt(0)); if (dexName && transferInfo && !hasVenueExposure) { await this.#handleHip3OrderRollback({ dexName, transferInfo }); return result; @@ -5797,8 +5983,8 @@ export class HyperLiquidProvider implements PerpsProvider { * * The whole ladder goes in a single `order` action, which is one round trip * and one signature rather than one per rung. It is **not** atomic: an `na` - * grouping evaluates each entry independently. Every rung must either rest - * or fill; otherwise all known resting rungs are retracted before failure. + * grouping evaluates each entry independently. Accepted rungs remain live and + * are returned to the caller when another rung is rejected. * * @param params - Order parameters. * @param context - Prepared asset and sizing context. @@ -5840,46 +6026,154 @@ export class HyperLiquidProvider implements PerpsProvider { }); const exchangeClient = this.#clientService.getExchangeClient(); - const result = await exchangeClient.order({ - orders, - grouping: 'na', - ...(builder && { builder }), - }); + let result: ScaleBulkOrderResponse; + try { + result = await exchangeClient.order({ + orders, + grouping: 'na', + ...(builder && { builder }), + }); + } catch (error) { + const bulkResponse = getScaleBulkOrderResponseFromError(error, count); + if (!bulkResponse) { + throw error; + } + result = bulkResponse; + } - const statuses = result.response?.data?.statuses ?? []; - const outcomes = statuses - .slice(0, count) - .map((status) => this.#readOrderPlacementOutcome(status)); + const rawStatuses = result.response?.data?.statuses; + const statuses = Array.isArray(rawStatuses) ? rawStatuses : []; + const outcomes = Array.from({ length: count }, (_unused, index) => + parseScaleBulkOrderStatus(statuses[index]), + ); const acceptedCount = outcomes.filter( - (outcome) => outcome !== undefined, + (outcome) => outcome?.kind === 'accepted', ).length; - const restingChildOrderIds = outcomes.flatMap((outcome) => - outcome?.state === 'resting' ? [outcome.orderId] : [], + const acceptedRungs = outcomes.flatMap((outcome, index) => + outcome?.kind === 'accepted' + ? [ + { + outcome, + price: prices[index], + size: sizes[index], + }, + ] + : [], + ); + const restingChildOrderIds = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'resting' ? [rung.outcome.orderId] : [], + ); + // Waiting rungs have no exchange order ID and cannot be recovered after + // the in-memory Scale group registry is cleared on disconnect. + const hasWaitingRungs = acceptedRungs.some( + (rung) => + rung.outcome.state === 'waitingForFill' || + rung.outcome.state === 'waitingForTrigger', + ); + const cleanupClientOrderIds = outcomes.flatMap((outcome, index) => + outcome?.kind === 'error' || outcome?.state === 'resting' + ? [] + : [clientOrderIds[index]], + ); + const acceptedChildren: ScaleOrderChild[] = acceptedRungs.map( + ({ outcome }) => + outcome.state === 'resting' || outcome.state === 'filled' + ? { orderId: outcome.orderId, state: outcome.state } + : { state: outcome.state }, + ); + const acceptedSize = acceptedRungs.reduce( + (total, rung) => total.plus(rung.size), + new BigNumber(0), + ); + const acceptedNotional = acceptedRungs.reduce( + (total, rung) => total.plus(new BigNumber(rung.size).times(rung.price)), + new BigNumber(0), ); - const filledChildOrderIds = outcomes.flatMap((outcome) => - outcome?.state === 'filled' ? [outcome.orderId] : [], + const filledRungs = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'filled' + ? [{ ...rung, outcome: rung.outcome }] + : [], ); + const filledSize = filledRungs.reduce( + (total, rung) => total.plus(rung.outcome.filledSize), + new BigNumber(0), + ); + const executedNotional = filledRungs.reduce( + (total, rung) => + total.plus( + new BigNumber(rung.outcome.filledSize).times( + rung.outcome.averagePrice, + ), + ), + new BigNumber(0), + ); + const acceptedResult = { + childOrderIds: restingChildOrderIds, + submittedSize: formattedSize, + acceptedSize: acceptedSize.toFixed(), + ...(acceptedSize.gt(0) && { + weightedAverageLimitPrice: acceptedNotional + .dividedBy(acceptedSize) + .toFixed(), + }), + acceptedChildren, + ...(filledSize.gt(0) && { + filledSize: filledSize.toFixed(), + averagePrice: executedNotional.dividedBy(filledSize).toFixed(), + }), + } satisfies Partial; + const buildAcceptedResult = (): OrderResult => ({ + success: true, + orderId: groupId, + ...acceptedResult, + }); + const cancelNonRejectedOrders = async (): Promise<{ + orderIds: string[]; + clientOrderIds: Hex[]; + }> => { + const [orderIds, pendingClientOrderIds] = await Promise.all([ + this.#cancelOrderRequests( + exchangeClient, + restingChildOrderIds.map((orderId) => ({ + a: assetId, + o: Number(orderId), + })), + ), + this.#cancelOrderCloidRequests( + exchangeClient, + cleanupClientOrderIds.map((clientOrderId) => ({ + asset: assetId, + cloid: clientOrderId, + })), + ), + ]); + return { + orderIds: orderIds.map(String), + clientOrderIds: pendingClientOrderIds, + }; + }; if (generation !== this.#strategyGeneration) { - const remainingOrderIds = await this.#cancelOrderRequests( - exchangeClient, - restingChildOrderIds.map((orderId) => ({ - a: assetId, - o: Number(orderId), - })), - ); - const recoverableOrderIds = [ - ...filledChildOrderIds, - ...remainingOrderIds.map(String), - ]; + const remaining = await cancelNonRejectedOrders(); + if ( + remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ) { + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + ...remaining, + }); + } return createErrorResult( new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE), { success: false, - submittedSize: formattedSize, - ...(recoverableOrderIds.length > 0 && { - childOrderIds: recoverableOrderIds, - }), + ...acceptedResult, + ...(remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ? { orderId: groupId } + : {}), + childOrderIds: remaining.orderIds, }, ); } @@ -5887,48 +6181,61 @@ export class HyperLiquidProvider implements PerpsProvider { if ( result.status !== 'ok' || statuses.length !== count || - acceptedCount !== count + acceptedCount !== count || + hasWaitingRungs ) { this.#deps.debugLogger.log('Scale ladder was not fully accepted', { accepted: acceptedCount, - filled: filledChildOrderIds.length, + filled: filledRungs.length, resting: restingChildOrderIds.length, requested: count, statuses, }); - const remainingOrderIds = await this.#cancelOrderRequests( - exchangeClient, - restingChildOrderIds.map((orderId) => ({ - a: assetId, - o: Number(orderId), - })), - ); - const recoverableOrderIds = [ - ...filledChildOrderIds, - ...remainingOrderIds.map(String), - ]; - if (remainingOrderIds.length > 0) { - const remainingRestingOrderIds = remainingOrderIds.map(String); + const everyStatusClassified = + statuses.length === count && + outcomes.every((outcome) => outcome !== undefined); + const rejectedCount = outcomes.filter( + (outcome) => outcome?.kind === 'error', + ).length; + const isValidPartial = + result.status === 'ok' && + everyStatusClassified && + !hasWaitingRungs && + acceptedCount > 0 && + rejectedCount > 0; + if (isValidPartial) { + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + orderIds: restingChildOrderIds, + clientOrderIds: [], + }); + return buildAcceptedResult(); + } + const remaining = await cancelNonRejectedOrders(); + if ( + remaining.orderIds.length > 0 || + remaining.clientOrderIds.length > 0 + ) { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, - orderIds: remainingRestingOrderIds, + ...remaining, }); return createErrorResult( new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE), { success: false, orderId: groupId, - childOrderIds: recoverableOrderIds, - submittedSize: formattedSize, + ...acceptedResult, + childOrderIds: remaining.orderIds, }, ); } - if (recoverableOrderIds.length > 0) { + if (acceptedCount > 0) { return createErrorResult(new Error(PERPS_ERROR_CODES.ORDER_REJECTED), { success: false, - childOrderIds: recoverableOrderIds, - submittedSize: formattedSize, + ...acceptedResult, + childOrderIds: [], }); } @@ -5938,13 +6245,9 @@ export class HyperLiquidProvider implements PerpsProvider { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, orderIds: restingChildOrderIds, + clientOrderIds: [], }); - return { - success: true, - orderId: groupId, - childOrderIds: restingChildOrderIds, - submittedSize: formattedSize, - }; + return buildAcceptedResult(); } /** @@ -7546,9 +7849,15 @@ export class HyperLiquidProvider implements PerpsProvider { a: assetId, o: Number(orderId), })); - const remaining = ( - await this.#cancelOrderRequests(exchangeClient, cancelRequests) - ).map(String); + const cancelByCloidRequests = group.clientOrderIds.map((clientOrderId) => ({ + asset: assetId, + cloid: clientOrderId, + })); + const [remainingOrderIds, remainingClientOrderIds] = await Promise.all([ + this.#cancelOrderRequests(exchangeClient, cancelRequests), + this.#cancelOrderCloidRequests(exchangeClient, cancelByCloidRequests), + ]); + const remaining = remainingOrderIds.map(String); /* * A rung that filled or was cancelled individually comes back as a @@ -7556,7 +7865,7 @@ export class HyperLiquidProvider implements PerpsProvider { * distinguishes that result from a refusal while retaining every child * after a malformed or non-ok batch response. */ - if (remaining.length === 0) { + if (remaining.length === 0 && remainingClientOrderIds.length === 0) { this.#cancelledScaleOrderGroups.add(params.orderId); this.#scaleOrderGroups.delete(params.orderId); return { success: true, orderId: params.orderId }; @@ -7568,11 +7877,13 @@ export class HyperLiquidProvider implements PerpsProvider { this.#scaleOrderGroups.set(params.orderId, { symbol: group.symbol, orderIds: remaining, + clientOrderIds: remainingClientOrderIds, }); this.#deps.debugLogger.log('Scale group cancel left children resting', { groupId: params.orderId, - remaining: remaining.length, - total: group.orderIds.length, + remainingOrderIds: remaining.length, + remainingClientOrderIds: remainingClientOrderIds.length, + total: group.orderIds.length + group.clientOrderIds.length, }); return createErrorResult( @@ -7695,6 +8006,55 @@ export class HyperLiquidProvider implements PerpsProvider { .remainingOrderIds; } + /** + * Cancel pending orders by client order ID and retain every request that may + * still be live. + * + * @param exchangeClient - Client that owns the orders. + * @param requests - Venue cancel-by-CLOID requests. + * @returns Client order IDs that may still be pending. + */ + async #cancelOrderCloidRequests( + exchangeClient: ExchangeClient, + requests: ExchangeCancelByCloidRequest[], + ): Promise { + if (requests.length === 0) { + return []; + } + + const getRemainingClientOrderIds = (statuses: unknown[]): Hex[] => + requests.flatMap((request, index) => + classifyCancelStatus(statuses[index]) === CancelChildOutcome.Refused + ? [request.cloid] + : [], + ); + + try { + const result = await exchangeClient.cancelByCloid({ + cancels: requests, + }); + const statuses = result.response?.data?.statuses ?? []; + if (result.status !== 'ok' || statuses.length !== requests.length) { + return requests.map((request) => request.cloid); + } + + return getRemainingClientOrderIds(statuses); + } catch (error) { + const statuses = getCancelStatusesFromError(error, requests.length); + if (statuses) { + return getRemainingClientOrderIds(statuses); + } + this.#deps.debugLogger.log('Order cancellation by CLOID failed', { + error: ensureError( + error, + 'HyperLiquidProvider.cancelOrderCloidRequests', + ).message, + clientOrderIds: requests.map((request) => request.cloid), + }); + return requests.map((request) => request.cloid); + } + } + /** * Cancel a batch while distinguishing confirmed cancellations from orders * that were already gone. Replacement rollback may only restore the former. @@ -7715,17 +8075,7 @@ export class HyperLiquidProvider implements PerpsProvider { }; } - try { - const result = await exchangeClient.cancel({ cancels: requests }); - const statuses = result.response?.data?.statuses ?? []; - if (result.status !== 'ok' || statuses.length !== requests.length) { - return { - remainingOrderIds: requests.map((request) => request.o), - cancelledOrderIds: [], - responseComplete: false, - }; - } - + const classifyStatuses = (statuses: unknown[]): CancelOrderBatchOutcome => { const remainingOrderIds: number[] = []; const cancelledOrderIds: number[] = []; requests.forEach((request, index) => { @@ -7737,7 +8087,25 @@ export class HyperLiquidProvider implements PerpsProvider { } }); return { remainingOrderIds, cancelledOrderIds, responseComplete: true }; + }; + + try { + const result = await exchangeClient.cancel({ cancels: requests }); + const statuses = result.response?.data?.statuses ?? []; + if (result.status !== 'ok' || statuses.length !== requests.length) { + return { + remainingOrderIds: requests.map((request) => request.o), + cancelledOrderIds: [], + responseComplete: false, + }; + } + + return classifyStatuses(statuses); } catch (error) { + const statuses = getCancelStatusesFromError(error, requests.length); + if (statuses) { + return classifyStatuses(statuses); + } this.#deps.debugLogger.log('Order cancellation batch failed', { error: ensureError(error, 'HyperLiquidProvider.cancelOrderRequests') .message, @@ -10762,6 +11130,7 @@ export class HyperLiquidProvider implements PerpsProvider { const group = recovered.get(order.strategyGroupId) ?? { symbol: order.symbol, orderIds: [], + clientOrderIds: [], }; group.orderIds.push(order.orderId); recovered.set(order.strategyGroupId, group); diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f0bee6b908..4e51d30269 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -188,6 +188,12 @@ export class TradingService { result?.success === true ? PERPS_EVENT_VALUE.STATUS.EXECUTED : PERPS_EVENT_VALUE.STATUS.FAILED; + const trackedOrderSize = parseFloat( + result?.filledSize ?? + result?.acceptedSize ?? + result?.submittedSize ?? + params.size, + ); // Build base properties const properties: PerpsAnalyticsProperties = { @@ -198,9 +204,7 @@ export class TradingService { : PERPS_EVENT_VALUE.DIRECTION.SHORT, [PERPS_EVENT_PROPERTY.ORDER_TYPE]: params.orderType, [PERPS_EVENT_PROPERTY.LEVERAGE]: parseFloat(String(params.leverage ?? 1)), - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: parseFloat( - result?.filledSize ?? params.size, - ), + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: trackedOrderSize, [PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: duration, }; @@ -219,8 +223,13 @@ export class TradingService { } // Trigger limit placements carry a real limit price too, so the companion // property must not go missing when order_type is stop_limit/take_profit_limit. - if (isLimitExecutionOrderType(params.orderType) && params.price) { - properties[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat(params.price); + const limitPrice = result?.weightedAverageLimitPrice ?? params.price; + if ( + limitPrice && + (result?.weightedAverageLimitPrice || + isLimitExecutionOrderType(params.orderType)) + ) { + properties[PERPS_EVENT_PROPERTY.LIMIT_PRICE] = parseFloat(limitPrice); } if (params.trackingData?.source) { properties[PERPS_EVENT_PROPERTY.SOURCE] = params.trackingData.source; @@ -250,12 +259,16 @@ export class TradingService { } // Calculate order value in USD (size * price) - const orderSize = parseFloat(result?.filledSize ?? params.size); const assetPrice = result?.averagePrice ? parseFloat(result.averagePrice) : params.trackingData?.marketPrice; - if (assetPrice && orderSize) { - properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = orderSize * assetPrice; + let orderValuePrice = assetPrice; + if (!result?.averagePrice && result?.weightedAverageLimitPrice) { + orderValuePrice = parseFloat(result.weightedAverageLimitPrice); + } + if (orderValuePrice && trackedOrderSize) { + properties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = + trackedOrderSize * orderValuePrice; } // Add success-specific properties @@ -316,45 +329,59 @@ export class TradingService { // Emit an additional partially filled trade event when the fill is partial, // mirroring the close path so the fill's partiality is visible in analytics // rather than hidden behind a status=executed event. Classification is based - // on the provider's final submitted size (post precision rounding, USD - // recalculation, and $10-minimum retry), not the caller's pre-normalization - // params.size — the provider transforms the size before submission and a + // on the provider's accepted size, falling back to its final submitted size + // when no accepted-size distinction applies. This avoids counting rejected + // Scale rungs as unfilled exposure. Both values are post-normalization, so a // complete fill of the normalized size must not look partial. When the - // provider did not report a submitted size we do not classify (rather than - // guess from params.size). The partial event mirrors the close schema: - // order_size = submitted size, amount_filled = filled, remaining = the rest. + // provider reports neither value we do not classify rather than guess from + // params.size. The partial event mirrors the close schema: order_size = + // accepted size, amount_filled = filled, remaining = the rest. // Compare and subtract the decimal size strings with arbitrary-precision // math (BigNumber): routing them through parseFloat can introduce // binary-float artifacts that collapse distinct values (misclassifying the // fill) or leave e-17 dust in remaining_amount. Only convert to Number for // the emitted analytics values, after the exact decimal subtraction. - const submittedSize = - result?.submittedSize === undefined + const acceptedSizeString = result?.acceptedSize ?? result?.submittedSize; + const acceptedSize = + acceptedSizeString === undefined ? undefined - : new BigNumber(result.submittedSize); + : new BigNumber(acceptedSizeString); const filledSize = result?.filledSize === undefined ? undefined : new BigNumber(result.filledSize); if ( result?.success === true && - submittedSize !== undefined && + acceptedSize !== undefined && filledSize !== undefined && - submittedSize.isFinite() && + acceptedSize.isFinite() && filledSize.isFinite() && filledSize.gt(0) && - filledSize.lt(submittedSize) + filledSize.lt(acceptedSize) ) { - this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { + const partialProperties: PerpsAnalyticsProperties = { ...properties, [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: submittedSize.toNumber(), + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: acceptedSize.toNumber(), [PERPS_EVENT_PROPERTY.AMOUNT_FILLED]: filledSize.toNumber(), - [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: submittedSize + [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: acceptedSize .minus(filledSize) .toNumber(), - }); + }; + if (result.weightedAverageLimitPrice !== undefined) { + const acceptedOrderValue = acceptedSize.times( + result.weightedAverageLimitPrice, + ); + if (acceptedOrderValue.isFinite()) { + partialProperties[PERPS_EVENT_PROPERTY.ORDER_VALUE] = + acceptedOrderValue.toNumber(); + } + } + this.#deps.metrics.trackPerpsEvent( + PerpsAnalyticsEvent.TradeTransaction, + partialProperties, + ); } this.#deps.metrics.trackPerpsEvent( diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 82e6e037d7..5938736e7e 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -307,6 +307,16 @@ export type OrderParams = { providerId?: PerpsProviderType; }; +export type ScaleOrderChild = + | { + state: 'resting' | 'filled'; + orderId: string; + } + | { + state: 'waitingForFill' | 'waitingForTrigger'; + orderId?: never; + }; + export type OrderResult = { success?: boolean; /** @@ -326,28 +336,39 @@ export type OrderResult = { orderId?: string; error?: string; filledSize?: string; // Amount filled - // Final normalized size actually submitted to the exchange (post precision - // rounding, USD recalculation, and any $10-minimum retry). Present only when - // the provider reached submission; used to classify partial fills against the - // real submitted size rather than the caller's pre-normalization params.size. + // Full normalized size submitted to the exchange (post precision rounding, + // USD recalculation, and any $10-minimum retry). Present only when the + // provider reached submission. submittedSize?: string; - averagePrice?: string; // Average execution price + // Normalized size of the submitted rungs that the exchange accepted. This + // differs from `submittedSize` when a non-atomic Scale batch is partly + // rejected. + acceptedSize?: string; + // Size-weighted average execution price. Present only when the result + // includes fills. + averagePrice?: string; + // Size-weighted limit price of accepted Scale rungs. This is a submission + // price, not an execution price. + weightedAverageLimitPrice?: string; + // Every accepted child of a Scale batch and its immediate placement state. + // Waiting children do not carry an exchange order ID; cancel the Scale handle + // to cancel both resting and waiting children. + acceptedChildren?: ScaleOrderChild[]; // Exchange IDs tied to a multi-order or recovery result. On a successful // strategy placement, `orderId` carries the strategy handle and these IDs // identify its individual children. // - // For a `scale` ladder they stay valid: the rungs are placed once and are not - // replaced, so they remain cancellable even after the session-scoped handle is - // gone. For a `chase` this is only the order resting at placement time — the + // For a `scale` ladder these identify only resting, cancellable rungs. Every + // accepted rung and its state is reported in `acceptedChildren`. Scale rungs + // are never replaced, so a resting ID stays valid after the handle is gone. + // For a `chase` this is only the order resting at placement time — the // strategy cancels and re-places as the touch moves, and each replacement has // a new ID that is held in the session rather than reported here, so the value // goes stale on the first re-price. Cancel a live chase by its handle. // - // Failure results can mix filled IDs with orders that may still rest, so a - // caller must not blindly cancel every ID. When TP/SL protection cannot be - // fully restored, these identify the old orders that survived, may still be - // live when reconciliation failed, or were recreated; an empty array means - // none are known or potentially live. + // When TP/SL protection cannot be fully restored, these identify the old + // orders that survived, may still be live when reconciliation failed, or were + // recreated; an empty array means none are known or potentially live. childOrderIds?: string[]; providerId?: PerpsProviderType; // Multi-provider: which provider executed this order (injected by aggregator) }; diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts index f788305e69..23853b3f13 100644 --- a/packages/perps-controller/tests/src/constants/eventNames.test.ts +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -321,6 +321,35 @@ describe('PERPS_EVENT_VALUE.INTERACTION_TYPE extensions', () => { }); }); +describe('Scale analytics constants', () => { + it('exports Scale property keys', () => { + expect(PERPS_EVENT_PROPERTY.SCALE_ORDER_COUNT).toBe('scale_order_count'); + expect(PERPS_EVENT_PROPERTY.SCALE_RANGE_PCT).toBe('scale_range_pct'); + expect(PERPS_EVENT_PROPERTY.SCALE_SKEW).toBe('scale_skew'); + expect(PERPS_EVENT_PROPERTY.REDUCE_ONLY).toBe('reduce_only'); + }); + + it('exports Scale interaction values', () => { + expect(PERPS_EVENT_VALUE.INTERACTION_TYPE.SCALE_CONFIG_CHANGED).toBe( + 'scale_config_changed', + ); + expect( + PERPS_EVENT_VALUE.INTERACTION_TYPE.SCALE_VALIDATION_ERROR_SHOWN, + ).toBe('scale_validation_error_shown'); + }); + + it('exports Scale setting values', () => { + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_START_PRICE).toBe( + 'start_price', + ); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_END_PRICE).toBe('end_price'); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_TOTAL_ORDERS).toBe( + 'total_orders', + ); + expect(PERPS_EVENT_VALUE.SETTING_TYPE.SCALE_SIZE_SKEW).toBe('size_skew'); + }); +}); + describe('PERPS_EVENT_VALUE.BUTTON_CLICKED extensions', () => { it('exports WATCHLIST', () => { expect(PERPS_EVENT_VALUE.BUTTON_CLICKED.WATCHLIST).toBe('watchlist'); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index dbbff37734..1bc91f4720 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -1,3 +1,5 @@ +import { HyperliquidError } from '@nktkas/hyperliquid'; + import { BUILDER_FEE_CONFIG } from '../../../src/constants/hyperLiquidConfig.js'; import { CHASE_ORDER_CONFIG, @@ -39,7 +41,9 @@ import { // The HyperLiquid SDK is never exercised directly: every exchange and info call // goes through the mocked client service below. -jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@nktkas/hyperliquid', () => ({ + HyperliquidError: class MockHyperliquidError extends Error {}, +})); jest.mock('../../../src/services/HyperLiquidClientService'); jest.mock('../../../src/services/HyperLiquidWalletService'); jest.mock('../../../src/services/HyperLiquidSubscriptionService'); @@ -92,6 +96,16 @@ jest.mock('../../../src/utils/hyperLiquidAdapter', () => { // Use jest.createMockFromModule for proper mock creation jest.mock('../../../src/services/TradingReadinessCache'); +class TestApiRequestError extends HyperliquidError { + readonly response: unknown; + + constructor(response: unknown, message?: string) { + super(message); + this.name = 'ApiRequestError'; + this.response = response; + } +} + const MockedHyperLiquidClientService = HyperLiquidClientService as jest.MockedClass; const MockedHyperLiquidWalletService = @@ -334,6 +348,16 @@ const createMockExchangeClient = (overrides: MockClient = {}): MockClient => ({ status: 'ok', response: { data: { statuses: ['success'] } }, }), + cancelByCloid: jest + .fn() + .mockImplementation((request: { cancels: unknown[] }) => + Promise.resolve({ + status: 'ok', + response: { + data: { statuses: request.cancels.map(() => 'success') }, + }, + }), + ), withdraw3: jest.fn().mockResolvedValue({ status: 'ok', }), @@ -3130,16 +3154,31 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.success).toBe(true); expect(result.childOrderIds).toStrictEqual(['11', '22', '33']); + expect(result.acceptedChildren).toStrictEqual([ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'resting' }, + { orderId: '33', state: 'resting' }, + ]); + expect(result.submittedSize).toBe('1'); + expect(result.acceptedSize).toBe('1'); + expect(result.weightedAverageLimitPrice).toBe('2499.95'); + expect(result.averagePrice).toBeUndefined(); expect(result.orderId).toMatch(/^scale:/u); }); - it('fails when the ladder rested nothing', async () => { + it('fails when every rung is rejected', async () => { useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ status: 'ok', response: { - data: { statuses: [{ error: 'Insufficient margin' }] }, + data: { + statuses: [ + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + ], + }, }, }), }, @@ -3157,7 +3196,34 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_REJECTED); }); - it('reports filled rungs but keeps only resting rungs in a recovery group', async () => { + it.each([ + ['missing', undefined], + ['non-array', { resting: { oid: 11 } }], + ])('rejects %s placement statuses', async (_label, statuses) => { + useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses } }, + }), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + }); + + it('reports accepted IDs after a non-ok response but keeps only resting rungs recoverable', async () => { const cancel = jest .fn() .mockResolvedValueOnce({ @@ -3173,11 +3239,13 @@ describe('HyperLiquidProvider - strategy order types', () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ - status: 'ok', + status: 'err', response: { data: { statuses: [ - { filled: { oid: 11 } }, + { + filled: { oid: 11, avgPx: '2050', totalSz: '0.2' }, + }, { resting: { oid: 22 } }, { error: 'Insufficient margin' }, ], @@ -3199,8 +3267,11 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, - childOrderIds: ['11', '22'], + childOrderIds: ['22'], submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.2', + averagePrice: '2050', }); expect(placed.orderId).toMatch(/^scale:/u); if (!placed.orderId) { @@ -3375,6 +3446,42 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); + it('cleans a waiting child instead of registering it for later recovery', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'waitingForFill', + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + ], + }, + }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + expect(placed.orderId).toBeUndefined(); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: [{ asset: 1, cloid: submittedOrders[0].c }], + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + it('adds later Scale rungs to a partially recovered group', async () => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -4967,6 +5074,7 @@ describe('HyperLiquidProvider - strategy order types', () => { const partlyRested = { status: 'ok', response: { + type: 'order', data: { statuses: [ { resting: { oid: 11 } }, @@ -4977,17 +5085,442 @@ describe('HyperLiquidProvider - strategy order types', () => { }, }; - it('retracts every rung when the ladder only partly rests', async () => { + const mixedStatuses = [ + { resting: { oid: 11 } }, + 'waitingForFill', + { filled: { oid: 33, avgPx: '2400', totalSz: '0.1' } }, + 'waitingForTrigger', + { filled: { oid: 55, avgPx: '2800', totalSz: '0.05' } }, + { error: 'Insufficient margin' }, + ]; + + it.each(['resolved', 'thrown'] as const)( + 'rejects and cleans every accepted status in a mixed %s response containing waiting children', + async (responseKind) => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { statuses: mixedStatuses }, + }, + }; + const order = + responseKind === 'resolved' + ? jest.fn().mockResolvedValue(response) + : jest + .fn() + .mockRejectedValue( + new TestApiRequestError( + response, + 'order 5: Insufficient margin', + ), + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 6, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: [], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { state: 'waitingForFill' }, + { orderId: '33', state: 'filled' }, + { state: 'waitingForTrigger' }, + { orderId: '55', state: 'filled' }, + ], + submittedSize: '1', + acceptedSize: '0.8334', + filledSize: '0.15', + averagePrice: '2533.33333333333333333333', + weightedAverageLimitPrice: '2399.80801535877129829614', + }); + expect(placed.orderId).toBeUndefined(); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], + }); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: [ + { asset: 1, cloid: submittedOrders[1].c }, + { asset: 1, cloid: submittedOrders[2].c }, + { asset: 1, cloid: submittedOrders[3].c }, + { asset: 1, cloid: submittedOrders[4].c }, + ], + }); + }, + ); + + it('treats already-gone waiting-child cleanup as complete', async () => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { + statuses: [ + 'waitingForFill', + 'waitingForTrigger', + { error: 'Insufficient margin' }, + ], + }, + }, + }; + const cancelResponse = { + status: 'ok' as const, + response: { + type: 'cancel' as const, + data: { + statuses: [ + { + error: 'Order was never placed, already canceled, or filled.', + }, + 'success', + ], + }, + }, + }; const { exchangeClient } = useStrategyClients({ exchange: { - order: jest.fn().mockResolvedValue(partlyRested), + order: jest.fn().mockResolvedValue(response), + cancelByCloid: jest + .fn() + .mockRejectedValue(new TestApiRequestError(cancelResponse)), + }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: [], + }); + expect(placed.orderId).toBeUndefined(); + expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); + }); + + it('rejects an all-waiting Scale batch and cleans every rung by CLOID', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: ['waitingForFill', 'waitingForTrigger', 'waitingForFill'], + }, + }, + }); + const { exchangeClient } = useStrategyClients({ exchange: { order } }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: [], + }); + expect(placed.orderId).toBeUndefined(); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: submittedOrders.map((submittedOrder) => ({ + asset: 1, + cloid: submittedOrder.c, + })), + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'future status', + [ + { resting: { oid: 11 } }, + { scheduled: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + [ + 'malformed status', + [ + { resting: { oid: 11 } }, + { filled: { oid: '22' } }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + [ + 'multi-key hybrid status', + [ + { resting: { oid: 11 } }, + { resting: { oid: 22 }, error: 'Unknown status' }, + { error: 'Insufficient margin' }, + ], + [1], + [11], + ], + ['truncated status array', [{ resting: { oid: 11 } }], [1, 2], [11]], + ['malformed status payload', { resting: { oid: 11 } }, [0, 1, 2], []], + ])( + 'cancels every unclassified rung by CLOID for a %s', + async (_label, statuses, unclassifiedIndexes, restingOrderIds) => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { data: { statuses } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_REJECTED, + }); + const submittedOrders = order.mock.calls[0][0].orders; + expect(exchangeClient.cancelByCloid).toHaveBeenCalledWith({ + cancels: unclassifiedIndexes.map((index) => ({ + asset: 1, + cloid: submittedOrders[index].c, + })), + }); + expect(exchangeClient.cancel.mock.calls[0]?.[0]).toStrictEqual( + restingOrderIds.length > 0 + ? { + cancels: restingOrderIds.map((orderId) => ({ + a: 1, + o: orderId, + })), + } + : undefined, + ); + }, + ); + + it('keeps an unclassified rung retryable when CLOID cleanup is refused', async () => { + const order = jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + { error: 'Insufficient margin' }, + { scheduled: { oid: 22 } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }); + const cancelByCloid = jest + .fn() + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: [{ error: 'Invalid nonce' }] } }, + }) + .mockResolvedValueOnce({ + status: 'ok', + response: { data: { statuses: ['success'] } }, + }); + const { exchangeClient } = useStrategyClients({ + exchange: { order, cancelByCloid }, + }); + + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: false, + error: PERPS_ERROR_CODES.ORDER_STRATEGY_CANCEL_INCOMPLETE, + childOrderIds: [], + }); + expect(placed.orderId).toMatch(/^scale:/u); + const submittedOrders = order.mock.calls[0][0].orders; + expect(cancelByCloid).toHaveBeenNthCalledWith(1, { + cancels: [{ asset: 1, cloid: submittedOrders[1].c }], + }); + + const retried = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(retried).toMatchObject({ success: true, orderId: placed.orderId }); + expect(cancelByCloid).toHaveBeenNthCalledWith(2, { + cancels: [{ asset: 1, cloid: submittedOrders[1].c }], + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it('recovers a mixed Scale response thrown by the SDK', async () => { + const response = { + status: 'ok' as const, + response: { + type: 'order' as const, + data: { + statuses: [ + { resting: { oid: 11 } }, + { filled: { oid: 22, avgPx: '2475', totalSz: '0.2' } }, + { error: 'Insufficient margin' }, + ], + }, + }, + }; + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest + .fn() + .mockRejectedValue( + new TestApiRequestError(response, 'order 2: Insufficient margin'), + ), cancel: jest.fn().mockResolvedValue({ status: 'ok', - response: { data: { statuses: ['success', 'success'] } }, + response: { data: { statuses: ['success'] } }, }), }, }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(placed).toMatchObject({ + success: true, + childOrderIds: ['11'], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + ], + submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.2', + averagePrice: '2475', + weightedAverageLimitPrice: '2249.96250187490625468727', + }); + + expect( + await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }), + ).toMatchObject({ success: true }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 11 }], + }); + }); + + it.each([ + [ + 'unknown status', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + 'unknownStatus', + ], + ], + [ + 'invalid order ID', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: -1 } }, + ], + ], + [ + 'hybrid accepted and error entry', + [ + { resting: { oid: 11 } }, + { error: 'Insufficient margin' }, + { resting: { oid: 33 }, error: 'Invalid status' }, + ], + ], + ])( + 'does not unwrap a mixed response with a %s', + async (label, statuses) => { + const error = new TestApiRequestError( + { + status: 'ok', + response: { type: 'order', data: { statuses } }, + }, + `Malformed bulk response: ${label}`, + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: false, + error: `Malformed bulk response: ${label}`, + }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }, + ); + + it('preserves an all-rejected SDK error for existing error mapping', async () => { + const error = new TestApiRequestError( + { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [ + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + { error: 'Multi-sig required' }, + ], + }, + }, + }, + 'order 0: Multi-sig required', + ); + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, + }); + const result = await provider.placeOrder({ ...baseOrder, orderType: 'scale', @@ -4998,17 +5531,77 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(result).toMatchObject({ success: false, - error: PERPS_ERROR_CODES.ORDER_REJECTED, + error: PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED, }); - expect(exchangeClient.cancel).toHaveBeenCalledWith({ - cancels: [ - { a: 1, o: 11 }, - { a: 1, o: 33 }, - ], + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'top-level', + new TestApiRequestError( + { status: 'err', response: 'Invalid nonce' }, + 'Invalid nonce', + ), + PERPS_ERROR_CODES.EXCHANGE_INVALID_NONCE, + ], + [ + 'malformed', + new TestApiRequestError( + { + status: 'ok', + response: { type: 'order', data: { statuses: 'invalid' } }, + }, + 'Malformed bulk response', + ), + 'Malformed bulk response', + ], + ['unrelated SDK', new HyperliquidError('SDK failure'), 'SDK failure'], + ['non-SDK', new Error('Network unavailable'), 'Network unavailable'], + ])('does not unwrap a %s SDK error', async (_label, error, message) => { + const { exchangeClient } = useStrategyClients({ + exchange: { order: jest.fn().mockRejectedValue(error) }, }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ success: false, error: message }); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); + }); + + it('keeps accepted rungs when the ladder only partly rests', async () => { + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue(partlyRested), + }, + }); + + const result = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + + expect(result).toMatchObject({ + success: true, + childOrderIds: ['11', '33'], + submittedSize: '1', + acceptedSize: '0.6667', + weightedAverageLimitPrice: '2499.92500374981250937453', + }); + expect(result.averagePrice).toBeUndefined(); + expect(exchangeClient.cancel).not.toHaveBeenCalled(); }); - it('reports submitted exposure when a partial ladder fills a rung', async () => { + it('returns filled and resting IDs but cancels only resting rungs', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ @@ -5016,7 +5609,9 @@ describe('HyperLiquidProvider - strategy order types', () => { response: { data: { statuses: [ - { filled: { oid: 11 } }, + { + filled: { oid: 11, avgPx: '2010', totalSz: '0.25' }, + }, { error: 'Insufficient margin' }, { resting: { oid: 33 } }, ], @@ -5039,11 +5634,26 @@ describe('HyperLiquidProvider - strategy order types', () => { } satisfies OrderParams); expect(result).toMatchObject({ - success: false, - error: PERPS_ERROR_CODES.ORDER_REJECTED, - childOrderIds: ['11'], + success: true, + childOrderIds: ['33'], + acceptedChildren: [ + { orderId: '11', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ], submittedSize: '1', + acceptedSize: '0.6667', + filledSize: '0.25', + averagePrice: '2010', + weightedAverageLimitPrice: '2499.92500374981250937453', }); + + const cancelled = await provider.cancelOrder({ + orderId: result.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled.success).toBe(true); expect(exchangeClient.cancel).toHaveBeenCalledWith({ cancels: [{ a: 1, o: 33 }], }); @@ -5064,7 +5674,10 @@ describe('HyperLiquidProvider - strategy order types', () => { }); const { exchangeClient } = useStrategyClients({ exchange: { - order: jest.fn().mockResolvedValue(partlyRested), + order: jest.fn().mockResolvedValue({ + ...partlyRested, + status: 'err', + }), cancel, }, }); @@ -5102,7 +5715,7 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); - it('accepts filled rungs but exposes only resting rungs for cancellation', async () => { + it('accepts filled rungs and exposes all accepted IDs in the result', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ @@ -5111,7 +5724,9 @@ describe('HyperLiquidProvider - strategy order types', () => { data: { statuses: [ { resting: { oid: 11 } }, - { filled: { oid: 22 } }, + { + filled: { oid: 22, avgPx: '2525', totalSz: '0.3' }, + }, { resting: { oid: 33 } }, ], }, @@ -5135,7 +5750,16 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: true, childOrderIds: ['11', '33'], + acceptedChildren: [ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ], submittedSize: '1', + acceptedSize: '1', + filledSize: '0.3', + averagePrice: '2525', + weightedAverageLimitPrice: '2499.95', }); const cancelled = await provider.cancelOrder({ @@ -5159,7 +5783,7 @@ describe('HyperLiquidProvider - strategy order types', () => { ['unsafe', Number.MAX_SAFE_INTEGER + 1], ['non-numeric', '22'], ])( - 'rejects a %s scale order ID and retracts valid rungs', + 'rejects a malformed %s scale order ID instead of treating it as a rejected rung', async (_label, oid) => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -5189,6 +5813,8 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(placed).toMatchObject({ success: false, error: PERPS_ERROR_CODES.ORDER_REJECTED, + childOrderIds: [], + submittedSize: '1', }); expect(exchangeClient.cancel).toHaveBeenCalledWith({ cancels: [{ a: 1, o: 11 }], @@ -8769,7 +9395,7 @@ describe('HyperLiquidProvider - strategy order types', () => { data: { statuses: [ { resting: { oid: 11 } }, - { filled: { oid: 22 } }, + { filled: { oid: 22, avgPx: '2500', totalSz: '0.3333' } }, { resting: { oid: 33 } }, ], }, @@ -8805,7 +9431,12 @@ describe('HyperLiquidProvider - strategy order types', () => { submittedSize: '1', }); expect(placed.orderId).toBeUndefined(); - expect(placed.childOrderIds).toStrictEqual(['22']); + expect(placed.childOrderIds).toStrictEqual([]); + expect(placed.acceptedChildren).toStrictEqual([ + { orderId: '11', state: 'resting' }, + { orderId: '22', state: 'filled' }, + { orderId: '33', state: 'resting' }, + ]); }); }); diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index cee0872a44..25a3d9c8bf 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -411,6 +411,95 @@ describe('TradingService', () => { ); }); + it('tracks accepted Scale size and weighted limit price separately from execution price', async () => { + const orderParams: OrderParams = { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + trackingData: { marketPrice: 3000 }, + }; + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + submittedSize: '1', + acceptedSize: '0.6667', + weightedAverageLimitPrice: '2499.9250037498123', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: orderParams, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect(mockDeps.metrics.trackPerpsEvent).toHaveBeenCalledWith( + PerpsAnalyticsEvent.TradeTransaction, + expect.objectContaining({ + order_size: 0.6667, + asset_price: 3000, + limit_price: 2499.9250037498123, + }), + ); + const resultProperties = + mockDeps.metrics.trackPerpsEvent.mock.calls[1][1]; + expect(resultProperties.order_value).toBeCloseTo(1666.7); + }); + + it('keeps mixed Scale executed and partial analytics on separate size and notional meanings', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + submittedSize: '1', + acceptedSize: '0.8334', + filledSize: '0.15', + averagePrice: '2533.33333333333333333333', + weightedAverageLimitPrice: '2399.80801535877129829614', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'ETH', + isBuy: true, + size: '1', + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 6, + trackingData: { marketPrice: 3000 }, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + const resultProperties = + mockDeps.metrics.trackPerpsEvent.mock.calls[2][1]; + expect(resultProperties).toEqual( + expect.objectContaining({ + order_size: 0.15, + asset_price: 2533.3333333333335, + }), + ); + expect(resultProperties.limit_price).toBeCloseTo(2399.8080153587714); + expect(resultProperties.order_value).toBeCloseTo(380); + + const partialProperties = + mockDeps.metrics.trackPerpsEvent.mock.calls[1][1]; + expect(partialProperties).toEqual( + expect.objectContaining({ + order_size: 0.8334, + amount_filled: 0.15, + remaining_amount: 0.6834, + }), + ); + expect(partialProperties.order_value).toBeCloseTo(2000); + }); + it('includes trade_with_token and mm_pay fields when trackingData has tradeWithToken and pay token/network', async () => { const orderParams: OrderParams = { symbol: 'BTC', @@ -3051,7 +3140,7 @@ describe('TradingService', () => { }); describe('partial fill on open trade', () => { - it('emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the submitted size', async () => { + it('emits an additional partially_filled trade event with order_size, amount_filled, and remaining_amount from the accepted size', async () => { mockProvider.placeOrder.mockResolvedValue({ success: true, orderId: 'order-1', @@ -3092,6 +3181,45 @@ describe('TradingService', () => { ).toBeDefined(); }); + it('does not count rejected Scale rungs as remaining fill exposure', async () => { + mockProvider.placeOrder.mockResolvedValue({ + success: true, + orderId: 'scale:group', + filledSize: '4', + submittedSize: '10', + acceptedSize: '6', + averagePrice: '50000', + }); + + await tradingService.placeOrder({ + provider: mockProvider, + params: { + symbol: 'BTC', + isBuy: true, + size: '10', + orderType: 'scale', + scaleMinPrice: '49000', + scaleMaxPrice: '51000', + scaleNumOrders: 3, + }, + context: mockContext, + reportOrderToDataLake: mockReportOrderToDataLake, + }); + + expect( + findCall( + PerpsAnalyticsEvent.TradeTransaction, + 'partially_filled', + )?.[1], + ).toEqual( + expect.objectContaining({ + order_size: 6, + amount_filled: 4, + remaining_amount: 2, + }), + ); + }); + it('does not emit a partially_filled event on a complete fill of the normalized submitted size', async () => { // The provider rounds the requested size (params.size = 10) down to the // normalized size it actually submits (9.99) and that fills completely.