mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-02 02:17:42 +00:00
Initial commit: Arb Pulse monolith with CI and optional Fly deploy.
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>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ExchangeId } from "../../domain/entities/index.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
|
||||
interface BybitBookData {
|
||||
s: string;
|
||||
b: [string, string][];
|
||||
a: [string, string][];
|
||||
u: number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
interface BybitMessage {
|
||||
topic?: string;
|
||||
type?: "snapshot" | "delta";
|
||||
data?: BybitBookData;
|
||||
op?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bybit WebSocket v5 — spot `orderbook.50` channel.
|
||||
* Docs: https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook
|
||||
* Snapshot replaces the book; delta patches levels (size "0" = remove).
|
||||
*/
|
||||
export class BybitConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "bybit";
|
||||
protected readonly url = "wss://stream.bybit.com/v5/public/spot";
|
||||
private readonly symbol = "BTCUSDT";
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return { op: "subscribe", args: [`orderbook.50.${this.symbol}`] };
|
||||
}
|
||||
|
||||
protected override customPing(): string | null {
|
||||
return JSON.stringify({ op: "ping" });
|
||||
}
|
||||
|
||||
protected handleMessage(msg: unknown): void {
|
||||
const m = msg as BybitMessage;
|
||||
if (m.op === "pong" || m.op === "subscribe" || m.op === "ping") return;
|
||||
if (!m.topic || !m.topic.startsWith("orderbook") || !m.data) return;
|
||||
|
||||
if (m.type === "snapshot") {
|
||||
this.book.reset();
|
||||
}
|
||||
|
||||
for (const [price, size] of m.data.b ?? []) {
|
||||
this.book.bids.apply(Number(price), Number(size));
|
||||
}
|
||||
for (const [price, size] of m.data.a ?? []) {
|
||||
this.book.asks.apply(Number(price), Number(size));
|
||||
}
|
||||
|
||||
this.emit(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ExchangeId } from "../../domain/entities/index.js";
|
||||
import type { MarketDataFeed, MarketDataFeedFactory } from "../../domain/ports/ports.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
import { KrakenConnector } from "./kraken.js";
|
||||
import { BybitConnector } from "./bybit.js";
|
||||
import { OkxConnector } from "./okx.js";
|
||||
import { BinanceConnector } from "./binance.js";
|
||||
|
||||
export function createConnector(id: ExchangeId): ExchangeConnector {
|
||||
switch (id) {
|
||||
case "kraken":
|
||||
return new KrakenConnector();
|
||||
case "bybit":
|
||||
return new BybitConnector();
|
||||
case "okx":
|
||||
return new OkxConnector();
|
||||
case "binance":
|
||||
return new BinanceConnector();
|
||||
default: {
|
||||
const _exhaustive: never = id;
|
||||
throw new Error(`unknown exchange: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConnectors(): ExchangeConnector[] {
|
||||
return [createConnector("kraken"), createConnector("bybit"), createConnector("okx"), createConnector("binance")];
|
||||
}
|
||||
|
||||
/** Real-feed factory: builds a live WS connector per exchange. */
|
||||
export class ConnectorFactory implements MarketDataFeedFactory {
|
||||
create(id: ExchangeId): MarketDataFeed {
|
||||
return createConnector(id);
|
||||
}
|
||||
}
|
||||
|
||||
export { ExchangeConnector };
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ExchangeId } from "../../domain/entities/index.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
|
||||
interface KrakenLevel {
|
||||
price: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
interface KrakenBookData {
|
||||
symbol: string;
|
||||
bids: KrakenLevel[];
|
||||
asks: KrakenLevel[];
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface KrakenMessage {
|
||||
channel?: string;
|
||||
type?: "snapshot" | "update";
|
||||
data?: KrakenBookData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kraken WebSocket v2 — `book` channel.
|
||||
* Docs: https://docs.kraken.com/websockets-v2/
|
||||
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove).
|
||||
*/
|
||||
export class KrakenConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "kraken";
|
||||
protected readonly url = "wss://ws.kraken.com/v2";
|
||||
private readonly symbol = "BTC/USDT";
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return {
|
||||
method: "subscribe",
|
||||
params: { channel: "book", symbol: [this.symbol], depth: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
protected handleMessage(msg: unknown): void {
|
||||
const m = msg as KrakenMessage;
|
||||
if (m.channel !== "book" || !Array.isArray(m.data)) return;
|
||||
|
||||
const data = m.data[0];
|
||||
if (!data) return;
|
||||
|
||||
if (m.type === "snapshot") {
|
||||
this.book.reset();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
|
||||
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Level } from "../../domain/entities/index.js";
|
||||
|
||||
/**
|
||||
* Maintains one side of an order book from snapshot + incremental deltas.
|
||||
* Internally a price->qty map; emits a sorted, depth-capped array on demand.
|
||||
* A qty of 0 removes the price level (standard exchange convention).
|
||||
*/
|
||||
export class BookSide {
|
||||
private levels = new Map<number, number>();
|
||||
|
||||
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {}
|
||||
|
||||
clear(): void {
|
||||
this.levels.clear();
|
||||
}
|
||||
|
||||
apply(price: number, qty: number): void {
|
||||
if (qty <= 0) {
|
||||
this.levels.delete(price);
|
||||
} else {
|
||||
this.levels.set(price, qty);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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));
|
||||
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.levels.size;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalBook {
|
||||
readonly bids: BookSide;
|
||||
readonly asks: BookSide;
|
||||
|
||||
constructor(depth: number) {
|
||||
this.bids = new BookSide("bid", depth);
|
||||
this.asks = new BookSide("ask", depth);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.bids.clear();
|
||||
this.asks.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ExchangeId } from "../../domain/entities/index.js";
|
||||
import { ExchangeConnector } from "./base.js";
|
||||
|
||||
interface OkxBookData {
|
||||
asks: [string, string, string, string][];
|
||||
bids: [string, string, string, string][];
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface OkxMessage {
|
||||
event?: string;
|
||||
arg?: { channel: string; instId: string };
|
||||
data?: OkxBookData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* OKX WebSocket v5 — `books5` channel.
|
||||
* Docs: https://www.okx.com/docs-v5/en/#order-book-trading-market-data
|
||||
* `books5` pushes a full top-5 snapshot every 100ms, so we replace the book
|
||||
* on every message (no sequence/delta management required).
|
||||
*/
|
||||
export class OkxConnector extends ExchangeConnector {
|
||||
readonly id: ExchangeId = "okx";
|
||||
protected readonly url = "wss://ws.okx.com:8443/ws/v5/public";
|
||||
private readonly instId = "BTC-USDT";
|
||||
|
||||
protected subscribeMessage(): unknown {
|
||||
return { op: "subscribe", args: [{ channel: "books5", instId: this.instId }] };
|
||||
}
|
||||
|
||||
protected override customPing(): string | null {
|
||||
return "ping";
|
||||
}
|
||||
|
||||
protected handleMessage(msg: unknown): void {
|
||||
const m = msg as OkxMessage;
|
||||
if (m.event) return; // subscribe/error acks
|
||||
if (!m.data || m.data.length === 0) return;
|
||||
|
||||
const data = m.data[0];
|
||||
if (!data) return;
|
||||
|
||||
this.book.reset();
|
||||
for (const [price, sz] of data.bids ?? []) {
|
||||
this.book.bids.apply(Number(price), Number(sz));
|
||||
}
|
||||
for (const [price, sz] of data.asks ?? []) {
|
||||
this.book.asks.apply(Number(price), Number(sz));
|
||||
}
|
||||
|
||||
const exchangeTs = Number(data.ts);
|
||||
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user