Skip to content
markpaper

src/pending/memory.ts

v0.2.1 · 5.5 KB

Download file
// Memory of OWN confirmed writes on top of a lagging book read (knowledge base: orders.md §7.2, §7.4).
//
// Lighter is a zk-rollup: the active-orders list catches up with your writes seconds later. Code that
// decides what to send from "what I read is what there is" breaks both ways:
//   - placed an order -> next read does not show it yet -> the same order is placed AGAIN (extra
//     protective orders are harmless, extra entries fill more than intended);
//   - cancelled an order -> confirmed -> the read still shows it -> cancelled again, and repeated
//     cancels of one order spend the write budget.
//
// Fix: a short memory (echo TTL ~45 s) of confirmed writes. A duplicate placement is NOT SENT; a
// confirmed cancel is STRUCK OUT of the read. Never insert synthetic orders into the read instead: they
// have no order_index and cannot be cancelled.
//
// The placement key is coin + side + price, without the size: a caller that recomputes the size on
// every pass would otherwise get a new key for the same order and place it again. The key assumes at
// most one live order per price per side.
//
// Code 21734 ("too far from the mark") is structural: the order will not pass until the market comes
// closer. Remember the key for minutes (not forever: the mark moves), so the write budget is not spent
// on the same rejection over and over.

/** Default echo TTL of confirmed writes. */
export const DEFAULT_ECHO_TTL_MS = 45_000;
/** How long a key rejected with 21734 is not retried. */
export const DEFAULT_FAR_FROM_MARK_TTL_MS = 5 * 60_000;

/** Placement key: `coin|B|price` / `coin|A|price`. The price is the ON-GRID STRING, never a float. */
export function placeKey(coin: string, isBuy: boolean, priceStr: string): string {
  return `${coin}|${isBuy ? 'B' : 'A'}|${priceStr}`;
}

export interface PendingMemoryOptions {
  echoTtlMs?: number;
  farFromMarkTtlMs?: number;
  /** Clock, for tests. */
  now?: () => number;
}

export interface PendingCounts {
  placed: number;
  cancelled: number;
  farFromMark: number;
}

export interface PendingMemory {
  /** A resting placement was confirmed (`ok:true`) for this key. IoC orders are never noted. */
  notePlaced(key: string, now?: number): void;
  /** The same key was confirmed recently and the read has not caught up: do not send again. */
  isPlaced(key: string, now?: number): boolean;
  /** The order appeared in the book, or was cancelled: the guess is no longer needed. */
  forgetPlaced(key: string): void;
  /** A cancel was confirmed (`ok:true`) for this exact order id. */
  noteCancelled(orderId: string, now?: number): void;
  /** Confirmed cancelled and waiting for the read to notice: strike out of reads, do not cancel again. */
  isCancelled(orderId: string, now?: number): boolean;
  /** The exchange rejected this key with 21734. */
  noteFarFromMark(key: string, now?: number): void;
  /** Recently rejected by distance to the mark: skip without sending. */
  isFarFromMark(key: string, now?: number): boolean;
  /** Diagnostics: live entries per map after sweeping. */
  counts(now?: number): PendingCounts;
  reset(): void;
}

function sweep(map: Map<string, number>, cutoff: number): void {
  for (const [k, at] of map) if (at <= cutoff) map.delete(k);
}

export function createPendingMemory(opts: PendingMemoryOptions = {}): PendingMemory {
  const echoTtl = opts.echoTtlMs ?? DEFAULT_ECHO_TTL_MS;
  const farTtl = opts.farFromMarkTtlMs ?? DEFAULT_FAR_FROM_MARK_TTL_MS;
  const clock = opts.now ?? Date.now;
  const placed = new Map<string, number>();
  const cancelled = new Map<string, number>();
  const farFromMark = new Map<string, number>();

  return {
    notePlaced(key, now = clock()) {
      sweep(placed, now - echoTtl);
      placed.set(key, now);
    },
    isPlaced(key, now = clock()) {
      sweep(placed, now - echoTtl);
      return placed.has(key);
    },
    forgetPlaced(key) {
      placed.delete(key);
    },
    noteCancelled(orderId, now = clock()) {
      sweep(cancelled, now - echoTtl);
      cancelled.set(String(orderId), now);
    },
    isCancelled(orderId, now = clock()) {
      sweep(cancelled, now - echoTtl);
      return cancelled.has(String(orderId));
    },
    noteFarFromMark(key, now = clock()) {
      sweep(farFromMark, now - farTtl);
      farFromMark.set(key, now);
    },
    isFarFromMark(key, now = clock()) {
      sweep(farFromMark, now - farTtl);
      return farFromMark.has(key);
    },
    counts(now = clock()) {
      sweep(placed, now - echoTtl);
      sweep(cancelled, now - echoTtl);
      sweep(farFromMark, now - farTtl);
      return { placed: placed.size, cancelled: cancelled.size, farFromMark: farFromMark.size };
    },
    reset() {
      placed.clear();
      cancelled.clear();
      farFromMark.clear();
    },
  };
}

/** Minimal shape of a decoded active order for {@link applyPendingToRead}. */
export interface ReadOrderLike {
  /** Exact string id. */
  orderId: string;
  coin: string;
  isBuy: boolean;
  /** On-grid price string as the exchange returned it. */
  priceStr: string;
}

/**
 * Applies the memory to a fresh full read: confirmed-cancelled orders are struck out, and every key
 * that did appear is forgotten from `placed`. Returns the filtered list (input order preserved).
 */
export function applyPendingToRead<T extends ReadOrderLike>(
  orders: readonly T[],
  memory: PendingMemory,
  now?: number,
): T[] {
  const out: T[] = [];
  for (const o of orders) {
    if (memory.isCancelled(o.orderId, now)) continue;
    memory.forgetPlaced(placeKey(o.coin, o.isBuy, o.priceStr));
    out.push(o);
  }
  return out;
}
All files