Files
arbpulse_github/src/infrastructure/exchanges/local-book.ts
T
Mauricio Barragan 4ffce9a59e fix(exchanges): truncate Kraken book to subscribed depth + crossed-book guard (WAY-77)
Kraken WS v2 book channel does not send deletes for levels evicted from
its top-N window; without client-side truncation those levels lingered
forever as phantom quotes, eventually crossing the local book (bid >= ask)
and feeding the engine a fake permanent arbitrage (~$62.9M bogus P&L).

- BookSide.truncate() removes levels beyond the best depth prices from
  the internal map (not just the emitted array)
- KrakenConnector uses depth 10 consistently (subscription + LocalBook)
  and truncates both sides after every update
- ExchangeConnector.emit() drops internally crossed books, logs and
  forces a resync (book reset + reconnect for a fresh snapshot)
- Unit tests for truncation and the crossed-book guard
- OpenSpec: order-book-integrity spec; change archived (2026-07-19)

Refs: Linear WAY-77
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 11:43:12 -06:00

77 lines
2.0 KiB
TypeScript

import type { Level } from "../../domain/entities/index.js";
/**
* Maintains one side of an order book from snapshot + incremental deltas.
* Internally a price->qty map; emits a sorted, depth-capped array on demand.
* A qty of 0 removes the price level (standard exchange convention).
*/
export class BookSide {
private levels = new Map<number, number>();
constructor(
private readonly side: "bid" | "ask",
private readonly depth: number,
) {}
clear(): void {
this.levels.clear();
}
apply(price: number, qty: number): void {
if (qty <= 0) {
this.levels.delete(price);
} else {
this.levels.set(price, qty);
}
}
/**
* Remove levels beyond the best `depth` prices from the internal map.
* Required by delta feeds that do NOT send deletes for levels evicted from
* their top-N window (e.g. Kraken v2 `book`): without this, evicted levels
* linger forever as phantom quotes.
*/
truncate(): void {
if (this.levels.size <= this.depth) return;
const prices = [...this.levels.keys()].sort((a, b) =>
this.side === "bid" ? b - a : a - b,
);
for (const price of prices.slice(this.depth)) this.levels.delete(price);
}
/** Sorted (bids desc, asks asc) and capped to `depth` levels. */
toArray(): Level[] {
const arr: Level[] = [];
for (const [price, qty] of this.levels) arr.push({ price, qty });
arr.sort((a, b) =>
this.side === "bid" ? b.price - a.price : a.price - b.price,
);
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
}
get size(): number {
return this.levels.size;
}
}
export class LocalBook {
readonly bids: BookSide;
readonly asks: BookSide;
constructor(depth: number) {
this.bids = new BookSide("bid", depth);
this.asks = new BookSide("ask", depth);
}
reset(): void {
this.bids.clear();
this.asks.clear();
}
/** Truncate both sides to their depth (see BookSide.truncate). */
truncate(): void {
this.bids.truncate();
this.asks.truncate();
}
}