mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-03 10:47:44 +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,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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IClock } from "../../domain/ports/ports.js";
|
||||
|
||||
export class SystemClock implements IClock {
|
||||
now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user