Skip to content
markpaper

src/orders/cancel.ts

v0.3.0 · 15.2 KB

Download file
// Cancels by oid / cloid and the scheduled cancel-all (dead man's switch).

import {
  interpretExchangeError,
  invokeAsync,
  parseActionResponse,
  parseCancelResponse,
  recommendRetry,
  type CancelStatusResult,
  type ExchangeErrorKind,
  type ExchangeFailure,
  type RetryAdvice,
} from '../errors/index.js';
import { isCloid, normalizeCloid, type Cloid } from './cloid.js';
import { invalidArgument, messageOf } from './errors.js';
import { exchangeCallOptions } from './place.js';
import type { AssetResolver, CancelExchange, ExchangeCallOptions, ScheduleCancelExchange } from './types.js';

/** What to cancel. */
export type CancelTarget = { coin: string; oid: number } | { coin: string; cloid: string };

/** Per-target cancel result. */
export type CancelOutcome =
  /** Explicitly canceled. The order may still have partly filled just before. */
  | { readonly status: 'success' }
  /** `Order was never placed, already canceled, or filled.`: not in the book. The position may have moved. */
  | { readonly status: 'alreadyGone'; readonly message: string }
  /** Any other rejection: the order MAY still rest. Do not place a replacement until reconciled. */
  | { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Response could not be confirmed (empty statuses, unknown shape, count mismatch). */
  | { readonly status: 'unconfirmed'; readonly reason: string }
  /** Transport outcome unknown. Cancels are idempotent: retrying is safe. */
  | { readonly status: 'unknown'; readonly message: string }
  /**
   * Provably not applied (429 / 4xx), not signed, rejected by SDK validation, or not sent because the
   * coin could not be resolved right now (registry unavailable). The order MAY still rest.
   */
  | { readonly status: 'not_sent'; readonly message: string; readonly retryable: boolean }
  /** Malformed oid / cloid, or a coin the registry proves unlisted; nothing was sent for this target. */
  | { readonly status: 'invalid_target'; readonly message: string };

export interface CancelItem {
  readonly index: number;
  readonly coin: string;
  readonly oid?: number;
  readonly cloid?: Cloid;
  readonly outcome: CancelOutcome;
  /** `success` or `alreadyGone`. */
  readonly confirmed: boolean;
}

export interface CancelOrdersResult {
  readonly items: readonly CancelItem[];
  /** Every target is confirmed out of the book. */
  readonly allConfirmed: boolean;
  /**
   * Some order may still rest. Why: reading `{status:'err'}` or empty statuses as "canceled" puts a
   * replacement over a live order and duplicates it. Place nothing on that side and coin until a
   * reconcile.
   */
  readonly needsReconcile: boolean;
  /** Worst advice over the sent batches (`null` when all went through). Cancels are idempotent. */
  readonly retry: RetryAdvice | null;
  readonly failures: readonly ExchangeFailure[];
}

export interface CancelOrdersOptions {
  vaultAddress?: string;
  expiresAfter?: number;
  signal?: AbortSignal;
}

type Pending = { index: number; coin: string; asset: number; oid?: number; cloid?: Cloid };

/**
 * Cancels orders by oid (`exchange.cancel`) and by cloid (`exchange.cancelByCloid`), one batch each
 * (orders.md §9.1).
 *
 * Fail-closed: only `'success'` confirms a cancel; `alreadyGone` means "not in the book" (forget it
 * locally, but the position may have changed). Everything else keeps the order as possibly live.
 * A partially failed batch is parsed element by element. An oid is only sent when it is a positive
 * safe integer.
 *
 * Cancel by cloid is @experimental: supported by SDK 0.33.3 and documented by HL, but not verified
 * live. Risk: a cloid cancel that HL answers differently would come back as
 * `unconfirmed` / `error`, never as a false success.
 *
 * Run cancels before placements in a tick: cancels free margin.
 */
export async function cancelOrders(
  exchange: CancelExchange,
  registry: AssetResolver,
  targets: readonly CancelTarget[],
  opts: CancelOrdersOptions = {},
): Promise<CancelOrdersResult> {
  if (!Array.isArray(targets)) invalidArgument('targets must be an array');
  const callOpts = exchangeCallOptions(opts);
  const items: CancelItem[] = [];
  const byOid: Pending[] = [];
  const byCloid: Pending[] = [];

  for (let index = 0; index < targets.length; index++) {
    const t = targets[index] as CancelTarget & { oid?: unknown; cloid?: unknown };
    const coin = typeof t?.coin === 'string' ? t.coin : '';
    const invalid = (message: string) => {
      items[index] = { index, coin, outcome: { status: 'invalid_target', message }, confirmed: false };
    };
    const hasOid = t !== null && typeof t === 'object' && 'oid' in t && t.oid !== undefined;
    const hasCloid = t !== null && typeof t === 'object' && 'cloid' in t && t.cloid !== undefined;
    if (!coin || hasOid === hasCloid) {
      invalid('target needs a coin and exactly one of oid / cloid');
      continue;
    }
    if (hasOid && !(typeof t.oid === 'number' && Number.isSafeInteger(t.oid) && t.oid > 0)) {
      invalid(`invalid oid ${String(t.oid)}`);
      continue;
    }
    if (hasCloid && !isCloid(t.cloid)) {
      invalid(`invalid cloid ${String(t.cloid)}`);
      continue;
    }
    let asset: number;
    try {
      asset = (await registry.resolve(coin)).assetId;
    } catch (err) {
      if (isDefinitelyUnlisted(err)) {
        invalid(messageOf(err));
      } else {
        // Why: a failed or unknown meta read does not mean the order is gone. Reporting it as an invalid
        // target (no reconcile) would let the caller place a replacement over a live order.
        items[index] = {
          index,
          coin,
          ...(hasOid ? { oid: t.oid as number } : { cloid: normalizeCloid(t.cloid) }),
          outcome: { status: 'not_sent', message: `asset not resolved: ${messageOf(err)}`, retryable: true },
          confirmed: false,
        };
      }
      continue;
    }
    const pending: Pending = hasOid
      ? { index, coin, asset, oid: t.oid as number }
      : { index, coin, asset, cloid: normalizeCloid(t.cloid) };
    (hasOid ? byOid : byCloid).push(pending);
    items[index] = {
      index,
      coin,
      ...(pending.oid === undefined ? {} : { oid: pending.oid }),
      ...(pending.cloid === undefined ? {} : { cloid: pending.cloid }),
      outcome: { status: 'unconfirmed', reason: 'not-processed' },
      confirmed: false,
    };
  }

  const failures: ExchangeFailure[] = [];
  const advice: RetryAdvice[] = [];
  let needsReconcile = false;
  const run = async (group: Pending[], call: () => PromiseLike<unknown>) => {
    if (group.length === 0) return;
    const r = await sendCancelBatch(group.length, call);
    group.forEach((p, i) => {
      const outcome = r.outcomes[i] ?? { status: 'unconfirmed', reason: 'missing' };
      const prev = items[p.index] as CancelItem;
      items[p.index] = { ...prev, outcome, confirmed: outcome.status === 'success' || outcome.status === 'alreadyGone' };
    });
    if (r.failure) failures.push(r.failure);
    if (r.retry) advice.push(r.retry);
    needsReconcile ||= r.needsReconcile;
  };

  await run(byOid, () => {
    const params = { cancels: byOid.map((p) => ({ a: p.asset, o: p.oid as number })) };
    return callOpts ? exchange.cancel(params, callOpts) : exchange.cancel(params);
  });
  await run(byCloid, () => {
    const params = { cancels: byCloid.map((p) => ({ asset: p.asset, cloid: p.cloid as Cloid })) };
    return callOpts ? exchange.cancelByCloid(params, callOpts) : exchange.cancelByCloid(params);
  });

  const allConfirmed = items.length > 0 && items.every((i) => i.confirmed);
  const stillMaybeLive = items.some((i) => !i.confirmed && i.outcome.status !== 'invalid_target');
  return {
    items,
    allConfirmed,
    needsReconcile: needsReconcile || stillMaybeLive,
    retry: worstAdvice(advice),
    failures,
  };
}

function worstAdvice(list: RetryAdvice[]): RetryAdvice | null {
  if (list.includes('reconcile')) return 'reconcile';
  if (list.includes('retry')) return 'retry';
  if (list.includes('give-up')) return 'give-up';
  return null;
}

async function sendCancelBatch(
  count: number,
  call: () => PromiseLike<unknown>,
): Promise<{ outcomes: CancelOutcome[]; needsReconcile: boolean; retry: RetryAdvice | null; failure?: ExchangeFailure }> {
  const fill = (o: CancelOutcome) => Array.from({ length: count }, () => o);
  let response: unknown;
  try {
    response = await invokeAsync(call);
  } catch (err) {
    const failure = interpretExchangeError(err, { expectedCount: count });
    const retry = recommendRetry(err, { idempotent: true });
    switch (failure.type) {
      case 'partial':
        if (failure.batch.kind === 'cancel') {
          const results = failure.batch.results;
          return {
            outcomes: fill({ status: 'unconfirmed', reason: 'missing' }).map((o, i) => (results[i] ? toCancelOutcome(results[i]) : o)),
            needsReconcile: failure.batch.needsReconcile,
            retry,
            failure,
          };
        }
        return { outcomes: fill({ status: 'unconfirmed', reason: 'unexpected batch body' }), needsReconcile: true, retry, failure };
      case 'rejected':
        return { outcomes: fill({ status: 'error', message: failure.message, kind: failure.kind }), needsReconcile: true, retry, failure };
      case 'validation':
      case 'signing':
        return { outcomes: fill({ status: 'not_sent', message: failure.message, retryable: false }), needsReconcile: true, retry, failure };
      case 'transport':
        if (failure.info.outcome === 'not-applied') {
          return {
            outcomes: fill({ status: 'not_sent', message: failure.message, retryable: failure.info.transient }),
            needsReconcile: true,
            retry,
            failure,
          };
        }
        return { outcomes: fill({ status: 'unknown', message: failure.message }), needsReconcile: true, retry, failure };
    }
  }
  const parsed = parseCancelResponse(response, { expectedCount: count });
  if (parsed.ok) {
    return { outcomes: parsed.results.map(toCancelOutcome), needsReconcile: parsed.needsReconcile, retry: parsed.needsReconcile ? 'retry' : null };
  }
  if (parsed.reason === 'rejected') {
    return { outcomes: fill({ status: 'error', message: parsed.message, kind: parsed.kind }), needsReconcile: true, retry: 'give-up' };
  }
  return { outcomes: fill({ status: 'unconfirmed', reason: parsed.detail }), needsReconcile: true, retry: 'retry' };
}

function toCancelOutcome(r: CancelStatusResult): CancelOutcome {
  switch (r.status) {
    case 'success':
      return { status: 'success' };
    case 'alreadyGone':
      return { status: 'alreadyGone', message: r.message };
    case 'error':
      return { status: 'error', message: r.message, kind: r.kind };
    case 'invalid':
      return { status: 'unconfirmed', reason: r.reason };
  }
}

/** Minimum lead time of a scheduled cancel per HL documentation and the SDK: 5 seconds. */
export const MIN_SCHEDULE_CANCEL_LEAD_MS = 5_000;

export interface ScheduleCancelOptions {
  vaultAddress?: string;
  expiresAfter?: number;
  signal?: AbortSignal;
  /** Clock for the lead-time check (tests). Default `Date.now`. */
  now?: () => number;
}

/** Result of a `scheduleCancel` call. */
export type ScheduleCancelResult =
  | { readonly ok: true; readonly time: number | null }
  | {
      readonly ok: false;
      readonly time: number | null;
      /** `rejected` by the exchange, `not-applied`, `unknown` outcome, or a `malformed` answer. */
      readonly reason: 'rejected' | 'not-applied' | 'unknown' | 'malformed';
      readonly message: string;
      /** The action is idempotent: `retry` is safe for transient failures. */
      readonly retry: RetryAdvice;
      readonly failure?: ExchangeFailure;
    };

/**
 * Dead man's switch: asks the exchange to cancel ALL open orders of the account at `time` (ms since
 * epoch) unless the schedule is renewed or cleared first. Keep renewing it from a healthy process so
 * a crashed bot does not leave its book resting.
 *
 * @experimental Not verified live (the knowledge base records only the resulting `scheduledCancel`
 * order status). Per HL documentation and SDK 0.33.3: `time` must be at least 5 s in the future
 * (checked locally), and the number of triggers per day is limited by HL. Risk: if the exchange
 * ignores or rejects the schedule, orders simply stay; the result is fail-closed (`ok` only on an
 * explicit `status: 'ok'`), so never treat a failed call as armed.
 */
export async function scheduleCancel(
  exchange: ScheduleCancelExchange,
  time: number | Date,
  opts: ScheduleCancelOptions = {},
): Promise<ScheduleCancelResult> {
  const ms = time instanceof Date ? time.getTime() : time;
  const now = (opts.now ?? Date.now)();
  if (!Number.isSafeInteger(ms) || ms < now + MIN_SCHEDULE_CANCEL_LEAD_MS) {
    invalidArgument(`scheduleCancel time must be an integer ms timestamp at least ${MIN_SCHEDULE_CANCEL_LEAD_MS} ms ahead`);
  }
  const callOpts = exchangeCallOptions(opts);
  return runScheduleCancel(ms, () =>
    callOpts ? exchange.scheduleCancel({ time: ms }, callOpts) : exchange.scheduleCancel({ time: ms }),
  );
}

/**
 * Removes the scheduled cancel-all. @experimental see {@link scheduleCancel}.
 *
 * With call options it sends `scheduleCancel({ time: undefined }, opts)`. Why: SDK 0.33.3 tells
 * params from options by the presence of a `time` key, and `ExchangeClient.scheduleCancel(opts)`
 * forwards `{}` as params to the method function, which then takes `{}` as the options and silently
 * drops `vaultAddress` / `signal` / `expiresAfter` (the sub-account schedule would stay armed). The
 * undefined key is not serialized: the action and its L1 hash are the same as without it (checked in
 * sdk-contract.test.ts).
 */
export async function clearScheduledCancel(
  exchange: ScheduleCancelExchange,
  opts: Omit<ScheduleCancelOptions, 'now'> = {},
): Promise<ScheduleCancelResult> {
  const callOpts: ExchangeCallOptions | undefined = exchangeCallOptions(opts);
  return runScheduleCancel(null, () =>
    callOpts ? exchange.scheduleCancel({ time: undefined }, callOpts) : exchange.scheduleCancel(),
  );
}

async function runScheduleCancel(time: number | null, call: () => PromiseLike<unknown>): Promise<ScheduleCancelResult> {
  let response: unknown;
  try {
    response = await invokeAsync(call);
  } catch (err) {
    const failure = interpretExchangeError(err);
    const retry = recommendRetry(err, { idempotent: true });
    const reason =
      failure.type === 'rejected'
        ? 'rejected'
        : failure.type === 'transport' && failure.info.outcome === 'unknown'
          ? 'unknown'
          : failure.type === 'partial'
            ? 'malformed'
            : 'not-applied';
    return { ok: false, time, reason, message: failure.message, retry, failure };
  }
  const parsed = parseActionResponse(response);
  if (parsed.ok) return { ok: true, time };
  if (parsed.reason === 'rejected') return { ok: false, time, reason: 'rejected', message: parsed.message, retry: 'give-up' };
  return { ok: false, time, reason: 'malformed', message: parsed.detail, retry: 'retry' };
}

/** `true` only for an `AssetRegistryError` that proves the coin is not listed (then no order can exist). */
function isDefinitelyUnlisted(err: unknown): boolean {
  const e = err as { name?: unknown; status?: unknown } | null;
  return typeof e === 'object' && e !== null && e.name === 'AssetRegistryError' && e.status === 'unlisted';
}
All files