diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 9064a9195b..03a3ea9c1b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,10 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add typed Scale analytics property, interaction, and setting constants ([#9989](https://github.com/MetaMask/core/pull/9989)) + ### Fixed - Prevent transient HyperLiquid WebSocket disconnects from failing the first TP/SL update by checking builder-fee approval over HTTP ([#9997](https://github.com/MetaMask/core/pull/9997)) - Stop reporting `TPSL_UPDATE_FAILED` from `updatePositionTPSL` when HyperLiquid accepts a trigger with `waitingForTrigger`; accepted triggers without response order IDs are reconciled before mixed-failure cleanup ([#9995](https://github.com/MetaMask/core/pull/9995)) +- Preserve every accepted HyperLiquid Scale rung after a partial batch rejection, including `waitingForFill` and `waitingForTrigger` statuses and responses wrapped in `ApiRequestError`; cancel unclassified rungs by client order ID before returning failure; keep `childOrderIds` limited to resting orders; expose each accepted status through `acceptedChildren`; distinguish `acceptedSize` from the full `submittedSize`; and report `weightedAverageLimitPrice` separately from the fill-weighted `averagePrice` ([#9989](https://github.com/MetaMask/core/pull/9989)) ## [13.1.0] @@ -105,7 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `OrderType` is a wider union again, so — exactly as for the trigger types added in 11.0.0 — any consumer signature that narrows it back to a smaller set no longer accepts a value typed `OrderType`. Such signatures must widen to `OrderType` or narrow explicitly at the call site. - A strategy placement expands one request into an execution schedule rather than a single resting order, so `OrderResult.orderId` carries a _handle_ — a venue TWAP id, or a client-generated group/session id — rather than an exchange order id. Its documentation says so; the individual exchange ids are in `childOrderIds`. - `twap` slices the size over `OrderParams.twapDuration` whole minutes, optionally varying each suborder's size by up to ±20% with `OrderParams.twapRandomize`. On HyperLiquid it is submitted through the venue's own TWAP action, not the order book, and `HYPERLIQUID_TWAP_LIMITS` bounds the window to the pinned SDK's 5–1440-minute range. - - `scale` fans out `OrderParams.scaleNumOrders` limit orders on an inclusive price ladder between `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, submitted as a single batch. Sizes are split in whole units of the asset's size grid, so the rungs sum to exactly the submitted size. The batch is not atomic — the venue can rest some rungs and reject others — so `OrderResult.submittedSize` reports only the rungs that actually rested. + - `scale` fans out `OrderParams.scaleNumOrders` limit orders on an inclusive price ladder between `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, submitted as a single batch. Sizes are split in whole units of the asset's size grid, so the rungs sum to exactly the submitted size. The batch is not atomic. The venue can accept some rungs and reject others, so `OrderResult.submittedSize` reports the full normalized batch while `acceptedSize`, `acceptedChildren`, and `weightedAverageLimitPrice` describe the accepted rungs. `childOrderIds` remains limited to resting, cancellable orders, and `averagePrice` describes fills only. - The venue applies its minimum order value to what it receives, not to the strategy total: a `scale` ladder's notional must leave every submitted rung above the per-order minimum, and a `twap`'s total must clear the venue's own minimum TWAP size (`HYPERLIQUID_TWAP_LIMITS.MinNotionalUsd`). Both are rejected locally rather than by the exchange. The ladder check needs the asset's size grid, so it runs during placement — before anything is signed — rather than in `validateOrder`, which cannot see the grid and could only guess. - A `chase` verifies its order is still live whenever its own price stops showing on the book, so an order that fills without the loop noticing ends the session and releases its concurrency slot instead of holding both until the window closes. - A `chase` interrupted by `disconnect` resolves as a failure with `ORDER_CHASE_ABANDONED` rather than a success, because no strategy is running behind it. Interrupted anywhere before its submission it signs nothing at all. Interrupted while that submission is in flight — the one window it cannot check ahead of — it tries to take the order back before returning, through the client it signed with rather than one asked for after the teardown, so an account switch cannot strand it. That attempt is best-effort: the venue can refuse the cancel, and the transport underneath the client may already be closing. When it does not take, the order is reported in `OrderResult.childOrderIds`, where the ordinary single-order cancel can still reach it for as long as the provider signs as the account that placed it. 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 f8ce4dba42..56fadd1d14 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -198,6 +198,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 3cd7735180..8ef9bddd8a 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'; @@ -117,6 +118,7 @@ import type { OrderFill, OrderParams, OrderResult, + ScaleOrderChild, PerpsMarketData, DirectProviderOrderCapabilities, Position, @@ -245,6 +247,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. @@ -352,6 +533,8 @@ type HyperLiquidTwapSliceFillEntry = Awaited< type ExchangeCancelRequest = { a: number; o: number }; +type ExchangeCancelByCloidRequest = { asset: number; cloid: Hex }; + type CancelOrderBatchOutcome = { remainingOrderIds: number[]; cancelledOrderIds: number[]; @@ -735,6 +918,7 @@ type ScaleOrderIdentity = { type ScaleOrderGroup = { symbol: string; orderIds: string[]; + clientOrderIds: Hex[]; }; /** @@ -5394,10 +5578,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; @@ -5791,8 +5977,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. @@ -5834,46 +6020,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, + clientOrderId: clientOrderIds[index], + price: prices[index], + size: sizes[index], + }, + ] + : [], + ); + const restingChildOrderIds = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'resting' ? [rung.outcome.orderId] : [], + ); + const waitingClientOrderIds = acceptedRungs.flatMap((rung) => + rung.outcome.state === 'waitingForFill' || + rung.outcome.state === 'waitingForTrigger' + ? [rung.clientOrderId] + : [], + ); + 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, }, ); } @@ -5885,44 +6179,55 @@ export class HyperLiquidProvider implements PerpsProvider { ) { 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 && + acceptedCount > 0 && + rejectedCount > 0; + if (isValidPartial) { + this.#scaleOrderGroups.set(groupId, { + symbol: params.symbol, + orderIds: restingChildOrderIds, + clientOrderIds: waitingClientOrderIds, + }); + 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: [], }); } @@ -5932,13 +6237,9 @@ export class HyperLiquidProvider implements PerpsProvider { this.#scaleOrderGroups.set(groupId, { symbol: params.symbol, orderIds: restingChildOrderIds, + clientOrderIds: waitingClientOrderIds, }); - return { - success: true, - orderId: groupId, - childOrderIds: restingChildOrderIds, - submittedSize: formattedSize, - }; + return buildAcceptedResult(); } /** @@ -7540,9 +7841,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 @@ -7550,7 +7857,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 }; @@ -7562,11 +7869,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( @@ -7689,6 +7998,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. @@ -7709,17 +8067,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) => { @@ -7731,7 +8079,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, @@ -10756,6 +11122,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..c8893c6217 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,42 +329,43 @@ 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, { ...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(), }); diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 585156f595..a51f637cb6 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..3632ed5695 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,84 @@ describe('HyperLiquidProvider - strategy order types', () => { }); }); + it('finishes cancellation when a waiting child gains an order ID', async () => { + const cancelResponse = { + status: 'ok' as const, + response: { + type: 'cancel' as const, + data: { + statuses: [ + { + error: 'Order was never placed, already canceled, or filled.', + }, + ], + }, + }, + }; + const { exchangeClient } = useStrategyClients({ + exchange: { + order: jest.fn().mockResolvedValue({ + status: 'ok', + response: { + data: { + statuses: [ + 'waitingForFill', + { error: 'Insufficient margin' }, + { error: 'Insufficient margin' }, + ], + }, + }, + }), + cancel: jest + .fn() + .mockRejectedValue(new TestApiRequestError(cancelResponse)), + }, + }); + const placed = await provider.placeOrder({ + ...baseOrder, + orderType: 'scale', + scaleMinPrice: '2000', + scaleMaxPrice: '3000', + scaleNumOrders: 3, + } satisfies OrderParams); + if (!placed.orderId) { + throw new Error('Expected a Scale group handle'); + } + + mockSubscriptionService.getOrdersCacheIfInitialized.mockReturnValue([ + { + orderId: '44', + symbol: 'ETH', + side: 'buy', + orderType: 'limit', + size: '0.3334', + originalSize: '0.3334', + price: '2000', + filledSize: '0', + remainingSize: '0.3334', + status: 'open', + timestamp: 1_700_000_000_000, + strategyGroupId: placed.orderId, + }, + ]); + await provider.getOpenOrders(); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled).toStrictEqual({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancel).toHaveBeenCalledWith({ + cancels: [{ a: 1, o: 44 }], + }); + expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); + }); + it('adds later Scale rungs to a partially recovered group', async () => { const { exchangeClient } = useStrategyClients({ exchange: { @@ -4967,6 +5116,7 @@ describe('HyperLiquidProvider - strategy order types', () => { const partlyRested = { status: 'ok', response: { + type: 'order', data: { statuses: [ { resting: { oid: 11 } }, @@ -4977,17 +5127,415 @@ 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)( + 'classifies every accepted status in a mixed %s response', + 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: true, + childOrderIds: ['11'], + 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', + }); + + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled.success).toBe(true); + 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[3].c }, + ], + }); + }, + ); + + it('finishes waiting-child cancellation when the SDK throws already-gone statuses', 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); + const cancelled = await provider.cancelOrder({ + orderId: placed.orderId, + symbol: 'ETH', + orderType: 'scale', + }); + + expect(cancelled).toMatchObject({ + success: true, + orderId: placed.orderId, + }); + expect(exchangeClient.cancelByCloid).toHaveBeenCalledTimes(1); + }); + + 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 +5546,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('reports submitted exposure when a partial ladder fills a rung', async () => { + 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('returns filled and resting IDs but cancels only resting rungs', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue({ @@ -5016,7 +5624,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 +5649,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 +5689,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 +5730,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 +5739,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 +5765,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 +5798,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 +5828,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 +9410,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 +9446,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..e45df8e9f3 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -411,6 +411,84 @@ 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('pairs filled Scale size with average execution price for order value', 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); + }); + 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 +3129,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 +3170,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.