src/errors/statuses.ts
v0.3.0 · 17.5 KB
// Fail-closed parsing of /exchange response bodies.
//
// Success is only an EXPLICIT per-element confirmation. `{ status: 'ok', statuses: [] }`, a missing
// `statuses`, or an element of an unknown shape are "not confirmed" - and "not confirmed" does not
// mean "never reached the exchange": reconcile against the book before acting. Reading
// `{status:'err'}` or empty statuses as a successful cancel places a replacement over a live order
// and duplicates it.
import { classifyExchangeError, type ExchangeErrorKind } from './classify.js';
import { addDecimal, compareDecimal, parseDecimal, PLAIN_UNSIGNED_DECIMAL, toPlainString, type Dec } from './decimal.js';
/** Overfill tolerance from the knowledge base: `totalSz > requestedSz + 1e-9` is not trusted. */
const OVERFILL_TOLERANCE: Dec = { units: 1n, scale: 9 };
/** Why an element could not be confirmed. */
export type InvalidStatusReason =
| 'unknown-shape'
| 'missing'
| 'bad-oid'
| 'bad-fill-size'
| 'bad-fill-price'
| 'overfill';
/** String statuses that accept an order without returning its oid. */
export type AcceptedWithoutOid = 'resting' | 'waitingForFill' | 'waitingForTrigger';
/** One normalized element of `response.data.statuses` for `order` / `batchModify`. */
export type OrderStatusResult =
/** Resting in the book. */
| { readonly status: 'resting'; readonly oid: number; readonly cloid?: `0x${string}` }
/** Filled immediately (for IoC possibly partially). Sizes and prices stay exact decimal strings. */
| {
readonly status: 'filled';
readonly oid: number;
readonly totalSz: string;
readonly avgPx: string;
readonly cloid?: `0x${string}`;
}
/**
* Accepted, but the exchange answered with a bare string and no oid. `grouping: 'positionTpsl'`
* returns `'waitingForTrigger'` / `'resting'` like this (observed live); SDK 0.33.3 types also
* list `'waitingForFill'`. Find the oid via `frontendOpenOrders` (coin, isTrigger, reduceOnly,
* orderType, triggerPx).
*/
| { readonly status: 'accepted'; readonly detail: AcceptedWithoutOid }
/** Rejected by the exchange. */
| { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Could not be confirmed either way: reconcile. */
| { readonly status: 'invalid'; readonly reason: InvalidStatusReason; readonly raw: unknown };
/** One normalized element of `response.data.statuses` for `cancel` / `cancelByCloid`. */
export type CancelStatusResult =
/** Explicitly canceled. A fill may still have happened just before the cancel. */
| { readonly status: 'success' }
/**
* `Order was never placed, already canceled, or filled.` - the order is not in the book. Forget it
* locally instead of cancelling forever, but the position may have moved (it may have filled).
*/
| { readonly status: 'alreadyGone'; readonly message: string }
/** Any other rejection: the order may still rest. Keep it and do not place a replacement until reconciled. */
| { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Unknown shape or missing element: not confirmed. */
| { readonly status: 'invalid'; readonly reason: 'unknown-shape' | 'missing'; readonly raw: unknown };
/** Whole-response parse result. */
export type ExchangeResponseParse<R> =
| {
readonly ok: true;
readonly results: readonly R[];
/** `true` when any element is `invalid` or the element count differs from the request. */
readonly needsReconcile: boolean;
}
/** `{ status: 'err', response: '<text>' }`: the whole action was rejected, nothing was applied. */
| { readonly ok: false; readonly reason: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
/** Anything else (no `statuses`, empty `statuses`, not `status: 'ok'`): NOT confirmed, reconcile. */
| { readonly ok: false; readonly reason: 'malformed'; readonly detail: string };
/** Options for order status parsing. */
export interface OrderStatusParseOptions {
/**
* Requested size of each order, in batch order (decimal string or number). Enables the overfill
* check: a `filled.totalSz` above the requested size is not trusted. Also sets the expected count.
*/
readonly requestedSizes?: ReadonlyArray<string | number | undefined>;
/** Number of orders sent. Missing elements become `invalid` / `missing`. */
readonly expectedCount?: number;
}
type Obj = Record<string, unknown>;
function isObj(v: unknown): v is Obj {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
const CLOID = /^0x[0-9a-f]{32}$/i;
/**
* An oid must be a positive safe integer. Ids above 2^53 are silently rounded by `JSON.parse`, and a
* cancel by a rounded id "succeeds" while the order stays in the book, so an unsafe value is
* rejected instead of being trusted. Digit strings are accepted for callers
* that parse JSON losslessly.
*/
function parseOid(v: unknown): number | null {
if (typeof v === 'number') return Number.isSafeInteger(v) && v > 0 ? v : null;
if (typeof v === 'string' && /^[1-9]\d{0,15}$/.test(v)) {
const n = Number(v);
return Number.isSafeInteger(n) ? n : null;
}
return null;
}
function parseCloid(v: unknown): `0x${string}` | undefined {
return typeof v === 'string' && CLOID.test(v) ? (v.toLowerCase() as `0x${string}`) : undefined;
}
/** Positive decimal kept as the exchange string (or rendered from a number without exponent). */
function positiveDecimal(v: unknown): string | null {
let text: string;
if (typeof v === 'string') {
if (!PLAIN_UNSIGNED_DECIMAL.test(v)) return null; // 'Infinity', '-1', '1e3', '' are not a fill
text = v;
} else if (typeof v === 'number') {
const d = parseDecimal(v);
if (!d) return null;
text = toPlainString(d);
} else {
return null;
}
const d = parseDecimal(text);
return d && d.units > 0n ? text : null;
}
function invalid(reason: InvalidStatusReason, raw: unknown): OrderStatusResult {
return { status: 'invalid', reason, raw };
}
function singleKey(o: Obj, keys: readonly string[]): string | null {
const present = keys.filter((k) => k in o);
return present.length === 1 ? (present[0] as string) : null;
}
/**
* Normalizes one `statuses[i]` element of an `order` / `batchModify` response.
*
* - `{ resting: { oid, cloid? } }` -> `resting`; `{ filled: { oid, totalSz, avgPx, cloid? } }` -> `filled`;
* - `'resting' | 'waitingForFill' | 'waitingForTrigger'` -> `accepted` (no oid);
* - `{ error: string }` -> `error` with a classified `kind`;
* - anything else -> `invalid` (fail-closed). A fill with a non-numeric, zero or negative `totalSz`
* / `avgPx`, a bad oid, or `totalSz` above `requestedSz` is `invalid`, never a trusted fill.
*/
export function parseOrderStatus(raw: unknown, requestedSz?: string | number): OrderStatusResult {
if (raw === 'resting' || raw === 'waitingForFill' || raw === 'waitingForTrigger') {
return { status: 'accepted', detail: raw };
}
if (!isObj(raw)) return invalid('unknown-shape', raw);
const key = singleKey(raw, ['resting', 'filled', 'error']);
if (key === null) return invalid('unknown-shape', raw);
if (key === 'error') {
const message = raw.error;
if (typeof message !== 'string') return invalid('unknown-shape', raw);
return { status: 'error', message, kind: classifyExchangeError(message) };
}
const body = raw[key];
if (!isObj(body)) return invalid('unknown-shape', raw);
const oid = parseOid(body.oid);
if (oid === null) return invalid('bad-oid', raw);
const cloid = parseCloid(body.cloid);
if (key === 'resting') {
return cloid ? { status: 'resting', oid, cloid } : { status: 'resting', oid };
}
const totalSz = positiveDecimal(body.totalSz);
if (totalSz === null) return invalid('bad-fill-size', raw);
const avgPx = positiveDecimal(body.avgPx);
if (avgPx === null) return invalid('bad-fill-price', raw);
if (requestedSz !== undefined) {
const req = parseDecimal(requestedSz);
const got = parseDecimal(totalSz);
// A fill larger than the order is impossible; trusting it would corrupt position accounting.
// - Tolerance 1e-9 (knowledge base): a requested size computed as a float (`0.28999999999999997`
// for an order sent as "0.29") must not turn a normal full fill into "invalid".
// - A requested size <= 0 has no upper bound: `s: '0'` on a positionTpsl order tracks the whole
// position, so any fill is legitimate.
if (req && got && req.units > 0n && compareDecimal(got, addDecimal(req, OVERFILL_TOLERANCE)) > 0) {
return invalid('overfill', raw);
}
}
return cloid
? { status: 'filled', oid, totalSz, avgPx, cloid }
: { status: 'filled', oid, totalSz, avgPx };
}
/**
* Normalizes a `statuses` array of an `order` / `batchModify` response; `statuses[i]` belongs to the
* i-th order sent. A non-array gives `[]` (use {@link parseOrderResponse} to tell that apart).
* With `expectedCount` / `requestedSizes`, missing trailing elements become `invalid` / `missing`.
*/
export function parseOrderStatuses(
statuses: unknown,
opts: OrderStatusParseOptions = {},
): OrderStatusResult[] {
const list: readonly unknown[] = Array.isArray(statuses) ? statuses : [];
const expected = opts.expectedCount ?? opts.requestedSizes?.length;
const results = list.map((s, i) => parseOrderStatus(s, opts.requestedSizes?.[i]));
if (expected !== undefined) {
for (let i = results.length; i < expected; i++) results.push(invalid('missing', undefined));
}
return results;
}
/** Oids of orders that were placed (resting or filled). Use it to keep track of orders from a failed batch. */
export function placedOids(results: readonly OrderStatusResult[]): number[] {
const oids: number[] = [];
for (const r of results) if (r.status === 'resting' || r.status === 'filled') oids.push(r.oid);
return oids;
}
interface Located {
readonly statuses: unknown[] | null;
readonly rejected: { message: string; kind: ExchangeErrorKind } | null;
readonly detail: string;
}
function rejectionText(response: unknown): string {
if (typeof response === 'string') return response;
try {
return JSON.stringify(response) ?? String(response);
} catch {
return String(response);
}
}
function locateStatuses(body: unknown): Located {
if (!isObj(body)) return { statuses: null, rejected: null, detail: 'body is not an object' };
if (body.status === 'err') {
const message = rejectionText(body.response);
return { statuses: null, rejected: { message, kind: classifyExchangeError(message) }, detail: '' };
}
if (body.status !== 'ok') return { statuses: null, rejected: null, detail: `status is ${rejectionText(body.status)}` };
const response = body.response;
const data = isObj(response) ? response.data : undefined;
const statuses = isObj(data) ? data.statuses : undefined;
if (!Array.isArray(statuses)) return { statuses: null, rejected: null, detail: 'no statuses array' };
return { statuses, rejected: null, detail: '' };
}
function parseBatch<R extends { status: string }>(
body: unknown,
expected: number | undefined,
parseAll: (statuses: unknown[]) => R[],
): ExchangeResponseParse<R> {
const loc = locateStatuses(body);
if (loc.rejected) return { ok: false, reason: 'rejected', ...loc.rejected };
if (!loc.statuses) return { ok: false, reason: 'malformed', detail: loc.detail };
// `{status:'ok', statuses:[]}` is a trap: it confirms nothing.
if (loc.statuses.length === 0 && expected !== 0) {
return { ok: false, reason: 'malformed', detail: 'empty statuses' };
}
const results = parseAll(loc.statuses);
const countMismatch = expected !== undefined && loc.statuses.length !== expected;
return { ok: true, results, needsReconcile: countMismatch || results.some((r) => r.status === 'invalid') };
}
/**
* Parses a whole `order` / `batchModify` body (a successful SDK result or the `response` of an
* `ApiRequestError`), fail-closed:
*
* - `status: 'err'` -> `{ ok: false, reason: 'rejected' }`: nothing applied;
* - `status` other than `'ok'`, no `statuses`, or an empty `statuses` -> `malformed`: not confirmed;
* - otherwise per-element results, with `needsReconcile` set if anything is unconfirmed.
*/
export function parseOrderResponse(
body: unknown,
opts: OrderStatusParseOptions = {},
): ExchangeResponseParse<OrderStatusResult> {
const expected = opts.expectedCount ?? opts.requestedSizes?.length;
return parseBatch(body, expected, (statuses) => parseOrderStatuses(statuses, opts));
}
/**
* Normalizes one cancel status. Only the literal `'success'` confirms a cancel; `alreadyGone` means
* the order is not in the book; every other shape is `error` / `invalid` and must block a
* replacement on that side until the book is reconciled.
*/
export function parseCancelStatus(raw: unknown): CancelStatusResult {
if (raw === 'success') return { status: 'success' };
if (isObj(raw) && typeof raw.error === 'string' && Object.keys(raw).length === 1) {
const kind = classifyExchangeError(raw.error);
return kind === 'orderNotFound'
? { status: 'alreadyGone', message: raw.error }
: { status: 'error', message: raw.error, kind };
}
return { status: 'invalid', reason: 'unknown-shape', raw };
}
/** Normalizes a cancel `statuses` array; missing elements up to `expectedCount` become `invalid`. */
export function parseCancelStatuses(statuses: unknown, opts: { expectedCount?: number } = {}): CancelStatusResult[] {
const list: readonly unknown[] = Array.isArray(statuses) ? statuses : [];
const results = list.map(parseCancelStatus);
if (opts.expectedCount !== undefined) {
for (let i = results.length; i < opts.expectedCount; i++) {
results.push({ status: 'invalid', reason: 'missing', raw: undefined });
}
}
return results;
}
/** Parses a whole `cancel` / `cancelByCloid` body, fail-closed (see {@link parseOrderResponse}). */
export function parseCancelResponse(
body: unknown,
opts: { expectedCount?: number } = {},
): ExchangeResponseParse<CancelStatusResult> {
return parseBatch(body, opts.expectedCount, (statuses) => parseCancelStatuses(statuses, opts));
}
/** `true` when the order is confirmed out of the book (`success` or `alreadyGone`). */
export function isCancelConfirmed(result: CancelStatusResult): boolean {
return result.status === 'success' || result.status === 'alreadyGone';
}
/** Normalized `twapOrder` / `twapCancel` result. */
export type TwapStatusResult =
| { readonly status: 'running'; readonly twapId: number }
| { readonly status: 'success' }
| { readonly status: 'error'; readonly message: string; readonly kind: ExchangeErrorKind }
| { readonly status: 'invalid'; readonly detail: string };
/**
* Parses a `twapOrder` / `twapCancel` body. TWAP errors come with the OUTER status still `'ok'` and
* the text inside `response.data.status.error` (e.g. `Invalid TWAP duration: 1 min(s)`), so checking
* only the outer status reports a rejected TWAP as placed.
*/
export function parseTwapResponse(body: unknown): TwapStatusResult {
if (!isObj(body)) return { status: 'invalid', detail: 'body is not an object' };
if (body.status === 'err') {
const message = rejectionText(body.response);
return { status: 'error', message, kind: classifyExchangeError(message) };
}
if (body.status !== 'ok') return { status: 'invalid', detail: 'status is not ok' };
const data = isObj(body.response) ? body.response.data : undefined;
const st = isObj(data) ? data.status : undefined;
if (st === 'success') return { status: 'success' };
if (isObj(st)) {
if (typeof st.error === 'string') return { status: 'error', message: st.error, kind: classifyExchangeError(st.error) };
const running = st.running;
if (isObj(running)) {
const id = running.twapId;
if (typeof id === 'number' && Number.isSafeInteger(id) && id >= 0) return { status: 'running', twapId: id };
return { status: 'invalid', detail: 'bad twapId' };
}
}
return { status: 'invalid', detail: 'unknown twap status shape' };
}
/** Result of a single-effect action (`updateLeverage`, `approveAgent`, `scheduleCancel`, transfers...). */
export type ActionResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: 'rejected'; readonly message: string; readonly kind: ExchangeErrorKind }
| { readonly ok: false; readonly reason: 'malformed'; readonly detail: string };
/**
* Checks the body of an action that answers `{ status: 'ok', response: { type: 'default' } }`.
*
* Always check it: a silently swallowed failed `updateLeverage` keeps opening positions at the
* account's previous leverage. Only `status: 'ok'` without embedded errors
* (`data.statuses[i].error`, `data.status.error`) is success.
*/
export function parseActionResponse(body: unknown): ActionResult {
if (!isObj(body)) return { ok: false, reason: 'malformed', detail: 'body is not an object' };
if (body.status === 'err') {
const message = rejectionText(body.response);
return { ok: false, reason: 'rejected', message, kind: classifyExchangeError(message) };
}
if (body.status !== 'ok') return { ok: false, reason: 'malformed', detail: 'status is not ok' };
const data = isObj(body.response) ? body.response.data : undefined;
if (isObj(data)) {
if (isObj(data.status) && typeof data.status.error === 'string') {
return { ok: false, reason: 'rejected', message: data.status.error, kind: classifyExchangeError(data.status.error) };
}
if (Array.isArray(data.statuses)) {
const firstError = data.statuses.find((s): s is { error: string } => isObj(s) && typeof s.error === 'string');
if (firstError) {
return { ok: false, reason: 'rejected', message: firstError.error, kind: classifyExchangeError(firstError.error) };
}
}
}
return { ok: true };
}