src/orders/place.ts
v0.3.0 · 18.3 KB
// 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;
}