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:
Mauricio Barragan
2026-06-08 21:03:33 -06:00
parent 908aefb2d8
commit 2e8744ccf0
87 changed files with 10414 additions and 0 deletions
+172
View File
@@ -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;
}
}
+102
View File
@@ -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 */
}
}
}
+55
View File
@@ -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);
}
}
+37
View File
@@ -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 };
+56
View File
@@ -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();
}
}
+54
View File
@@ -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);
}
}