Files
arbpulse_github/src/infrastructure/exchanges/base.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

220 lines
6.4 KiB
TypeScript

import WebSocket from "ws";
import * as Sentry from "@sentry/node";
import type { ExchangeId, OrderBook } from "../../domain/entities/index.js";
import { withSpan } from "../../instrumentation/otel.js";
import { createLogger, type Logger } from "../logging/logger.js";
import type { MarketDataFeed } from "../../domain/ports/ports.js";
import { LocalBook } from "./local-book.js";
export type BookListener = (book: OrderBook) => void;
/**
* Base WebSocket connector with auto-reconnect (exponential backoff),
* heartbeat/ping, and a normalized order book emit. Subclasses implement
* exchange-specific URL, subscription payload, and message parsing.
*/
export abstract class ExchangeConnector implements MarketDataFeed {
abstract readonly id: ExchangeId;
protected abstract readonly url: string;
protected readonly depth: number;
protected ws: WebSocket | null = null;
protected book: LocalBook;
// Initialized in start(), where the subclass `id` field is available.
protected log: Logger = createLogger("ws");
private listeners: BookListener[] = [];
private reconnectAttempts = 0;
private pingTimer: NodeJS.Timeout | null = null;
protected closed = false;
private lastEmitTs = 0;
/** `depth` must match the depth the connector subscribes with. */
constructor(depth = 15) {
this.depth = depth;
this.book = new LocalBook(depth);
}
onBook(listener: BookListener): void {
this.listeners.push(listener);
}
start(): void {
this.closed = false;
this.log = createLogger(`ws:${this.id}`);
this.connect();
}
stop(): void {
this.closed = true;
this.clearPing();
this.ws?.close();
this.ws = null;
}
protected connect(): void {
this.log.info(`connecting to ${this.url}`);
const ws = new WebSocket(this.url);
this.ws = ws;
ws.on("open", () => {
this.reconnectAttempts = 0;
this.book.reset();
this.log.info(
this.skipSubscribe() ? "connected" : "connected, subscribing",
);
if (!this.skipSubscribe()) {
try {
ws.send(JSON.stringify(this.subscribeMessage()));
} catch (err) {
this.log.error("subscribe send failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
this.startPing();
this.onConnected();
});
ws.on("message", (data: WebSocket.RawData) => {
const text = data.toString();
// Some exchanges (OKX) reply to app-level pings with a plain "pong" frame.
if (text === "pong" || text === "ping") return;
void withSpan(
"ws.message",
{ exchange: this.id, bytes: text.length },
async () => {
try {
this.handleMessage(JSON.parse(text));
} catch (err) {
this.log.warn("failed to parse message", {
error: err instanceof Error ? err.message : String(err),
});
}
},
);
});
ws.on("error", (err) => {
const error = err instanceof Error ? err : new Error(String(err));
this.log.error("socket error", { error: error.message });
Sentry.captureException(error, {
tags: { exchange: this.id, component: "ws" },
});
});
ws.on("close", () => {
this.clearPing();
this.onDisconnected();
if (this.closed) return;
this.scheduleReconnect();
});
ws.on("pong", () => {
/* heartbeat ack */
});
}
private scheduleReconnect(): void {
this.reconnectAttempts += 1;
const delay = Math.min(
30_000,
500 * 2 ** Math.min(this.reconnectAttempts, 6),
);
this.log.warn(
`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`,
);
setTimeout(() => {
if (!this.closed) this.connect();
}, delay);
}
private startPing(): void {
this.clearPing();
this.pingTimer = setInterval(() => {
const custom = this.customPing();
if (custom !== null) {
try {
this.ws?.send(custom);
} catch {
/* ignore */
}
} else if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.ping();
}
}, 15_000);
}
private clearPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
/** Emit the current normalized book to listeners. */
protected emit(exchangeTs: number | null): void {
const now = Date.now();
// Throttle emits to avoid flooding (max ~50/s); engine recomputes on each.
if (now - this.lastEmitTs < 20) return;
this.lastEmitTs = now;
const book: OrderBook = {
exchange: this.id,
bids: this.book.bids.toArray(),
asks: this.book.asks.toArray(),
recvTs: now,
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;
}
/** Hook after the WebSocket opens and optional subscribe is sent. */
protected onConnected(): void {}
/** Hook when the WebSocket closes (before reconnect scheduling). */
protected onDisconnected(): void {}
/** Exchange-specific subscribe payload (sent on open). */
protected abstract subscribeMessage(): unknown;
/** Handle one parsed message: update `this.book` and call `emit`. */
protected abstract handleMessage(msg: unknown): void;
/**
* Some exchanges require an application-level ping string instead of a
* protocol ping frame. Return that string, or null to use ws.ping().
*/
protected customPing(): string | null {
return null;
}
}