src/orders/status.ts
v0.3.0 · 9.6 KB
// Order status lookups (`orderStatus`, weight 2) and reconciliation of unknown placement outcomes.
import type { InfoRequester } from '../transport/types.js';
import { isCloid, normalizeCloid, type Cloid } from './cloid.js';
import { describeValue, invalidArgument, messageOf, normalizeAddress } from './errors.js';
import { infoOptions, type OrdersInfoOptions } from './mids.js';
import type { PlaceOrdersResult } from './place.js';
/** `orderStatus` is a light /info request. */
export const ORDER_STATUS_WEIGHT = 2;
/**
* What an `orderStatus` answer means for the caller (orders.md §12):
* - `live`: `open`, or `triggered` (a trigger fired and the order is in flight: re-check);
* - `filled`: executed;
* - `canceled`: any `...Canceled` status or `scheduledCancel` - not executed as such (an order may
* have partially filled before; compare `origSz` and `sz`);
* - `rejected`: `rejected` or any `...Rejected` status;
* - `other`: a status this version does not know; handle conservatively.
*/
export type OrderStatusCategory = 'live' | 'filled' | 'canceled' | 'rejected' | 'other';
/** Normalized `orderStatus` answer. */
export type OrderStatusLookup =
| {
readonly state: 'found';
/** Raw processing status (`open`, `filled`, `canceled`, `badAloPxRejected`...). */
readonly status: string;
readonly category: OrderStatusCategory;
readonly oid: number;
readonly cloid: Cloid | null;
readonly coin: string;
readonly side: 'B' | 'A' | null;
readonly limitPx: string | null;
/** Remaining size as reported. */
readonly sz: string | null;
/** Original size, when reported. */
readonly origSz: string | null;
readonly statusTimestamp: number | null;
readonly raw: unknown;
}
/**
* `unknownOid`. NOT proof that the order never existed or never filled: an oid past HL retention
* answers this too (terminal but ambiguous), and a just-placed order may not be registered yet.
*/
| { readonly state: 'unknownOid' }
/** The read failed (network, 5xx, 429). Change nothing; ask again later. */
| { readonly state: 'query_failed'; readonly message: string; readonly error: unknown }
/** The answer has an unexpected shape. Change nothing. */
| { readonly state: 'invalid_response'; readonly detail: string; readonly raw: unknown };
const OID_RE = /^[1-9]\d{0,15}$/;
function categoryOf(status: string): OrderStatusCategory {
if (status === 'open' || status === 'triggered') return 'live';
if (status === 'filled') return 'filled';
if (status === 'canceled' || status === 'scheduledCancel' || status.endsWith('Canceled')) return 'canceled';
if (status === 'rejected' || status.endsWith('Rejected')) return 'rejected';
return 'other';
}
function str(v: unknown): string | null {
return typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : null;
}
/**
* Reads the status of one order by oid or cloid: `{ type: 'orderStatus', user, oid }` (weight 2).
*
* `user` is the account that owns the order: the sub-account / vault address when trading with
* `vaultAddress`, never the agent address. Lookup by cloid is @experimental: it is documented by HL
* and typed by SDK 0.33.3 but not verified live. Transport failures come
* back as `query_failed` instead of throwing: a failed read must never change local state.
*
* @throws HlOrderError `INVALID_ARGUMENT` for a malformed user, oid or cloid.
*/
export async function fetchOrderStatus(
info: InfoRequester,
user: string,
id: number | string,
opts: OrdersInfoOptions = {},
): Promise<OrderStatusLookup> {
const address = normalizeAddress(user, 'user');
const oid = normalizeOrderId(id);
let raw: unknown;
try {
raw = await info<unknown>({ type: 'orderStatus', user: address, oid }, infoOptions(opts, ORDER_STATUS_WEIGHT));
} catch (error) {
return { state: 'query_failed', message: messageOf(error), error };
}
return parseOrderStatusAnswer(raw);
}
function normalizeOrderId(id: number | string): number | Cloid {
if (typeof id === 'number') {
if (!Number.isSafeInteger(id) || id <= 0) invalidArgument(`Invalid oid ${String(id)}: expected a positive safe integer`);
return id;
}
if (isCloid(id)) return normalizeCloid(id);
if (typeof id === 'string' && OID_RE.test(id) && Number.isSafeInteger(Number(id))) return Number(id);
return invalidArgument(`Invalid order id ${describeValue(id)}: expected an oid or a cloid`);
}
/** Parses a raw `orderStatus` answer. Exported for callers with their own transport. */
export function parseOrderStatusAnswer(raw: unknown): OrderStatusLookup {
if (typeof raw !== 'object' || raw === null) return { state: 'invalid_response', detail: `answer is ${describeValue(raw)}`, raw };
const body = raw as { status?: unknown; order?: unknown };
if (body.status === 'unknownOid') return { state: 'unknownOid' };
if (body.status !== 'order') return { state: 'invalid_response', detail: `status is ${describeValue(body.status)}`, raw };
const wrapper = body.order as { order?: unknown; status?: unknown; statusTimestamp?: unknown } | undefined;
if (typeof wrapper !== 'object' || wrapper === null || typeof wrapper.status !== 'string') {
return { state: 'invalid_response', detail: 'order.status missing', raw };
}
const order = wrapper.order as Record<string, unknown> | undefined;
if (typeof order !== 'object' || order === null) return { state: 'invalid_response', detail: 'order.order missing', raw };
const oid = order.oid;
if (typeof oid !== 'number' || !Number.isSafeInteger(oid) || oid <= 0) {
return { state: 'invalid_response', detail: `bad oid ${describeValue(oid)}`, raw };
}
const side = order.side === 'B' || order.side === 'A' ? order.side : null;
const ts = wrapper.statusTimestamp;
return {
state: 'found',
status: wrapper.status,
category: categoryOf(wrapper.status),
oid,
cloid: isCloid(order.cloid) ? normalizeCloid(order.cloid) : null,
coin: typeof order.coin === 'string' ? order.coin : '',
side,
limitPx: str(order.limitPx),
sz: str(order.sz),
origSz: str(order.origSz),
statusTimestamp: typeof ts === 'number' && Number.isFinite(ts) ? ts : null,
raw,
};
}
/** Result of {@link reconcileByCloid}. */
export type CloidReconciliation =
/** The order reached the exchange; see `lookup.category`. Do not re-send it. */
| { readonly resolution: 'found'; readonly cloid: Cloid; readonly attempts: number; readonly lookup: Extract<OrderStatusLookup, { state: 'found' }> }
/**
* Still `unknownOid` after every attempt. The order was most likely not placed, but this is not
* proof: before re-sending a non-idempotent order, also check `frontendOpenOrders` / fills.
*/
| { readonly resolution: 'not_found'; readonly cloid: Cloid; readonly attempts: number }
/** No trustworthy answer (read failed or malformed). Change nothing; try again on the next tick. */
| { readonly resolution: 'unresolved'; readonly cloid: Cloid; readonly attempts: number; readonly lookup: OrderStatusLookup };
export interface ReconcileOptions extends OrdersInfoOptions {
/** Reads per cloid while the answer is `unknownOid` or failed. Default 2. */
attempts?: number;
/** Pause between reads, ms. Default 500 (an order is not registered instantly). */
delayMs?: number;
/** Injected sleep (tests). */
sleep?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Resolves an `unknown` placement outcome by its cloid (orders.md §10, §12).
*
* Why: after a 5xx or timeout the order may be live; re-sending it blindly can duplicate the
* order. Reads `orderStatus` by cloid up to `attempts` times with `delayMs` between
* reads (a just-placed order may not be visible yet).
*
* @experimental `orderStatus` by cloid is documented by HL and typed by the SDK but not verified
* live. Risk: if HL answered `unknownOid` for a placed cloid, a caller trusting `not_found`
* could duplicate the order; `not_found` is therefore documented as "not proof".
*/
export async function reconcileByCloid(
info: InfoRequester,
user: string,
cloid: string,
opts: ReconcileOptions = {},
): Promise<CloidReconciliation> {
const id = normalizeCloid(cloid);
normalizeAddress(user, 'user');
const attempts = opts.attempts ?? 2;
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 20) invalidArgument(`Invalid attempts ${String(attempts)}`);
const delayMs = opts.delayMs ?? 500;
if (!Number.isFinite(delayMs) || delayMs < 0) invalidArgument(`Invalid delayMs ${String(delayMs)}`);
const sleep = opts.sleep ?? defaultSleep;
let last: OrderStatusLookup = { state: 'unknownOid' };
for (let attempt = 1; attempt <= attempts; attempt++) {
if (attempt > 1) await sleep(delayMs);
last = await fetchOrderStatus(info, user, id, opts);
if (last.state === 'found') return { resolution: 'found', cloid: id, attempts: attempt, lookup: last };
if (last.state === 'invalid_response') return { resolution: 'unresolved', cloid: id, attempts: attempt, lookup: last };
}
return last.state === 'unknownOid'
? { resolution: 'not_found', cloid: id, attempts }
: { resolution: 'unresolved', cloid: id, attempts, lookup: last };
}
/**
* Runs {@link reconcileByCloid} sequentially for every `reconcileCloids` entry of a placement result.
* @experimental see {@link reconcileByCloid}.
*/
export async function reconcilePlacement(
info: InfoRequester,
user: string,
result: Pick<PlaceOrdersResult, 'reconcileCloids'>,
opts: ReconcileOptions = {},
): Promise<CloidReconciliation[]> {
const out: CloidReconciliation[] = [];
for (const cloid of result.reconcileCloids) out.push(await reconcileByCloid(info, user, cloid, opts));
return out;
}