Skip to content
markpaper

src/ids/exact.ts

v0.2.1 · 6.4 KB

Download file
// 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;
}
All files