Skip to content
markpaper

src/orders/place.ts

v0.3.0 · 18.3 KB

Download file
// Batch placement with per-order, fail-closed results.

import {
  errorKindInfo,
  interpretExchangeError,
  invokeAsync,
  parseOrderResponse,
  recommendRetry,
  type AcceptedWithoutOid,
  type ErrorDisposition,
  type ExchangeErrorKind,
  type ExchangeFailure,
  type OrderStatusResult,
  type RetryAdvice,
} from '../errors/index.js';
import { isFullyFilled } from '../format/index.js';
import { buildWith, type BuildOrderOptions, type BuildSkipReason, type OrderIntentInput, type PlaceableOrder } from './build.js';
import type { Cloid } from './cloid.js';
import { nonNegativeDiff } from './decimal.js';
import { HlOrderError, invalidArgument, messageOf, normalizeAddress } from './errors.js';
import { createMidCache } from './mids.js';
import type { AssetResolver, ExchangeCallOptions, OrderActionParams, OrderExchange, WireBuilder } from './types.js';

/** Builder fee cap for perps in tenths of a basis point (0.1%). */
export const MAX_PERP_BUILDER_FEE = 100;
/** Builder fee cap accepted by SDK 0.33.3 for other markets (tenths of a basis point). */
export const MAX_BUILDER_FEE = 1000;

/** Why an order was not sent (or provably not applied). */
export type NotSentReason =
  /** The exchange provably did not apply the request (HTTP 429, other 4xx). */
  | 'not-applied'
  /** SDK parameter validation failed before signing; no nonce was used. */
  | 'validation'
  /** The wallet did not sign. */
  | 'signing'
  /** Held back because an earlier batch of the same call has an unknown outcome. */
  | 'held';

/** Normalized result of one order. */
export type OrderOutcome =
  /** Resting in the book. */
  | { readonly status: 'resting'; readonly oid: number; readonly cloid?: Cloid }
  /**
   * Filled immediately. For IoC the fill may be PARTIAL and the rest is dropped by the exchange:
   * `remainingSz` is what is left of the requested size and must be re-queued by the caller.
   */
  | {
      readonly status: 'filled';
      readonly oid: number;
      readonly totalSz: string;
      readonly avgPx: string;
      /** `filled >= requested - one lot`. */
      readonly fullyFilled: boolean;
      /** `max(0, requested - filled)`. */
      readonly remainingSz: string;
      readonly cloid?: Cloid;
    }
  /** Accepted without an oid (`waitingForTrigger` / `waitingForFill` / bare `resting`). */
  | { readonly status: 'accepted'; readonly detail: AcceptedWithoutOid }
  /** Rejected by the exchange; nothing was placed for this order. */
  | {
      readonly status: 'rejected';
      readonly message: string;
      readonly kind: ExchangeErrorKind;
      readonly disposition: ErrorDisposition;
    }
  /** The response element could not be confirmed either way: the order MAY be live, reconcile. */
  | { readonly status: 'unconfirmed'; readonly reason: string }
  /** Transport outcome unknown (5xx, timeout, network): the order MAY be live. Never re-send blindly. */
  | { readonly status: 'unknown'; readonly message: string }
  /** Not sent or provably not applied. */
  | { readonly status: 'not_sent'; readonly reason: NotSentReason; readonly retryable: boolean; readonly message: string }
  /** Not sent by decision of `buildOrder`: `deferred` exits are to be retried, `skipped` orders dropped. */
  | { readonly status: 'skipped' | 'deferred'; readonly reason: BuildSkipReason }
  /** The intent could not be built (bad input, unknown asset, failed mid read). Not sent. */
  | { readonly status: 'build_failed'; readonly message: string; readonly error: unknown };

export interface PlacedOrder {
  /** Index in the `intents` array. */
  readonly index: number;
  readonly coin: string;
  readonly cloid?: Cloid;
  /** The order as built, when it was placeable. */
  readonly order?: PlaceableOrder;
  readonly outcome: OrderOutcome;
}

export type BatchOutcome = 'ok' | 'partial' | 'rejected' | 'not-applied' | 'unknown' | 'malformed' | 'held';

/** One `exchange.order` call. */
export interface BatchReport {
  readonly indices: readonly number[];
  readonly withBuilder: boolean;
  readonly outcome: BatchOutcome;
  /** Some order of this batch may be live but is not confirmed. */
  readonly needsReconcile: boolean;
  /**
   * What to do with the WHOLE batch: `retry` (re-send as is, only when nothing was applied or every
   * order is a full reduceOnly close), `reconcile` (do not re-send; check by cloid first), `give-up`.
   * `null` when the batch went through.
   */
  readonly retry: RetryAdvice | null;
  readonly failure?: ExchangeFailure;
}

export interface PlaceOrdersResult {
  readonly orders: readonly PlacedOrder[];
  readonly batches: readonly BatchReport[];
  /** At least one exchange call was attempted. */
  readonly sent: boolean;
  /**
   * Some order may be live without confirmation. Block NEW placements on these markets until a
   * reconcile succeeds (cancels stay allowed).
   */
  readonly needsReconcile: boolean;
  /** Cloids of `unknown` / `unconfirmed` orders, for {@link reconcileByCloid}. */
  readonly reconcileCloids: readonly Cloid[];
  /** Oids that are resting or filled, including those salvaged from a partially failed batch. */
  readonly placedOids: readonly number[];
}

export interface PlaceOrdersOptions extends BuildOrderOptions {
  /** Default `'na'`. */
  grouping?: 'na' | 'normalTpsl';
  /**
   * Builder fee `{ b, f }` (`f` in tenths of a basis point, max 100 on perps). Never attached to
   * reduceOnly orders: they are sent in a separate batch without it. Why: if the user revoked the
   * builder approval and the cache does not know yet, a protective order carrying the fee is
   * rejected.
   */
  builder?: { b: string; f: number };
  /** Sub-account or vault address to trade for. */
  vaultAddress?: string;
  expiresAfter?: number;
}

type Item = { index: number; order: PlaceableOrder };

/**
 * Builds and places a batch of orders through `exchange.order` (orders.md §7, §10; sdk-and-api.md §7).
 *
 * - Every intent gets its own result, in input order. An intent that cannot be built does not stop
 *   the others. Why: when one place throws, the rest of the batch (including reduceOnly closes) must
 *   still go out.
 * - A partially failed batch is not lost: the SDK throws `ApiRequestError` when any element is an
 *   error, even though neighbours were placed; their oids are salvaged from the error body. Why:
 *   treating "the SDK threw" as "nothing placed" loses track of live orders and can orphan a TP/SL pair.
 * - Transport failures are classified: `not_sent` + `not-applied` (HTTP 429 / 4xx) may be retried;
 *   `unknown` (5xx, timeout, network) must NOT be re-sent blindly (a re-send after a timeout can
 *   duplicate the order): reconcile the returned cloids with {@link reconcileByCloid} first.
 * - Empty `statuses`, unknown element shapes, bad fills and count mismatches are `unconfirmed`, never
 *   success and never "not sent".
 * - One `allMids` read per dex for the whole call.
 *
 * Throws `HlOrderError` only for invalid call options (builder, vaultAddress).
 */
export async function placeOrders(
  exchange: OrderExchange,
  registry: AssetResolver,
  intents: readonly OrderIntentInput[],
  opts: PlaceOrdersOptions = {},
): Promise<PlaceOrdersResult> {
  if (!Array.isArray(intents)) invalidArgument('intents must be an array');
  const grouping = opts.grouping ?? 'na';
  if (grouping !== 'na' && grouping !== 'normalTpsl') invalidArgument(`Invalid grouping ${JSON.stringify(grouping)}`);
  const callOpts = exchangeCallOptions(opts);
  const builderInput = opts.builder === undefined ? undefined : checkBuilderShape(opts.builder);

  const results: PlacedOrder[] = [];
  const placeable: Item[] = [];
  const midSource = createMidCache(opts.info, opts);
  const seenCloids = new Set<string>();

  for (let index = 0; index < intents.length; index++) {
    const intent = intents[index] as OrderIntentInput;
    const coin = typeof intent?.coin === 'string' ? intent.coin : '';
    try {
      const built = await buildWith(registry, intent, opts, midSource);
      if (built.action !== 'place') {
        results[index] = {
          index,
          coin: built.coin,
          outcome: { status: built.action === 'defer' ? 'deferred' : 'skipped', reason: built.reason },
        };
        continue;
      }
      if (built.cloid) {
        if (seenCloids.has(built.cloid)) {
          throw new HlOrderError('INVALID_ARGUMENT', `Duplicate cloid ${built.cloid} in one batch`);
        }
        seenCloids.add(built.cloid);
      }
      placeable.push({ index, order: built });
      results[index] = { index, coin: built.coin, order: built, ...(built.cloid ? { cloid: built.cloid } : {}), outcome: PENDING };
    } catch (error) {
      results[index] = { index, coin, outcome: { status: 'build_failed', message: messageOf(error), error } };
    }
  }

  const builder = builderInput ? finalizeBuilder(builderInput, placeable) : undefined;
  const groups: { items: Item[]; builder?: WireBuilder }[] = builder
    ? [
        { items: placeable.filter((i) => i.order.reduceOnly) },
        { items: placeable.filter((i) => !i.order.reduceOnly), builder },
      ]
    : [{ items: placeable }];

  const batches: BatchReport[] = [];
  let hold = false;
  let sent = false;
  for (const group of groups) {
    if (group.items.length === 0) continue;
    const indices = group.items.map((i) => i.index);
    if (hold) {
      // Why: any doubt about what is live forbids NEW placements until a reconcile; the held
      // orders were never sent, so re-sending them later is safe.
      for (const item of group.items) {
        setOutcome(results, item, {
          status: 'not_sent',
          reason: 'held',
          retryable: true,
          message: 'held: an earlier batch of this call has an unknown outcome',
        });
      }
      batches.push({ indices, withBuilder: group.builder !== undefined, outcome: 'held', needsReconcile: false, retry: 'retry' });
      continue;
    }
    sent = true;
    let report: Omit<BatchReport, 'indices' | 'withBuilder'>;
    try {
      report = await sendBatch(exchange, group.items, grouping, group.builder, callOpts, results);
    } catch (error) {
      // Never expected (response parsing does not throw), but the request was already sent: the
      // orders may be live, so they must not be reported as anything but unknown.
      for (const item of group.items) setOutcome(results, item, { status: 'unknown', message: messageOf(error) });
      report = { outcome: 'unknown', needsReconcile: true, retry: 'reconcile' };
    }
    batches.push({ ...report, indices, withBuilder: group.builder !== undefined });
    if (report.needsReconcile || report.outcome === 'unknown' || report.outcome === 'malformed') hold = true;
  }

  const reconcileCloids: Cloid[] = [];
  const placedOids: number[] = [];
  let unconfirmed = false;
  for (const r of results) {
    const s = r.outcome.status;
    if (s === 'unknown' || s === 'unconfirmed') {
      unconfirmed = true;
      if (r.cloid) reconcileCloids.push(r.cloid);
    }
    if (r.outcome.status === 'resting' || r.outcome.status === 'filled') placedOids.push(r.outcome.oid);
  }

  return {
    orders: results,
    batches,
    sent,
    needsReconcile: unconfirmed || batches.some((b) => b.needsReconcile),
    reconcileCloids,
    placedOids,
  };
}

/** `true` when the order may be resting or in flight: resting, accepted, unconfirmed or unknown. */
export function mayBeLive(outcome: OrderOutcome): boolean {
  return (
    outcome.status === 'resting' ||
    outcome.status === 'accepted' ||
    outcome.status === 'unconfirmed' ||
    outcome.status === 'unknown'
  );
}

const PENDING: OrderOutcome = { status: 'unconfirmed', reason: 'not-processed' };

function setOutcome(results: PlacedOrder[], item: Item, outcome: OrderOutcome): void {
  const prev = results[item.index] as PlacedOrder;
  results[item.index] = { ...prev, outcome };
}

async function sendBatch(
  exchange: OrderExchange,
  items: Item[],
  grouping: 'na' | 'normalTpsl',
  builder: WireBuilder | undefined,
  callOpts: ExchangeCallOptions | undefined,
  results: PlacedOrder[],
): Promise<Omit<BatchReport, 'indices' | 'withBuilder'>> {
  const params: OrderActionParams = { orders: items.map((i) => i.order.wire), grouping };
  if (builder) params.builder = builder;
  const requestedSizes = items.map((i) => i.order.sz);
  // Why: only a FULL reduceOnly close is idempotent; a retried partial reduce can reduce the
  // position twice.
  const idempotent = items.every((i) => i.order.reduceOnly && i.order.sizeIntent === 'fullClose');
  const all = (outcome: OrderOutcome) => items.forEach((item) => setOutcome(results, item, outcome));

  let response: unknown;
  try {
    response = await invokeAsync(() => (callOpts ? exchange.order(params, callOpts) : exchange.order(params)));
  } catch (err) {
    const failure = interpretExchangeError(err, { requestedSizes });
    const retry = recommendRetry(err, { idempotent });
    switch (failure.type) {
      case 'partial': {
        if (failure.batch.kind !== 'order') {
          all({ status: 'unconfirmed', reason: `unexpected ${failure.batch.responseType || 'batch'} body` });
          return { outcome: 'malformed', needsReconcile: true, retry: 'reconcile', failure };
        }
        const statuses = failure.batch.results;
        items.forEach((item, i) => setOutcome(results, item, toOutcome(statuses[i], item.order)));
        if (!failure.batch.needsReconcile && statuses.length === items.length && statuses.every((s) => s.status === 'error')) {
          // Every element was rejected (the usual shape of a one-order batch the SDK threw on): nothing
          // was placed, so the advice follows the rejection kinds instead of a needless 'reconcile'
          // (a dust close must stop the loop, not trigger a book reconcile).
          const retryable = statuses.every((s) => s.status === 'error' && errorKindInfo(s.kind).disposition === 'retryable');
          return { outcome: 'rejected', needsReconcile: false, retry: retryable ? 'retry' : 'give-up', failure };
        }
        return { outcome: 'partial', needsReconcile: failure.batch.needsReconcile, retry, failure };
      }
      case 'rejected':
        all(rejected(failure.message, failure.kind));
        return { outcome: 'rejected', needsReconcile: false, retry, failure };
      case 'validation':
      case 'signing':
        all({ status: 'not_sent', reason: failure.type, retryable: false, message: failure.message });
        return { outcome: 'not-applied', needsReconcile: false, retry, failure };
      case 'transport':
        if (failure.info.outcome === 'not-applied') {
          all({ status: 'not_sent', reason: 'not-applied', retryable: failure.info.transient, message: failure.message });
          return { outcome: 'not-applied', needsReconcile: false, retry, failure };
        }
        all({ status: 'unknown', message: failure.message });
        return { outcome: 'unknown', needsReconcile: true, retry, failure };
    }
  }

  const parsed = parseOrderResponse(response, { requestedSizes });
  if (parsed.ok) {
    items.forEach((item, i) => setOutcome(results, item, toOutcome(parsed.results[i], item.order)));
    return { outcome: 'ok', needsReconcile: parsed.needsReconcile, retry: parsed.needsReconcile ? 'reconcile' : null };
  }
  if (parsed.reason === 'rejected') {
    all(rejected(parsed.message, parsed.kind));
    const disposition = errorKindInfo(parsed.kind).disposition;
    return { outcome: 'rejected', needsReconcile: false, retry: disposition === 'retryable' ? 'retry' : 'give-up' };
  }
  // `{status:'ok', statuses:[]}` and friends confirm nothing and do not prove nothing was placed.
  all({ status: 'unconfirmed', reason: parsed.detail });
  return { outcome: 'malformed', needsReconcile: true, retry: 'reconcile' };
}

function rejected(message: string, kind: ExchangeErrorKind): OrderOutcome {
  return { status: 'rejected', message, kind, disposition: errorKindInfo(kind).disposition };
}

function toOutcome(result: OrderStatusResult | undefined, order: PlaceableOrder): OrderOutcome {
  if (!result) return { status: 'unconfirmed', reason: 'missing' };
  switch (result.status) {
    case 'resting': {
      const cloid = result.cloid ?? order.cloid;
      return cloid ? { status: 'resting', oid: result.oid, cloid } : { status: 'resting', oid: result.oid };
    }
    case 'filled': {
      const cloid = result.cloid ?? order.cloid;
      return {
        status: 'filled',
        oid: result.oid,
        totalSz: result.totalSz,
        avgPx: result.avgPx,
        fullyFilled: isFullyFilled(order.sz, result.totalSz, order.asset.szDecimals),
        remainingSz: nonNegativeDiff(order.sz, result.totalSz),
        ...(cloid ? { cloid } : {}),
      };
    }
    case 'accepted':
      return { status: 'accepted', detail: result.detail };
    case 'error':
      return rejected(result.message, result.kind);
    case 'invalid':
      return { status: 'unconfirmed', reason: result.reason };
  }
}

/** @internal */
export function exchangeCallOptions(opts: {
  signal?: AbortSignal;
  vaultAddress?: string;
  expiresAfter?: number;
}): ExchangeCallOptions | undefined {
  const out: ExchangeCallOptions = {};
  if (opts.signal) out.signal = opts.signal;
  if (opts.vaultAddress !== undefined) out.vaultAddress = normalizeAddress(opts.vaultAddress, 'vaultAddress');
  if (opts.expiresAfter !== undefined) {
    if (!Number.isSafeInteger(opts.expiresAfter) || opts.expiresAfter <= 0) {
      invalidArgument(`Invalid expiresAfter ${String(opts.expiresAfter)}: expected a positive ms timestamp`);
    }
    out.expiresAfter = opts.expiresAfter;
  }
  return Object.keys(out).length > 0 ? out : undefined;
}

function checkBuilderShape(builder: { b: string; f: number }): WireBuilder {
  if (typeof builder !== 'object' || builder === null) invalidArgument('builder must be an object');
  // Why: HL checks the builder address against the signature; a mixed-case (checksum) address is rejected.
  const b = normalizeAddress(builder.b, 'builder.b');
  if (!Number.isSafeInteger(builder.f) || builder.f < 0 || builder.f > MAX_BUILDER_FEE) {
    invalidArgument(`Invalid builder.f ${String(builder.f)}: expected an integer 0..${MAX_BUILDER_FEE} (tenths of a basis point)`);
  }
  return { b, f: builder.f };
}

function finalizeBuilder(builder: WireBuilder, items: Item[]): WireBuilder {
  const perp = items.some((i) => !i.order.reduceOnly && i.order.asset.market === 'perp');
  if (perp && builder.f > MAX_PERP_BUILDER_FEE) {
    invalidArgument(`builder.f ${builder.f} exceeds the perp cap of ${MAX_PERP_BUILDER_FEE} (0.1%)`);
  }
  return builder;
}
All files