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>
103 lines
2.7 KiB
TypeScript
103 lines
2.7 KiB
TypeScript
import type { ExchangeId } from "../../domain/entities/index.js";
|
|
import { ExchangeConnector } from "./base.js";
|
|
|
|
interface BinanceDepthPayload {
|
|
lastUpdateId?: number;
|
|
bids?: [string, string][];
|
|
asks?: [string, string][];
|
|
}
|
|
|
|
interface BinanceRestDepth {
|
|
lastUpdateId: number;
|
|
bids: [string, string][];
|
|
asks: [string, string][];
|
|
}
|
|
|
|
const REST_URL = "https://api.binance.com/api/v3/depth?symbol=BTCUSDT&limit=10";
|
|
const REST_POLL_MS = 500;
|
|
|
|
/**
|
|
* Binance spot partial depth stream — top 10 levels @ 100ms.
|
|
* Docs: https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams
|
|
* Combined stream URL (no subscribe message). REST polling fills gaps when WS is down.
|
|
*/
|
|
export class BinanceConnector extends ExchangeConnector {
|
|
readonly id: ExchangeId = "binance";
|
|
protected readonly url = "wss://stream.binance.com:9443/ws/btcusdt@depth10@100ms";
|
|
|
|
private restTimer: NodeJS.Timeout | null = null;
|
|
private wsLive = false;
|
|
|
|
protected override skipSubscribe(): boolean {
|
|
return true;
|
|
}
|
|
|
|
protected override subscribeMessage(): unknown {
|
|
return {};
|
|
}
|
|
|
|
override start(): void {
|
|
super.start();
|
|
if (!this.wsLive) this.startRestPoll();
|
|
}
|
|
|
|
override stop(): void {
|
|
this.stopRestPoll();
|
|
super.stop();
|
|
}
|
|
|
|
protected override onConnected(): void {
|
|
this.wsLive = true;
|
|
this.stopRestPoll();
|
|
}
|
|
|
|
protected override onDisconnected(): void {
|
|
this.wsLive = false;
|
|
if (!this.closed) this.startRestPoll();
|
|
}
|
|
|
|
protected override handleMessage(msg: unknown): void {
|
|
const m = msg as BinanceDepthPayload;
|
|
if (!m.bids?.length && !m.asks?.length) return;
|
|
this.applyDepth(m.bids ?? [], m.asks ?? []);
|
|
this.emit(null);
|
|
}
|
|
|
|
private applyDepth(bids: [string, string][], asks: [string, string][]): void {
|
|
this.book.reset();
|
|
for (const [price, size] of bids) {
|
|
this.book.bids.apply(Number(price), Number(size));
|
|
}
|
|
for (const [price, size] of asks) {
|
|
this.book.asks.apply(Number(price), Number(size));
|
|
}
|
|
}
|
|
|
|
private startRestPoll(): void {
|
|
if (this.restTimer || this.closed) return;
|
|
this.restTimer = setInterval(() => {
|
|
if (this.wsLive || this.closed) return;
|
|
void this.pollRest();
|
|
}, REST_POLL_MS);
|
|
void this.pollRest();
|
|
}
|
|
|
|
private stopRestPoll(): void {
|
|
if (!this.restTimer) return;
|
|
clearInterval(this.restTimer);
|
|
this.restTimer = null;
|
|
}
|
|
|
|
private async pollRest(): Promise<void> {
|
|
try {
|
|
const res = await fetch(REST_URL, { signal: AbortSignal.timeout(4000) });
|
|
if (!res.ok) return;
|
|
const data = (await res.json()) as BinanceRestDepth;
|
|
this.applyDepth(data.bids ?? [], data.asks ?? []);
|
|
this.emit(null);
|
|
} catch {
|
|
/* network blip — next poll retries */
|
|
}
|
|
}
|
|
}
|