Skip to content
markpaper

src/orders/build.ts

v0.3.0 · 17 KB

Download file
// From a human order intent to a quantized wire order.

import type { AssetInfo } from '../assets/index.js';
import {
  DEFAULT_EXIT_SLIPPAGE_FLOOR,
  exitSlippage,
  formatPrice,
  isValidPrice,
  marketLimitPrice,
  normalizeDecimal,
  planOrderSize,
  postOnlyPrice,
  validateOrder,
  type Numeric,
  type OrderIntent,
  type OrderSide,
  type SizeSkipReason,
} from '../format/index.js';
import type { InfoRequester } from '../transport/types.js';
import { createCloid, normalizeCloid, type Cloid } from './cloid.js';
import { HlOrderError, invalidArgument } from './errors.js';
import { createMidCache, parseMid, type OrdersInfoOptions } from './mids.js';
import type { AssetResolver, Tif, WireOrder } from './types.js';

/**
 * Smallest slippage that reliably crosses the spread (0.5%). An IoC exactly at mid does
 * not cross and never fills; below this a {@link BuildWarning} is reported.
 */
export const RECOMMENDED_MIN_IOC_SLIPPAGE = 0.005;

/** A "market" order: IoC limit at `mid * (1 +/- slippage)`. */
export interface MarketPricing {
  /** Fraction, `0.01` = 1%. Must be > 0 and < 1. For reduceOnly orders the exit floor applies. */
  slippage: number;
  /** Reference price. When omitted it is read from `allMids` of the asset's dex (needs `info`). */
  mid?: Numeric;
}

/** Trigger (TP/SL) parameters. Triggers fire on the mark price. */
export interface TriggerSpec {
  triggerPx: Numeric;
  tpsl: 'tp' | 'sl';
  /** Default `true`: execute as market after triggering, `p` is the worst accepted price. */
  isMarket?: boolean;
}

/** What the caller wants to trade, in human terms. */
export interface OrderIntentInput {
  /** `BTC`, `xyz:TSLA`, `PURR/USDC`... */
  coin: string;
  side: OrderSide;
  /** Absolute size in base units (for `sizeIntent: 'fullClose'`: the absolute position size). */
  size: Numeric;
  /** Limit price. Exactly one of `price` / `market` is required. */
  price?: Numeric;
  /**
   * "Market" order: IoC limit at mid +/- slippage (HL has no market order type). With `trigger`,
   * the slippage is applied to the trigger price instead of a mid.
   */
  market?: MarketPricing;
  /** Default `'Gtc'` for a limit price, `'Ioc'` for `market`. Not allowed with `trigger`. */
  tif?: Tif;
  /** Default: `true` for `partialReduce` / `fullClose`, `false` otherwise. */
  reduceOnly?: boolean;
  /**
   * Decides rounding and the $10 minimum (orders.md §5.4). Default `partialReduce` when
   * `reduceOnly`, else `open`. `fullClose` must be explicit: it rounds UP and bumps to the minimum,
   * which is only safe for a whole-position reduceOnly close.
   */
  sizeIntent?: OrderIntent;
  /**
   * Makes this a trigger (TP/SL) order. `grouping: 'positionTpsl'` is not supported. `reduceOnly` (or
   * `sizeIntent`) must be given explicitly: a protective trigger without `r: true` opens a position.
   */
  trigger?: TriggerSpec;
  /** Best bid/ask for an `Alo` quote: the price is clamped one tick inside the opposite side. */
  book?: { bestBid?: Numeric; bestAsk?: Numeric };
  /** Explicit cloid (`0x` + 32 hex), `false` for none, omitted = generated when `autoCloid`. */
  cloid?: string | false;
}

/** How an explicit limit price is put on the grid. */
export type LimitPriceRounding = 'passive' | 'aggressive' | 'round' | 'exact';

export interface BuildOrderOptions extends OrdersInfoOptions {
  /** Used to read `allMids` when a market order has no `mid`. */
  info?: InfoRequester;
  /** Generate a cloid for orders without one. Default `true`: an unknown outcome can only be reconciled by cloid. */
  autoCloid?: boolean;
  /** Ownership prefix for generated cloids, see `createCloid`. */
  cloidPrefix?: string;
  /** Custom cloid generator (must return `0x` + 32 hex). */
  cloidFactory?: () => string;
  /**
   * Rounding of an explicit limit price. Default `'passive'` (buy floor, sell ceil): rounding never
   * gives a worse price than requested and never makes a quote cross. `'exact'` refuses off-grid prices.
   */
  priceRounding?: LimitPriceRounding;
  /** Slippage floor for reduceOnly market orders (fraction). Default 10%; `0` disables. */
  exitSlippageFloor?: number;
  /** Default $10. Never set it below the real exchange minimum. */
  minNotionalUsd?: number;
  /** Passed to the price grid, see `formatPrice`. */
  integerExemption?: boolean;
}

export type BuildSkipReason =
  | SizeSkipReason
  /** No usable order price. */
  | 'no_reference_price'
  /** Market order without a mid (`allMids` has no valid value). */
  | 'no_mid_price'
  /** New exposure on a delisted market. */
  | 'market_delisted';

export type BuildWarning =
  /** Market slippage below {@link RECOMMENDED_MIN_IOC_SLIPPAGE}: the IoC may not cross the spread. */
  | 'slippage_below_recommended'
  /** The explicit limit or trigger price was moved onto the grid. */
  | 'price_rounded';

/** An order ready to send. */
export interface PlaceableOrder {
  readonly action: 'place';
  readonly coin: string;
  readonly asset: AssetInfo;
  readonly wire: WireOrder;
  readonly side: 'B' | 'A';
  /** `null` for trigger orders. */
  readonly tif: Tif | null;
  readonly reduceOnly: boolean;
  readonly sizeIntent: OrderIntent;
  /** Wire price. */
  readonly px: string;
  /** Wire size. */
  readonly sz: string;
  /** Exact `px * sz`. */
  readonly notional: string;
  /** A full close was raised above ceil(size) to reach the minimum. */
  readonly bumped: boolean;
  readonly triggerPx?: string;
  /** Reference price of a market order. */
  readonly mid?: string;
  /** Slippage actually applied (after the exit floor). */
  readonly slippage?: number;
  readonly cloid?: Cloid;
  readonly warnings: readonly BuildWarning[];
}

/** An order that must not be sent now. `defer`: an exit to retry later; `skip`: drop it. */
export interface SkippedOrder {
  readonly action: 'skip' | 'defer';
  readonly coin: string;
  readonly reason: BuildSkipReason;
  readonly asset?: AssetInfo;
  readonly sz?: string;
  readonly notional?: string;
}

export type BuildOrderResult = PlaceableOrder | SkippedOrder;

type MidSource = (asset: AssetInfo) => Promise<string | null>;

const SIDES: readonly string[] = ['B', 'A', 'buy', 'sell'];
const TIFS: readonly string[] = ['Gtc', 'Ioc', 'Alo'];
const INTENTS: readonly string[] = ['open', 'increase', 'partialReduce', 'fullClose'];
const ROUNDINGS: readonly string[] = ['passive', 'aggressive', 'round', 'exact'];

/**
 * Builds a quantized wire order from an intent (orders.md §1, §2, §5, §6).
 *
 * - Asset id and `szDecimals` come from the registry (throws `AssetRegistryError` for an unlisted or
 *   unknown coin: an unknown listing state is never "not listed").
 * - Limit prices are rounded passively by default; market orders are IoC limits at
 *   `mid * (1 +/- slippage)` rounded aggressively, with a 10% slippage floor for reduceOnly exits.
 *   Why: a narrow cap on a reduceOnly exit may not cross the book during lag or a 429 storm, and the
 *   position stays open; an aggressive reduceOnly exit cannot flip the position.
 * - Size is decided by `planOrderSize` at the ORDER price, after rounding: opens, increases and
 *   partial reduces are floored and skipped below $10 (never bumped: at $8 a lot, a one-lot bump turns a
 *   $10 open into $16); a full close is ceiled and bumped to the minimum in lots (a gated close leaves
 *   dust open forever).
 * - No mid: an entry is `skip`ped, an exit is `defer`red (retry, do not mark the exit as done).
 * - A cloid is generated unless disabled.
 * - Spot pairs are built on the spot price grid (`8 - szDecimals` decimals) with the same $10 gate;
 *   both are @experimental for spot (single-source rules, never exercised with live spot orders).
 *   Risk: a spot order on a wrong grid or minimum is rejected, nothing is placed.
 *
 * Never throws for market conditions; throws `HlOrderError` / `HlFormatError` for malformed or
 * contradictory input, and rethrows transport errors from the `allMids` read.
 */
export async function buildOrder(
  registry: AssetResolver,
  intent: OrderIntentInput,
  opts: BuildOrderOptions = {},
): Promise<BuildOrderResult> {
  return buildWith(registry, intent, opts, createMidCache(opts.info, opts));
}

/** @internal Shared by `placeOrders`, which passes one mid cache for the whole batch. */
export async function buildWith(
  registry: AssetResolver,
  intent: OrderIntentInput,
  opts: BuildOrderOptions,
  midSource: MidSource | undefined,
): Promise<BuildOrderResult> {
  checkIntentShape(intent, opts);
  const { reduceOnly, sizeIntent } = resolveIntent(intent);
  const asset = await registry.resolve(intent.coin);
  const coin = asset.coin;
  const buy = intent.side === 'B' || intent.side === 'buy';
  const grid = {
    szDecimals: asset.szDecimals,
    market: asset.market,
    ...(opts.integerExemption === undefined ? {} : { integerExemption: opts.integerExemption }),
  };

  if (asset.isDelisted && !reduceOnly) return { action: 'skip', coin, asset, reason: 'market_delisted' };

  const warnings: BuildWarning[] = [];
  let px: string;
  let triggerPx: string | undefined;
  let mid: string | undefined;
  let slippage: number | undefined;

  if (intent.trigger) {
    if (opts.priceRounding === 'exact' && !isValidPrice(intent.trigger.triggerPx, grid)) {
      invalidArgument(
        `Trigger price ${String(intent.trigger.triggerPx)} of ${coin} is not on the price grid (szDecimals=${asset.szDecimals})`,
      );
    }
    triggerPx = formatPrice(intent.trigger.triggerPx, { ...grid, mode: 'round' });
    if (triggerPx !== normalizeDecimal(intent.trigger.triggerPx)) warnings.push('price_rounded');
  }

  if (intent.market) {
    let ref: string | null;
    if (triggerPx !== undefined) {
      ref = triggerPx;
    } else if (intent.market.mid !== undefined) {
      ref = parseMid(intent.market.mid);
    } else {
      if (!midSource) invalidArgument(`Market order for ${coin} needs market.mid or options.info`);
      ref = await midSource(asset);
    }
    if (ref === null) return { action: reduceOnly ? 'defer' : 'skip', coin, asset, reason: 'no_mid_price' };
    slippage = reduceOnly
      ? exitSlippage(intent.market.slippage, opts.exitSlippageFloor ?? DEFAULT_EXIT_SLIPPAGE_FLOOR)
      : intent.market.slippage;
    if (slippage < RECOMMENDED_MIN_IOC_SLIPPAGE) warnings.push('slippage_below_recommended');
    px = marketLimitPrice({ ...grid, mid: ref, side: intent.side, slippage });
    if (triggerPx === undefined) mid = ref;
  } else {
    const requested = intent.price as Numeric;
    const tif = intent.tif ?? 'Gtc';
    const mode = opts.priceRounding ?? 'passive';
    // 'exact' promises that the caller's price goes out unchanged, including Alo quotes (which would
    // otherwise be moved by postOnlyPrice without an error).
    if (mode === 'exact' && !isValidPrice(requested, grid)) {
      invalidArgument(`Price ${String(requested)} of ${coin} is not on the price grid (szDecimals=${asset.szDecimals})`);
    }
    if (tif === 'Alo' && intent.book && !intent.trigger) {
      const book: { bestBid?: Numeric; bestAsk?: Numeric } = {};
      if (intent.book.bestBid !== undefined) book.bestBid = intent.book.bestBid;
      if (intent.book.bestAsk !== undefined) book.bestAsk = intent.book.bestAsk;
      px = postOnlyPrice({ ...grid, ...book, px: requested, side: intent.side });
    } else if (mode === 'exact') {
      px = normalizeDecimal(requested);
    } else {
      px = formatPrice(requested, { ...grid, mode, side: intent.side });
    }
    if (px !== normalizeDecimal(requested) && !warnings.includes('price_rounded')) warnings.push('price_rounded');
  }

  const plan = planOrderSize({
    intent: sizeIntent,
    size: intent.size,
    px,
    szDecimals: asset.szDecimals,
    ...(opts.minNotionalUsd === undefined ? {} : { minNotionalUsd: opts.minNotionalUsd }),
  });
  if (plan.action !== 'place') {
    return 'sz' in plan
      ? { action: plan.action, coin, asset, reason: plan.reason, sz: plan.sz, notional: plan.notional }
      : { action: plan.action, coin, asset, reason: plan.reason };
  }

  const tif: Tif | null = intent.trigger ? null : intent.tif ?? (intent.market ? 'Ioc' : 'Gtc');

  const cloid = pickCloid(intent, opts);
  const wire: WireOrder = {
    a: asset.assetId,
    b: buy,
    p: px,
    s: plan.sz,
    r: reduceOnly,
    t:
      intent.trigger && triggerPx !== undefined
        ? { trigger: { isMarket: intent.trigger.isMarket ?? true, triggerPx, tpsl: intent.trigger.tpsl } }
        : { limit: { tif: tif ?? 'Gtc' } },
  };
  if (cloid) wire.c = cloid;

  // Final self-check with the same validator a caller would use before sending.
  const check = validateOrder({
    ...grid,
    px,
    sz: plan.sz,
    reduceOnly,
    fullClose: sizeIntent === 'fullClose',
    ...(triggerPx === undefined ? {} : { triggerPx }),
    ...(opts.minNotionalUsd === undefined ? {} : { minNotionalUsd: opts.minNotionalUsd }),
  });
  if (!check.ok) {
    throw new HlOrderError('INVALID_ORDER', `Built order for ${coin} failed validation: ${check.issues.join(', ')}`);
  }

  const out: PlaceableOrder = {
    action: 'place',
    coin,
    asset,
    wire,
    side: buy ? 'B' : 'A',
    tif,
    reduceOnly,
    sizeIntent,
    px,
    sz: plan.sz,
    notional: plan.notional,
    bumped: plan.bumped,
    warnings,
    ...(triggerPx === undefined ? {} : { triggerPx }),
    ...(mid === undefined ? {} : { mid }),
    ...(slippage === undefined ? {} : { slippage }),
    ...(cloid ? { cloid } : {}),
  };
  return out;
}

function checkIntentShape(intent: OrderIntentInput, opts: BuildOrderOptions): void {
  if (typeof intent !== 'object' || intent === null) invalidArgument('Order intent must be an object');
  if (typeof intent.coin !== 'string' || intent.coin === '') invalidArgument('Order intent needs a coin');
  if (!SIDES.includes(intent.side as string)) {
    invalidArgument(`Invalid side ${JSON.stringify(intent.side)} (expected 'B' | 'A' | 'buy' | 'sell')`);
  }
  if (intent.tif !== undefined && !TIFS.includes(intent.tif)) {
    invalidArgument(`Invalid tif ${JSON.stringify(intent.tif)} (expected 'Gtc' | 'Ioc' | 'Alo')`);
  }
  if (intent.sizeIntent !== undefined && !INTENTS.includes(intent.sizeIntent)) {
    invalidArgument(`Invalid sizeIntent ${JSON.stringify(intent.sizeIntent)}`);
  }
  const hasPrice = intent.price !== undefined;
  const hasMarket = intent.market !== undefined;
  if (hasPrice === hasMarket) invalidArgument(`Order for ${intent.coin} needs exactly one of price / market`);
  if (hasMarket) {
    if (typeof intent.market !== 'object' || intent.market === null) invalidArgument('market must be an object');
    if (intent.tif !== undefined && intent.tif !== 'Ioc') {
      invalidArgument(`Market order for ${intent.coin} must be Ioc, got ${intent.tif}`);
    }
  }
  if (intent.trigger) {
    if (intent.trigger.tpsl !== 'tp' && intent.trigger.tpsl !== 'sl') invalidArgument(`Invalid trigger.tpsl ${JSON.stringify(intent.trigger.tpsl)}`);
    if (intent.trigger.isMarket !== undefined && typeof intent.trigger.isMarket !== 'boolean') invalidArgument('trigger.isMarket must be a boolean');
    if (intent.tif !== undefined) invalidArgument('tif is not allowed on a trigger order');
    if (intent.market?.mid !== undefined) invalidArgument('market.mid is not used with a trigger: slippage applies to triggerPx');
    if (intent.reduceOnly === undefined && intent.sizeIntent === undefined) {
      // Why: a TP/SL trigger is almost always an exit, and one sent without r:true opens or flips a
      // position when it fires (or when it outlives the position it protected). Stop entries exist
      // too, so the default cannot be guessed safely: the caller must state it.
      invalidArgument(`Trigger order for ${intent.coin} needs an explicit reduceOnly (or sizeIntent)`);
    }
  }
  if (intent.reduceOnly !== undefined && typeof intent.reduceOnly !== 'boolean') invalidArgument('reduceOnly must be a boolean');
  if (opts.priceRounding !== undefined && !ROUNDINGS.includes(opts.priceRounding)) {
    invalidArgument(`Invalid priceRounding ${JSON.stringify(opts.priceRounding)}`);
  }
}

function resolveIntent(intent: OrderIntentInput): { reduceOnly: boolean; sizeIntent: OrderIntent } {
  const reducing = intent.sizeIntent === 'partialReduce' || intent.sizeIntent === 'fullClose';
  if (intent.sizeIntent === undefined) {
    const reduceOnly = intent.reduceOnly === true;
    return { reduceOnly, sizeIntent: reduceOnly ? 'partialReduce' : 'open' };
  }
  if (reducing && intent.reduceOnly === false) {
    // Why: the ceil + bump of a full close is only harmless because reduceOnly clamps the fill;
    // a plain order larger than the position flips it.
    invalidArgument(`sizeIntent ${intent.sizeIntent} requires reduceOnly (got reduceOnly: false)`);
  }
  if (!reducing && intent.reduceOnly === true) {
    invalidArgument(`sizeIntent ${intent.sizeIntent} contradicts reduceOnly: true`);
  }
  return { reduceOnly: reducing, sizeIntent: intent.sizeIntent };
}

function pickCloid(intent: OrderIntentInput, opts: BuildOrderOptions): Cloid | undefined {
  if (intent.cloid === false) return undefined;
  if (intent.cloid !== undefined) return normalizeCloid(intent.cloid);
  if (opts.autoCloid === false) return undefined;
  if (opts.cloidFactory) return normalizeCloid(opts.cloidFactory());
  return createCloid(opts.cloidPrefix === undefined ? {} : { prefix: opts.cloidPrefix });
}
All files