src/ids/exact.ts
v0.2.1 · 6.4 KB
// Exact order ids above 2^53 (knowledge base: orders.md §5).
//
// Lighter numbers orders around 1e16, beyond the exact integer range of a double (2^53 ~ 9.007e15).
// `JSON.parse` rounds `order_index` silently; the response also carries `order_id` as a string, and the
// difference between the two is the lost bit. A cancel by the rounded number is a valid transaction for
// a non-existent order: the exchange answers OK and the order stays. Two different orders can also
// collapse into one number.
//
// The identity of an order is the STRING. This file gives two ways to keep it:
// - `parseJsonExact` re-quotes bare integer values of chosen keys in the raw response text before
// `JSON.parse`, so `order_index` arrives as a string too;
// - `exactOrderId` picks the exact string out of a decoded record and `orderIdPrecision` says whether
// what it found is exact, rounded or missing.
// `String(Number(x)) === String(x)` proves nothing: `x` was rounded when the JSON was parsed.
/** Keys whose bare integer values are re-quoted by {@link parseJsonExact} by default. */
export const EXACT_ID_KEYS: readonly string[] = ['order_index', 'order_id'];
const DIGITS = /^[0-9]+$/;
function isWhitespace(code: number): boolean {
// JSON whitespace: space, tab, LF, CR
return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
}
function isDigit(code: number): boolean {
return code >= 0x30 && code <= 0x39;
}
/**
* Rewrites JSON text so that bare integer VALUES of the given keys become strings:
* `"order_index": 12345678901234565` -> `"order_index": "12345678901234565"` (a synthetic id above 2^53).
*
* A small tokenizer, not a regex: string contents (including escaped quotes and a key-looking text
* inside a value) are never touched, and only a plain integer literal (no `.`, no exponent) directly
* after `key:` is quoted. Values that are already strings, floats, objects or arrays are left alone.
* The output is valid JSON whenever the input was.
*/
export function quoteIntegerFields(text: string, keys: Iterable<string> = EXACT_ID_KEYS): string {
const wanted = new Set(keys);
const n = text.length;
let out = '';
let i = 0;
while (i < n) {
const ch = text[i] as string;
if (ch !== '"') {
out += ch;
i++;
continue;
}
// String token: copy verbatim, honouring escapes.
const start = i;
i++;
while (i < n) {
const c = text[i];
if (c === '\\') {
i += 2;
continue;
}
i++;
if (c === '"') break;
}
const token = text.slice(start, i);
out += token;
// Is it a key? (next non-whitespace is ':')
let j = i;
while (j < n && isWhitespace(text.charCodeAt(j))) j++;
if (text[j] !== ':') continue;
out += text.slice(i, j + 1);
i = j + 1;
let k = i;
while (k < n && isWhitespace(text.charCodeAt(k))) k++;
out += text.slice(i, k);
i = k;
if (!wanted.has(token.slice(1, -1))) continue;
// Integer literal?
let e = i;
if (text[e] === '-') e++;
const digitsStart = e;
while (e < n && isDigit(text.charCodeAt(e))) e++;
if (e === digitsStart) continue;
const next = text[e];
if (next === '.' || next === 'e' || next === 'E') continue; // a float: leave it
out += `"${text.slice(i, e)}"`;
i = e;
}
return out;
}
export interface ParseJsonExactOptions {
/** Keys to protect. Default {@link EXACT_ID_KEYS}. */
keys?: Iterable<string>;
}
/**
* `JSON.parse` of the raw response text with the ids of {@link EXACT_ID_KEYS} preserved as strings.
* Use it on the text of `accountActiveOrders` (and any other response carrying `order_index`).
*/
export function parseJsonExact<T = unknown>(text: string, opts: ParseJsonExactOptions = {}): T {
return JSON.parse(quoteIntegerFields(text, opts.keys ?? EXACT_ID_KEYS)) as T;
}
export interface RawOrderIdFields {
order_id?: unknown;
order_index?: unknown;
}
export type OrderIdPrecision = 'exact' | 'rounded' | 'missing';
/** Digit string or a safe non-negative integer -> canonical digit string; anything else -> null. */
function exactString(v: unknown): string | null {
if (typeof v === 'string') {
const s = v.trim();
return DIGITS.test(s) ? s.replace(/^0+(?=\d)/, '') : null;
}
if (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) return String(v);
return null;
}
/**
* Whether the exact identity of an order can be recovered from a decoded record:
* `exact` when `order_id` or `order_index` is a digit string or a safe integer; `rounded` when only a
* number beyond 2^53 is present (already damaged by `JSON.parse`); `missing` otherwise.
*/
export function orderIdPrecision(raw: RawOrderIdFields): OrderIdPrecision {
if (exactString(raw.order_id) !== null || exactString(raw.order_index) !== null) return 'exact';
for (const v of [raw.order_id, raw.order_index]) {
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return 'rounded';
}
return 'missing';
}
/**
* Exact identity of an order from a decoded record: the string `order_id` first, then a string
* `order_index` (after {@link parseJsonExact}), then a SAFE integer in either field.
*
* With `allowRounded: true` (the knowledge base's fallback) a rounded `order_index` is returned as a
* string so the order stays visible for matching; check {@link orderIdPrecision} before using such a
* value for a cancel. Default `false`: a rounded id yields `null`, and a cancel must not be sent.
*/
export function exactOrderId(raw: RawOrderIdFields, opts: { allowRounded?: boolean } = {}): string | null {
const exact = exactString(raw.order_id) ?? exactString(raw.order_index);
if (exact !== null) return exact;
if (!opts.allowRounded) return null;
for (const v of [raw.order_id, raw.order_index]) {
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return BigInt(Math.round(v)).toString();
}
return null;
}
/** True when `id` survives the round trip through a double (i.e. `Number(id)` is a safe integer equal to it). */
export function isSafeOrderId(id: string): boolean {
const n = Number(id);
return Number.isSafeInteger(n) && String(n) === id;
}
/** Compares the string `order_id` with the numeric `order_index` of a decoded record: true when they differ (a lost bit). */
export function orderIndexWasRounded(raw: RawOrderIdFields): boolean {
const exact = exactString(raw.order_id);
if (exact === null || typeof raw.order_index !== 'number') return false;
return BigInt(Math.round(raw.order_index)).toString() !== exact;
}