src/pending/memory.ts
v0.2.1 · 5.5 KB
// 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;
}