mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-29 00:27:44 +00:00
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:
@@ -0,0 +1,72 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
|
||||
/** Minimal connector for exercising emit() without a real WebSocket. */
|
||||
class TestConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "kraken";
|
||||
protected readonly url = "ws://unused";
|
||||
resyncCount = 0;
|
||||
|
||||
constructor() {
|
||||
super(5);
|
||||
}
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected handleMessage(): void {}
|
||||
|
||||
protected override resync(): void {
|
||||
this.resyncCount += 1;
|
||||
super.resync();
|
||||
}
|
||||
|
||||
applyLevels(bids: [number, number][], asks: [number, number][]): void {
|
||||
for (const [price, qty] of bids) this.book.bids.apply(price, qty);
|
||||
for (const [price, qty] of asks) this.book.asks.apply(price, qty);
|
||||
}
|
||||
|
||||
emitNow(): void {
|
||||
this.emit(null);
|
||||
}
|
||||
|
||||
get bookSize(): number {
|
||||
return this.book.bids.size + this.book.asks.size;
|
||||
}
|
||||
}
|
||||
|
||||
test("normal book is emitted to listeners", () => {
|
||||
const c = new TestConnector();
|
||||
const received: OrderBook[] = [];
|
||||
c.onBook((b) => received.push(b));
|
||||
|
||||
c.applyLevels([[64560, 1]], [[64561, 1]]);
|
||||
c.emitNow();
|
||||
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]?.bids[0]?.price, 64560);
|
||||
assert.equal(c.resyncCount, 0);
|
||||
});
|
||||
|
||||
test("crossed book is not emitted and triggers resync", () => {
|
||||
const c = new TestConnector();
|
||||
const received: OrderBook[] = [];
|
||||
c.onBook((b) => received.push(b));
|
||||
|
||||
// Phantom bid above the real ask: corrupted local book.
|
||||
c.applyLevels(
|
||||
[
|
||||
[64925.9, 0.15],
|
||||
[64560, 1],
|
||||
],
|
||||
[[64561, 1]],
|
||||
);
|
||||
c.emitNow();
|
||||
|
||||
assert.equal(received.length, 0, "corrupted book must not reach listeners");
|
||||
assert.equal(c.resyncCount, 1);
|
||||
assert.equal(c.bookSize, 0, "local book is reset for a fresh snapshot");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BookSide, LocalBook } from "./local-book.js";
|
||||
|
||||
test("truncate removes worst bid levels beyond depth", () => {
|
||||
const bids = new BookSide("bid", 3);
|
||||
for (const price of [100, 101, 102]) bids.apply(price, 1);
|
||||
// A better bid arrives; Kraken sends no delete for the evicted 100.
|
||||
bids.apply(103, 1);
|
||||
bids.truncate();
|
||||
|
||||
assert.equal(bids.size, 3);
|
||||
assert.deepEqual(
|
||||
bids.toArray().map((l) => l.price),
|
||||
[103, 102, 101],
|
||||
);
|
||||
});
|
||||
|
||||
test("truncate removes worst ask levels beyond depth", () => {
|
||||
const asks = new BookSide("ask", 3);
|
||||
for (const price of [100, 101, 102]) asks.apply(price, 1);
|
||||
asks.apply(99, 1);
|
||||
asks.truncate();
|
||||
|
||||
assert.equal(asks.size, 3);
|
||||
assert.deepEqual(
|
||||
asks.toArray().map((l) => l.price),
|
||||
[99, 100, 101],
|
||||
);
|
||||
});
|
||||
|
||||
test("truncate is a no-op when size <= depth", () => {
|
||||
const bids = new BookSide("bid", 5);
|
||||
bids.apply(100, 1);
|
||||
bids.apply(101, 2);
|
||||
bids.truncate();
|
||||
|
||||
assert.equal(bids.size, 2);
|
||||
assert.deepEqual(
|
||||
bids.toArray().map((l) => l.price),
|
||||
[101, 100],
|
||||
);
|
||||
});
|
||||
|
||||
test("phantom high bid disappears across a rally-then-drop (Kraken window)", () => {
|
||||
const depth = 3;
|
||||
const book = new LocalBook(depth);
|
||||
|
||||
// Snapshot during a rally.
|
||||
book.bids.apply(64920, 1);
|
||||
book.bids.apply(64921, 1);
|
||||
book.bids.apply(64922, 1);
|
||||
book.truncate();
|
||||
|
||||
// Higher bids push the low ones out of Kraken's top-3 window. Kraken sends
|
||||
// NO delete for the evicted 64920/64921 — only the client-side truncate
|
||||
// removes them. Without it they linger as phantoms.
|
||||
book.bids.apply(64924, 1);
|
||||
book.bids.apply(64925.9, 0.15);
|
||||
book.truncate();
|
||||
|
||||
// Market drops: in-window levels get explicit qty-0 deletes and lower bids
|
||||
// enter the window.
|
||||
book.bids.apply(64925.9, 0);
|
||||
book.bids.apply(64924, 0);
|
||||
book.bids.apply(64922, 0);
|
||||
book.bids.apply(64564, 1);
|
||||
book.bids.apply(64563, 1);
|
||||
book.bids.apply(64562, 1);
|
||||
book.truncate();
|
||||
|
||||
const top = book.bids.toArray();
|
||||
assert.equal(top.length, depth);
|
||||
assert.deepEqual(
|
||||
top.map((l) => l.price),
|
||||
[64564, 64563, 64562],
|
||||
);
|
||||
assert.ok(
|
||||
top.every((l) => l.price < 64900),
|
||||
"no phantom bid survives",
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,10 @@ import type { Level } from "../../domain/entities/index.js";
|
||||
export class BookSide {
|
||||
private levels = new Map<number, number>();
|
||||
|
||||
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
|
||||
constructor(
|
||||
private readonly side: "bid" | "ask",
|
||||
private readonly depth: number,
|
||||
) {}
|
||||
|
||||
clear(): void {
|
||||
this.levels.clear();
|
||||
@@ -22,11 +25,27 @@ export class BookSide {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -48,4 +67,10 @@ export class LocalBook {
|
||||
this.bids.clear();
|
||||
this.asks.clear();
|
||||
}
|
||||
|
||||
/** Truncate both sides to their depth (see BookSide.truncate). */
|
||||
truncate(): void {
|
||||
this.bids.truncate();
|
||||
this.asks.truncate();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user