Skip to content
markpaper

src/orders/close.ts

v0.3.0 · 17.5 KB

Download file
// 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 },
  );
}
All files