src/orders/close.ts
v0.3.0 · 17.5 KB
// Closing positions with a reduceOnly IoC.
import type { AssetInfo } from '../assets/index.js';
import { qualifyCoin } from '../assets/index.js';
import type { RetryAdvice } from '../errors/index.js';
import { DEFAULT_EXIT_SLIPPAGE_FLOOR, isFullyFilled, sizeFromNotional, type Numeric } from '../format/index.js';
import type { InfoRequester } from '../transport/types.js';
import type { Cloid } from './cloid.js';
import { abs, cmp, dec, mul, nonNegativeDiff, plain, sign } from './decimal.js';
import { HlOrderError, describeValue, invalidArgument, normalizeAddress } from './errors.js';
import { fetchMid, infoOptions, parseMid, type OrdersInfoOptions } from './mids.js';
import { placeOrders, type PlaceOrdersOptions, type PlaceOrdersResult } from './place.js';
import type { AssetResolver, OrderExchange } from './types.js';
/** `clearinghouseState` is a light /info request. */
export const CLEARINGHOUSE_STATE_WEIGHT = 2;
/**
* Default slippage of a close: the 10% exit floor. Why: a narrow cap on a reduceOnly exit may not
* cross the book during lag, a 429 storm or on an illiquid HIP-3 market, and the position stays open;
* the IoC still fills at the best book prices, the limit is only a cap.
*/
export const DEFAULT_CLOSE_SLIPPAGE = DEFAULT_EXIT_SLIPPAGE_FLOOR;
/**
* Over-sizing factor for a close whose size had to be estimated from `positionValue`. Harmless: a
* reduceOnly order is clamped to the live position.
*/
export const ESTIMATED_CLOSE_SIZE_FACTOR = '1.1';
/** One position read from `clearinghouseState`. */
export interface PositionSnapshot {
readonly coin: string;
readonly side: 'long' | 'short';
/** Absolute size; `null` when `szi` was missing or zero and only `positionValue` shows the position. */
readonly size: string | null;
readonly szi: string | null;
readonly positionValue: string | null;
readonly entryPx: string | null;
}
function optionalDecimal(value: unknown, field: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== 'string' && typeof value !== 'number') {
throw new HlOrderError('INVALID_RESPONSE', `clearinghouseState ${field} is ${describeValue(value)}`);
}
try {
return plain(dec(value));
} catch {
throw new HlOrderError('INVALID_RESPONSE', `clearinghouseState ${field} is ${describeValue(value)}`);
}
}
/**
* Reads the position of one asset from `clearinghouseState` of its dex (weight 2).
*
* - The read is per dex: without `dex` HIP-3 positions are invisible and the account looks flat.
* - An answer without an `assetPositions` array throws `INVALID_RESPONSE`. Why: a degraded read is
* not "no positions"; an empty snapshot read as flat everywhere doubles every open.
* - HIP-3 position coins may come without the prefix, so names are qualified before matching.
* - @experimental HIP-3 quirk (medium confidence in the knowledge base): `szi` may be `"0"` or missing
* while `positionValue` carries the signed notional. Such a position is returned with
* `size: null` and the side taken from the sign of `positionValue`. Risk: in the normal format
* `positionValue` is an absolute notional, so if this quirk ever shows an unsigned value a short
* reads as `long`; the resulting reduceOnly close is rejected by the exchange (it cannot open a
* position) and the position stays open, so re-read and alert when such a close keeps failing.
*
* Perp markets only: spot balances are not positions.
* `user` is the account holding the position (the sub-account / vault when trading with
* `vaultAddress`). Returns `null` when flat. Transport errors are rethrown.
*/
export async function fetchPosition(
info: InfoRequester,
user: string,
asset: Pick<AssetInfo, 'coin' | 'dex'>,
opts: OrdersInfoOptions = {},
): Promise<PositionSnapshot | null> {
const address = normalizeAddress(user, 'user');
const body = asset.dex
? { type: 'clearinghouseState', user: address, dex: asset.dex }
: { type: 'clearinghouseState', user: address };
const raw = await info<unknown>(body, infoOptions(opts, CLEARINGHOUSE_STATE_WEIGHT));
const positions = (raw as { assetPositions?: unknown } | null)?.assetPositions;
if (typeof raw !== 'object' || raw === null || !Array.isArray(positions)) {
throw new HlOrderError('INVALID_RESPONSE', 'clearinghouseState has no assetPositions array: position unknown, not flat');
}
let found: PositionSnapshot | null = null;
let matches = 0;
for (const entry of positions) {
const p = (entry as { position?: unknown } | null)?.position as Record<string, unknown> | undefined;
if (typeof p !== 'object' || p === null || typeof p.coin !== 'string') continue;
if (qualifyCoin(asset.dex, p.coin) !== asset.coin) continue;
matches += 1;
const szi = optionalDecimal(p.szi, 'szi');
const positionValue = optionalDecimal(p.positionValue, 'positionValue');
const entryPx = optionalDecimal(p.entryPx, 'entryPx');
const s = szi === null ? 0 : sign(dec(szi));
if (s !== 0) {
found = { coin: asset.coin, side: s > 0 ? 'long' : 'short', size: plain(abs(dec(szi as string))), szi, positionValue, entryPx };
} else if (positionValue !== null && sign(dec(positionValue)) !== 0) {
found = { coin: asset.coin, side: sign(dec(positionValue)) > 0 ? 'long' : 'short', size: null, szi, positionValue, entryPx };
}
}
if (matches > 1) throw new HlOrderError('INVALID_RESPONSE', `clearinghouseState lists ${asset.coin} ${matches} times`);
return found;
}
export type CloseStatus =
/** Filled within one lot of the target. Re-read the position once (see `recheckPosition`). */
| 'filled'
/** IoC filled partially: the exchange dropped the rest. Re-queue `remainingSz` through the same close path. */
| 'partial'
/** Rejected without a fill (for example the IoC found nothing to match): widen slippage and retry later. */
| 'not_filled'
/**
* Rejected with the minimum-value error. Mark the remainder as dust, stop the loop and ask a human
* (close in the UI). Why: re-trying dust in a loop only spams rejects.
*/
| 'dust'
/** Outcome unknown or unconfirmed. A FULL close is idempotent and may be retried; a partial one must be reconciled. */
| 'unknown'
/** Provably not applied (429 / 4xx), not signed or rejected by SDK validation. */
| 'not_sent'
/** No usable mid or order price: retry on the next cycle, do not mark the exit as done. */
| 'deferred'
/** Nothing to send (partial size rounds to zero or is below the minimum). */
| 'skipped'
/** `clearinghouseState` shows no position: a safe no-op. */
| 'no_position'
/** The order could not be built (see `message`). */
| 'failed';
export interface CloseResult {
readonly status: CloseStatus;
readonly coin: string;
readonly side: 'long' | 'short' | null;
readonly fullClose: boolean;
/** Only a full close may be retried after an unknown outcome. */
readonly idempotent: boolean;
/** Size meant to be closed: the position size for a full close. */
readonly targetSz: string;
/** Wire size; may exceed the position after ceil and the $10 bump (the exchange clamps it). */
readonly requestedSz?: string;
readonly filledSz: string;
readonly avgPx?: string;
/** What is left of `targetSz` after this IoC (`'0'` when filled within one lot). */
readonly remainingSz: string;
readonly limitPx?: string;
readonly oid?: number;
readonly cloid?: Cloid;
/**
* `true` after a full close filled completely: reduceOnly clamps to the REAL position, so if the size
* came from a stale snapshot the real position may have been larger. Re-read the position and use
* `hasHiddenRemainder(freshSize, filledSz, szDecimals)`.
*/
readonly recheckPosition: boolean;
/** Size was estimated from `positionValue` (@experimental HIP-3 quirk). */
readonly sizeEstimated: boolean;
readonly retry: RetryAdvice | null;
readonly reason?: string;
readonly message?: string;
readonly placement?: PlaceOrdersResult;
}
export interface MarketCloseInput {
coin: string;
/** The position being closed, from a fresh snapshot. */
position: { side: 'long' | 'short'; size: Numeric };
/** Partial reduce size. Omitted, or >= the position size, means a full close. */
size?: Numeric;
/** Fraction. Default {@link DEFAULT_CLOSE_SLIPPAGE}; the exit floor applies to anything lower. */
slippage?: number;
/** Reference price; read from `allMids` when omitted (needs `options.info`). */
mid?: Numeric;
cloid?: string | false;
}
/** Placement options for a close. A builder fee is never attached to a close. */
export type MarketCloseOptions = Omit<PlaceOrdersOptions, 'builder' | 'grouping'>;
/**
* Closes (or reduces) a known position with one reduceOnly IoC (orders.md §3, §6.3, §7.4, §8).
*
* - Full close: size ceiled and bumped to the $10 minimum at the ORDER price, never gated by the
* minimum. Why: a floored close leaves unclosable dust, and a gated close leaves a sub-$10 remainder
* open forever; reduceOnly clamps the fill to the live position, so the oversize is harmless.
* - Partial reduce: floored, skipped below the minimum, never bumped (a ceiled partial reduce can
* close the whole position).
* - Wide limit: slippage at least 10% for exits.
* - An IoC may fill partially and the rest is dropped: `remainingSz` must be re-queued periodically
* through the same path until a read shows flat, with an alert after a deadline.
*/
export async function marketClose(
exchange: OrderExchange,
registry: AssetResolver,
input: MarketCloseInput,
opts: MarketCloseOptions = {},
): Promise<CloseResult> {
if (typeof input !== 'object' || input === null) invalidArgument('close input must be an object');
const { side } = input.position ?? {};
if (side !== 'long' && side !== 'short') invalidArgument(`Invalid position.side ${describeValue(side)}`);
const signedSize = dec(input.position.size);
if (signedSize.n === 0n) invalidArgument('position.size must be non-zero');
if (signedSize.n < 0n && side === 'long') {
// A negative size is a signed `szi` of a short; with side 'long' the close would be sent on the
// wrong side (reduceOnly would reject it, and the position would silently stay open).
invalidArgument(`position.size ${plain(signedSize)} contradicts position.side ${side}`);
}
const positionSize = abs(signedSize);
const fullClose = input.size === undefined || cmp(abs(dec(input.size)), positionSize) >= 0;
const targetSz = plain(fullClose ? positionSize : abs(dec(input.size as Numeric)));
return closeWith(exchange, registry, input, opts, { side, fullClose, targetSz, sizeEstimated: false });
}
async function closeWith(
exchange: OrderExchange,
registry: AssetResolver,
input: Omit<MarketCloseInput, 'position'>,
opts: MarketCloseOptions,
ctx: { side: 'long' | 'short'; fullClose: boolean; targetSz: string; sizeEstimated: boolean },
): Promise<CloseResult> {
const { side, fullClose, targetSz, sizeEstimated } = ctx;
const placement = await placeOrders(
exchange,
registry,
[
{
coin: input.coin,
side: side === 'long' ? 'sell' : 'buy',
size: targetSz,
market: {
slippage: input.slippage ?? DEFAULT_CLOSE_SLIPPAGE,
...(input.mid === undefined ? {} : { mid: input.mid }),
},
reduceOnly: true,
sizeIntent: fullClose ? 'fullClose' : 'partialReduce',
...(input.cloid === undefined ? {} : { cloid: input.cloid }),
},
],
// No builder fee on a close, even if an untyped caller passes one: a revoked builder approval
// would reject the protective order.
{ ...opts, grouping: 'na', builder: undefined },
);
const placed = placement.orders[0];
const order = placed?.order;
const base = {
coin: order?.coin ?? placed?.coin ?? input.coin,
side,
fullClose,
idempotent: fullClose,
targetSz,
sizeEstimated,
placement,
retry: placement.batches[0]?.retry ?? null,
recheckPosition: false,
...(order ? { requestedSz: order.sz, limitPx: order.px } : {}),
...(placed?.cloid ? { cloid: placed.cloid } : {}),
};
const nothingFilled = { ...base, filledSz: '0', remainingSz: targetSz };
const outcome = placed?.outcome;
if (!outcome) return { ...nothingFilled, status: 'failed', message: 'no result' };
switch (outcome.status) {
case 'filled': {
const szDecimals = order?.asset.szDecimals ?? 0;
// An estimated (over-sized) close is clamped by the exchange, so any fill may be complete:
// report it as filled and require a re-read.
const complete = sizeEstimated || isFullyFilled(targetSz, outcome.totalSz, szDecimals);
return {
...base,
status: complete ? 'filled' : 'partial',
filledSz: outcome.totalSz,
avgPx: outcome.avgPx,
remainingSz: complete ? '0' : nonNegativeDiff(targetSz, outcome.totalSz),
oid: outcome.oid,
recheckPosition: complete && fullClose,
};
}
case 'rejected':
return {
...nothingFilled,
status: outcome.kind === 'minNotional' ? 'dust' : 'not_filled',
reason: outcome.kind,
message: outcome.message,
};
case 'resting':
// A reduceOnly IoC cannot rest; an answer saying so is not trusted.
return { ...nothingFilled, status: 'unknown', oid: outcome.oid, reason: 'unexpected-resting', retry: fullClose ? 'retry' : 'reconcile' };
case 'accepted':
return { ...nothingFilled, status: 'unknown', reason: outcome.detail, retry: fullClose ? 'retry' : 'reconcile' };
case 'unconfirmed':
return { ...nothingFilled, status: 'unknown', reason: outcome.reason, retry: fullClose ? 'retry' : 'reconcile' };
case 'unknown':
return { ...nothingFilled, status: 'unknown', message: outcome.message };
case 'not_sent':
return { ...nothingFilled, status: 'not_sent', reason: outcome.reason, message: outcome.message };
case 'deferred':
return { ...nothingFilled, status: 'deferred', reason: outcome.reason };
case 'skipped':
return { ...nothingFilled, status: 'skipped', reason: outcome.reason };
case 'build_failed':
return { ...nothingFilled, status: 'failed', message: outcome.message };
}
}
export interface ClosePositionInput {
/** Account holding the position (sub-account / vault address when trading with `vaultAddress`). */
user: string;
coin: string;
/** Partial reduce size; omitted = full close. */
size?: Numeric;
slippage?: number;
/** Reference price; read from `allMids` when omitted. */
mid?: Numeric;
cloid?: string | false;
}
/**
* Reads the live position and closes it with a reduceOnly IoC ({@link marketClose}).
*
* No position is a safe no-op (`no_position`). No mid means `deferred`. A read that fails or has an
* unexpected shape THROWS (nothing was sent): an unreadable state is never "flat".
* HIP-3 markets traded through an agent need `agentEnableDexAbstraction` first.
*/
export async function closePosition(
exchange: OrderExchange,
info: InfoRequester,
registry: AssetResolver,
input: ClosePositionInput,
opts: MarketCloseOptions = {},
): Promise<CloseResult> {
if (typeof input !== 'object' || input === null) invalidArgument('close input must be an object');
normalizeAddress(input.user, 'user');
const asset = await registry.resolve(input.coin);
if (asset.market !== 'perp') {
// Spot has no position in clearinghouseState: the read would always report "no position", which
// must not be returned as a safe no-op for a balance that is still held.
invalidArgument(`closePosition supports perp markets only, ${asset.coin} is ${asset.market}`);
}
const position = await fetchPosition(info, input.user, asset, opts);
const empty = {
coin: asset.coin,
fullClose: input.size === undefined,
idempotent: input.size === undefined,
filledSz: '0',
remainingSz: '0',
targetSz: '0',
recheckPosition: false,
sizeEstimated: false,
retry: null,
};
if (!position) return { ...empty, side: null, status: 'no_position' };
const mid = input.mid !== undefined ? parseMid(input.mid) : await fetchMid(info, asset, opts);
const rest = {
coin: asset.coin,
...(input.slippage === undefined ? {} : { slippage: input.slippage }),
...(input.cloid === undefined ? {} : { cloid: input.cloid }),
};
if (mid === null) {
const target = position.size ?? '0';
return { ...empty, side: position.side, targetSz: target, remainingSz: target, status: 'deferred', reason: 'no_mid_price' };
}
if (position.size === null) {
if (input.size !== undefined) {
// A partial reduce does not need the position size: floored, gated by the minimum, clamped
// by reduceOnly. Whether it is in fact a full close cannot be known here.
return closeWith(exchange, registry, { ...rest, mid }, { ...opts, info }, {
side: position.side,
fullClose: false,
targetSz: plain(abs(dec(input.size))),
sizeEstimated: false,
});
}
// @experimental: size unknown, estimate from |positionValue| / mid with a 10% margin; the
// reduceOnly flag clamps the fill to the live position.
const notionalAbs = plain(mul(abs(dec(position.positionValue as string)), dec(ESTIMATED_CLOSE_SIZE_FACTOR)));
const estimate = sizeFromNotional(notionalAbs, mid, asset.szDecimals, 'ceil');
return closeWith(exchange, registry, { ...rest, mid }, { ...opts, info }, {
side: position.side,
fullClose: true,
targetSz: estimate,
sizeEstimated: true,
});
}
return marketClose(
exchange,
registry,
{ ...rest, mid, position: { side: position.side, size: position.size }, ...(input.size === undefined ? {} : { size: input.size }) },
{ ...opts, info },
);
}