Skip to content
markpaper

src/orders/status.ts

v0.3.0 · 9.6 KB

Download file
// Order status lookups (`orderStatus`, weight 2) and reconciliation of unknown placement outcomes.

import type { InfoRequester } from '../transport/types.js';
import { isCloid, normalizeCloid, type Cloid } from './cloid.js';
import { describeValue, invalidArgument, messageOf, normalizeAddress } from './errors.js';
import { infoOptions, type OrdersInfoOptions } from './mids.js';
import type { PlaceOrdersResult } from './place.js';

/** `orderStatus` is a light /info request. */
export const ORDER_STATUS_WEIGHT = 2;

/**
 * What an `orderStatus` answer means for the caller (orders.md §12):
 * - `live`: `open`, or `triggered` (a trigger fired and the order is in flight: re-check);
 * - `filled`: executed;
 * - `canceled`: any `...Canceled` status or `scheduledCancel` - not executed as such (an order may
 *   have partially filled before; compare `origSz` and `sz`);
 * - `rejected`: `rejected` or any `...Rejected` status;
 * - `other`: a status this version does not know; handle conservatively.
 */
export type OrderStatusCategory = 'live' | 'filled' | 'canceled' | 'rejected' | 'other';

/** Normalized `orderStatus` answer. */
export type OrderStatusLookup =
  | {
      readonly state: 'found';
      /** Raw processing status (`open`, `filled`, `canceled`, `badAloPxRejected`...). */
      readonly status: string;
      readonly category: OrderStatusCategory;
      readonly oid: number;
      readonly cloid: Cloid | null;
      readonly coin: string;
      readonly side: 'B' | 'A' | null;
      readonly limitPx: string | null;
      /** Remaining size as reported. */
      readonly sz: string | null;
      /** Original size, when reported. */
      readonly origSz: string | null;
      readonly statusTimestamp: number | null;
      readonly raw: unknown;
    }
  /**
   * `unknownOid`. NOT proof that the order never existed or never filled: an oid past HL retention
   * answers this too (terminal but ambiguous), and a just-placed order may not be registered yet.
   */
  | { readonly state: 'unknownOid' }
  /** The read failed (network, 5xx, 429). Change nothing; ask again later. */
  | { readonly state: 'query_failed'; readonly message: string; readonly error: unknown }
  /** The answer has an unexpected shape. Change nothing. */
  | { readonly state: 'invalid_response'; readonly detail: string; readonly raw: unknown };

const OID_RE = /^[1-9]\d{0,15}$/;

function categoryOf(status: string): OrderStatusCategory {
  if (status === 'open' || status === 'triggered') return 'live';
  if (status === 'filled') return 'filled';
  if (status === 'canceled' || status === 'scheduledCancel' || status.endsWith('Canceled')) return 'canceled';
  if (status === 'rejected' || status.endsWith('Rejected')) return 'rejected';
  return 'other';
}

function str(v: unknown): string | null {
  return typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : null;
}

/**
 * Reads the status of one order by oid or cloid: `{ type: 'orderStatus', user, oid }` (weight 2).
 *
 * `user` is the account that owns the order: the sub-account / vault address when trading with
 * `vaultAddress`, never the agent address. Lookup by cloid is @experimental: it is documented by HL
 * and typed by SDK 0.33.3 but not verified live. Transport failures come
 * back as `query_failed` instead of throwing: a failed read must never change local state.
 *
 * @throws HlOrderError `INVALID_ARGUMENT` for a malformed user, oid or cloid.
 */
export async function fetchOrderStatus(
  info: InfoRequester,
  user: string,
  id: number | string,
  opts: OrdersInfoOptions = {},
): Promise<OrderStatusLookup> {
  const address = normalizeAddress(user, 'user');
  const oid = normalizeOrderId(id);
  let raw: unknown;
  try {
    raw = await info<unknown>({ type: 'orderStatus', user: address, oid }, infoOptions(opts, ORDER_STATUS_WEIGHT));
  } catch (error) {
    return { state: 'query_failed', message: messageOf(error), error };
  }
  return parseOrderStatusAnswer(raw);
}

function normalizeOrderId(id: number | string): number | Cloid {
  if (typeof id === 'number') {
    if (!Number.isSafeInteger(id) || id <= 0) invalidArgument(`Invalid oid ${String(id)}: expected a positive safe integer`);
    return id;
  }
  if (isCloid(id)) return normalizeCloid(id);
  if (typeof id === 'string' && OID_RE.test(id) && Number.isSafeInteger(Number(id))) return Number(id);
  return invalidArgument(`Invalid order id ${describeValue(id)}: expected an oid or a cloid`);
}

/** Parses a raw `orderStatus` answer. Exported for callers with their own transport. */
export function parseOrderStatusAnswer(raw: unknown): OrderStatusLookup {
  if (typeof raw !== 'object' || raw === null) return { state: 'invalid_response', detail: `answer is ${describeValue(raw)}`, raw };
  const body = raw as { status?: unknown; order?: unknown };
  if (body.status === 'unknownOid') return { state: 'unknownOid' };
  if (body.status !== 'order') return { state: 'invalid_response', detail: `status is ${describeValue(body.status)}`, raw };
  const wrapper = body.order as { order?: unknown; status?: unknown; statusTimestamp?: unknown } | undefined;
  if (typeof wrapper !== 'object' || wrapper === null || typeof wrapper.status !== 'string') {
    return { state: 'invalid_response', detail: 'order.status missing', raw };
  }
  const order = wrapper.order as Record<string, unknown> | undefined;
  if (typeof order !== 'object' || order === null) return { state: 'invalid_response', detail: 'order.order missing', raw };
  const oid = order.oid;
  if (typeof oid !== 'number' || !Number.isSafeInteger(oid) || oid <= 0) {
    return { state: 'invalid_response', detail: `bad oid ${describeValue(oid)}`, raw };
  }
  const side = order.side === 'B' || order.side === 'A' ? order.side : null;
  const ts = wrapper.statusTimestamp;
  return {
    state: 'found',
    status: wrapper.status,
    category: categoryOf(wrapper.status),
    oid,
    cloid: isCloid(order.cloid) ? normalizeCloid(order.cloid) : null,
    coin: typeof order.coin === 'string' ? order.coin : '',
    side,
    limitPx: str(order.limitPx),
    sz: str(order.sz),
    origSz: str(order.origSz),
    statusTimestamp: typeof ts === 'number' && Number.isFinite(ts) ? ts : null,
    raw,
  };
}

/** Result of {@link reconcileByCloid}. */
export type CloidReconciliation =
  /** The order reached the exchange; see `lookup.category`. Do not re-send it. */
  | { readonly resolution: 'found'; readonly cloid: Cloid; readonly attempts: number; readonly lookup: Extract<OrderStatusLookup, { state: 'found' }> }
  /**
   * Still `unknownOid` after every attempt. The order was most likely not placed, but this is not
   * proof: before re-sending a non-idempotent order, also check `frontendOpenOrders` / fills.
   */
  | { readonly resolution: 'not_found'; readonly cloid: Cloid; readonly attempts: number }
  /** No trustworthy answer (read failed or malformed). Change nothing; try again on the next tick. */
  | { readonly resolution: 'unresolved'; readonly cloid: Cloid; readonly attempts: number; readonly lookup: OrderStatusLookup };

export interface ReconcileOptions extends OrdersInfoOptions {
  /** Reads per cloid while the answer is `unknownOid` or failed. Default 2. */
  attempts?: number;
  /** Pause between reads, ms. Default 500 (an order is not registered instantly). */
  delayMs?: number;
  /** Injected sleep (tests). */
  sleep?: (ms: number) => Promise<void>;
}

const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

/**
 * Resolves an `unknown` placement outcome by its cloid (orders.md §10, §12).
 *
 * Why: after a 5xx or timeout the order may be live; re-sending it blindly can duplicate the
 * order. Reads `orderStatus` by cloid up to `attempts` times with `delayMs` between
 * reads (a just-placed order may not be visible yet).
 *
 * @experimental `orderStatus` by cloid is documented by HL and typed by the SDK but not verified
 * live. Risk: if HL answered `unknownOid` for a placed cloid, a caller trusting `not_found`
 * could duplicate the order; `not_found` is therefore documented as "not proof".
 */
export async function reconcileByCloid(
  info: InfoRequester,
  user: string,
  cloid: string,
  opts: ReconcileOptions = {},
): Promise<CloidReconciliation> {
  const id = normalizeCloid(cloid);
  normalizeAddress(user, 'user');
  const attempts = opts.attempts ?? 2;
  if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 20) invalidArgument(`Invalid attempts ${String(attempts)}`);
  const delayMs = opts.delayMs ?? 500;
  if (!Number.isFinite(delayMs) || delayMs < 0) invalidArgument(`Invalid delayMs ${String(delayMs)}`);
  const sleep = opts.sleep ?? defaultSleep;

  let last: OrderStatusLookup = { state: 'unknownOid' };
  for (let attempt = 1; attempt <= attempts; attempt++) {
    if (attempt > 1) await sleep(delayMs);
    last = await fetchOrderStatus(info, user, id, opts);
    if (last.state === 'found') return { resolution: 'found', cloid: id, attempts: attempt, lookup: last };
    if (last.state === 'invalid_response') return { resolution: 'unresolved', cloid: id, attempts: attempt, lookup: last };
  }
  return last.state === 'unknownOid'
    ? { resolution: 'not_found', cloid: id, attempts }
    : { resolution: 'unresolved', cloid: id, attempts, lookup: last };
}

/**
 * Runs {@link reconcileByCloid} sequentially for every `reconcileCloids` entry of a placement result.
 * @experimental see {@link reconcileByCloid}.
 */
export async function reconcilePlacement(
  info: InfoRequester,
  user: string,
  result: Pick<PlaceOrdersResult, 'reconcileCloids'>,
  opts: ReconcileOptions = {},
): Promise<CloidReconciliation[]> {
  const out: CloidReconciliation[] = [];
  for (const cloid of result.reconcileCloids) out.push(await reconcileByCloid(info, user, cloid, opts));
  return out;
}
All files