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>
This commit is contained in:
Mauricio Barragan
2026-07-19 11:43:12 -06:00
parent 5d84df3e25
commit 4ffce9a59e
12 changed files with 496 additions and 6 deletions
+27 -3
View File
@@ -16,7 +16,7 @@ export type BookListener = (book: OrderBook) => void;
export abstract class ExchangeConnector implements MarketDataFeed {
abstract readonly id: ExchangeId;
protected abstract readonly url: string;
protected readonly depth = 15;
protected readonly depth: number;
protected ws: WebSocket | null = null;
protected book: LocalBook;
@@ -29,8 +29,10 @@ export abstract class ExchangeConnector implements MarketDataFeed {
protected closed = false;
private lastEmitTs = 0;
constructor() {
this.book = new LocalBook(this.depth);
/** `depth` must match the depth the connector subscribes with. */
constructor(depth = 15) {
this.depth = depth;
this.book = new LocalBook(depth);
}
onBook(listener: BookListener): void {
@@ -165,9 +167,31 @@ export abstract class ExchangeConnector implements MarketDataFeed {
exchangeTs,
};
if (book.bids.length === 0 || book.asks.length === 0) return;
// A crossed book (best bid >= best ask) is impossible on a spot venue:
// it means our local copy is corrupted (e.g. phantom levels). Never feed
// it to the engine — drop it and force a fresh snapshot via reconnect.
const bestBid = book.bids[0]!;
const bestAsk = book.asks[0]!;
if (bestBid.price >= bestAsk.price) {
this.log.warn("crossed local book detected, forcing resync", {
bid: bestBid.price,
ask: bestAsk.price,
});
this.resync();
return;
}
for (const listener of this.listeners) listener(book);
}
/** Drop the corrupted local book and reconnect to receive a fresh snapshot. */
protected resync(): void {
this.book.reset();
// close() triggers the existing reconnect-with-backoff path (unless stopped).
this.ws?.close();
}
/** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
protected skipSubscribe(): boolean {
return false;