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
+55
View File
@@ -0,0 +1,55 @@
import type { ExchangeId } from "../../domain/entities/index.js";
function num(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : fallback;
}
function bool(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (raw === undefined) return fallback;
return raw === "1" || raw.toLowerCase() === "true";
}
export const TAKER_FEES: Record<ExchangeId, number> = {
kraken: 0.0026,
bybit: 0.001,
okx: 0.001,
binance: 0.001,
};
export const WITHDRAWAL_FEES_BTC: Record<ExchangeId, number> = {
kraken: 0.00002,
bybit: 0.00005,
okx: 0.00004,
binance: 0.0005,
};
export const config = {
port: num("PORT", 8080),
symbol: "BTC/USDT" as const,
minNetProfitPct: num("MIN_NET_PROFIT_PCT", 0.0005),
maxTradeBtc: num("MAX_TRADE_BTC", 0.25),
staleMs: num("STALE_MS", 3000),
flickerConfirmMs: num("FLICKER_CONFIRM_MS", 150),
latencyMs: num("LATENCY_MS", 120),
latencySlippageBps: num("LATENCY_SLIPPAGE_BPS", 2),
circuitBreakerLosses: num("CIRCUIT_BREAKER_LOSSES", 5),
circuitBreakerCooldownMs: num("CIRCUIT_BREAKER_COOLDOWN_MS", 15000),
initialUsdt: num("INITIAL_USDT", 50000),
initialBtc: num("INITIAL_BTC", 0.5),
rebalanceMinBtcRatio: 0.15,
rebalanceMinUsdtRatio: 0.15,
rebalanceIntervalMs: 20000,
demoMode: bool("DEMO_MODE", false),
recordFeed: bool("RECORD_FEED", false),
broadcastMs: 250,
pnlSeriesMax: 600,
recentEventsMax: 60,
takerFees: TAKER_FEES,
withdrawalFeesBtc: WITHDRAWAL_FEES_BTC,
} as const;
export type AppConfig = typeof config;
+31
View File
@@ -0,0 +1,31 @@
import { config } from "./config.js";
import { EXCHANGE_IDS, type ExchangeId } from "../../domain/entities/index.js";
export type ActiveExchanges = Record<ExchangeId, boolean>;
export interface TunableConfig {
minNetProfitPct: number;
maxTradeBtc: number;
flickerConfirmMs: number;
activeExchanges: ActiveExchanges;
}
function defaultActiveExchanges(): ActiveExchanges {
return Object.fromEntries(EXCHANGE_IDS.map((id) => [id, true])) as ActiveExchanges;
}
export const runtimeDefaults: TunableConfig = {
minNetProfitPct: config.minNetProfitPct,
maxTradeBtc: config.maxTradeBtc,
flickerConfirmMs: config.flickerConfirmMs,
activeExchanges: defaultActiveExchanges(),
};
export const runtime = {
demoMode: config.demoMode,
recordFeed: config.recordFeed,
minNetProfitPct: config.minNetProfitPct,
maxTradeBtc: config.maxTradeBtc,
flickerConfirmMs: config.flickerConfirmMs,
activeExchanges: defaultActiveExchanges(),
};
@@ -0,0 +1,55 @@
import type { ExchangeId } from "../../domain/entities/index.js";
import type { TradingPolicy } from "../../domain/ports/ports.js";
import { config } from "./config.js";
import { runtime } from "./runtime.js";
/** Production TradingPolicy: static costs from config, live thresholds from runtime. */
export class RuntimeTradingPolicy implements TradingPolicy {
takerFee(exchange: ExchangeId): number {
return config.takerFees[exchange];
}
withdrawalFeeBtc(exchange: ExchangeId): number {
return config.withdrawalFeesBtc[exchange];
}
minNetProfitPct(): number {
return runtime.minNetProfitPct;
}
maxTradeBtc(): number {
return runtime.maxTradeBtc;
}
flickerConfirmMs(): number {
return runtime.flickerConfirmMs;
}
latencySlippageBps(): number {
return config.latencySlippageBps;
}
circuitBreakerLosses(): number {
return config.circuitBreakerLosses;
}
circuitBreakerCooldownMs(): number {
return config.circuitBreakerCooldownMs;
}
rebalanceIntervalMs(): number {
return config.rebalanceIntervalMs;
}
rebalanceMinBtc(): number {
return config.initialBtc * config.rebalanceMinBtcRatio;
}
rebalanceMinUsdt(): number {
return config.initialUsdt * config.rebalanceMinUsdtRatio;
}
isDemo(): boolean {
return runtime.demoMode;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
import { join } from "node:path";
import { createLogger } from "../logging/logger.js";
import type { OrderBook } from "../../domain/entities/index.js";
import { runtime } from "../config/runtime.js";
const log = createLogger("recorder");
/**
* Append-only NDJSON feed recorder. When enabled, every normalized book tick is
* written to data/feed-<timestamp>.ndjson. This gives durable history (in-memory
* state survives only while the process runs) and enables deterministic replay
* for tests/demos. Each line: { ts, exchange, bids, asks }.
*/
export class FeedRecorder {
private stream: WriteStream | null = null;
private path: string | null = null;
ensureOpen(): void {
if (this.stream) return;
const dir = join(process.cwd(), "data");
try {
mkdirSync(dir, { recursive: true });
} catch {
/* already exists */
}
this.path = join(dir, `feed-${Date.now()}.ndjson`);
this.stream = createWriteStream(this.path, { flags: "a" });
log.info(`recording feed to ${this.path}`);
}
record(book: OrderBook): void {
if (!runtime.recordFeed) return;
this.ensureOpen();
const line = JSON.stringify({
ts: book.recvTs,
exchange: book.exchange,
bids: book.bids.slice(0, 5),
asks: book.asks.slice(0, 5),
});
this.stream?.write(line + "\n");
}
close(): void {
this.stream?.end();
this.stream = null;
}
}
+123
View File
@@ -0,0 +1,123 @@
import { EXCHANGE_IDS, type ExchangeId, type Level, type OrderBook } from "../../domain/entities/index.js";
import { createLogger } from "../logging/logger.js";
import type { MarketDataFeed } from "../../domain/ports/ports.js";
const log = createLogger("demo");
type BookListener = (book: OrderBook) => void;
/**
* Self-contained synthetic market data generator for demo mode. Produces
* realistic per-exchange order books around a random-walking mid price and
* periodically injects a clean, net-profitable cross-exchange divergence so the
* full engine (detection -> risk -> execution -> P&L -> rebalance) is visible
* during evaluation even when the real market is efficient or feeds are blocked.
*
* Every book it emits is flagged demo upstream; nothing here is presented as
* real market data.
*/
export class SyntheticFeed implements MarketDataFeed {
private listeners: BookListener[] = [];
private timer: NodeJS.Timeout | null = null;
private mid = 100_000;
private injectUntil = 0;
private injectBuy: ExchangeId = "bybit";
private injectSell: ExchangeId = "okx";
private injectEdgePct = 0;
onBook(listener: BookListener): void {
this.listeners.push(listener);
}
start(): void {
if (this.timer) return;
log.info("synthetic demo feed started");
this.timer = setInterval(() => this.emitAll(), 300);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
log.info("synthetic demo feed stopped");
}
}
private emitAll(): void {
// Random walk the global mid price.
this.mid += (Math.random() - 0.5) * 20;
if (this.mid < 50_000) this.mid = 50_000;
const now = Date.now();
this.maybeScheduleInjection(now);
for (const exchange of EXCHANGE_IDS) {
const book = this.buildBook(exchange, now);
for (const listener of this.listeners) listener(book);
}
}
private maybeScheduleInjection(now: number): void {
if (now < this.injectUntil) return;
// ~1 in 6 chance each cycle to open a new profitable window for ~600ms.
if (Math.random() < 0.16) {
const pair = this.pickPair();
this.injectBuy = pair[0];
this.injectSell = pair[1];
this.injectEdgePct = 0.0025 + Math.random() * 0.003; // 0.25%0.55% gross edge
this.injectUntil = now + 600;
}
}
/** Prefer low-fee pairs (bybit/okx) so injected edges clear the fee hurdle. */
private pickPair(): [ExchangeId, ExchangeId] {
const pairs: [ExchangeId, ExchangeId][] = [
["bybit", "okx"],
["okx", "bybit"],
["binance", "bybit"],
["bybit", "binance"],
["okx", "kraken"],
["bybit", "kraken"],
];
return pairs[Math.floor(Math.random() * pairs.length)] ?? ["bybit", "okx"];
}
private buildBook(exchange: ExchangeId, now: number): OrderBook {
// Per-exchange persistent micro-offset so books aren't identical.
const offset =
exchange === "kraken" ? 8 : exchange === "bybit" ? -4 : exchange === "binance" ? 0 : 2;
let mid = this.mid + offset + (Math.random() - 0.5) * 6;
const active = now < this.injectUntil;
if (active && exchange === this.injectBuy) {
// Make this venue cheap to buy: pull mid down so its ask < other's bid.
mid *= 1 - this.injectEdgePct / 2;
} else if (active && exchange === this.injectSell) {
// Make this venue expensive to sell into: push mid up.
mid *= 1 + this.injectEdgePct / 2;
}
const halfSpread = mid * 0.00002; // ~0.2 bps half-spread
const bestBid = mid - halfSpread;
const bestAsk = mid + halfSpread;
return {
exchange,
bids: this.buildSide(bestBid, -1),
asks: this.buildSide(bestAsk, 1),
recvTs: now,
exchangeTs: now,
};
}
private buildSide(best: number, dir: 1 | -1): Level[] {
const levels: Level[] = [];
let price = best;
for (let i = 0; i < 10; i += 1) {
const qty = 0.05 + Math.random() * 1.5;
levels.push({ price: Math.round(price * 100) / 100, qty: Math.round(qty * 1e6) / 1e6 });
price += dir * (1 + Math.random() * 3);
}
return levels;
}
}
+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);
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { IIdGenerator } from "../../domain/ports/ports.js";
let counter = 0;
export function nextId(prefix: string): string {
counter = (counter + 1) % 1_000_000;
return `${prefix}_${Date.now().toString(36)}_${counter.toString(36)}`;
}
export class IdGenerator implements IIdGenerator {
next(prefix: string): string {
return nextId(prefix);
}
}
+23
View File
@@ -0,0 +1,23 @@
type Level = "info" | "warn" | "error";
function emit(level: Level, scope: string, msg: string, extra?: unknown): void {
const ts = new Date().toISOString();
const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`;
if (level === "error") {
console.error(line, extra ?? "");
} else if (level === "warn") {
console.warn(line, extra ?? "");
} else {
console.log(line, extra ?? "");
}
}
export function createLogger(scope: string) {
return {
info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra),
warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra),
error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra),
};
}
export type Logger = ReturnType<typeof createLogger>;
@@ -0,0 +1,73 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { Rebalancer } from "./rebalancer.js";
import { FakeInventory, FakePolicy, FakeStore, SeqIds } from "../../test-support/test-fakes.js";
function setup(init: ConstructorParameters<typeof FakeInventory>[0]) {
const inv = new FakeInventory(init);
const store = new FakeStore();
const policy = new FakePolicy();
policy.rebalanceIntervalMsValue = 0;
policy.rebalanceMinBtcValue = 0.3;
policy.rebalanceMinUsdtValue = 1;
const reb = new Rebalancer(inv, store, policy, new SeqIds());
return { inv, store, policy, reb };
}
test("transfers BTC from richest to poorest when below threshold", () => {
const { inv, store, reb } = setup({
kraken: { usdt: 50000, btc: 0.5 },
bybit: { usdt: 50000, btc: 0.1 },
okx: { usdt: 50000, btc: 0.5 },
});
reb.tick(1000);
assert.equal(store.rebalances.length, 1);
const ev = store.rebalances[0]!;
assert.equal(ev.asset, "BTC");
assert.equal(ev.fromExchange, "kraken");
assert.equal(ev.toExchange, "bybit");
assert.ok(Math.abs(ev.amount - 0.2) < 1e-9, `amount ${ev.amount}`);
assert.equal(inv.transfers.length, 1);
});
test("withdrawal fee is charged ONLY on rebalance, using the source venue's BTC fee", () => {
const { inv, store, policy, reb } = setup({
kraken: { usdt: 50000, btc: 0.5 },
bybit: { usdt: 50000, btc: 0.1 },
okx: { usdt: 50000, btc: 0.5 },
});
reb.tick(1000);
const ev = store.rebalances[0]!;
assert.equal(ev.withdrawalFee, policy.withdrawalFeesBtc.kraken);
const received = ev.amount - ev.withdrawalFee;
assert.ok(Math.abs(inv.get("bybit").btc - (0.1 + received)) < 1e-12, `bybit btc ${inv.get("bybit").btc}`);
assert.equal(inv.transfers[0]!.fee, policy.withdrawalFeesBtc.kraken);
});
test("does not rebalance when every venue is above the threshold", () => {
const { store, reb } = setup({
kraken: { usdt: 50000, btc: 0.5 },
bybit: { usdt: 50000, btc: 0.4 },
okx: { usdt: 50000, btc: 0.5 },
});
reb.tick(1000);
assert.equal(store.rebalances.length, 0);
});
test("respects the rebalance interval (no run before interval elapses)", () => {
const inv = new FakeInventory({ bybit: { usdt: 50000, btc: 0.1 } });
const store = new FakeStore();
const policy = new FakePolicy();
policy.rebalanceIntervalMsValue = 20000;
policy.rebalanceMinBtcValue = 0.3;
const reb = new Rebalancer(inv, store, policy, new SeqIds());
reb.tick(1000);
assert.equal(store.rebalances.length, 0);
});
@@ -0,0 +1,70 @@
import { createLogger } from "../logging/logger.js";
import { EXCHANGE_IDS, type ExchangeId } from "../../domain/entities/index.js";
import type { IIdGenerator, IInventory, IRebalancer, IStateStore, TradingPolicy } from "../../domain/ports/ports.js";
const log = createLogger("rebalancer");
export class Rebalancer implements IRebalancer {
private lastRun = 0;
constructor(
private readonly inventory: IInventory,
private readonly store: IStateStore,
private readonly policy: TradingPolicy,
private readonly ids: IIdGenerator,
) {}
tick(now: number): void {
if (now - this.lastRun < this.policy.rebalanceIntervalMs()) return;
this.lastRun = now;
this.rebalanceAsset("BTC", this.policy.rebalanceMinBtc(), now);
this.rebalanceAsset("USDT", this.policy.rebalanceMinUsdt(), now);
}
private amountOf(exchange: ExchangeId, asset: "BTC" | "USDT"): number {
const w = this.inventory.get(exchange);
return asset === "BTC" ? w.btc : w.usdt;
}
private rebalanceAsset(asset: "BTC" | "USDT", minThreshold: number, now: number): void {
let poorest: ExchangeId | null = null;
let richest: ExchangeId | null = null;
let minVal = Infinity;
let maxVal = -Infinity;
for (const e of EXCHANGE_IDS) {
const v = this.amountOf(e, asset);
if (v < minVal) {
minVal = v;
poorest = e;
}
if (v > maxVal) {
maxVal = v;
richest = e;
}
}
if (!poorest || !richest || poorest === richest) return;
if (minVal >= minThreshold) return;
const target = (minVal + maxVal) / 2;
const amount = target - minVal;
if (amount <= 0) return;
const fee = asset === "BTC" ? this.policy.withdrawalFeeBtc(richest) : 1;
this.inventory.applyTransfer(richest, poorest, asset, amount, fee);
this.store.addRebalance({
id: this.ids.next("rebal"),
ts: now,
fromExchange: richest,
toExchange: poorest,
asset,
amount,
withdrawalFee: fee,
reason: `${poorest} ${asset} below ${minThreshold.toFixed(asset === "BTC" ? 4 : 0)} threshold`,
});
log.info(`rebalanced ${amount.toFixed(6)} ${asset} ${richest} -> ${poorest} (fee ${fee})`);
}
}
@@ -0,0 +1,92 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { ExecutionSimulator } from "./execution-simulator.js";
import { FakeInventory, FakePolicy, SeqIds } from "../../test-support/test-fakes.js";
import type { Opportunity } from "../../domain/entities/index.js";
function opportunity(over: Partial<Opportunity> = {}): Opportunity {
return {
id: "opp_1",
ts: 1000,
buyExchange: "bybit",
sellExchange: "okx",
topBuyAsk: 100000,
topSellBid: 100600,
volumeBtc: 0.1,
buyVwap: 100000,
sellVwap: 100600,
grossSpread: 600,
grossSpreadPct: 0.006,
feeBuy: 0,
feeSell: 0,
netProfit: 0,
netProfitPct: 0,
status: "executed",
reason: "executed",
demo: false,
...over,
};
}
const EPS = 1e-6;
test("applies adverse latency drift to both legs", () => {
const inv = new FakeInventory();
const policy = new FakePolicy();
policy.latencyBps = 2;
const sim = new ExecutionSimulator(inv, policy, new SeqIds());
const trade = sim.execute(opportunity(), 2000);
assert.ok(Math.abs(trade.execBuyVwap - 100020) < EPS, `execBuy ${trade.execBuyVwap}`);
assert.ok(Math.abs(trade.execSellVwap - 100579.88) < EPS, `execSell ${trade.execSellVwap}`);
});
test("netProfit matches the drifted proceeds-minus-cost (golden)", () => {
const inv = new FakeInventory();
const policy = new FakePolicy();
policy.latencyBps = 2;
const sim = new ExecutionSimulator(inv, policy, new SeqIds());
const trade = sim.execute(opportunity(), 2000);
assert.ok(Math.abs(trade.netProfit - 35.928012) < EPS, `net ${trade.netProfit}`);
assert.ok(Math.abs(trade.feeBuy - 10.002) < EPS, `feeBuy ${trade.feeBuy}`);
assert.ok(Math.abs(trade.feeSell - 10.057988) < EPS, `feeSell ${trade.feeSell}`);
assert.ok(Math.abs(trade.netProfitPct - 35.928012 / 10002) < EPS, `pct ${trade.netProfitPct}`);
});
test("updates wallets under the pre-positioned model (spend USDT on buy, gain USDT on sell)", () => {
const inv = new FakeInventory({ bybit: { usdt: 50000, btc: 0.5 }, okx: { usdt: 50000, btc: 0.5 } });
const policy = new FakePolicy();
policy.latencyBps = 2;
const sim = new ExecutionSimulator(inv, policy, new SeqIds());
sim.execute(opportunity(), 2000);
const bybit = inv.get("bybit");
const okx = inv.get("okx");
assert.ok(Math.abs(bybit.btc - 0.6) < EPS, `bybit btc ${bybit.btc}`);
assert.ok(Math.abs(bybit.usdt - (50000 - 10012.002)) < EPS, `bybit usdt ${bybit.usdt}`);
assert.ok(Math.abs(okx.btc - 0.4) < EPS, `okx btc ${okx.btc}`);
assert.ok(Math.abs(okx.usdt - (50000 + 10047.930012)) < EPS, `okx usdt ${okx.usdt}`);
});
test("higher taker fees reduce realized net profit", () => {
const policy = new FakePolicy();
policy.latencyBps = 0;
const low = new ExecutionSimulator(new FakeInventory(), policy, new SeqIds()).execute(opportunity(), 1);
const policyHi = new FakePolicy();
policyHi.latencyBps = 0;
policyHi.takerFees = { kraken: 0.0026, bybit: 0.0026, okx: 0.0026, binance: 0.0026 };
const hi = new ExecutionSimulator(new FakeInventory(), policyHi, new SeqIds()).execute(opportunity(), 1);
assert.ok(hi.netProfit < low.netProfit, `expected ${hi.netProfit} < ${low.netProfit}`);
});
test("marks partial fills from opportunity status", () => {
const sim = new ExecutionSimulator(new FakeInventory(), new FakePolicy(), new SeqIds());
const trade = sim.execute(opportunity({ status: "executed_partial" }), 1);
assert.equal(trade.partial, true);
});
@@ -0,0 +1,49 @@
import type { Opportunity, Trade } from "../../domain/entities/index.js";
import type { IIdGenerator, IInventory, ITradeExecutor, TradingPolicy } from "../../domain/ports/ports.js";
import { netProfit, netProfitPct, takerFeeCost } from "../../domain/services/pricing.js";
export class ExecutionSimulator implements ITradeExecutor {
constructor(
private readonly inventory: IInventory,
private readonly policy: TradingPolicy,
private readonly ids: IIdGenerator,
) {}
execute(op: Opportunity, now: number): Trade {
const drift = this.policy.latencySlippageBps() / 10_000;
const execBuyVwap = op.buyVwap * (1 + drift);
const execSellVwap = op.sellVwap * (1 - drift);
const feeBuyRate = this.policy.takerFee(op.buyExchange);
const feeSellRate = this.policy.takerFee(op.sellExchange);
const feeBuy = takerFeeCost(execBuyVwap, op.volumeBtc, feeBuyRate);
const feeSell = takerFeeCost(execSellVwap, op.volumeBtc, feeSellRate);
const quoteCost = execBuyVwap * op.volumeBtc + feeBuy;
const quoteProceeds = execSellVwap * op.volumeBtc - feeSell;
const net = netProfit(execBuyVwap, execSellVwap, op.volumeBtc, feeBuyRate, feeSellRate);
this.inventory.applyBuy(op.buyExchange, op.volumeBtc, quoteCost);
this.inventory.applySell(op.sellExchange, op.volumeBtc, quoteProceeds);
const notional = execBuyVwap * op.volumeBtc;
return {
id: this.ids.next("trade"),
ts: now,
buyExchange: op.buyExchange,
sellExchange: op.sellExchange,
volumeBtc: op.volumeBtc,
requestedBtc: op.volumeBtc,
buyVwap: op.buyVwap,
sellVwap: op.sellVwap,
execBuyVwap,
execSellVwap,
feeBuy,
feeSell,
netProfit: net,
netProfitPct: netProfitPct(net, notional),
partial: op.status === "executed_partial",
demo: op.demo,
};
}
}
@@ -0,0 +1,50 @@
import { createLogger } from "../logging/logger.js";
import type { IRiskGate, IStateStore, TradingPolicy } from "../../domain/ports/ports.js";
const log = createLogger("risk");
export class RiskManager implements IRiskGate {
private trippedUntil = 0;
constructor(
private readonly store: IStateStore,
private readonly policy: TradingPolicy,
) {}
evaluate(now: number): void {
if (this.store.circuit === "paused") return;
if (this.store.consecutiveLosses >= this.policy.circuitBreakerLosses()) {
const cooldownMs = this.policy.circuitBreakerCooldownMs();
this.trippedUntil = now + cooldownMs;
this.store.circuit = "tripped";
log.warn(
`circuit breaker tripped after ${this.store.consecutiveLosses} consecutive losses; ` +
`cooling down ${cooldownMs}ms`,
);
}
}
tick(now: number): void {
if (this.store.circuit === "tripped" && now >= this.trippedUntil) {
this.store.circuit = "running";
this.store.consecutiveLosses = 0;
log.info("circuit breaker reset, resuming execution");
}
}
canExecute(): boolean {
return this.store.circuit === "running";
}
pause(): void {
this.store.circuit = "paused";
log.info("execution paused by operator");
}
resume(): void {
this.store.circuit = "running";
this.store.consecutiveLosses = 0;
this.trippedUntil = 0;
log.info("execution resumed by operator");
}
}
@@ -0,0 +1,53 @@
import { config } from "../config/config.js";
import {
EXCHANGE_IDS,
type BestQuote,
type ExchangeId,
type FeedStatus,
type OrderBook,
} from "../../domain/entities/index.js";
import type { IQuoteBook } from "../../domain/ports/ports.js";
export class OrderBookManager implements IQuoteBook {
private books = new Map<ExchangeId, OrderBook>();
update(book: OrderBook): void {
this.books.set(book.exchange, book);
}
getBook(exchange: ExchangeId): OrderBook | undefined {
return this.books.get(exchange);
}
isFresh(exchange: ExchangeId, now: number): boolean {
const book = this.books.get(exchange);
if (!book) return false;
return now - book.recvTs <= config.staleMs;
}
private statusFor(book: OrderBook | undefined, now: number): FeedStatus {
if (!book) return "connecting";
const age = now - book.recvTs;
if (age > config.staleMs * 3) return "down";
if (age > config.staleMs) return "stale";
return "live";
}
bestQuotes(now: number): BestQuote[] {
return EXCHANGE_IDS.map((exchange) => {
const book = this.books.get(exchange);
const topBid = book?.bids[0];
const topAsk = book?.asks[0];
return {
exchange,
bid: topBid?.price ?? null,
bidQty: topBid?.qty ?? null,
ask: topAsk?.price ?? null,
askQty: topAsk?.qty ?? null,
recvTs: book?.recvTs ?? null,
status: this.statusFor(book, now),
ageMs: book ? now - book.recvTs : null,
};
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { CircuitState, Opportunity, PnlPoint, RebalanceEvent, Trade } from "../../domain/entities/index.js";
import type { IStateStore } from "../../domain/ports/ports.js";
import { config } from "../config/config.js";
export class Store implements IStateStore {
readonly startedAt = Date.now();
private opportunities: Opportunity[] = [];
private trades: Trade[] = [];
private rebalances: RebalanceEvent[] = [];
private pnl: PnlPoint[] = [];
ticksProcessed = 0;
opportunitiesDetected = 0;
tradesExecuted = 0;
tradesRejected = 0;
realizedPnl = 0;
consecutiveLosses = 0;
circuit: CircuitState = "running";
tickTimeEwma = 0;
recordTickTime(ms: number): void {
const alpha = 0.05;
this.tickTimeEwma = this.tickTimeEwma === 0 ? ms : this.tickTimeEwma * (1 - alpha) + ms * alpha;
}
addOpportunity(op: Opportunity): void {
this.opportunitiesDetected += 1;
this.opportunities.unshift(op);
if (this.opportunities.length > config.recentEventsMax) this.opportunities.pop();
}
addTrade(trade: Trade): void {
this.tradesExecuted += 1;
this.realizedPnl += trade.netProfit;
this.trades.unshift(trade);
if (this.trades.length > config.recentEventsMax) this.trades.pop();
if (trade.netProfit < 0) {
this.consecutiveLosses += 1;
} else {
this.consecutiveLosses = 0;
}
this.pnl.push({ ts: trade.ts, pnl: this.realizedPnl });
if (this.pnl.length > config.pnlSeriesMax) this.pnl.shift();
}
addRebalance(event: RebalanceEvent): void {
this.rebalances.unshift(event);
if (this.rebalances.length > 20) this.rebalances.pop();
}
recentOpportunities(): Opportunity[] {
return this.opportunities;
}
recentTrades(): Trade[] {
return this.trades;
}
recentRebalances(): RebalanceEvent[] {
return this.rebalances;
}
pnlSeries(): PnlPoint[] {
return this.pnl;
}
reset(): void {
this.opportunities = [];
this.trades = [];
this.rebalances = [];
this.pnl = [];
this.ticksProcessed = 0;
this.opportunitiesDetected = 0;
this.tradesExecuted = 0;
this.tradesRejected = 0;
this.realizedPnl = 0;
this.consecutiveLosses = 0;
this.circuit = "running";
this.tickTimeEwma = 0;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { config } from "../config/config.js";
import { EXCHANGE_IDS, type ExchangeId, type Wallet } from "../../domain/entities/index.js";
import type { IInventory } from "../../domain/ports/ports.js";
export class WalletBook implements IInventory {
private wallets = new Map<ExchangeId, Wallet>();
constructor() {
this.reset();
}
reset(): void {
this.wallets.clear();
for (const exchange of EXCHANGE_IDS) {
this.wallets.set(exchange, {
exchange,
usdt: config.initialUsdt,
btc: config.initialBtc,
});
}
}
get(exchange: ExchangeId): Wallet {
const w = this.wallets.get(exchange);
if (!w) throw new Error(`unknown exchange wallet: ${exchange}`);
return w;
}
all(): Wallet[] {
return EXCHANGE_IDS.map((e) => ({ ...this.get(e) }));
}
maxBuyableBtc(exchange: ExchangeId, vwapWithFee: number): number {
if (vwapWithFee <= 0) return 0;
return this.get(exchange).usdt / vwapWithFee;
}
sellableBtc(exchange: ExchangeId): number {
return this.get(exchange).btc;
}
applyBuy(exchange: ExchangeId, btc: number, quoteCost: number): void {
const w = this.get(exchange);
w.btc += btc;
w.usdt -= quoteCost;
}
applySell(exchange: ExchangeId, btc: number, quoteProceeds: number): void {
const w = this.get(exchange);
w.btc -= btc;
w.usdt += quoteProceeds;
}
applyTransfer(from: ExchangeId, to: ExchangeId, asset: "BTC" | "USDT", amount: number, fee: number): void {
const src = this.get(from);
const dst = this.get(to);
if (asset === "BTC") {
src.btc -= amount;
dst.btc += amount - fee;
} else {
src.usdt -= amount;
dst.usdt += amount - fee;
}
}
totalEquity(btcRef: number): number {
let total = 0;
for (const w of this.wallets.values()) total += w.usdt + w.btc * btcRef;
return total;
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { IClock } from "../../domain/ports/ports.js";
export class SystemClock implements IClock {
now(): number {
return Date.now();
}
}