mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-27 15:47:43 +00:00
2e8744ccf0
Real-time BTC cross-exchange arbitrage detection (Kraken, Bybit, OKX, Binance) with React dashboard, GitHub Actions CI, and documented Fly.io deploy workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
4.9 KiB
TypeScript
173 lines
4.9 KiB
TypeScript
import WebSocket from "ws";
|
|
import type { ExchangeId, OrderBook } from "../../domain/entities/index.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 = 15;
|
|
|
|
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;
|
|
|
|
constructor() {
|
|
this.book = new LocalBook(this.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", 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;
|
|
try {
|
|
this.handleMessage(JSON.parse(text));
|
|
} catch (err) {
|
|
this.log.warn("failed to parse message", err);
|
|
}
|
|
});
|
|
|
|
ws.on("error", (err) => {
|
|
this.log.error("socket error", err instanceof Error ? err.message : err);
|
|
});
|
|
|
|
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;
|
|
for (const listener of this.listeners) listener(book);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|