src/orders/build.ts
v0.3.0 · 17 KB
// 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 });
}