src/orders/cancel.ts
v0.3.0 · 15.2 KB
// Cancels by oid / cloid and the scheduled cancel-all (dead man's switch).
import {
interpretExchangeError,
invokeAsync,
parseActionResponse,
parseCancelResponse,
recommendRetry,
type CancelStatusResult,
type ExchangeErrorKind,
type ExchangeFailure,
type RetryAdvice,
} from '../errors/index.js';
import { isCloid, normalizeCloid, type Cloid } from './cloid.js';
import { invalidArgument, messageOf } from './errors.js';
import { exchangeCallOptions } from './place.js';
import type { AssetResolver, CancelExchange, ExchangeCallOptions, ScheduleCancelExchange } from './types.js';
/** What to cancel. */
export type CancelTarget = { coin: string; oid: number } | { coin: string; cloid: string };
/** Per-target cancel result. */
export type CancelOutcome =
/** Explicitly canceled. The order may still have partly filled just before. */
| { readonly status: 'success' }
/** `Order was never placed, already canceled, or filled.`: not in the book. The position may have moved. */
| { readonly status: 'alreadyGone'; readonly message: string }
/** Any other rejection: the order MAY still rest. Do not place a replacement until reconciled. */
| { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Response could not be confirmed (empty statuses, unknown shape, count mismatch). */
| { readonly status: 'unconfirmed'; readonly reason: string }
/** Transport outcome unknown. Cancels are idempotent: retrying is safe. */
| { readonly status: 'unknown'; readonly message: string }
/**
* Provably not applied (429 / 4xx), not signed, rejected by SDK validation, or not sent because the
* coin could not be resolved right now (registry unavailable). The order MAY still rest.
*/
| { readonly status: 'not_sent'; readonly message: string; readonly retryable: boolean }
/** Malformed oid / cloid, or a coin the registry proves unlisted; nothing was sent for this target. */
| { readonly status: 'invalid_target'; readonly message: string };
export interface CancelItem {
readonly index: number;
readonly coin: string;
readonly oid?: number;
readonly cloid?: Cloid;
readonly outcome: CancelOutcome;
/** `success` or `alreadyGone`. */
readonly confirmed: boolean;
}
export interface CancelOrdersResult {
readonly items: readonly CancelItem[];
/** Every target is confirmed out of the book. */
readonly allConfirmed: boolean;
/**
* Some order may still rest. Why: reading `{status:'err'}` or empty statuses as "canceled" puts a
* replacement over a live order and duplicates it. Place nothing on that side and coin until a
* reconcile.
*/
readonly needsReconcile: boolean;
/** Worst advice over the sent batches (`null` when all went through). Cancels are idempotent. */
readonly retry: RetryAdvice | null;
readonly failures: readonly ExchangeFailure[];
}
export interface CancelOrdersOptions {
vaultAddress?: string;
expiresAfter?: number;
signal?: AbortSignal;
}
type Pending = { index: number; coin: string; asset: number; oid?: number; cloid?: Cloid };
/**
* Cancels orders by oid (`exchange.cancel`) and by cloid (`exchange.cancelByCloid`), one batch each
* (orders.md §9.1).
*
* Fail-closed: only `'success'` confirms a cancel; `alreadyGone` means "not in the book" (forget it
* locally, but the position may have changed). Everything else keeps the order as possibly live.
* A partially failed batch is parsed element by element. An oid is only sent when it is a positive
* safe integer.
*
* Cancel by cloid is @experimental: supported by SDK 0.33.3 and documented by HL, but not verified
* live. Risk: a cloid cancel that HL answers differently would come back as
* `unconfirmed` / `error`, never as a false success.
*
* Run cancels before placements in a tick: cancels free margin.
*/
export async function cancelOrders(
exchange: CancelExchange,
registry: AssetResolver,
targets: readonly CancelTarget[],
opts: CancelOrdersOptions = {},
): Promise<CancelOrdersResult> {
if (!Array.isArray(targets)) invalidArgument('targets must be an array');
const callOpts = exchangeCallOptions(opts);
const items: CancelItem[] = [];
const byOid: Pending[] = [];
const byCloid: Pending[] = [];
for (let index = 0; index < targets.length; index++) {
const t = targets[index] as CancelTarget & { oid?: unknown; cloid?: unknown };
const coin = typeof t?.coin === 'string' ? t.coin : '';
const invalid = (message: string) => {
items[index] = { index, coin, outcome: { status: 'invalid_target', message }, confirmed: false };
};
const hasOid = t !== null && typeof t === 'object' && 'oid' in t && t.oid !== undefined;
const hasCloid = t !== null && typeof t === 'object' && 'cloid' in t && t.cloid !== undefined;
if (!coin || hasOid === hasCloid) {
invalid('target needs a coin and exactly one of oid / cloid');
continue;
}
if (hasOid && !(typeof t.oid === 'number' && Number.isSafeInteger(t.oid) && t.oid > 0)) {
invalid(`invalid oid ${String(t.oid)}`);
continue;
}
if (hasCloid && !isCloid(t.cloid)) {
invalid(`invalid cloid ${String(t.cloid)}`);
continue;
}
let asset: number;
try {
asset = (await registry.resolve(coin)).assetId;
} catch (err) {
if (isDefinitelyUnlisted(err)) {
invalid(messageOf(err));
} else {
// Why: a failed or unknown meta read does not mean the order is gone. Reporting it as an invalid
// target (no reconcile) would let the caller place a replacement over a live order.
items[index] = {
index,
coin,
...(hasOid ? { oid: t.oid as number } : { cloid: normalizeCloid(t.cloid) }),
outcome: { status: 'not_sent', message: `asset not resolved: ${messageOf(err)}`, retryable: true },
confirmed: false,
};
}
continue;
}
const pending: Pending = hasOid
? { index, coin, asset, oid: t.oid as number }
: { index, coin, asset, cloid: normalizeCloid(t.cloid) };
(hasOid ? byOid : byCloid).push(pending);
items[index] = {
index,
coin,
...(pending.oid === undefined ? {} : { oid: pending.oid }),
...(pending.cloid === undefined ? {} : { cloid: pending.cloid }),
outcome: { status: 'unconfirmed', reason: 'not-processed' },
confirmed: false,
};
}
const failures: ExchangeFailure[] = [];
const advice: RetryAdvice[] = [];
let needsReconcile = false;
const run = async (group: Pending[], call: () => PromiseLike<unknown>) => {
if (group.length === 0) return;
const r = await sendCancelBatch(group.length, call);
group.forEach((p, i) => {
const outcome = r.outcomes[i] ?? { status: 'unconfirmed', reason: 'missing' };
const prev = items[p.index] as CancelItem;
items[p.index] = { ...prev, outcome, confirmed: outcome.status === 'success' || outcome.status === 'alreadyGone' };
});
if (r.failure) failures.push(r.failure);
if (r.retry) advice.push(r.retry);
needsReconcile ||= r.needsReconcile;
};
await run(byOid, () => {
const params = { cancels: byOid.map((p) => ({ a: p.asset, o: p.oid as number })) };
return callOpts ? exchange.cancel(params, callOpts) : exchange.cancel(params);
});
await run(byCloid, () => {
const params = { cancels: byCloid.map((p) => ({ asset: p.asset, cloid: p.cloid as Cloid })) };
return callOpts ? exchange.cancelByCloid(params, callOpts) : exchange.cancelByCloid(params);
});
const allConfirmed = items.length > 0 && items.every((i) => i.confirmed);
const stillMaybeLive = items.some((i) => !i.confirmed && i.outcome.status !== 'invalid_target');
return {
items,
allConfirmed,
needsReconcile: needsReconcile || stillMaybeLive,
retry: worstAdvice(advice),
failures,
};
}
function worstAdvice(list: RetryAdvice[]): RetryAdvice | null {
if (list.includes('reconcile')) return 'reconcile';
if (list.includes('retry')) return 'retry';
if (list.includes('give-up')) return 'give-up';
return null;
}
async function sendCancelBatch(
count: number,
call: () => PromiseLike<unknown>,
): Promise<{ outcomes: CancelOutcome[]; needsReconcile: boolean; retry: RetryAdvice | null; failure?: ExchangeFailure }> {
const fill = (o: CancelOutcome) => Array.from({ length: count }, () => o);
let response: unknown;
try {
response = await invokeAsync(call);
} catch (err) {
const failure = interpretExchangeError(err, { expectedCount: count });
const retry = recommendRetry(err, { idempotent: true });
switch (failure.type) {
case 'partial':
if (failure.batch.kind === 'cancel') {
const results = failure.batch.results;
return {
outcomes: fill({ status: 'unconfirmed', reason: 'missing' }).map((o, i) => (results[i] ? toCancelOutcome(results[i]) : o)),
needsReconcile: failure.batch.needsReconcile,
retry,
failure,
};
}
return { outcomes: fill({ status: 'unconfirmed', reason: 'unexpected batch body' }), needsReconcile: true, retry, failure };
case 'rejected':
return { outcomes: fill({ status: 'error', message: failure.message, kind: failure.kind }), needsReconcile: true, retry, failure };
case 'validation':
case 'signing':
return { outcomes: fill({ status: 'not_sent', message: failure.message, retryable: false }), needsReconcile: true, retry, failure };
case 'transport':
if (failure.info.outcome === 'not-applied') {
return {
outcomes: fill({ status: 'not_sent', message: failure.message, retryable: failure.info.transient }),
needsReconcile: true,
retry,
failure,
};
}
return { outcomes: fill({ status: 'unknown', message: failure.message }), needsReconcile: true, retry, failure };
}
}
const parsed = parseCancelResponse(response, { expectedCount: count });
if (parsed.ok) {
return { outcomes: parsed.results.map(toCancelOutcome), needsReconcile: parsed.needsReconcile, retry: parsed.needsReconcile ? 'retry' : null };
}
if (parsed.reason === 'rejected') {
return { outcomes: fill({ status: 'error', message: parsed.message, kind: parsed.kind }), needsReconcile: true, retry: 'give-up' };
}
return { outcomes: fill({ status: 'unconfirmed', reason: parsed.detail }), needsReconcile: true, retry: 'retry' };
}
function toCancelOutcome(r: CancelStatusResult): CancelOutcome {
switch (r.status) {
case 'success':
return { status: 'success' };
case 'alreadyGone':
return { status: 'alreadyGone', message: r.message };
case 'error':
return { status: 'error', message: r.message, kind: r.kind };
case 'invalid':
return { status: 'unconfirmed', reason: r.reason };
}
}
/** Minimum lead time of a scheduled cancel per HL documentation and the SDK: 5 seconds. */
export const MIN_SCHEDULE_CANCEL_LEAD_MS = 5_000;
export interface ScheduleCancelOptions {
vaultAddress?: string;
expiresAfter?: number;
signal?: AbortSignal;
/** Clock for the lead-time check (tests). Default `Date.now`. */
now?: () => number;
}
/** Result of a `scheduleCancel` call. */
export type ScheduleCancelResult =
| { readonly ok: true; readonly time: number | null }
| {
readonly ok: false;
readonly time: number | null;
/** `rejected` by the exchange, `not-applied`, `unknown` outcome, or a `malformed` answer. */
readonly reason: 'rejected' | 'not-applied' | 'unknown' | 'malformed';
readonly message: string;
/** The action is idempotent: `retry` is safe for transient failures. */
readonly retry: RetryAdvice;
readonly failure?: ExchangeFailure;
};
/**
* Dead man's switch: asks the exchange to cancel ALL open orders of the account at `time` (ms since
* epoch) unless the schedule is renewed or cleared first. Keep renewing it from a healthy process so
* a crashed bot does not leave its book resting.
*
* @experimental Not verified live (the knowledge base records only the resulting `scheduledCancel`
* order status). Per HL documentation and SDK 0.33.3: `time` must be at least 5 s in the future
* (checked locally), and the number of triggers per day is limited by HL. Risk: if the exchange
* ignores or rejects the schedule, orders simply stay; the result is fail-closed (`ok` only on an
* explicit `status: 'ok'`), so never treat a failed call as armed.
*/
export async function scheduleCancel(
exchange: ScheduleCancelExchange,
time: number | Date,
opts: ScheduleCancelOptions = {},
): Promise<ScheduleCancelResult> {
const ms = time instanceof Date ? time.getTime() : time;
const now = (opts.now ?? Date.now)();
if (!Number.isSafeInteger(ms) || ms < now + MIN_SCHEDULE_CANCEL_LEAD_MS) {
invalidArgument(`scheduleCancel time must be an integer ms timestamp at least ${MIN_SCHEDULE_CANCEL_LEAD_MS} ms ahead`);
}
const callOpts = exchangeCallOptions(opts);
return runScheduleCancel(ms, () =>
callOpts ? exchange.scheduleCancel({ time: ms }, callOpts) : exchange.scheduleCancel({ time: ms }),
);
}
/**
* Removes the scheduled cancel-all. @experimental see {@link scheduleCancel}.
*
* With call options it sends `scheduleCancel({ time: undefined }, opts)`. Why: SDK 0.33.3 tells
* params from options by the presence of a `time` key, and `ExchangeClient.scheduleCancel(opts)`
* forwards `{}` as params to the method function, which then takes `{}` as the options and silently
* drops `vaultAddress` / `signal` / `expiresAfter` (the sub-account schedule would stay armed). The
* undefined key is not serialized: the action and its L1 hash are the same as without it (checked in
* sdk-contract.test.ts).
*/
export async function clearScheduledCancel(
exchange: ScheduleCancelExchange,
opts: Omit<ScheduleCancelOptions, 'now'> = {},
): Promise<ScheduleCancelResult> {
const callOpts: ExchangeCallOptions | undefined = exchangeCallOptions(opts);
return runScheduleCancel(null, () =>
callOpts ? exchange.scheduleCancel({ time: undefined }, callOpts) : exchange.scheduleCancel(),
);
}
async function runScheduleCancel(time: number | null, call: () => PromiseLike<unknown>): Promise<ScheduleCancelResult> {
let response: unknown;
try {
response = await invokeAsync(call);
} catch (err) {
const failure = interpretExchangeError(err);
const retry = recommendRetry(err, { idempotent: true });
const reason =
failure.type === 'rejected'
? 'rejected'
: failure.type === 'transport' && failure.info.outcome === 'unknown'
? 'unknown'
: failure.type === 'partial'
? 'malformed'
: 'not-applied';
return { ok: false, time, reason, message: failure.message, retry, failure };
}
const parsed = parseActionResponse(response);
if (parsed.ok) return { ok: true, time };
if (parsed.reason === 'rejected') return { ok: false, time, reason: 'rejected', message: parsed.message, retry: 'give-up' };
return { ok: false, time, reason: 'malformed', message: parsed.detail, retry: 'retry' };
}
/** `true` only for an `AssetRegistryError` that proves the coin is not listed (then no order can exist). */
function isDefinitelyUnlisted(err: unknown): boolean {
const e = err as { name?: unknown; status?: unknown } | null;
return typeof e === 'object' && e !== null && e.name === 'AssetRegistryError' && e.status === 'unlisted';
}