Skip to content
markpaper

src/orders/twap.ts

v0.3.0 · 7.7 KB

Download file
// Native TWAP orders. Everything here is @experimental: TWAP placement through the API is not verified
// live (twap.md §2, §7); shapes follow HL documentation and SDK 0.33.3.

import {
  interpretExchangeError,
  invokeAsync,
  parseTwapResponse,
  recommendRetry,
  type ExchangeErrorKind,
  type ExchangeFailure,
  type RetryAdvice,
} from '../errors/index.js';
import { formatSize, type Numeric, type OrderSide } from '../format/index.js';
import { invalidArgument } from './errors.js';
import { exchangeCallOptions } from './place.js';
import type { AssetResolver, TwapExchange } from './types.js';

/** Shortest TWAP duration accepted (minutes). `Invalid TWAP duration: 1 min(s)` was observed for 1. */
export const MIN_TWAP_MINUTES = 5;
/** Longest TWAP duration accepted (minutes, 24 h). */
export const MAX_TWAP_MINUTES = 1440;

export interface TwapIntent {
  coin: string;
  side: OrderSide;
  /** Total size in base units; floored to the lot. */
  size: Numeric;
  /** Integer duration, {@link MIN_TWAP_MINUTES}..{@link MAX_TWAP_MINUTES}. */
  minutes: number;
  reduceOnly?: boolean;
  /** Randomize slice timing/size (exact semantics not verified). Default `false`. */
  randomize?: boolean;
}

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

export type TwapPlaceResult =
  | { readonly status: 'running'; readonly twapId: number; readonly sz: string; readonly assetId: number }
  /** Rejected by the exchange (e.g. `invalidTwapDuration`). Nothing runs. */
  | { readonly status: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Provably not applied (429 / 4xx), not signed, or rejected by SDK validation. */
  | { readonly status: 'not_sent'; readonly message: string; readonly retry: RetryAdvice; readonly failure: ExchangeFailure }
  /**
   * Outcome unknown (5xx, timeout) or an unreadable answer: a TWAP may be running. `twapOrder` is not
   * idempotent: do not re-send; check `userTwapSliceFills` (slices never appear in `userFills`).
   */
  | { readonly status: 'unknown'; readonly message: string; readonly retry: RetryAdvice; readonly failure?: ExchangeFailure };

/** Validates a TWAP duration in minutes. @throws HlOrderError `INVALID_ARGUMENT`. */
export function assertTwapMinutes(minutes: number): void {
  if (!Number.isSafeInteger(minutes) || minutes < MIN_TWAP_MINUTES || minutes > MAX_TWAP_MINUTES) {
    invalidArgument(
      `Invalid TWAP duration ${String(minutes)}: expected an integer ${MIN_TWAP_MINUTES}..${MAX_TWAP_MINUTES} minutes`,
    );
  }
}

/**
 * Places a native TWAP (`twapOrder`).
 *
 * @experimental Not verified live. Known from documentation only: a slice about every 30 s,
 * each capped at 3% slippage, catch-up limited to 3x a normal slice, no execution in post-only mode.
 * The minimum notional of a TWAP and of its slices is unknown, so no $10 gate is applied here: size
 * it with margin. Risks: a TWAP error arrives inside `data.status.error` with the outer status `'ok'`
 * (handled), slices bypass `userFills` (PnL and position tracking must also read
 * `userTwapSliceFills`). Run the smoke test from twap.md §7.1 on a small sub-account first.
 *
 * @throws HlOrderError for an invalid duration, side or a size below one lot.
 */
export async function placeTwap(
  exchange: TwapExchange,
  registry: AssetResolver,
  intent: TwapIntent,
  opts: TwapCallOptions = {},
): Promise<TwapPlaceResult> {
  if (typeof intent !== 'object' || intent === null) invalidArgument('TWAP intent must be an object');
  assertTwapMinutes(intent.minutes);
  if (!['B', 'A', 'buy', 'sell'].includes(intent.side as string)) invalidArgument(`Invalid side ${JSON.stringify(intent.side)}`);
  if (intent.reduceOnly !== undefined && typeof intent.reduceOnly !== 'boolean') invalidArgument('reduceOnly must be a boolean');
  if (intent.randomize !== undefined && typeof intent.randomize !== 'boolean') invalidArgument('randomize must be a boolean');
  const callOpts = exchangeCallOptions(opts);
  const reduceOnly = intent.reduceOnly === true;
  const asset = await registry.resolve(intent.coin, { rejectDelisted: !reduceOnly });
  const sz = formatSize(intent.size, asset.szDecimals, 'floor');
  if (sz === '0') invalidArgument(`TWAP size ${String(intent.size)} of ${asset.coin} is below one lot`);
  const params = {
    twap: {
      a: asset.assetId,
      b: intent.side === 'B' || intent.side === 'buy',
      s: sz,
      r: reduceOnly,
      m: intent.minutes,
      t: intent.randomize === true,
    },
  };

  let response: unknown;
  try {
    response = await invokeAsync(() => (callOpts ? exchange.twapOrder(params, callOpts) : exchange.twapOrder(params)));
  } catch (err) {
    const failure = interpretExchangeError(err);
    const retry = recommendRetry(err, { idempotent: false });
    if (failure.type === 'rejected') return { status: 'rejected', message: failure.message, kind: failure.kind };
    if (failure.type === 'validation' || failure.type === 'signing' || (failure.type === 'transport' && failure.info.outcome === 'not-applied')) {
      return { status: 'not_sent', message: failure.message, retry, failure };
    }
    return { status: 'unknown', message: failure.message, retry, failure };
  }
  const parsed = parseTwapResponse(response);
  if (parsed.status === 'running') return { status: 'running', twapId: parsed.twapId, sz, assetId: asset.assetId };
  if (parsed.status === 'error') return { status: 'rejected', message: parsed.message, kind: parsed.kind };
  return { status: 'unknown', message: parsed.status === 'invalid' ? parsed.detail : 'unexpected success shape', retry: 'reconcile' };
}

export type TwapCancelResult =
  | { readonly status: 'success' }
  /** `TWAP was never placed, already canceled, or filled.` */
  | { readonly status: 'alreadyGone'; readonly message: string }
  | { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
  /** Not applied, unknown or unreadable. Cancels are idempotent: `retry` is safe for transient failures. */
  | { readonly status: 'unconfirmed'; readonly message: string; readonly retry: RetryAdvice; readonly failure?: ExchangeFailure };

/**
 * Cancels a TWAP by id (`twapCancel`). @experimental see {@link placeTwap}.
 * @throws HlOrderError for an invalid `twapId`.
 */
export async function cancelTwap(
  exchange: TwapExchange,
  registry: AssetResolver,
  input: { coin: string; twapId: number },
  opts: TwapCallOptions = {},
): Promise<TwapCancelResult> {
  if (typeof input !== 'object' || input === null) invalidArgument('TWAP cancel input must be an object');
  if (!Number.isSafeInteger(input.twapId) || input.twapId < 0) invalidArgument(`Invalid twapId ${String(input.twapId)}`);
  const callOpts = exchangeCallOptions(opts);
  const asset = await registry.resolve(input.coin);
  const params = { a: asset.assetId, t: input.twapId };
  let response: unknown;
  try {
    response = await invokeAsync(() => (callOpts ? exchange.twapCancel(params, callOpts) : exchange.twapCancel(params)));
  } catch (err) {
    const failure = interpretExchangeError(err);
    if (failure.type === 'rejected') return rejectedCancel(failure.message, failure.kind);
    return { status: 'unconfirmed', message: failure.message, retry: recommendRetry(err, { idempotent: true }), failure };
  }
  const parsed = parseTwapResponse(response);
  if (parsed.status === 'success') return { status: 'success' };
  if (parsed.status === 'error') return rejectedCancel(parsed.message, parsed.kind);
  return { status: 'unconfirmed', message: parsed.status === 'invalid' ? parsed.detail : 'unexpected shape', retry: 'retry' };
}

function rejectedCancel(message: string, kind: ExchangeErrorKind): TwapCancelResult {
  return kind === 'orderNotFound' ? { status: 'alreadyGone', message } : { status: 'error', message, kind };
}
All files