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
co-authored by Cursor
parent 5d84df3e25
commit 4ffce9a59e
12 changed files with 496 additions and 6 deletions
+11 -1
View File
@@ -19,20 +19,29 @@ interface KrakenMessage {
data?: KrakenBookData[];
}
const KRAKEN_BOOK_DEPTH = 10;
/**
* Kraken WebSocket v2 — `book` channel.
* Docs: https://docs.kraken.com/websockets-v2/
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
* Kraken does NOT send deletes for levels evicted from the top-N window: the
* client must truncate its local book to the subscribed depth after every
* update, or evicted levels linger forever as phantom quotes.
*/
export class KrakenConnector extends ExchangeConnector {
readonly id: ExchangeId = "kraken";
protected readonly url = "wss://ws.kraken.com/v2";
private readonly symbol = "BTC/USDT";
constructor() {
super(KRAKEN_BOOK_DEPTH);
}
protected subscribeMessage(): unknown {
return {
method: "subscribe",
params: { channel: "book", symbol: [this.symbol], depth: 10 },
params: { channel: "book", symbol: [this.symbol], depth: this.depth },
};
}
@@ -49,6 +58,7 @@ export class KrakenConnector extends ExchangeConnector {
for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty);
this.book.truncate();
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);