Merge pull request #1 from mauricioabh/dev

Initial release: Arb Pulse monolith
This commit is contained in:
Mauricio Barragan
2026-06-08 21:10:23 -06:00
committed by GitHub
87 changed files with 10414 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules
web/node_modules
.env
.env.*
!.env.example
.git
.gitignore
data
web/dist
dist
.cursor
*.ndjson
feed.ndjson
README.md
AGENTS.md
.github
coverage
*.log
+24
View File
@@ -0,0 +1,24 @@
# Server
PORT=8080
# Engine tuning
MIN_NET_PROFIT_PCT=0.0005 # 0.05% minimum net return to execute
MAX_TRADE_BTC=0.25 # max BTC per simulated trade
STALE_MS=3000 # quote older than this is ignored
FLICKER_CONFIRM_MS=150 # opportunity must persist this long before executing
LATENCY_MS=120 # simulated detection->execution latency
LATENCY_SLIPPAGE_BPS=2 # adverse price drift applied during latency (basis points)
# Risk
CIRCUIT_BREAKER_LOSSES=5 # consecutive losing trades before pausing
CIRCUIT_BREAKER_COOLDOWN_MS=15000
# Wallets (per exchange, simulated)
INITIAL_USDT=50000
INITIAL_BTC=0.5
# Demo mode (injects synthetic divergences, clearly flagged)
DEMO_MODE=false
# Feed recorder (NDJSON to data/)
RECORD_FEED=false
+45
View File
@@ -0,0 +1,45 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
quality:
name: quality
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: |
package-lock.json
web/package-lock.json
- name: Install dependencies (root)
run: npm ci
- name: Install dependencies (web)
run: npm --prefix web ci
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- name: Build
run: npm run build
+18
View File
@@ -0,0 +1,18 @@
# Optional Fly.io deploy — manual only. Production deploy is on Render.
# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
# Requires FLY_API_TOKEN secret. Docs: .cursor/rules/fly-deploy.mdc
name: Fly Deploy
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group # optional: ensure only one action runs at a time
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+25
View File
@@ -0,0 +1,25 @@
# Dependencies
node_modules/
web/node_modules/
# Build output
dist/
web/dist/
# Environment & secrets
.env
.env.local
.env.*.local
# Local agent / IDE (not part of the product)
.cursor/
AGENTS.md
# Recorded feeds (can grow large)
data/
*.ndjson
# Logs & OS
*.log
.DS_Store
Thumbs.db
+32
View File
@@ -0,0 +1,32 @@
# Stage 1 — Vite frontend (web/dist)
FROM node:20-alpine AS web-build
WORKDIR /app
# web/package.json links "arb-pulse": "file:.." — root package.json required for npm ci
COPY package.json package-lock.json ./
COPY web/package.json web/package-lock.json ./web/
RUN npm ci --prefix web
COPY web/ ./web/
RUN npm run build --prefix web
# Stage 2 — production server (tsx, no tsc emit)
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
COPY --from=web-build /app/web/dist ./web/dist
EXPOSE 8080
CMD ["npm", "start"]
+354
View File
@@ -0,0 +1,354 @@
# Arb Pulse
**Detección y simulación de arbitraje de Bitcoin en tiempo real** entre Kraken, Bybit, OKX y Binance.
Monolito Node.js + TypeScript: API REST, stream SSE y dashboard React servidos desde **un solo proceso y una sola URL**. Escucha libros de órdenes por WebSocket, calcula rentabilidad **neta de fees y slippage VWAP**, y simula ejecución con inventario pre-posicionado. No mueve fondos reales.
---
## Descripción
Arb Pulse compara **BTC/USDT** en cuatro exchanges, camina el order book nivel a nivel, descarta oportunidades que no pagan comisiones taker, y —si pasan anti-flicker y riesgo— simula compra y venta en paralelo actualizando wallets virtuales por venue.
En mercado **real** verás sobre todo oportunidades `rejected · fees` (comportamiento esperado en mercados eficientes). El **modo demo** inyecta divergencias sintéticas con badge visible para demostrar el pipeline completo: detección → riesgo → ejecución → P&L → rebalanceo.
---
## Capturas de pantalla
### Feed en vivo (mercado real)
Badge **LIVE**, price matrix con cuatro venues, oportunidades rechazadas por fees y P&L en cero — el filtro económico es estricto.
![Dashboard en modo live — feeds reales, oportunidades rechazadas por fees](docs/screenshots/overview-live.png)
### Modo demo (feed sintético)
Badge **Demo / Simulated Feed**, trades simulados y curva de P&L — útil para presentaciones sin confundir datos sintéticos con mercado real.
![Dashboard en modo demo — trades simulados y badge demo](docs/screenshots/overview-demo.png)
Capturas completas (scroll): [`dashboard-live.png`](docs/screenshots/dashboard-live.png) · [`dashboard-demo.png`](docs/screenshots/dashboard-demo.png)
---
## Stack tecnológico
| Capa | Tecnología |
|-------------|-------------------------------------------------|
| Runtime | Node.js 20+, TypeScript (strict), `tsx` |
| HTTP | Express — REST + SSE + Swagger UI (`/api-docs`) |
| Feeds | WebSocket nativo (`ws`) — APIs públicas |
| Frontend | React 18, Vite, Tailwind CSS v3 |
| Estado | In-memory (sin base de datos) |
| Deploy | [Fly.io](https://fly.io) — Docker multi-stage (`Dockerfile` + `fly.toml`) |
No se requieren API keys: los feeds de mercado son públicos.
---
## Instalación
### Requisitos
- [Node.js](https://nodejs.org/) **20+**
- [Git](https://git-scm.com/)
### Pasos
```bash
git clone https://github.com/mauricioabh/arbpulse.git
cd arbpulse
# Dependencias (raíz + frontend)
npm install
npm --prefix web install
# Variables opcionales (defaults en .env.example)
cp .env.example .env # Linux / macOS
# copy .env.example .env # Windows
# Build del frontend
npm run build
```
### Arrancar
**Producción local** (un proceso, recomendado para probar el dashboard):
```bash
npm start
# → http://localhost:8080
```
**Desarrollo** (backend + Vite con hot reload):
```bash
npm run dev # Backend :8080
npm run dev:web # Frontend :5173 (proxy /api → :8080)
```
**Modo demo** (presentación con actividad visible en segundos):
```powershell
# Windows PowerShell
$env:DEMO_MODE="true"; npm start
```
```bash
# Linux / macOS
DEMO_MODE=true npm start
```
### Verificar
1. Abre `http://localhost:8080` — el badge debe mostrar **LIVE** (o **Demo / Simulated Feed** si usaste demo).
2. La price matrix debe listar los cuatro exchanges con bid/ask numéricos.
3. Healthcheck: `GET http://localhost:8080/api/health``{ "success": true, "data": { "status": "ok" } }`.
### Calidad de código
```bash
npm run typecheck # TypeScript backend + frontend
npm test # Tests unitarios (node:test)
npm run build # Frontend Vite → web/dist
```
Los PRs hacia `dev` o `main` deben pasar **GitHub Actions** (`CI / quality`: typecheck, test, build).
---
## Por qué existe y qué problema resuelve
En mercados líquidos, las divergencias de precio entre exchanges suelen **desaparecer en neto** en cuanto sumas comisiones taker y el costo real de cruzar el libro. Muchas demos de arbitraje muestran spreads brutos o asumen un slippage fijo del 0,1 %, y eso infla resultados.
Arb Pulse prioriza tres cosas:
1. **Precisión económica** — VWAP nivel a nivel, fees taker por exchange, profit neto sin doble conteo de slippage.
2. **Modelo operativo creíble** — inventario USDT + BTC en cada exchange; no se “transfiere BTC por trade” (eso tarda decenas de minutos on-chain).
3. **Honestidad en vivo** — en feed real verás sobre todo rechazos por fees; el modo demo inyecta divergencias sintéticas con badge visible para demostrar el pipeline completo.
---
## Decisiones de diseño que importan
### Inventario pre-posicionado (no transferencia por operación)
El arbitraje cross-exchange real asume que **ya tienes BTC y USDT en cada venue**. En cada oportunidad: compras BTC donde está barato (gastas USDT) y vendes BTC donde está caro (recibes USDT), **en paralelo**, sin mover monedas entre exchanges en esa operación.
El inventario deriva con el tiempo. Un **Rebalancer** corrige desbalances cuando el ratio de BTC o USDT cae por debajo de umbrales. El **withdrawal fee de red solo se aplica en rebalanceo**, amortizado sobre muchos trades — no en cada arbitraje simulado. Cobrar withdrawal por trade es un error común en simuladores simplificados.
### Mismo par en todos lados: BTC/USDT
Comparar `BTC/USDT` con `BTC/USD` mezcla basis de stablecoin y genera “arbitrajes” que no son ejecutables de forma consistente. Aquí los conectores normalizan **BTC/USDT**.
### Slippage con VWAP, no un porcentaje fijo
El volumen objetivo recorre el libro bid/ask nivel a nivel (`src/domain/services/vwap.ts`). El profit neto usa los VWAP de compra y venta; el slippage ya está dentro de esos precios:
```
profit = sellVwap × vol × (1 feeSell) buyVwap × vol × (1 + feeBuy)
```
### Fees taker reales (el arbitraje siempre cruza el spread)
| Exchange | Taker (aprox.) |
|----------|----------------|
| Kraken | 0,26 % |
| Bybit | 0,10 % |
| OKX | 0,10 % |
| Binance | 0,10 % |
Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen **rejected · fees** en el log. Eso es comportamiento correcto, no un bug.
### Robustez en el hot path
- **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico).
- **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución.
- **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia).
- **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
- **Circuit breaker** — tras N pérdidas consecutivas, pausa con cooldown.
- **Latency drift** — entre detección y ejecución simulada se aplica deslizamiento adverso (P&L realista, no best-case).
### Modo demo claramente etiquetado
Los arbitrajes netos positivos son raros en vivo. Con `DEMO_MODE=true` un feed sintético inyecta divergencias para mostrar detección → riesgo → ejecución → P&L → rebalanceo. El dashboard muestra el badge **Demo / Simulated Feed**; nunca debe confundirse con mercado real.
Opcionalmente, `RECORD_FEED=true` graba ticks en `data/*.ndjson` (ignorados por git; pueden pesar mucho).
---
## Arquitectura
```
Exchanges (WS: Kraken v2, Bybit v5, OKX v5, Binance depth10@100ms + REST fallback)
│ libros normalizados (snapshot + deltas, staleness)
OrderBookManager ── mejor bid/ask y frescura por venue
ArbitrageEngine ── matriz N×N · VWAP · profit neto · scoring · anti-flicker
RiskManager ── umbral mínimo · circuit breaker · pause/resume
ExecutionSimulator ── partial fills · latency drift · wallets
▼ ▲
Rebalancer ── corrección de inventario (withdrawal fee en rebalanceo)
Store (in-memory) + FeedRecorder (NDJSON opcional)
API REST + SSE ──► Dashboard React
```
**SSE para el dashboard** — el flujo servidor→cliente es unidireccional; SSE reconecta sobre HTTP sin un segundo WebSocket hacia el navegador.
**Monolito en Fly.io** — el motor necesita conexiones WS persistentes y estado en memoria continuo; no encaja en serverless efímero. Un proceso Node sirve `/api`, `/api/stream` y los estáticos de `web/dist`.
El dominio (`src/domain/`) no importa infraestructura ni HTTP; los casos de uso en `application/` orquestan; `composition/bootstrap.ts` cablea adaptadores concretos.
---
## Estructura del repositorio
```
src/
index.ts Express + bootstrap + estáticos web/dist
composition/ bootstrap.ts, application-service.ts (wiring)
domain/
entities/ OrderBook, Opportunity, Trade, Wallet, …
ports/ MarketDataFeed, IQuoteBook, TradingPolicy, …
services/ vwap, pricing, ArbitrageEngine
application/
use-cases/ ProcessOrderBookUpdate, ExecuteArbitrage, …
infrastructure/
exchanges/ WS Kraken / Bybit / OKX / Binance
demo/ SyntheticFeed, FeedRecorder (NDJSON)
state/ Store, WalletBook, OrderBookManager
config/ config.ts, runtime.ts (único lector de env)
simulation/ ExecutionSimulator, RiskManager
rebalancing/ Rebalancer
logging/ logger
interfaces/
http/ REST → ApplicationService, OpenAPI spec (`openapi.ts`)
sse/ SSE hub
test-support/ fakes + FakeMarketDataFeed (tests)
web/
src/ Dashboard (StatsBar, PriceMatrix, PnL, TradeLog, Wallets, Controls, ConfigPanel)
Dockerfile Imagen Node 20 (build Vite + tsx en prod)
fly.toml Región, env vars y healthcheck Fly.io
.env.example Variables documentadas (sin secretos)
```
---
## Dashboard
El frontend consume solo la API del mismo origen (en dev, Vite hace proxy de `/api` al backend).
| Panel | Contenido |
|-------|-----------|
| **Stats bar** | P&L realizado, trades, rechazos, ticks, ms/tick del motor, estado LIVE/OFFLINE, circuit breaker |
| **Price matrix** | Mejor bid/ask por exchange y frescura |
| **P&L chart** | Serie temporal de beneficio simulado |
| **Trade log** | Ejecuciones y rechazos con motivo |
| **Wallets** | USDT y BTC por venue + rebalanceos recientes |
| **Opportunity feed** | Oportunidades detectadas (ejecutadas o no) |
| **Controls** | Pausa/reanudar, reset, demo on/off, umbral de edge mínimo, resumen de fees y stale/confirm |
| **Live config** | Min net profit, volumen por trade, ventana anti-flicker, activar/desactivar exchanges por venue |
---
## Variables de entorno
Copia `.env.example` a `.env` si quieres overrides locales. Todas son opcionales.
| Variable | Default | Descripción |
|----------|---------|-------------|
| `PORT` | `8080` | Puerto HTTP |
| `DEMO_MODE` | `false` | Feed sintético con divergencias inyectadas |
| `MIN_NET_PROFIT_PCT` | `0.0005` | Edge neto mínimo para ejecutar (0,05 %) |
| `MAX_TRADE_BTC` | `0.25` | Volumen máximo por trade simulado |
| `STALE_MS` | `3000` | Ignorar quotes más viejos que esto |
| `FLICKER_CONFIRM_MS` | `150` | Persistencia mínima antes de ejecutar |
| `LATENCY_MS` | `120` | Latencia simulada detección → ejecución |
| `LATENCY_SLIPPAGE_BPS` | `2` | Drift adverso de precio durante la latencia |
| `CIRCUIT_BREAKER_LOSSES` | `5` | Pérdidas seguidas antes de pausar |
| `CIRCUIT_BREAKER_COOLDOWN_MS` | `15000` | Cooldown del circuit breaker |
| `INITIAL_USDT` | `50000` | USDT inicial por exchange |
| `INITIAL_BTC` | `0.5` | BTC inicial por exchange |
| `RECORD_FEED` | `false` | Graba ticks en `data/*.ndjson` |
Umbral, volumen máximo, anti-flicker, exchanges activos y demo también se cambian en vivo desde el dashboard (`Controls` + `Live config`). La grabación NDJSON (`RECORD_FEED` / `POST /api/control/record`) puede activarse en caliente vía API.
---
## API HTTP
Respuestas REST siguen la forma `{ success, data?, error? }`.
| Método | Ruta | Descripción |
|--------|------|-------------|
| `GET` | `/api/health` | Healthcheck (Fly.io) |
| `GET` | `/api/state` | Snapshot completo del estado |
| `GET` | `/api/stream` | **SSE** — snapshots en tiempo real |
| `GET` | `/api/config` | Configuración del motor (fees, umbrales, exchanges activos) |
| `PATCH` | `/api/config` | Actualización parcial: `{ minNetProfitPct?, maxTradeBtc?, flickerConfirmMs?, activeExchanges? }` |
| `POST` | `/api/control/pause` | Pausar motor |
| `POST` | `/api/control/resume` | Reanudar |
| `POST` | `/api/control/reset` | Reiniciar estado simulado |
| `POST` | `/api/control/demo` | `{ "enabled": boolean }` |
| `POST` | `/api/control/record` | `{ "enabled": boolean }` — grabación NDJSON en `data/` |
| `POST` | `/api/control/threshold` | `{ "pct": number }` (atajo de `PATCH /api/config`) |
| `POST` | `/api/control/max-trade` | `{ "btc": number }` (atajo de `PATCH /api/config`) |
| `GET` | `/api-docs` | **Swagger UI** — documentación interactiva OpenAPI 3.0 |
Contrato completo y schemas en `src/interfaces/http/openapi.ts`.
---
## Deploy en Fly.io
El repo incluye `Dockerfile` (multi-stage: build Vite + runtime Node), `fly.toml` y el workflow `.github/workflows/fly-deploy.yml`.
1. Instala [flyctl](https://fly.io/docs/flyctl/install/) e inicia sesión: `fly auth login`.
2. Desde la raíz del repo: `fly launch` (primera vez) o `fly deploy`.
3. **Región primaria:** `sin` (Singapore) — menor latencia a matching engines de Binance, Bybit y OKX.
4. Fly expone el servicio en el puerto interno **8080** con healthcheck `GET /api/health`.
5. Variables de entorno: opcionales; `fly.toml` incluye defaults razonables (`STALE_MS`, inventario inicial, etc.).
6. **CI/CD:** cada push a `main` dispara deploy automático vía GitHub Actions (`fly-deploy.yml`; requiere secret `FLY_API_TOKEN`). Los PRs pasan `CI / quality` (typecheck, test, build) sin desplegar.
7. Abre la URL pública: badge **LIVE**, cuatro venues en la price matrix, y —en mercado real— sobre todo oportunidades rechazadas por fees (esperado).
> Producción usa **Fly.io**. `railway.json` es un artefacto legacy del deploy anterior; no lo uses.
Para una demo con actividad visible en pocos segundos, define `DEMO_MODE=true` en Fly (`fly secrets set` / `[env]` en `fly.toml`) o actívalo desde Controls.
---
## Qué esperar en producción con feed real
- Conexión estable a los cuatro feeds (Binance usa REST polling si el WS cae).
- Motor procesando ticks en sub-milisegundo medio (visible como **Engine /tick**).
- Pocas o ninguna ejecución rentable en neto; muchos eventos `rejected · fees`.
- Wallets y rebalanceos reflejando el modelo de inventario, no transferencias por trade.
Eso no indica que el bot “no funcione”: indica que el filtro económico es estricto.
---
## Roadmap
- Arbitraje triangular (misma exchange, otra ruta de precios).
- Replay determinista desde NDJSON grabado (hoy: recorder; loader en evolución).
---
## Licencia
MIT — ver archivo `LICENSE` cuando se añada al repositorio.
---
## Créditos y contexto
Proyecto de detección y simulación educativa/experimental de arbitraje BTC. No es asesoramiento financiero ni ejecución real de órdenes. Los fees y umbrales reflejan configuración documentada en código; verifica siempre las tablas oficiales de cada exchange antes de operar con capital real.
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+45
View File
@@ -0,0 +1,45 @@
# fly.toml app configuration file for arb-pulse
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'arb-pulse'
primary_region = 'sin'
[build]
[env]
CIRCUIT_BREAKER_COOLDOWN_MS = '15000'
CIRCUIT_BREAKER_LOSSES = '5'
DEMO_MODE = 'false'
FLICKER_CONFIRM_MS = '150'
INITIAL_BTC = '0.5'
INITIAL_USDT = '50000'
LATENCY_MS = '120'
LATENCY_SLIPPAGE_BPS = '2'
MAX_TRADE_BTC = '0.25'
MIN_NET_PROFIT_PCT = '0.0005'
PORT = '8080'
RECORD_FEED = 'false'
STALE_MS = '3000'
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
processes = ['app']
[[http_service.checks]]
interval = '30s'
timeout = '5s'
grace_period = '10s'
method = 'GET'
path = '/api/health'
[[vm]]
memory = '512mb'
cpu_kind = 'shared'
cpus = 1
memory_mb = 512
+1549
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "arb-pulse",
"version": "1.0.0",
"description": "Real-time BTC cross-exchange arbitrage detection & simulation bot (Kraken, Bybit, OKX, Binance)",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:web": "npm --prefix web run dev",
"start": "tsx src/index.ts",
"build": "npm --prefix web install && npm --prefix web run build",
"typecheck": "tsc --noEmit && npm --prefix web run typecheck",
"test": "node scripts/run-tests.mjs"
},
"dependencies": {
"express": "^4.21.2",
"swagger-ui-express": "^5.0.1",
"tsx": "^4.19.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.5.13",
"openapi-types": "^12.1.3",
"typescript": "^5.7.3"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "npm run build"
},
"deploy": {
"startCommand": "npm start",
"healthcheckPath": "/api/health",
"healthcheckTimeout": 100,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}
+32
View File
@@ -0,0 +1,32 @@
import { spawnSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/** @param {string} dir */
function collectTestFiles(dir) {
/** @type {string[]} */
const files = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
files.push(...collectTestFiles(path));
} else if (name.endsWith(".test.ts")) {
files.push(path);
}
}
return files;
}
const files = collectTestFiles("src");
if (files.length === 0) {
console.error("No test files found under src/");
process.exit(1);
}
const result = spawnSync(
process.execPath,
["--import", "tsx", "--test", ...files],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);
@@ -0,0 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { bootstrap } from "../../composition/bootstrap.js";
import { runtime } from "../../infrastructure/config/runtime.js";
import { FakeMarketDataFeedFactory } from "../../test-support/fake-market-data-feed.js";
import { book, FixedClock } from "../../test-support/test-fakes.js";
import { SyntheticFeed } from "../../infrastructure/demo/synthetic-feed.js";
const NOW = 2_000_000;
test("fixture feed drives detection through bootstrap without network", () => {
runtime.demoMode = false;
runtime.flickerConfirmMs = 0;
runtime.minNetProfitPct = 0.0001;
const fixtures = [
book("bybit", [{ price: 99_990, qty: 2 }], [{ price: 100_000, qty: 2 }], NOW),
book("okx", [{ price: 100_600, qty: 2 }], [{ price: 100_610, qty: 2 }], NOW),
book("kraken", [{ price: 100_200, qty: 2 }], [{ price: 100_210, qty: 2 }], NOW),
];
const ctx = bootstrap({
feedFactory: new FakeMarketDataFeedFactory(fixtures),
demoFeed: new SyntheticFeed(),
clock: new FixedClock(NOW),
});
ctx.start();
const snap = ctx.application.getSnapshot();
ctx.stop();
assert.ok(snap.stats.ticksProcessed >= 2, `ticks ${snap.stats.ticksProcessed}`);
const executed = snap.recentOpportunities.filter((o) => o.status === "executed" || o.status === "executed_partial");
assert.ok(executed.length >= 1, "expected at least one executed opportunity from fixture cross");
assert.ok(snap.stats.tradesExecuted >= 1, `trades ${snap.stats.tradesExecuted}`);
});
test("NDJSON-shaped fixture lines replay as order books", () => {
const line = {
ts: NOW,
exchange: "bybit" as const,
bids: [{ price: 99_000, qty: 1 }],
asks: [{ price: 99_010, qty: 1 }],
};
const fixtures = [book(line.exchange, line.bids, line.asks, line.ts)];
runtime.demoMode = false;
runtime.flickerConfirmMs = 0;
const ctx = bootstrap({
feedFactory: new FakeMarketDataFeedFactory(fixtures),
clock: new FixedClock(NOW),
});
ctx.start();
assert.equal(ctx.application.getSnapshot().stats.ticksProcessed, 1);
ctx.stop();
});
@@ -0,0 +1,127 @@
import { EXCHANGE_IDS, type ConfigPatch, type ExchangeId } from "../../domain/entities/index.js";
import type { IQuoteBook, IRiskGate } from "../../domain/ports/ports.js";
import type { StartMarketData } from "./start-market-data.js";
const MIN_PROFIT_PCT = 0.0001;
const MAX_PROFIT_PCT = 0.01;
const MIN_TRADE_BTC = 0.01;
const MAX_TRADE_BTC = 1.0;
const MAX_FLICKER_MS = 500;
export interface RuntimePort {
demoMode: boolean;
recordFeed: boolean;
minNetProfitPct: number;
maxTradeBtc: number;
flickerConfirmMs: number;
activeExchanges: Record<ExchangeId, boolean>;
}
export interface FeedRecorderControl {
close(): void;
}
/** Operator controls — mutates runtime via injected port, never reads process.env. */
export class ControlService {
constructor(
private readonly runtime: RuntimePort,
private readonly risk: IRiskGate,
private readonly quotes: IQuoteBook,
private readonly resetState: () => void,
private readonly marketData: StartMarketData,
private readonly recorder: FeedRecorderControl,
) {}
pause(): void {
this.risk.pause();
}
resume(): void {
this.risk.resume();
}
reset(): void {
this.resetState();
}
setDemoMode(enabled: boolean): void {
if (this.runtime.demoMode === enabled) return;
this.runtime.demoMode = enabled;
this.marketData.switchDemo(enabled);
}
setRecordFeed(enabled: boolean): void {
this.runtime.recordFeed = enabled;
if (!enabled) this.recorder.close();
}
setThreshold(pct: number): string | null {
if (!Number.isFinite(pct) || pct < MIN_PROFIT_PCT || pct > MAX_PROFIT_PCT) {
return `minNetProfitPct must be between ${MIN_PROFIT_PCT} and ${MAX_PROFIT_PCT}`;
}
this.runtime.minNetProfitPct = pct;
return null;
}
setMaxTradeBtc(btc: number): string | null {
if (!Number.isFinite(btc) || btc < MIN_TRADE_BTC || btc > MAX_TRADE_BTC) {
return `maxTradeBtc must be between ${MIN_TRADE_BTC} and ${MAX_TRADE_BTC}`;
}
this.runtime.maxTradeBtc = btc;
return null;
}
patchConfig(patch: ConfigPatch): string | null {
if (patch.minNetProfitPct !== undefined) {
const pct = patch.minNetProfitPct;
if (!Number.isFinite(pct) || pct < MIN_PROFIT_PCT || pct > MAX_PROFIT_PCT) {
return `minNetProfitPct must be between ${MIN_PROFIT_PCT} and ${MAX_PROFIT_PCT}`;
}
this.runtime.minNetProfitPct = pct;
}
if (patch.maxTradeBtc !== undefined) {
const btc = patch.maxTradeBtc;
if (!Number.isFinite(btc) || btc < MIN_TRADE_BTC || btc > MAX_TRADE_BTC) {
return `maxTradeBtc must be between ${MIN_TRADE_BTC} and ${MAX_TRADE_BTC}`;
}
this.runtime.maxTradeBtc = btc;
}
if (patch.flickerConfirmMs !== undefined) {
const ms = patch.flickerConfirmMs;
if (!Number.isFinite(ms) || ms < 0 || ms > MAX_FLICKER_MS) {
return `flickerConfirmMs must be between 0 and ${MAX_FLICKER_MS}`;
}
this.runtime.flickerConfirmMs = ms;
}
if (patch.activeExchanges !== undefined) {
const next = { ...this.runtime.activeExchanges };
for (const id of EXCHANGE_IDS) {
const enabled = patch.activeExchanges[id];
if (enabled !== undefined) next[id] = enabled;
}
if (!EXCHANGE_IDS.some((id) => next[id])) {
return "at least one exchange must remain active";
}
for (const id of EXCHANGE_IDS) {
if (this.runtime.activeExchanges[id] === next[id]) continue;
this.runtime.activeExchanges[id] = next[id];
this.marketData.applyExchangeToggle(id, next[id], (ex) => this.clearExchangeBook(ex));
}
}
return null;
}
private clearExchangeBook(exchange: ExchangeId): void {
this.quotes.update({
exchange,
bids: [],
asks: [],
recvTs: 0,
exchangeTs: null,
});
}
}
@@ -0,0 +1,17 @@
import type { Opportunity } from "../../domain/entities/index.js";
import type { IOpportunityExecutor, IRiskGate, IStateStore, ITradeExecutor } from "../../domain/ports/ports.js";
/** Risk gate + simulated execution + store update after a confirmed opportunity. */
export class ExecuteArbitrage implements IOpportunityExecutor {
constructor(
private readonly executor: ITradeExecutor,
private readonly store: IStateStore,
private readonly risk: IRiskGate,
) {}
execute(op: Opportunity, now: number): void {
const trade = this.executor.execute(op, now);
this.store.addTrade(trade);
this.risk.evaluate(now);
}
}
@@ -0,0 +1,21 @@
import type { OrderBook } from "../../domain/entities/index.js";
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
export interface FeedRecorderPort {
record(book: OrderBook): void;
}
/** Hot path: record optional NDJSON, then drive detection on the updated book. */
export class ProcessOrderBookUpdate {
constructor(
private readonly engine: ArbitrageEngine,
private readonly recorder: FeedRecorderPort,
private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean,
) {}
run(book: OrderBook): void {
if (!this.isExchangeActive(book.exchange)) return;
this.recorder.record(book);
this.engine.onBook(book);
}
}
@@ -0,0 +1,10 @@
import type { IRebalancer } from "../../domain/ports/ports.js";
/** Periodic inventory correction (withdrawal fee only here, not per trade). */
export class RebalanceInventory {
constructor(private readonly rebalancer: IRebalancer) {}
tick(now: number): void {
this.rebalancer.tick(now);
}
}
@@ -0,0 +1,90 @@
import { EXCHANGE_IDS, type ExchangeId } from "../../domain/entities/index.js";
import type { MarketDataFeed, MarketDataFeedFactory } from "../../domain/ports/ports.js";
export type FeedMode = "real" | "demo" | "stopped";
export interface DemoFeedPort {
start(): void;
stop(): void;
}
/**
* Starts real WS connectors or the synthetic demo feed. Listeners are wired
* once in composition; this class only controls lifecycle.
*/
export class StartMarketData {
private connectors = new Map<ExchangeId, MarketDataFeed>();
private mode: FeedMode = "stopped";
constructor(
private readonly feedFactory: MarketDataFeedFactory,
private readonly demoFeed: DemoFeedPort,
private readonly onBook: (book: import("../../domain/entities/index.js").OrderBook) => void,
private readonly isDemoMode: () => boolean,
private readonly isExchangeActive: (id: ExchangeId) => boolean,
) {}
getMode(): FeedMode {
return this.mode;
}
start(): void {
if (this.isDemoMode()) {
this.mode = "demo";
this.demoFeed.start();
} else {
this.mode = "real";
for (const id of EXCHANGE_IDS) {
if (this.isExchangeActive(id)) this.startConnector(id);
}
}
}
startConnector(id: ExchangeId): void {
if (this.connectors.has(id)) return;
const connector = this.feedFactory.create(id);
connector.onBook((book) => this.onBook(book));
connector.start();
this.connectors.set(id, connector);
}
stopConnector(id: ExchangeId): void {
const connector = this.connectors.get(id);
if (!connector) return;
connector.stop();
this.connectors.delete(id);
}
switchDemo(enabled: boolean): void {
this.stop();
if (enabled) {
this.mode = "demo";
this.demoFeed.start();
} else {
this.mode = "real";
for (const id of EXCHANGE_IDS) {
if (this.isExchangeActive(id)) this.startConnector(id);
}
}
}
applyExchangeToggle(id: ExchangeId, enabled: boolean, clearBook: (exchange: ExchangeId) => void): void {
if (this.mode === "demo") {
if (!enabled) clearBook(id);
return;
}
if (enabled) {
this.startConnector(id);
} else {
this.stopConnector(id);
clearBook(id);
}
}
stop(): void {
this.demoFeed.stop();
for (const c of this.connectors.values()) c.stop();
this.connectors.clear();
this.mode = "stopped";
}
}
@@ -0,0 +1,9 @@
import type { StartMarketData } from "./start-market-data.js";
export class StopMarketData {
constructor(private readonly marketData: StartMarketData) {}
run(): void {
this.marketData.stop();
}
}
@@ -0,0 +1,15 @@
import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js";
import type { RebalanceInventory } from "./rebalance-inventory.js";
/** Periodic risk cooldown reset + rebalancer tick (independent of feed). */
export class TickRiskAndRebalance {
constructor(
private readonly engine: ArbitrageEngine,
private readonly rebalance: RebalanceInventory,
) {}
tick(now: number): void {
this.engine.tick(now);
this.rebalance.tick(now);
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { ConfigPatch, PublicConfig, StateSnapshot } from "../domain/entities/index.js";
import { config } from "../infrastructure/config/config.js";
import { runtime, runtimeDefaults } from "../infrastructure/config/runtime.js";
import type { OrderBookManager } from "../infrastructure/state/order-book-manager.js";
import type { Store } from "../infrastructure/state/store.js";
import type { WalletBook } from "../infrastructure/state/wallet-book.js";
import type { ControlService } from "../application/use-cases/control-service.js";
/** REST/SSE facade wired at the composition root. */
export class ApplicationService {
constructor(
private readonly store: Store,
private readonly quotes: OrderBookManager,
private readonly wallets: WalletBook,
private readonly controls: ControlService,
) {}
getConfig(): PublicConfig {
return {
minNetProfitPct: runtime.minNetProfitPct,
maxTradeBtc: runtime.maxTradeBtc,
staleMs: config.staleMs,
flickerConfirmMs: runtime.flickerConfirmMs,
latencyMs: config.latencyMs,
activeExchanges: { ...runtime.activeExchanges },
defaults: {
minNetProfitPct: runtimeDefaults.minNetProfitPct,
maxTradeBtc: runtimeDefaults.maxTradeBtc,
flickerConfirmMs: runtimeDefaults.flickerConfirmMs,
activeExchanges: { ...runtimeDefaults.activeExchanges },
},
takerFees: config.takerFees,
withdrawalFeesBtc: config.withdrawalFeesBtc,
};
}
getSnapshot(): StateSnapshot {
const now = Date.now();
return {
ts: now,
quotes: this.quotes.bestQuotes(now),
wallets: this.wallets.all(),
stats: {
uptimeMs: now - this.store.startedAt,
ticksProcessed: this.store.ticksProcessed,
opportunitiesDetected: this.store.opportunitiesDetected,
tradesExecuted: this.store.tradesExecuted,
tradesRejected: this.store.tradesRejected,
realizedPnl: this.store.realizedPnl,
consecutiveLosses: this.store.consecutiveLosses,
circuit: this.store.circuit,
demoMode: runtime.demoMode,
avgTickMs: this.store.tickTimeEwma,
},
recentOpportunities: this.store.recentOpportunities(),
recentTrades: this.store.recentTrades(),
rebalances: this.store.recentRebalances(),
pnlSeries: this.store.pnlSeries(),
config: this.getConfig(),
};
}
patchConfig(patch: ConfigPatch): string | null {
return this.controls.patchConfig(patch);
}
pause(): void {
this.controls.pause();
}
resume(): void {
this.controls.resume();
}
reset(): void {
this.controls.reset();
}
setDemoMode(enabled: boolean): void {
this.controls.setDemoMode(enabled);
}
setRecordFeed(enabled: boolean): void {
this.controls.setRecordFeed(enabled);
}
setThreshold(pct: number): string | null {
return this.controls.setThreshold(pct);
}
setMaxTradeBtc(btc: number): string | null {
return this.controls.setMaxTradeBtc(btc);
}
}
+117
View File
@@ -0,0 +1,117 @@
import { createLogger } from "../infrastructure/logging/logger.js";
import { config } from "../infrastructure/config/config.js";
import { runtime } from "../infrastructure/config/runtime.js";
import { RuntimeTradingPolicy } from "../infrastructure/config/trading-policy.js";
import { ConnectorFactory } from "../infrastructure/exchanges/index.js";
import { SyntheticFeed } from "../infrastructure/demo/synthetic-feed.js";
import { FeedRecorder } from "../infrastructure/demo/recorder.js";
import { OrderBookManager } from "../infrastructure/state/order-book-manager.js";
import { WalletBook } from "../infrastructure/state/wallet-book.js";
import { Store } from "../infrastructure/state/store.js";
import { RiskManager } from "../infrastructure/simulation/risk-manager.js";
import { ExecutionSimulator } from "../infrastructure/simulation/execution-simulator.js";
import { Rebalancer } from "../infrastructure/rebalancing/rebalancer.js";
import { SystemClock } from "../infrastructure/time/clock.js";
import { IdGenerator } from "../infrastructure/ids/id-generator.js";
import { ArbitrageEngine } from "../domain/services/arbitrage-engine.js";
import { ExecuteArbitrage } from "../application/use-cases/execute-arbitrage.js";
import { ProcessOrderBookUpdate } from "../application/use-cases/process-order-book-update.js";
import { RebalanceInventory } from "../application/use-cases/rebalance-inventory.js";
import { TickRiskAndRebalance } from "../application/use-cases/tick-risk-and-rebalance.js";
import { StartMarketData } from "../application/use-cases/start-market-data.js";
import { StopMarketData } from "../application/use-cases/stop-market-data.js";
import { ControlService } from "../application/use-cases/control-service.js";
import { ApplicationService } from "./application-service.js";
import type { IClock, MarketDataFeedFactory } from "../domain/ports/ports.js";
const log = createLogger("bootstrap");
export interface BootstrapOptions {
feedFactory?: MarketDataFeedFactory;
demoFeed?: SyntheticFeed;
clock?: IClock;
}
export interface AppContext {
application: ApplicationService;
start(): void;
stop(): void;
}
export function bootstrap(options: BootstrapOptions = {}): AppContext {
const obm = new OrderBookManager();
const wallets = new WalletBook();
const store = new Store();
const policy = new RuntimeTradingPolicy();
const clock = options.clock ?? new SystemClock();
const ids = new IdGenerator();
const risk = new RiskManager(store, policy);
const exec = new ExecutionSimulator(wallets, policy, ids);
const executeArbitrage = new ExecuteArbitrage(exec, store, risk);
const engine = new ArbitrageEngine({
quotes: obm,
inventory: wallets,
store,
risk,
opportunityExecutor: executeArbitrage,
policy,
clock,
ids,
});
const rebalancer = new Rebalancer(wallets, store, policy, ids);
const rebalanceInventory = new RebalanceInventory(rebalancer);
const tickRisk = new TickRiskAndRebalance(engine, rebalanceInventory);
const recorder = new FeedRecorder();
const processBook = new ProcessOrderBookUpdate(
engine,
recorder,
(exchange) => runtime.activeExchanges[exchange],
);
const demoFeed = options.demoFeed ?? new SyntheticFeed();
demoFeed.onBook((book) => processBook.run(book));
const feedFactory = options.feedFactory ?? new ConnectorFactory();
const marketData = new StartMarketData(
feedFactory,
demoFeed,
(book) => processBook.run(book),
() => runtime.demoMode,
(id) => runtime.activeExchanges[id],
);
const controls = new ControlService(
runtime,
risk,
obm,
() => {
store.reset();
wallets.reset();
log.info("state and wallets reset");
},
marketData,
recorder,
);
const application = new ApplicationService(store, obm, wallets, controls);
const stopMarket = new StopMarketData(marketData);
let tickTimer: NodeJS.Timeout | null = null;
return {
application,
start() {
marketData.start();
tickTimer = setInterval(() => tickRisk.tick(Date.now()), 1000);
log.info(`engine started (demo=${runtime.demoMode})`);
},
stop() {
if (tickTimer) clearInterval(tickTimer);
stopMarket.run();
recorder.close();
},
};
}
export { config };
+184
View File
@@ -0,0 +1,184 @@
/**
* Domain types for the cross-exchange BTC arbitrage engine.
* All prices are quoted in USDT (BTC/USDT on every exchange — apples-to-apples,
* no USD/USDT basis distortion).
*/
export type ExchangeId = "kraken" | "bybit" | "okx" | "binance";
export const EXCHANGE_IDS: ExchangeId[] = ["kraken", "bybit", "okx", "binance"];
/** A single order book price level. */
export interface Level {
price: number;
qty: number;
}
/** Normalized order book for one exchange. Bids desc, asks asc. */
export interface OrderBook {
exchange: ExchangeId;
bids: Level[];
asks: Level[];
/** Local receive timestamp (ms epoch). */
recvTs: number;
/** Exchange-provided timestamp if available (ms epoch). */
exchangeTs: number | null;
}
/** Connection status of an exchange feed. */
export type FeedStatus = "connecting" | "live" | "stale" | "down";
/** Best bid/ask snapshot exposed to the UI. */
export interface BestQuote {
exchange: ExchangeId;
bid: number | null;
bidQty: number | null;
ask: number | null;
askQty: number | null;
recvTs: number | null;
status: FeedStatus;
/** ms since last update. */
ageMs: number | null;
}
/**
* Why an opportunity was or wasn't executed.
* Exhaustive union — handle every case.
*/
export type OpportunityStatus =
| "executed"
| "executed_partial"
| "rejected_fees"
| "rejected_liquidity"
| "rejected_risk"
| "rejected_flicker"
| "rejected_stale"
| "pending_confirm";
export interface Opportunity {
id: string;
ts: number;
buyExchange: ExchangeId;
sellExchange: ExchangeId;
/** Best (top-of-book) ask on the buy side, before walking depth. */
topBuyAsk: number;
/** Best (top-of-book) bid on the sell side, before walking depth. */
topSellBid: number;
/** Volume actually evaluated (BTC), after liquidity + wallet caps. */
volumeBtc: number;
/** Volume-weighted average buy price for volumeBtc. */
buyVwap: number;
/** Volume-weighted average sell price for volumeBtc. */
sellVwap: number;
grossSpread: number;
grossSpreadPct: number;
feeBuy: number;
feeSell: number;
netProfit: number;
netProfitPct: number;
status: OpportunityStatus;
reason: string;
/** True when generated by the synthetic demo injector. */
demo: boolean;
}
/** An executed (simulated) trade. */
export interface Trade {
id: string;
ts: number;
buyExchange: ExchangeId;
sellExchange: ExchangeId;
volumeBtc: number;
requestedBtc: number;
buyVwap: number;
sellVwap: number;
/** Buy VWAP after simulated latency drift. */
execBuyVwap: number;
/** Sell VWAP after simulated latency drift. */
execSellVwap: number;
feeBuy: number;
feeSell: number;
/** Net realized P&L in USDT (negative = loss). */
netProfit: number;
netProfitPct: number;
partial: boolean;
demo: boolean;
}
/** Per-exchange simulated wallet (pre-positioned inventory model). */
export interface Wallet {
exchange: ExchangeId;
usdt: number;
btc: number;
}
export interface RebalanceEvent {
id: string;
ts: number;
fromExchange: ExchangeId;
toExchange: ExchangeId;
asset: "BTC" | "USDT";
amount: number;
withdrawalFee: number;
reason: string;
}
export type CircuitState = "running" | "paused" | "tripped";
export interface EngineStats {
uptimeMs: number;
ticksProcessed: number;
opportunitiesDetected: number;
tradesExecuted: number;
tradesRejected: number;
realizedPnl: number;
consecutiveLosses: number;
circuit: CircuitState;
demoMode: boolean;
/** Average engine processing time per tick (ms). */
avgTickMs: number;
}
/** Full snapshot pushed to the dashboard over SSE. */
export interface StateSnapshot {
ts: number;
quotes: BestQuote[];
wallets: Wallet[];
stats: EngineStats;
recentOpportunities: Opportunity[];
recentTrades: Trade[];
rebalances: RebalanceEvent[];
pnlSeries: PnlPoint[];
config: PublicConfig;
}
export interface PnlPoint {
ts: number;
pnl: number;
}
/** Partial update body for PATCH /api/config. */
export interface ConfigPatch {
minNetProfitPct?: number;
maxTradeBtc?: number;
flickerConfirmMs?: number;
activeExchanges?: Partial<Record<ExchangeId, boolean>>;
}
/** Engine config surfaced to the UI (no secrets). */
export interface PublicConfig {
minNetProfitPct: number;
maxTradeBtc: number;
staleMs: number;
flickerConfirmMs: number;
latencyMs: number;
activeExchanges: Record<ExchangeId, boolean>;
defaults: {
minNetProfitPct: number;
maxTradeBtc: number;
flickerConfirmMs: number;
activeExchanges: Record<ExchangeId, boolean>;
};
takerFees: Record<ExchangeId, number>;
withdrawalFeesBtc: Record<ExchangeId, number>;
}
+115
View File
@@ -0,0 +1,115 @@
import type {
CircuitState,
ExchangeId,
Opportunity,
OrderBook,
RebalanceEvent,
Trade,
Wallet,
} from "../entities/index.js";
/**
* Hexagonal ports for the arbitrage core. Concrete adapters live in
* infrastructure; application use cases orchestrate them. Core domain services
* depend ONLY on these interfaces.
*/
/** Wall-clock source (injectable for deterministic tests). */
export interface IClock {
now(): number;
}
/** Opaque event-id generator. */
export interface IIdGenerator {
next(prefix: string): string;
}
/** Latest normalized order book per exchange + staleness. */
export interface IQuoteBook {
update(book: OrderBook): void;
getBook(exchange: ExchangeId): OrderBook | undefined;
isFresh(exchange: ExchangeId, now: number): boolean;
}
/** Per-exchange pre-positioned inventory (USDT + BTC). */
export interface IInventory {
get(exchange: ExchangeId): Wallet;
maxBuyableBtc(exchange: ExchangeId, vwapWithFee: number): number;
sellableBtc(exchange: ExchangeId): number;
applyBuy(exchange: ExchangeId, btc: number, quoteCost: number): void;
applySell(exchange: ExchangeId, btc: number, quoteProceeds: number): void;
applyTransfer(
from: ExchangeId,
to: ExchangeId,
asset: "BTC" | "USDT",
amount: number,
fee: number,
): void;
}
/** Simulates execution of a validated opportunity into a realized trade. */
export interface ITradeExecutor {
execute(op: Opportunity, now: number): Trade;
}
/** Post-detection execution pipeline (simulated fill + store + risk). */
export interface IOpportunityExecutor {
execute(op: Opportunity, now: number): void;
}
/** Circuit breaker / execution gate. */
export interface IRiskGate {
canExecute(): boolean;
evaluate(now: number): void;
tick(now: number): void;
pause(): void;
resume(): void;
}
/** In-memory state store (history + counters + P&L curve). */
export interface IStateStore {
ticksProcessed: number;
tradesRejected: number;
circuit: CircuitState;
consecutiveLosses: number;
recordTickTime(ms: number): void;
addOpportunity(op: Opportunity): void;
addTrade(trade: Trade): void;
addRebalance(event: RebalanceEvent): void;
}
/** A market-data source emitting normalized order books. */
export interface MarketDataFeed {
onBook(listener: (book: OrderBook) => void): void;
start(): void;
stop(): void;
}
/** Builds a per-exchange market-data feed. */
export interface MarketDataFeedFactory {
create(id: ExchangeId): MarketDataFeed;
}
/** Periodic inventory drift correction between venues. */
export interface IRebalancer {
tick(now: number): void;
}
/**
* Trading policy: fees, thresholds and mode flags. Methods (not fields) so
* live-tunable values are read fresh on each call.
*/
export interface TradingPolicy {
takerFee(exchange: ExchangeId): number;
withdrawalFeeBtc(exchange: ExchangeId): number;
minNetProfitPct(): number;
maxTradeBtc(): number;
flickerConfirmMs(): number;
latencySlippageBps(): number;
circuitBreakerLosses(): number;
circuitBreakerCooldownMs(): number;
rebalanceIntervalMs(): number;
rebalanceMinBtc(): number;
rebalanceMinUsdt(): number;
isDemo(): boolean;
}
@@ -0,0 +1,193 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { ArbitrageEngine } from "./arbitrage-engine.js";
import { ExecuteArbitrage } from "../../application/use-cases/execute-arbitrage.js";
import {
FakeExecutor,
FakeInventory,
FakePolicy,
FakeQuoteBook,
FakeRiskGate,
FakeStore,
FixedClock,
SeqIds,
book,
} from "../../test-support/test-fakes.js";
import type { ExchangeId, OpportunityStatus } from "../entities/index.js";
const NOW = 1_000_000;
interface Harness {
engine: ArbitrageEngine;
quotes: FakeQuoteBook;
store: FakeStore;
risk: FakeRiskGate;
executor: FakeExecutor;
policy: FakePolicy;
}
function harness(opts: { recvTs?: number; buyAsk?: number; sellBid?: number; flickerMs?: number } = {}): Harness {
const { recvTs = NOW, buyAsk = 100000, sellBid = 100600, flickerMs = 0 } = opts;
const quotes = new FakeQuoteBook();
const store = new FakeStore();
const risk = new FakeRiskGate(true);
const executor = new FakeExecutor(1);
const policy = new FakePolicy();
policy.maxTrade = 0.1;
policy.flickerMs = flickerMs;
quotes.update(book("bybit", [{ price: buyAsk - 10, qty: 1 }], [{ price: buyAsk, qty: 1 }], recvTs));
quotes.update(book("okx", [{ price: sellBid, qty: 1 }], [{ price: sellBid + 10, qty: 1 }], recvTs));
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
const engine = new ArbitrageEngine({
quotes,
inventory: new FakeInventory(),
store,
risk,
opportunityExecutor,
policy,
clock: new FixedClock(NOW),
ids: new SeqIds(),
});
return { engine, quotes, store, risk, executor, policy };
}
function statusesFor(h: Harness, buy: ExchangeId, sell: ExchangeId): OpportunityStatus[] {
return h.store.opportunities
.filter((o) => o.buyExchange === buy && o.sellExchange === sell)
.map((o) => o.status);
}
function trigger(h: Harness): void {
h.engine.onBook(h.quotes.getBook("okx")!);
}
test("executes a clean, net-profitable, fresh, confirmed cross", () => {
const h = harness({ flickerMs: 0 });
trigger(h);
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["executed"]);
assert.equal(h.executor.calls.length, 1);
assert.equal(h.store.trades.length, 1);
assert.equal(h.risk.evaluations.length, 1);
});
test("rejected_fees when the net edge is below the threshold", () => {
const h = harness({ buyAsk: 100000, sellBid: 100100, flickerMs: 0 });
trigger(h);
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_fees"]);
assert.equal(h.executor.calls.length, 0);
assert.equal(h.store.trades.length, 0);
});
test("rejected_stale when a crossing quote is older than staleMs", () => {
const h = harness({ recvTs: NOW - 5000, flickerMs: 0 });
trigger(h);
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_stale"]);
assert.equal(h.executor.calls.length, 0);
});
test("anti-flicker: first profitable tick is pending_confirm, not executed", () => {
const h = harness({ flickerMs: 150 });
trigger(h);
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["pending_confirm"]);
assert.equal(h.executor.calls.length, 0);
});
test("does not execute while the risk gate is closed", () => {
const h = harness({ flickerMs: 0 });
h.risk.allow = false;
trigger(h);
assert.deepEqual(statusesFor(h, "bybit", "okx"), ["rejected_risk"]);
assert.equal(h.executor.calls.length, 0);
});
test("counts ticks processed", () => {
const h = harness({ flickerMs: 0 });
trigger(h);
assert.equal(h.store.ticksProcessed, 1);
});
test("executes only the highest netProfit opportunity when multiple pairs confirm in one tick", () => {
const quotes = new FakeQuoteBook();
const store = new FakeStore();
const risk = new FakeRiskGate(true);
const executor = new FakeExecutor(1);
const policy = new FakePolicy();
policy.maxTrade = 0.1;
policy.flickerMs = 0;
const recvTs = NOW;
// Small edge: buy bybit, sell okx
quotes.update(book("bybit", [{ price: 99990, qty: 1 }], [{ price: 100000, qty: 1 }], recvTs));
quotes.update(book("okx", [{ price: 100600, qty: 1 }], [{ price: 100610, qty: 1 }], recvTs));
// Large edge: buy kraken (cheaper ask), sell bybit
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 99500, qty: 1 }], recvTs));
quotes.update(book("bybit", [{ price: 101500, qty: 1 }], [{ price: 101510, qty: 1 }], recvTs));
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
const engine = new ArbitrageEngine({
quotes,
inventory: new FakeInventory(),
store,
risk,
opportunityExecutor,
policy,
clock: new FixedClock(NOW),
ids: new SeqIds(),
});
engine.onBook(quotes.getBook("kraken")!);
assert.equal(executor.calls.length, 1);
assert.equal(executor.calls[0]!.op.buyExchange, "kraken");
assert.equal(executor.calls[0]!.op.sellExchange, "bybit");
assert.equal(statusesFor({ engine, quotes, store, risk, executor, policy }, "bybit", "okx").length, 0);
assert.equal(store.trades.length, 1);
});
test("defers lower-profit pair to a later tick after the winner executes", () => {
const quotes = new FakeQuoteBook();
const store = new FakeStore();
const risk = new FakeRiskGate(true);
const executor = new FakeExecutor(1);
const policy = new FakePolicy();
policy.maxTrade = 0.1;
policy.flickerMs = 0;
const recvTs = NOW;
quotes.update(book("okx", [{ price: 100600, qty: 1 }], [{ price: 100610, qty: 1 }], recvTs));
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 99500, qty: 1 }], recvTs));
quotes.update(book("bybit", [{ price: 101500, qty: 1 }], [{ price: 101510, qty: 1 }], recvTs));
const clock = new FixedClock(NOW);
const opportunityExecutor = new ExecuteArbitrage(executor, store, risk);
const engine = new ArbitrageEngine({
quotes,
inventory: new FakeInventory(),
store,
risk,
opportunityExecutor,
policy,
clock,
ids: new SeqIds(),
});
engine.onBook(quotes.getBook("kraken")!);
assert.equal(executor.calls.length, 1);
// Remove other crosses; bybit→okx should execute (anti-flicker already confirmed).
quotes.update(book("kraken", [{ price: 99400, qty: 1 }], [{ price: 101600, qty: 1 }], recvTs));
quotes.update(book("bybit", [{ price: 99990, qty: 1 }], [{ price: 100000, qty: 1 }], recvTs));
clock.t = NOW + 100;
engine.onBook(quotes.getBook("okx")!);
assert.equal(executor.calls.length, 2);
assert.equal(executor.calls[1]!.op.buyExchange, "bybit");
assert.equal(executor.calls[1]!.op.sellExchange, "okx");
});
+289
View File
@@ -0,0 +1,289 @@
import { walkBook, totalDepthBtc } from "./vwap.js";
import {
EXCHANGE_IDS,
type ExchangeId,
type Opportunity,
type OpportunityStatus,
type OrderBook,
} from "../entities/index.js";
import type {
IClock,
IIdGenerator,
IInventory,
IOpportunityExecutor,
IQuoteBook,
IRiskGate,
IStateStore,
TradingPolicy,
} from "../ports/ports.js";
import { netProfit, netProfitPct, takerFeeCost } from "./pricing.js";
const DUST_BTC = 1e-5;
const REJECT_THROTTLE_MS = 800;
/** Scored pair ready for execution — emitted and executed only if selected as best. */
interface ExecutableCandidate {
buy: ExchangeId;
sell: ExchangeId;
topAsk: number;
topBid: number;
volume: number;
buyVwap: number;
sellVwap: number;
feeBuy: number;
feeSell: number;
netProfit: number;
netProfitPct: number;
partial: boolean;
now: number;
}
/** Collaborators are ports (interfaces), never concrete classes. */
export interface EngineDeps {
quotes: IQuoteBook;
inventory: IInventory;
store: IStateStore;
risk: IRiskGate;
opportunityExecutor: IOpportunityExecutor;
policy: TradingPolicy;
clock: IClock;
ids: IIdGenerator;
}
/**
* Core arbitrage detection. On every order-book tick it re-evaluates all
* ordered exchange pairs (buy on A, sell on B).
*/
export class ArbitrageEngine {
private pending = new Map<string, number>();
private lastEmit = new Map<string, number>();
constructor(private readonly deps: EngineDeps) {}
onBook(book: OrderBook): void {
const start = performance.now();
this.deps.quotes.update(book);
this.deps.store.ticksProcessed += 1;
this.evaluate(this.deps.clock.now());
this.deps.store.recordTickTime(performance.now() - start);
}
tick(now: number): void {
this.deps.risk.tick(now);
}
private evaluate(now: number): void {
const executables: ExecutableCandidate[] = [];
for (const buy of EXCHANGE_IDS) {
for (const sell of EXCHANGE_IDS) {
if (buy === sell) continue;
const candidate = this.scorePair(buy, sell, now);
if (candidate) executables.push(candidate);
}
}
if (executables.length === 0) return;
const best = executables.reduce((a, b) => (this.compareCandidates(a, b) < 0 ? b : a));
const key = `${best.buy}->${best.sell}`;
const status: OpportunityStatus = best.partial ? "executed_partial" : "executed";
const opportunity = this.emit(
{ ...best, status, reason: "executed" },
false,
);
this.deps.opportunityExecutor.execute(opportunity, now);
this.pending.delete(key);
this.lastEmit.set(key, now);
}
/** Higher netProfit wins; tie-break netProfitPct, then lexicographic (buy, sell). */
private compareCandidates(a: ExecutableCandidate, b: ExecutableCandidate): number {
if (a.netProfit !== b.netProfit) return a.netProfit - b.netProfit;
if (a.netProfitPct !== b.netProfitPct) return a.netProfitPct - b.netProfitPct;
if (a.buy !== b.buy) return a.buy < b.buy ? -1 : 1;
return a.sell < b.sell ? -1 : a.sell === b.sell ? 0 : 1;
}
/** Score one pair; emit rejections and pending_confirm inline; return executable if confirmed. */
private scorePair(buy: ExchangeId, sell: ExchangeId, now: number): ExecutableCandidate | null {
const key = `${buy}->${sell}`;
const buyBook = this.deps.quotes.getBook(buy);
const sellBook = this.deps.quotes.getBook(sell);
const topAsk = buyBook?.asks[0];
const topBid = sellBook?.bids[0];
if (!buyBook || !sellBook || !topAsk || !topBid) {
this.pending.delete(key);
return null;
}
if (topAsk.price >= topBid.price) {
this.pending.delete(key);
return null;
}
if (!this.deps.quotes.isFresh(buy, now) || !this.deps.quotes.isFresh(sell, now)) {
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_stale", "stale quote", now);
this.pending.delete(key);
return null;
}
const feeBuyRate = this.deps.policy.takerFee(buy);
const feeSellRate = this.deps.policy.takerFee(sell);
const askDepth = totalDepthBtc(buyBook.asks);
const bidDepth = totalDepthBtc(sellBook.bids);
const buyable = this.deps.inventory.maxBuyableBtc(buy, topAsk.price * (1 + feeBuyRate));
const sellable = this.deps.inventory.sellableBtc(sell);
const requested = this.deps.policy.maxTradeBtc();
const target = Math.min(requested, askDepth, bidDepth, buyable, sellable);
if (target < DUST_BTC) {
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_liquidity", "no liquidity or inventory", now);
this.pending.delete(key);
return null;
}
const buySide = walkBook(buyBook.asks, target);
const sellSide = walkBook(sellBook.bids, target);
const volume = Math.min(buySide.filledBtc, sellSide.filledBtc);
if (volume < DUST_BTC) {
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_liquidity", "insufficient depth", now);
this.pending.delete(key);
return null;
}
const buyVwap = buySide.vwap;
const sellVwap = sellSide.vwap;
const feeBuy = takerFeeCost(buyVwap, volume, feeBuyRate);
const feeSell = takerFeeCost(sellVwap, volume, feeSellRate);
const net = netProfit(buyVwap, sellVwap, volume, feeBuyRate, feeSellRate);
const notional = buyVwap * volume;
const netPct = netProfitPct(net, notional);
const partial = volume < requested - DUST_BTC;
const base = {
buy,
sell,
topAsk: topAsk.price,
topBid: topBid.price,
volume,
buyVwap,
sellVwap,
feeBuy,
feeSell,
netProfit: net,
netProfitPct: netPct,
now,
};
if (netPct <= this.deps.policy.minNetProfitPct()) {
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_fees", "net edge below threshold", now);
this.pending.delete(key);
return null;
}
const firstTs = this.pending.get(key) ?? now;
if (!this.pending.has(key)) this.pending.set(key, now);
if (now - firstTs < this.deps.policy.flickerConfirmMs()) {
this.emit({ ...base, status: "pending_confirm", reason: "confirming edge persistence", partial }, true);
return null;
}
if (!this.deps.risk.canExecute()) {
this.emitRejection(buy, sell, topAsk.price, topBid.price, "rejected_risk", "circuit breaker active", now);
this.pending.delete(key);
return null;
}
return { ...base, partial };
}
private emitRejection(
buy: ExchangeId,
sell: ExchangeId,
topAsk: number,
topBid: number,
status: OpportunityStatus,
reason: string,
now: number,
): void {
const key = `${buy}->${sell}`;
const last = this.lastEmit.get(key) ?? 0;
if (now - last < REJECT_THROTTLE_MS) return;
this.lastEmit.set(key, now);
this.deps.store.tradesRejected += 1;
const gross = topBid - topAsk;
this.deps.store.addOpportunity({
id: this.deps.ids.next("opp"),
ts: now,
buyExchange: buy,
sellExchange: sell,
topBuyAsk: topAsk,
topSellBid: topBid,
volumeBtc: 0,
buyVwap: topAsk,
sellVwap: topBid,
grossSpread: gross,
grossSpreadPct: topAsk > 0 ? gross / topAsk : 0,
feeBuy: 0,
feeSell: 0,
netProfit: 0,
netProfitPct: 0,
status,
reason,
demo: this.deps.policy.isDemo(),
});
}
private emit(
p: {
buy: ExchangeId;
sell: ExchangeId;
topAsk: number;
topBid: number;
volume: number;
buyVwap: number;
sellVwap: number;
feeBuy: number;
feeSell: number;
netProfit: number;
netProfitPct: number;
status: OpportunityStatus;
reason: string;
partial: boolean;
now: number;
},
throttled: boolean,
): Opportunity {
const key = `${p.buy}->${p.sell}`;
const gross = p.topBid - p.topAsk;
const opportunity: Opportunity = {
id: this.deps.ids.next("opp"),
ts: p.now,
buyExchange: p.buy,
sellExchange: p.sell,
topBuyAsk: p.topAsk,
topSellBid: p.topBid,
volumeBtc: p.volume,
buyVwap: p.buyVwap,
sellVwap: p.sellVwap,
grossSpread: gross,
grossSpreadPct: p.topAsk > 0 ? gross / p.topAsk : 0,
feeBuy: p.feeBuy,
feeSell: p.feeSell,
netProfit: p.netProfit,
netProfitPct: p.netProfitPct,
status: p.status,
reason: p.reason,
demo: this.deps.policy.isDemo(),
};
if (throttled) {
const last = this.lastEmit.get(key) ?? 0;
if (p.now - last < REJECT_THROTTLE_MS) return opportunity;
this.lastEmit.set(key, p.now);
}
this.deps.store.addOpportunity(opportunity);
return opportunity;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { netProfit, netProfitPct, takerFeeCost } from "./pricing.js";
test("takerFeeCost = vwap * volume * rate", () => {
assert.equal(takerFeeCost(100, 2, 0.001), 0.2);
});
test("netProfit is positive when gross edge beats both taker fees", () => {
const net = netProfit(100000, 100600, 0.1, 0.001, 0.001);
assert.ok(Math.abs(net - 39.94) < 1e-9, `got ${net}`);
});
test("netProfit is negative when fees exceed gross edge", () => {
const net = netProfit(100, 100.1, 1, 0.001, 0.001);
assert.ok(net < 0, `expected negative, got ${net}`);
});
test("netProfit equals proceeds-minus-cost expansion (executor parity)", () => {
const buyVwap = 100020;
const sellVwap = 100579.88;
const vol = 0.1;
const fb = 0.001;
const fs = 0.001;
const feeBuy = takerFeeCost(buyVwap, vol, fb);
const feeSell = takerFeeCost(sellVwap, vol, fs);
const expected = sellVwap * vol - feeSell - (buyVwap * vol + feeBuy);
assert.equal(netProfit(buyVwap, sellVwap, vol, fb, fs), expected);
});
test("netProfitPct divides by notional and guards zero", () => {
assert.equal(netProfitPct(40, 10000), 0.004);
assert.equal(netProfitPct(40, 0), 0);
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Pure net-profit math — single source of truth for the arbitrage P&L formula.
* Slippage is already in the VWAPs — never subtract it again here.
*
* profit = sellVwap * vol * (1 - feeSell) - buyVwap * vol * (1 + feeBuy)
*/
/** Taker fee paid on one leg (quote currency). */
export function takerFeeCost(vwap: number, volumeBtc: number, feeRate: number): number {
return vwap * volumeBtc * feeRate;
}
/** Net profit in quote currency (USDT). */
export function netProfit(
buyVwap: number,
sellVwap: number,
volumeBtc: number,
feeBuyRate: number,
feeSellRate: number,
): number {
const feeBuy = takerFeeCost(buyVwap, volumeBtc, feeBuyRate);
const feeSell = takerFeeCost(sellVwap, volumeBtc, feeSellRate);
const proceeds = sellVwap * volumeBtc - feeSell;
const cost = buyVwap * volumeBtc + feeBuy;
return proceeds - cost;
}
/** Net profit as a fraction of notional (buy-side cost basis). */
export function netProfitPct(net: number, notional: number): number {
return notional > 0 ? net / notional : 0;
}
+49
View File
@@ -0,0 +1,49 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { walkBook, totalDepthBtc } from "./vwap.js";
import type { Level } from "../entities/index.js";
const asks: Level[] = [
{ price: 100, qty: 1 },
{ price: 101, qty: 2 },
{ price: 102, qty: 3 },
];
test("walkBook fills fully within first level", () => {
const r = walkBook(asks, 0.5);
assert.equal(r.filledBtc, 0.5);
assert.equal(r.vwap, 100);
assert.equal(r.fullyFilled, true);
});
test("walkBook computes VWAP across multiple levels (slippage)", () => {
const r = walkBook(asks, 2);
assert.equal(r.filledBtc, 2);
assert.equal(r.vwap, 100.5);
assert.equal(r.fullyFilled, true);
});
test("walkBook returns partial fill when depth is insufficient", () => {
const r = walkBook(asks, 10);
assert.equal(r.filledBtc, 6);
assert.equal(r.fullyFilled, false);
});
test("walkBook handles zero/negative target", () => {
assert.equal(walkBook(asks, 0).filledBtc, 0);
assert.equal(walkBook(asks, -1).vwap, 0);
});
test("net profit is negative when fees exceed gross edge", () => {
const vol = 1;
const buyVwap = 100;
const sellVwap = 100.1;
const feeBuy = 0.001;
const feeSell = 0.001;
const net = sellVwap * vol * (1 - feeSell) - buyVwap * vol * (1 + feeBuy);
assert.ok(net < 0, `expected negative net, got ${net}`);
});
test("totalDepthBtc sums all levels", () => {
assert.equal(totalDepthBtc(asks), 6);
});
+38
View File
@@ -0,0 +1,38 @@
import type { Level } from "../entities/index.js";
export interface VwapResult {
filledBtc: number;
quote: number;
vwap: number;
fullyFilled: boolean;
}
export function walkBook(levels: Level[], targetBtc: number): VwapResult {
if (targetBtc <= 0) {
return { filledBtc: 0, quote: 0, vwap: 0, fullyFilled: false };
}
let filled = 0;
let quote = 0;
for (const level of levels) {
if (filled >= targetBtc) break;
const take = Math.min(level.qty, targetBtc - filled);
quote += take * level.price;
filled += take;
}
const vwap = filled > 0 ? quote / filled : 0;
return {
filledBtc: filled,
quote,
vwap,
fullyFilled: filled >= targetBtc - 1e-12,
};
}
export function totalDepthBtc(levels: Level[]): number {
let sum = 0;
for (const level of levels) sum += level.qty;
return sum;
}
+63
View File
@@ -0,0 +1,63 @@
import express from "express";
import swaggerUi from "swagger-ui-express";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createLogger } from "./infrastructure/logging/logger.js";
import { bootstrap, config } from "./composition/bootstrap.js";
import { runtime } from "./infrastructure/config/runtime.js";
import { SseHub } from "./interfaces/sse/sse.js";
import { createRouter } from "./interfaces/http/routes.js";
import { openapiSpec } from "./interfaces/http/openapi.js";
const log = createLogger("server");
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "web", "dist");
function main(): void {
const ctx = bootstrap();
ctx.start();
const sse = new SseHub(ctx.application);
sse.start();
const server = express();
server.use(express.json());
server.use("/api", createRouter(ctx.application, sse));
server.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiSpec, {
customSiteTitle: "Arb Pulse — API Docs",
swaggerOptions: { defaultModelsExpandDepth: 2, defaultModelExpandDepth: 2 },
}));
if (existsSync(webDist)) {
server.use(express.static(webDist));
server.get("*", (_req, res) => {
res.sendFile(join(webDist, "index.html"));
});
} else {
server.get("/", (_req, res) => {
res
.status(200)
.send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).");
});
}
const httpServer = server.listen(config.port, () => {
log.info(`listening on :${config.port} (demo=${runtime.demoMode})`);
});
const shutdown = (): void => {
log.info("shutting down");
sse.stop();
ctx.stop();
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main();
+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();
}
}
+745
View File
@@ -0,0 +1,745 @@
/**
* OpenAPI 3.0 specification for the Arb Pulse REST API.
*
* Arb Pulse is a **simulated** cross-exchange BTC/USDT arbitrage system.
* No real funds are at risk — all trades are paper-executed against live order
* book data from Kraken, Bybit, OKX and Binance (or a synthetic feed in demo mode).
*/
import type { OpenAPIV3 } from "openapi-types";
// ---------------------------------------------------------------------------
// Reusable schema components
// ---------------------------------------------------------------------------
const schemas: Record<string, OpenAPIV3.SchemaObject> = {
ExchangeId: {
type: "string",
enum: ["kraken", "bybit", "okx", "binance"],
description: "One of the supported exchanges.",
},
FeedStatus: {
type: "string",
enum: ["connecting", "live", "stale", "down"],
description: "Connectivity state of the exchange WebSocket feed.",
},
CircuitState: {
type: "string",
enum: ["running", "paused", "tripped"],
description:
"State of the circuit-breaker: `running` = normal, `paused` = manually paused, `tripped` = auto-paused after N consecutive losses.",
},
OpportunityStatus: {
type: "string",
enum: [
"executed",
"executed_partial",
"rejected_fees",
"rejected_liquidity",
"rejected_risk",
"rejected_flicker",
"rejected_stale",
"pending_confirm",
],
description: "Disposition of a detected arbitrage opportunity.",
},
BestQuote: {
type: "object",
description: "Best bid/ask snapshot for one exchange, as seen by the engine.",
required: ["exchange", "bid", "bidQty", "ask", "askQty", "recvTs", "status", "ageMs"],
properties: {
exchange: { $ref: "#/components/schemas/ExchangeId" },
bid: { type: "number", nullable: true, description: "Best bid price in USDT (null when feed is down)." },
bidQty: { type: "number", nullable: true, description: "Quantity available at the best bid (BTC)." },
ask: { type: "number", nullable: true, description: "Best ask price in USDT (null when feed is down)." },
askQty: { type: "number", nullable: true, description: "Quantity available at the best ask (BTC)." },
recvTs: { type: "integer", nullable: true, description: "Local receive timestamp (ms since epoch)." },
status: { $ref: "#/components/schemas/FeedStatus" },
ageMs: { type: "integer", nullable: true, description: "Milliseconds since the last update was received." },
},
},
Wallet: {
type: "object",
description:
"Simulated per-exchange wallet. Each exchange starts with pre-positioned USDT **and** BTC so buy/sell legs execute in parallel without on-chain transfers.",
required: ["exchange", "usdt", "btc"],
properties: {
exchange: { $ref: "#/components/schemas/ExchangeId" },
usdt: { type: "number", description: "USDT balance at this exchange." },
btc: { type: "number", description: "BTC balance at this exchange." },
},
},
Opportunity: {
type: "object",
description:
"An arbitrage opportunity detected by the engine. May have been executed or rejected for various reasons (fees, liquidity, flicker, stale quotes, risk).",
required: [
"id", "ts", "buyExchange", "sellExchange",
"topBuyAsk", "topSellBid", "volumeBtc",
"buyVwap", "sellVwap",
"grossSpread", "grossSpreadPct",
"feeBuy", "feeSell",
"netProfit", "netProfitPct",
"status", "reason", "demo",
],
properties: {
id: { type: "string" },
ts: { type: "integer", description: "Detection timestamp (ms epoch)." },
buyExchange: { $ref: "#/components/schemas/ExchangeId" },
sellExchange: { $ref: "#/components/schemas/ExchangeId" },
topBuyAsk: { type: "number", description: "Top-of-book ask price on the buy exchange (USDT)." },
topSellBid: { type: "number", description: "Top-of-book bid price on the sell exchange (USDT)." },
volumeBtc: {
type: "number",
description: "Volume evaluated (BTC) after applying liquidity depth and wallet inventory caps.",
},
buyVwap: {
type: "number",
description: "Volume-weighted average buy price after walking the order book depth (USDT). Slippage is embedded here.",
},
sellVwap: {
type: "number",
description: "Volume-weighted average sell price after walking the order book depth (USDT). Slippage is embedded here.",
},
grossSpread: { type: "number", description: "Raw spread (sellVwap buyVwap) in USDT." },
grossSpreadPct: { type: "number", description: "Gross spread as a percentage of buyVwap." },
feeBuy: { type: "number", description: "Taker fee rate for the buy exchange (e.g. 0.0026 for Kraken)." },
feeSell: { type: "number", description: "Taker fee rate for the sell exchange." },
netProfit: {
type: "number",
description: "Net P&L in USDT: sellVwap·vol·(1feeSell) buyVwap·vol·(1+feeBuy). Negative = loss.",
},
netProfitPct: { type: "number", description: "Net profit as a percentage of cost basis." },
status: { $ref: "#/components/schemas/OpportunityStatus" },
reason: { type: "string", description: "Human-readable explanation of the disposition." },
demo: {
type: "boolean",
description: "True when this opportunity was synthetically injected by the demo feed — never real market data.",
},
},
},
Trade: {
type: "object",
description:
"A simulated trade that was executed (or partially executed). Includes latency-drift adjustment on both VWAP prices to model realistic fill slippage between detection and execution.",
required: [
"id", "ts", "buyExchange", "sellExchange",
"volumeBtc", "requestedBtc",
"buyVwap", "sellVwap", "execBuyVwap", "execSellVwap",
"feeBuy", "feeSell",
"netProfit", "netProfitPct",
"partial", "demo",
],
properties: {
id: { type: "string" },
ts: { type: "integer", description: "Execution timestamp (ms epoch)." },
buyExchange: { $ref: "#/components/schemas/ExchangeId" },
sellExchange: { $ref: "#/components/schemas/ExchangeId" },
volumeBtc: { type: "number", description: "Actual filled volume (BTC)." },
requestedBtc: { type: "number", description: "Originally requested volume before partial fill." },
buyVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." },
sellVwap: { type: "number", description: "VWAP at detection time (pre-latency, USDT)." },
execBuyVwap: {
type: "number",
description: "Simulated fill price after latency drift on the buy side (USDT). Reflects real-world price movement during order routing.",
},
execSellVwap: {
type: "number",
description: "Simulated fill price after latency drift on the sell side (USDT).",
},
feeBuy: { type: "number" },
feeSell: { type: "number" },
netProfit: { type: "number", description: "Realized net P&L in USDT (negative = loss)." },
netProfitPct: { type: "number" },
partial: { type: "boolean", description: "True when the fill was limited by order book depth or wallet balance." },
demo: { type: "boolean" },
},
},
RebalanceEvent: {
type: "object",
description:
"An inter-exchange rebalance triggered to correct inventory drift. Withdrawal fees apply here — not per-trade.",
required: ["id", "ts", "fromExchange", "toExchange", "asset", "amount", "withdrawalFee", "reason"],
properties: {
id: { type: "string" },
ts: { type: "integer" },
fromExchange: { $ref: "#/components/schemas/ExchangeId" },
toExchange: { $ref: "#/components/schemas/ExchangeId" },
asset: { type: "string", enum: ["BTC", "USDT"] },
amount: { type: "number" },
withdrawalFee: { type: "number", description: "Withdrawal fee in the transferred asset." },
reason: { type: "string" },
},
},
PnlPoint: {
type: "object",
description: "A cumulative P&L data point for the time-series chart.",
required: ["ts", "pnl"],
properties: {
ts: { type: "integer", description: "Timestamp (ms epoch)." },
pnl: { type: "number", description: "Cumulative realized P&L in USDT at this timestamp." },
},
},
EngineStats: {
type: "object",
description: "Runtime statistics for the arbitrage engine.",
required: [
"uptimeMs", "ticksProcessed", "opportunitiesDetected",
"tradesExecuted", "tradesRejected",
"realizedPnl", "consecutiveLosses",
"circuit", "demoMode", "avgTickMs",
],
properties: {
uptimeMs: { type: "integer", description: "Engine uptime in milliseconds." },
ticksProcessed: { type: "integer", description: "Total order-book ticks processed." },
opportunitiesDetected: { type: "integer" },
tradesExecuted: { type: "integer" },
tradesRejected: { type: "integer" },
realizedPnl: { type: "number", description: "Cumulative realized P&L in USDT." },
consecutiveLosses: {
type: "integer",
description: "Consecutive losing trades since the last reset — triggers the circuit-breaker when it reaches the configured threshold.",
},
circuit: { $ref: "#/components/schemas/CircuitState" },
demoMode: {
type: "boolean",
description: "When true the engine consumes a synthetic (clearly-labelled) feed instead of live exchange data.",
},
avgTickMs: { type: "number", description: "EWMA of tick processing time in milliseconds." },
},
},
PublicConfig: {
type: "object",
description: "Live engine configuration. All fields are read/write unless noted.",
required: [
"minNetProfitPct", "maxTradeBtc", "staleMs",
"flickerConfirmMs", "latencyMs",
"activeExchanges", "defaults", "takerFees", "withdrawalFeesBtc",
],
properties: {
minNetProfitPct: {
type: "number",
description: "Minimum net profit percentage required to execute a trade. Opportunities below this threshold are rejected as `rejected_fees`.",
},
maxTradeBtc: {
type: "number",
description: "Maximum BTC volume per simulated trade. Caps partial fills.",
},
staleMs: {
type: "integer",
description: "Quote age (ms) beyond which a price is considered stale and the opportunity is rejected. Read-only (set via env).",
},
flickerConfirmMs: {
type: "integer",
description: "Minimum time (ms) a spread must persist before execution. Prevents acting on transient latency artefacts.",
},
latencyMs: {
type: "integer",
description: "Simulated order-routing latency applied when computing exec VWAP drift. Read-only (set via env).",
},
activeExchanges: {
type: "object",
additionalProperties: { type: "boolean" },
description: "Map of exchangeId → enabled. Disabled exchanges are excluded from opportunity detection.",
},
defaults: {
type: "object",
description: "Factory defaults for the mutable fields.",
properties: {
minNetProfitPct: { type: "number" },
maxTradeBtc: { type: "number" },
flickerConfirmMs: { type: "integer" },
activeExchanges: { type: "object", additionalProperties: { type: "boolean" } },
},
},
takerFees: {
type: "object",
additionalProperties: { type: "number" },
description: "Taker fee rates per exchange (e.g. kraken: 0.0026, bybit: 0.001, okx: 0.001, binance: 0.001).",
},
withdrawalFeesBtc: {
type: "object",
additionalProperties: { type: "number" },
description: "BTC withdrawal fees per exchange — only relevant for the rebalancer, never charged per trade.",
},
},
},
StateSnapshot: {
type: "object",
description: "Full engine snapshot — same payload pushed over SSE on every tick.",
required: ["ts", "quotes", "wallets", "stats", "recentOpportunities", "recentTrades", "rebalances", "pnlSeries", "config"],
properties: {
ts: { type: "integer", description: "Snapshot timestamp (ms epoch)." },
quotes: { type: "array", items: { $ref: "#/components/schemas/BestQuote" } },
wallets: { type: "array", items: { $ref: "#/components/schemas/Wallet" } },
stats: { $ref: "#/components/schemas/EngineStats" },
recentOpportunities: { type: "array", items: { $ref: "#/components/schemas/Opportunity" } },
recentTrades: { type: "array", items: { $ref: "#/components/schemas/Trade" } },
rebalances: { type: "array", items: { $ref: "#/components/schemas/RebalanceEvent" } },
pnlSeries: { type: "array", items: { $ref: "#/components/schemas/PnlPoint" } },
config: { $ref: "#/components/schemas/PublicConfig" },
},
},
SuccessEnvelope: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean", enum: [true] },
data: { description: "Response payload (shape depends on the endpoint)." },
},
additionalProperties: false,
},
SuccessEnvelopeNoData: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean", enum: [true] },
},
additionalProperties: false,
example: { success: true },
},
ErrorEnvelope: {
type: "object",
required: ["success", "error"],
properties: {
success: { type: "boolean", enum: [false] },
error: { type: "string", description: "Human-readable error message." },
},
additionalProperties: false,
example: { success: false, error: "enabled must be a boolean" },
},
DemoControlBody: {
type: "object",
required: ["enabled"],
properties: {
enabled: {
type: "boolean",
description: "`true` to activate the synthetic feed, `false` to return to live data.",
example: true,
},
},
additionalProperties: false,
},
RecordControlBody: {
type: "object",
required: ["enabled"],
properties: {
enabled: { type: "boolean", description: "Start or stop NDJSON feed recording.", example: false },
},
additionalProperties: false,
},
ThresholdControlBody: {
type: "object",
required: ["pct"],
properties: {
pct: {
type: "number",
description: "Net profit threshold as a decimal percentage (0.00010.01, e.g. `0.0005` = 0.05 %).",
minimum: 0.0001,
maximum: 0.01,
example: 0.0005,
},
},
additionalProperties: false,
},
MaxTradeControlBody: {
type: "object",
required: ["btc"],
properties: {
btc: {
type: "number",
description: "Maximum BTC volume per trade (0.011.0).",
minimum: 0.01,
maximum: 1.0,
example: 0.05,
},
},
additionalProperties: false,
},
ConfigPatch: {
type: "object",
description: "Partial update for engine configuration. Only provided fields are changed.",
properties: {
minNetProfitPct: { type: "number" },
maxTradeBtc: { type: "number" },
flickerConfirmMs: { type: "integer" },
activeExchanges: {
type: "object",
additionalProperties: { type: "boolean" },
description: "Partial map — only provided exchange entries are updated.",
},
},
},
};
// ---------------------------------------------------------------------------
// Reusable response helpers
// ---------------------------------------------------------------------------
function successResponseNoData(description: string): OpenAPIV3.ResponseObject {
return {
description,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/SuccessEnvelopeNoData" },
},
},
};
}
function successResponse(description: string, dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, example?: unknown): OpenAPIV3.ResponseObject {
return {
description,
content: {
"application/json": {
schema: {
type: "object",
required: ["success", "data"],
properties: {
success: { type: "boolean", enum: [true] },
data: dataSchema,
},
additionalProperties: false,
},
...(example !== undefined ? { example } : {}),
},
},
};
}
const badRequestResponse: OpenAPIV3.ResponseObject = {
description: "Invalid request body or out-of-range value.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorEnvelope" },
},
},
};
const serverErrorResponse: OpenAPIV3.ResponseObject = {
description: "Unexpected server error.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorEnvelope" },
example: { success: false, error: "Internal server error" },
},
},
};
function controlBodyResponses(
okDescription: string,
dataSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject,
example: unknown,
): OpenAPIV3.ResponsesObject {
return {
"200": successResponse(okDescription, dataSchema, example),
"400": badRequestResponse,
"500": serverErrorResponse,
};
}
// ---------------------------------------------------------------------------
// Full OpenAPI document
// ---------------------------------------------------------------------------
export const openapiSpec: OpenAPIV3.Document = {
openapi: "3.0.3",
info: {
title: "Arb Pulse API",
version: "1.0.0",
description: `
**Arb Pulse** is a real-time BTC/USDT arbitrage detection and simulation engine
that monitors live order books from **Kraken**, **Bybit**, **OKX** and **Binance** simultaneously.
> ⚠️ **Simulated only** — no real funds are ever committed. All trades are paper-executed
> against live (or synthetic demo) market data. The system uses a *pre-positioned inventory
> model*: each exchange holds both USDT and BTC so buy and sell legs run in parallel without
> on-chain transfers.
### Key concepts
| Concept | Detail |
|---|---|
| Profit formula | \`sellVwap·vol·(1feeSell) buyVwap·vol·(1+feeBuy)\` |
| Slippage | Embedded in VWAP by walking the order book depth level by level |
| Anti-flicker | Spread must persist \`flickerConfirmMs\` before execution |
| Circuit breaker | Auto-pauses after N consecutive losses |
| Demo mode | Injects synthetic divergences — clearly labelled, never presented as real |
`.trim(),
contact: { name: "Arb Pulse" },
license: { name: "MIT" },
},
servers: [
{ url: "/", description: "Current host (Fly.io / local)" },
],
tags: [
{ name: "Monitoring", description: "Health and state inspection." },
{ name: "Streaming", description: "Server-Sent Events real-time feed." },
{ name: "Configuration", description: "Read and update engine parameters." },
{ name: "Control", description: "Pause, resume, reset and mode switches." },
],
paths: {
"/api/health": {
get: {
tags: ["Monitoring"],
summary: "Health check",
description:
"Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive. Always returns HTTP 200 while the process is running.",
operationId: "getHealth",
responses: {
"200": successResponse("Server is healthy.", {
type: "object",
required: ["status", "ts"],
properties: {
status: { type: "string", enum: ["ok"] },
ts: { type: "integer", description: "Server timestamp (ms epoch)." },
},
}, { success: true, data: { status: "ok", ts: 1_700_000_000_000 } }),
"500": serverErrorResponse,
},
},
},
"/api/state": {
get: {
tags: ["Monitoring"],
summary: "Full state snapshot",
description:
"Returns the complete in-memory engine state as a single JSON document: live quotes from all active exchanges, simulated wallet balances, engine statistics, the last N detected opportunities and executed trades, rebalance history, the cumulative P&L time-series, and the current engine configuration.",
operationId: "getState",
responses: {
"200": successResponse("Current engine state.", { $ref: "#/components/schemas/StateSnapshot" }),
"500": serverErrorResponse,
},
},
},
"/api/stream": {
get: {
tags: ["Streaming"],
summary: "SSE real-time feed",
description:
"Opens a persistent [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) connection. The server pushes a `StateSnapshot` JSON payload on every engine tick (typically every few hundred milliseconds). The dashboard React app subscribes here — no WebSocket or polling needed.\n\nConnect with `EventSource` or any SSE client. The stream never closes unless the server restarts or the client disconnects.",
operationId: "getStream",
responses: {
"200": {
description: "SSE stream opened. Each `data:` event carries a JSON-encoded `StateSnapshot`.",
content: {
"text/event-stream": {
schema: {
type: "string",
description: "Newline-delimited SSE events. Each `data:` line contains a serialized `StateSnapshot`.",
},
},
},
},
},
},
},
"/api/config": {
get: {
tags: ["Configuration"],
summary: "Get engine configuration",
description:
"Returns the current mutable and immutable engine parameters: profit threshold, max trade size, flicker window, active exchanges, fee tables and factory defaults.",
operationId: "getConfig",
responses: {
"200": successResponse("Current configuration.", { $ref: "#/components/schemas/PublicConfig" }),
"500": serverErrorResponse,
},
},
patch: {
tags: ["Configuration"],
summary: "Update engine configuration",
description:
"Applies a partial update to mutable engine parameters. Only the fields present in the request body are changed; omitted fields retain their current values.\n\nReturns the full updated configuration on success.",
operationId: "patchConfig",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ConfigPatch" } },
},
},
responses: {
"200": successResponse("Updated configuration.", { $ref: "#/components/schemas/PublicConfig" }),
"400": badRequestResponse,
"500": serverErrorResponse,
},
},
},
"/api/control/pause": {
post: {
tags: ["Control"],
summary: "Pause the arbitrage engine",
description:
"Manually pauses the engine. Opportunities are no longer evaluated and no new trades are executed until `resume` is called. Order book feeds remain connected. This is equivalent to setting the circuit state to `paused`.",
operationId: "controlPause",
responses: {
"200": successResponseNoData("Engine paused."),
"500": serverErrorResponse,
},
},
},
"/api/control/resume": {
post: {
tags: ["Control"],
summary: "Resume the arbitrage engine",
description:
"Resumes opportunity evaluation after a `pause` or a circuit-breaker trip. The engine immediately re-enters the `running` circuit state.",
operationId: "controlResume",
responses: {
"200": successResponseNoData("Engine resumed."),
"500": serverErrorResponse,
},
},
},
"/api/control/reset": {
post: {
tags: ["Control"],
summary: "Reset simulated state",
description:
"Resets the simulated wallets back to their initial pre-positioned balances, clears the trade history, resets the cumulative P&L to zero, and restores the consecutive-loss counter. Engine configuration (thresholds, fee tables) is not affected. Useful for starting a clean simulation session.",
operationId: "controlReset",
responses: {
"200": successResponseNoData("State reset."),
"500": serverErrorResponse,
},
},
},
"/api/control/demo": {
post: {
tags: ["Control"],
summary: "Enable / disable demo feed",
description:
"Switches between the live exchange feeds and the **synthetic demo injector**. When `enabled: true` the engine receives artificially inflated spreads that guarantee visible arbitrage opportunities — clearly labelled with `demo: true` in every event so they are never mistaken for real market signals.\n\n**Note:** Real feeds are paused while demo mode is active.",
operationId: "controlDemo",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/DemoControlBody" } },
},
},
responses: controlBodyResponses(
"Demo mode updated.",
{
type: "object",
required: ["demoMode"],
properties: { demoMode: { type: "boolean" } },
additionalProperties: false,
},
{ success: true, data: { demoMode: true } },
),
},
},
"/api/control/record": {
post: {
tags: ["Control"],
summary: "Enable / disable feed recording",
description:
"Starts or stops recording live order-book events to an NDJSON file on disk for later replay. Useful for capturing real market sessions to run deterministic back-tests without re-connecting to exchanges.",
operationId: "controlRecord",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/RecordControlBody" } },
},
},
responses: controlBodyResponses(
"Recording state updated.",
{
type: "object",
required: ["recordFeed"],
properties: { recordFeed: { type: "boolean" } },
additionalProperties: false,
},
{ success: true, data: { recordFeed: false } },
),
},
},
"/api/control/threshold": {
post: {
tags: ["Control"],
summary: "Set minimum net profit threshold",
description:
"Adjusts the minimum net profit percentage an opportunity must exceed to be executed. Lower values detect more opportunities but increase the chance of tiny losses due to fee rounding. Opportunities below this threshold are logged as `rejected_fees`.\n\nEquivalent to `PATCH /api/config` with `{ minNetProfitPct }` but provided as a convenience endpoint.",
operationId: "controlThreshold",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ThresholdControlBody" } },
},
},
responses: controlBodyResponses(
"Threshold updated.",
{
type: "object",
required: ["minNetProfitPct"],
properties: { minNetProfitPct: { type: "number" } },
additionalProperties: false,
},
{ success: true, data: { minNetProfitPct: 0.0005 } },
),
},
},
"/api/control/max-trade": {
post: {
tags: ["Control"],
summary: "Set maximum trade volume",
description:
"Sets the upper bound on BTC volume per simulated trade. The engine also caps volume by available order-book liquidity and wallet inventory — this parameter acts as an additional hard cap.\n\nEquivalent to `PATCH /api/config` with `{ maxTradeBtc }` but provided as a convenience endpoint.",
operationId: "controlMaxTrade",
requestBody: {
required: true,
content: {
"application/json": { schema: { $ref: "#/components/schemas/MaxTradeControlBody" } },
},
},
responses: controlBodyResponses(
"Max trade volume updated.",
{
type: "object",
required: ["maxTradeBtc"],
properties: { maxTradeBtc: { type: "number" } },
additionalProperties: false,
},
{ success: true, data: { maxTradeBtc: 0.05 } },
),
},
},
},
components: {
schemas,
responses: {
BadRequest: badRequestResponse,
ServerError: serverErrorResponse,
},
},
};
+97
View File
@@ -0,0 +1,97 @@
import { Router, type Request, type Response } from "express";
import type { ApplicationService } from "../../composition/application-service.js";
import type { SseHub } from "../sse/sse.js";
export function createRouter(app: ApplicationService, sse: SseHub): Router {
const router = Router();
router.get("/health", (_req: Request, res: Response) => {
res.json({ success: true, data: { status: "ok", ts: Date.now() } });
});
router.get("/state", (_req: Request, res: Response) => {
res.json({ success: true, data: app.getSnapshot() });
});
router.get("/stream", (req: Request, res: Response) => {
sse.handle(req, res);
});
router.get("/config", (_req: Request, res: Response) => {
res.json({ success: true, data: app.getConfig() });
});
router.patch("/config", (req: Request, res: Response) => {
const error = app.patchConfig(req.body ?? {});
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: app.getConfig() });
});
router.post("/control/pause", (_req: Request, res: Response) => {
app.pause();
res.json({ success: true });
});
router.post("/control/resume", (_req: Request, res: Response) => {
app.resume();
res.json({ success: true });
});
router.post("/control/reset", (_req: Request, res: Response) => {
app.reset();
res.json({ success: true });
});
router.post("/control/demo", (req: Request, res: Response) => {
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
return;
}
app.setDemoMode(enabled);
res.json({ success: true, data: { demoMode: enabled } });
});
router.post("/control/record", (req: Request, res: Response) => {
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
return;
}
app.setRecordFeed(enabled);
res.json({ success: true, data: { recordFeed: enabled } });
});
router.post("/control/threshold", (req: Request, res: Response) => {
const pct = req.body?.pct;
if (typeof pct !== "number" || !Number.isFinite(pct)) {
res.status(400).json({ success: false, error: "pct must be a finite number" });
return;
}
const error = app.setThreshold(pct);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { minNetProfitPct: pct } });
});
router.post("/control/max-trade", (req: Request, res: Response) => {
const btc = req.body?.btc;
if (typeof btc !== "number" || !Number.isFinite(btc)) {
res.status(400).json({ success: false, error: "btc must be a finite number" });
return;
}
const error = app.setMaxTradeBtc(btc);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { maxTradeBtc: btc } });
});
return router;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Request, Response } from "express";
import { config } from "../../infrastructure/config/config.js";
import { createLogger } from "../../infrastructure/logging/logger.js";
import type { ApplicationService } from "../../composition/application-service.js";
const log = createLogger("sse");
export class SseHub {
private clients = new Set<Response>();
private timer: NodeJS.Timeout | null = null;
constructor(private readonly app: ApplicationService) {}
start(): void {
this.timer = setInterval(() => this.broadcast(), config.broadcastMs);
}
stop(): void {
if (this.timer) clearInterval(this.timer);
for (const res of this.clients) res.end();
this.clients.clear();
}
handle(req: Request, res: Response): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.write(`retry: 2000\n\n`);
this.send(res, this.app.getSnapshot());
this.clients.add(res);
log.info(`client connected (${this.clients.size} total)`);
req.on("close", () => {
this.clients.delete(res);
});
}
private broadcast(): void {
if (this.clients.size === 0) return;
const snapshot = this.app.getSnapshot();
for (const res of this.clients) this.send(res, snapshot);
}
private send(res: Response, data: unknown): void {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { ExchangeId, Level, OrderBook } from "../domain/entities/index.js";
import type { MarketDataFeed, MarketDataFeedFactory } from "../domain/ports/ports.js";
/** NDJSON line shape written by FeedRecorder (replay-compatible). */
export interface NdjsonBookLine {
ts: number;
exchange: ExchangeId;
bids: Level[];
asks: Level[];
}
export function lineToOrderBook(line: NdjsonBookLine): OrderBook {
return {
exchange: line.exchange,
bids: line.bids,
asks: line.asks,
recvTs: line.ts,
exchangeTs: line.ts,
};
}
/** In-memory feed for integration tests — no network or WebSocket. */
export class FakeMarketDataFeed implements MarketDataFeed {
private listeners: Array<(book: OrderBook) => void> = [];
constructor(private readonly fixtures: OrderBook[]) {}
onBook(listener: (book: OrderBook) => void): void {
this.listeners.push(listener);
}
start(): void {
for (const fixture of this.fixtures) {
for (const listener of this.listeners) listener(fixture);
}
}
stop(): void {
/* no-op */
}
}
export class FakeMarketDataFeedFactory implements MarketDataFeedFactory {
constructor(private readonly fixtures: OrderBook[]) {}
create(id: ExchangeId): MarketDataFeed {
return new FakeMarketDataFeed(this.fixtures.filter((f) => f.exchange === id));
}
}
+236
View File
@@ -0,0 +1,236 @@
import {
EXCHANGE_IDS,
type CircuitState,
type ExchangeId,
type Level,
type Opportunity,
type OrderBook,
type RebalanceEvent,
type Trade,
type Wallet,
} from "../domain/entities/index.js";
import type {
IClock,
IIdGenerator,
IInventory,
IQuoteBook,
IRiskGate,
IStateStore,
ITradeExecutor,
TradingPolicy,
} from "../domain/ports/ports.js";
export class FakePolicy implements TradingPolicy {
takerFees: Record<ExchangeId, number> = { kraken: 0.0026, bybit: 0.001, okx: 0.001, binance: 0.001 };
withdrawalFeesBtc: Record<ExchangeId, number> = {
kraken: 0.00002,
bybit: 0.00005,
okx: 0.00004,
binance: 0.0005,
};
minNet = 0.0005;
maxTrade = 0.25;
flickerMs = 150;
latencyBps = 2;
breakerLosses = 5;
breakerCooldownMs = 15000;
rebalanceIntervalMsValue = 20000;
rebalanceMinBtcValue = 0.075;
rebalanceMinUsdtValue = 7500;
demo = false;
takerFee(exchange: ExchangeId): number {
return this.takerFees[exchange];
}
withdrawalFeeBtc(exchange: ExchangeId): number {
return this.withdrawalFeesBtc[exchange];
}
minNetProfitPct(): number {
return this.minNet;
}
maxTradeBtc(): number {
return this.maxTrade;
}
flickerConfirmMs(): number {
return this.flickerMs;
}
latencySlippageBps(): number {
return this.latencyBps;
}
circuitBreakerLosses(): number {
return this.breakerLosses;
}
circuitBreakerCooldownMs(): number {
return this.breakerCooldownMs;
}
rebalanceIntervalMs(): number {
return this.rebalanceIntervalMsValue;
}
rebalanceMinBtc(): number {
return this.rebalanceMinBtcValue;
}
rebalanceMinUsdt(): number {
return this.rebalanceMinUsdtValue;
}
isDemo(): boolean {
return this.demo;
}
}
export class FixedClock implements IClock {
constructor(public t: number) {}
now(): number {
return this.t;
}
}
export class SeqIds implements IIdGenerator {
private n = 0;
next(prefix: string): string {
this.n += 1;
return `${prefix}_${this.n}`;
}
}
export class FakeStore implements IStateStore {
ticksProcessed = 0;
tradesRejected = 0;
circuit: CircuitState = "running";
consecutiveLosses = 0;
tickTimes: number[] = [];
opportunities: Opportunity[] = [];
trades: Trade[] = [];
rebalances: RebalanceEvent[] = [];
recordTickTime(ms: number): void {
this.tickTimes.push(ms);
}
addOpportunity(op: Opportunity): void {
this.opportunities.push(op);
}
addTrade(trade: Trade): void {
this.trades.push(trade);
}
addRebalance(event: RebalanceEvent): void {
this.rebalances.push(event);
}
}
export class FakeRiskGate implements IRiskGate {
evaluations: number[] = [];
ticks: number[] = [];
constructor(public allow = true) {}
canExecute(): boolean {
return this.allow;
}
evaluate(now: number): void {
this.evaluations.push(now);
}
tick(now: number): void {
this.ticks.push(now);
}
pause(): void {
this.allow = false;
}
resume(): void {
this.allow = true;
}
}
export class FakeExecutor implements ITradeExecutor {
calls: { op: Opportunity; now: number }[] = [];
constructor(public netProfit = 1) {}
execute(op: Opportunity, now: number): Trade {
this.calls.push({ op, now });
return {
id: `trade_${this.calls.length}`,
ts: now,
buyExchange: op.buyExchange,
sellExchange: op.sellExchange,
volumeBtc: op.volumeBtc,
requestedBtc: op.volumeBtc,
buyVwap: op.buyVwap,
sellVwap: op.sellVwap,
execBuyVwap: op.buyVwap,
execSellVwap: op.sellVwap,
feeBuy: op.feeBuy,
feeSell: op.feeSell,
netProfit: this.netProfit,
netProfitPct: 0,
partial: op.status === "executed_partial",
demo: op.demo,
};
}
}
export class FakeInventory implements IInventory {
wallets = new Map<ExchangeId, Wallet>();
transfers: { from: ExchangeId; to: ExchangeId; asset: "BTC" | "USDT"; amount: number; fee: number }[] = [];
constructor(init: Partial<Record<ExchangeId, { usdt: number; btc: number }>> = {}) {
for (const e of EXCHANGE_IDS) {
const w = init[e] ?? { usdt: 50000, btc: 0.5 };
this.wallets.set(e, { exchange: e, usdt: w.usdt, btc: w.btc });
}
}
get(exchange: ExchangeId): Wallet {
const w = this.wallets.get(exchange);
if (!w) throw new Error(`unknown wallet ${exchange}`);
return w;
}
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 {
this.transfers.push({ from, to, asset, amount, fee });
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;
}
}
}
export class FakeQuoteBook implements IQuoteBook {
books = new Map<ExchangeId, OrderBook>();
constructor(public staleMs = 3000) {}
update(b: OrderBook): void {
this.books.set(b.exchange, b);
}
getBook(exchange: ExchangeId): OrderBook | undefined {
return this.books.get(exchange);
}
isFresh(exchange: ExchangeId, now: number): boolean {
const b = this.books.get(exchange);
if (!b) return false;
return now - b.recvTs <= this.staleMs;
}
}
export function book(
exchange: ExchangeId,
bids: Level[],
asks: Level[],
recvTs: number,
): OrderBook {
return { exchange, bids, asks, recvTs, exchangeTs: recvTs };
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@domain/*": ["src/domain/*"],
"@application/*": ["src/application/*"],
"@infrastructure/*": ["src/infrastructure/*"],
"@composition/*": ["src/composition/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "web", "data"]
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arb Pulse — BTC Arbitrage Engine</title>
<meta name="description" content="Real-time cross-exchange BTC arbitrage detection & simulation" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2806
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "arb-pulse-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"arb-pulse": "file:..",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import type { StateSnapshot } from "./types";
import { subscribeState } from "./api";
import { StatsBar } from "./components/StatsBar";
import { PriceMatrix } from "./components/PriceMatrix";
import { OpportunityFeed } from "./components/OpportunityFeed";
import { PnlChart } from "./components/PnlChart";
import { TradeLog } from "./components/TradeLog";
import { Wallets } from "./components/Wallets";
import { Controls } from "./components/Controls";
import { ConfigPanel } from "./components/ConfigPanel";
export function App(): JSX.Element {
const [snapshot, setSnapshot] = useState<StateSnapshot | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
return subscribeState(setSnapshot, setConnected);
}, []);
return (
<div className="mx-auto max-w-[1400px] px-4 py-6 lg:px-8">
<header className="mb-6 flex items-end justify-between">
<div>
<h1 className="font-display text-2xl font-extrabold tracking-tight text-slate-100">
Arb<span className="text-accent">Pulse</span>
</h1>
<p className="text-sm text-slate-500">Real-time cross-exchange BTC arbitrage engine · Kraken · Bybit · OKX · Binance</p>
</div>
<p className="hidden text-xs text-slate-600 sm:block">
BTC/USDT · WebSocket feeds · inventory model
</p>
</header>
{!snapshot ? (
<div className="flex h-[60vh] items-center justify-center text-slate-500">Connecting to engine</div>
) : (
<div className="space-y-5">
<StatsBar stats={snapshot.stats} connected={connected} />
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
<div className="space-y-5 lg:col-span-2">
<PriceMatrix quotes={snapshot.quotes} />
<PnlChart series={snapshot.pnlSeries} />
<TradeLog trades={snapshot.recentTrades} />
</div>
<div className="space-y-5">
<Controls stats={snapshot.stats} config={snapshot.config} />
<ConfigPanel config={snapshot.config} />
<Wallets wallets={snapshot.wallets} rebalances={snapshot.rebalances} />
<OpportunityFeed opportunities={snapshot.recentOpportunities} />
</div>
</div>
</div>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { StateSnapshot } from "./types";
/**
* Subscribe to the live state stream over SSE. Uses a relative URL so it works
* behind the Vite dev proxy and same-origin in production. EventSource
* auto-reconnects on drop.
*/
export function subscribeState(
onSnapshot: (s: StateSnapshot) => void,
onStatus: (connected: boolean) => void,
): () => void {
const source = new EventSource("/api/stream");
source.onopen = () => onStatus(true);
source.onerror = () => onStatus(false);
source.onmessage = (event) => {
try {
onSnapshot(JSON.parse(event.data) as StateSnapshot);
} catch {
/* ignore malformed frame */
}
};
return () => source.close();
}
async function post(path: string, body?: unknown): Promise<void> {
await fetch(`/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
export const control = {
pause: () => post("/control/pause"),
resume: () => post("/control/resume"),
reset: () => post("/control/reset"),
setDemo: (enabled: boolean) => post("/control/demo", { enabled }),
setRecord: (enabled: boolean) => post("/control/record", { enabled }),
setThreshold: (pct: number) => post("/control/threshold", { pct }),
setMaxTrade: (btc: number) => post("/control/max-trade", { btc }),
};
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { clsx } from "../format";
interface CardProps {
title: string;
subtitle?: string;
right?: ReactNode;
children: ReactNode;
className?: string;
}
export function Card({ title, subtitle, right, children, className }: CardProps): JSX.Element {
return (
<section
className={clsx(
"rounded-2xl border border-ink-600/60 bg-ink-800/60 backdrop-blur-sm shadow-xl shadow-black/30",
className,
)}
>
<header className="flex items-center justify-between gap-3 border-b border-ink-600/50 px-4 py-3">
<div>
<h2 className="font-display text-sm font-bold uppercase tracking-[0.18em] text-slate-200">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-slate-500">{subtitle}</p>}
</div>
{right}
</header>
<div className="p-4">{children}</div>
</section>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ExchangeId, PublicConfig } from "../types";
import { Card } from "./Card";
import { patchConfig } from "../config-api";
import { clsx, pct, btc } from "../format";
interface Props {
config: PublicConfig;
}
const EXCHANGES: { id: ExchangeId; label: string }[] = [
{ id: "kraken", label: "Kraken" },
{ id: "bybit", label: "Bybit" },
{ id: "okx", label: "OKX" },
{ id: "binance", label: "Binance" },
];
const MIN_PROFIT = 0.0001;
const MAX_PROFIT = 0.01;
const MIN_TRADE = 0.01;
const MAX_TRADE = 1.0;
const DEBOUNCE_MS = 200;
function Toggle({
label,
on,
modified,
onChange,
}: {
label: string;
on: boolean;
modified: boolean;
onChange: (v: boolean) => void;
}): JSX.Element {
return (
<button
type="button"
onClick={() => onChange(!on)}
className={clsx(
"flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left text-sm transition",
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30 hover:border-ink-500",
)}
>
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
<span
className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
on ? "left-[18px]" : "left-0.5",
)}
/>
</span>
</button>
);
}
function SliderRow({
label,
valueLabel,
min,
max,
step,
value,
modified,
onChange,
}: {
label: string;
valueLabel: string;
min: number;
max: number;
step: number;
value: number;
modified: boolean;
onChange: (v: number) => void;
}): JSX.Element {
return (
<div
className={clsx(
"rounded-xl border px-3 py-2",
modified ? "border-accent/50 bg-accent/5" : "border-ink-600/50 bg-ink-700/30",
)}
>
<div className="mb-1 flex items-center justify-between text-sm">
<span className={clsx(modified ? "text-accent" : "text-slate-300")}>{label}</span>
<span className="font-mono text-accent">{valueLabel}</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
);
}
export function ConfigPanel({ config }: Props): JSX.Element {
const [minProfit, setMinProfit] = useState(config.minNetProfitPct);
const [maxTrade, setMaxTrade] = useState(config.maxTradeBtc);
const [flickerMs, setFlickerMs] = useState(config.flickerConfirmMs);
const [active, setActive] = useState(config.activeExchanges);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setMinProfit(config.minNetProfitPct);
setMaxTrade(config.maxTradeBtc);
setFlickerMs(config.flickerConfirmMs);
setActive(config.activeExchanges);
}, [config]);
const sendPatch = useCallback((patch: Parameters<typeof patchConfig>[0]) => {
patchConfig(patch).catch(() => {
/* SSE will resync on next snapshot */
});
}, []);
const debouncedPatch = useCallback(
(patch: Parameters<typeof patchConfig>[0]) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => sendPatch(patch), DEBOUNCE_MS);
},
[sendPatch],
);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const defaults = config.defaults;
return (
<Card title="Live config" subtitle="Applied on next engine tick">
<div className="space-y-3">
<SliderRow
label="Min net profit"
valueLabel={pct(minProfit, 2)}
min={MIN_PROFIT}
max={MAX_PROFIT}
step={0.0001}
value={minProfit}
modified={minProfit !== defaults.minNetProfitPct}
onChange={(v) => {
setMinProfit(v);
debouncedPatch({ minNetProfitPct: v });
}}
/>
<SliderRow
label="Trade volume"
valueLabel={`${btc(maxTrade, 2)} BTC`}
min={MIN_TRADE}
max={MAX_TRADE}
step={0.01}
value={maxTrade}
modified={maxTrade !== defaults.maxTradeBtc}
onChange={(v) => {
setMaxTrade(v);
debouncedPatch({ maxTradeBtc: v });
}}
/>
<SliderRow
label="Anti-flicker window"
valueLabel={`${flickerMs} ms`}
min={0}
max={500}
step={10}
value={flickerMs}
modified={flickerMs !== defaults.flickerConfirmMs}
onChange={(v) => {
setFlickerMs(v);
debouncedPatch({ flickerConfirmMs: v });
}}
/>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-slate-500">Active exchanges</p>
{EXCHANGES.map(({ id, label }) => (
<Toggle
key={id}
label={label}
on={active[id]}
modified={active[id] !== defaults.activeExchanges[id]}
onChange={(enabled) => {
const next = { ...active, [id]: enabled };
setActive(next);
sendPatch({ activeExchanges: { [id]: enabled } });
}}
/>
))}
</div>
</div>
</Card>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from "react";
import type { EngineStats, PublicConfig } from "../types";
import { Card } from "./Card";
import { control } from "../api";
import { clsx, pct } from "../format";
interface Props {
stats: EngineStats;
config: PublicConfig;
}
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }): JSX.Element {
return (
<button
type="button"
onClick={() => onChange(!on)}
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-left text-sm transition hover:border-ink-500"
>
<span className="text-slate-300">{label}</span>
<span className={clsx("relative h-5 w-9 rounded-full transition", on ? "bg-accent" : "bg-ink-500")}>
<span
className={clsx(
"absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all",
on ? "left-[18px]" : "left-0.5",
)}
/>
</span>
</button>
);
}
export function Controls({ stats, config }: Props): JSX.Element {
const [threshold, setThreshold] = useState(config.minNetProfitPct);
const paused = stats.circuit === "paused";
return (
<Card title="Controls" subtitle="Operator panel">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{paused ? (
<button
type="button"
onClick={() => control.resume()}
className="rounded-xl border border-profit/40 bg-profit/10 px-3 py-2 text-sm font-semibold text-profit transition hover:bg-profit/20"
>
Resume
</button>
) : (
<button
type="button"
onClick={() => control.pause()}
className="rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm font-semibold text-warn transition hover:bg-warn/20"
>
Pause
</button>
)}
<button
type="button"
onClick={() => control.reset()}
className="rounded-xl border border-loss/40 bg-loss/10 px-3 py-2 text-sm font-semibold text-loss transition hover:bg-loss/20"
>
Reset
</button>
</div>
<Toggle label="Demo mode (synthetic feed)" on={stats.demoMode} onChange={(v) => control.setDemo(v)} />
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-slate-300">Min net edge</span>
<span className="font-mono text-accent">{pct(threshold)}</span>
</div>
<input
type="range"
min={0}
max={0.005}
step={0.0001}
value={threshold}
onChange={(e) => {
const v = Number(e.target.value);
setThreshold(v);
control.setThreshold(v);
}}
className="w-full accent-accent"
/>
</div>
<div className="rounded-xl border border-ink-600/50 bg-ink-700/30 px-3 py-2 text-xs text-slate-500">
<div className="flex justify-between">
<span>Taker fees</span>
<span className="font-mono">
K {pct(config.takerFees.kraken, 2)} · By {pct(config.takerFees.bybit, 2)} · O {pct(config.takerFees.okx, 2)} · Bn {pct(config.takerFees.binance, 2)}
</span>
</div>
<div className="mt-1 flex justify-between">
<span>Stale / confirm</span>
<span className="font-mono">
{config.staleMs}ms / {config.flickerConfirmMs}ms
</span>
</div>
</div>
</div>
</Card>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Opportunity, OpportunityStatus } from "../types";
import { Card } from "./Card";
import { clsx, pct, timeOf, usd } from "../format";
interface Props {
opportunities: Opportunity[];
}
function statusBadge(status: OpportunityStatus): { label: string; cls: string } {
switch (status) {
case "executed":
return { label: "EXECUTED", cls: "bg-profit/15 text-profit border-profit/40" };
case "executed_partial":
return { label: "PARTIAL", cls: "bg-profit/10 text-profit border-profit/30" };
case "pending_confirm":
return { label: "CONFIRMING", cls: "bg-accent/15 text-accent border-accent/40" };
case "rejected_fees":
return { label: "REJ · FEES", cls: "bg-warn/10 text-warn border-warn/30" };
case "rejected_liquidity":
return { label: "REJ · LIQ", cls: "bg-warn/10 text-warn border-warn/30" };
case "rejected_risk":
return { label: "REJ · RISK", cls: "bg-loss/10 text-loss border-loss/30" };
case "rejected_stale":
return { label: "REJ · STALE", cls: "bg-slate-500/10 text-slate-400 border-slate-500/30" };
case "rejected_flicker":
return { label: "REJ · FLICKER", cls: "bg-slate-500/10 text-slate-400 border-slate-500/30" };
default: {
const _exhaustive: never = status;
return { label: _exhaustive, cls: "" };
}
}
}
export function OpportunityFeed({ opportunities }: Props): JSX.Element {
return (
<Card title="Live Opportunities" subtitle="Detected divergences, scored net of real fees & slippage">
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{opportunities.length === 0 && (
<p className="py-8 text-center text-sm text-slate-500">Scanning venues for divergences</p>
)}
{opportunities.map((op) => {
const badge = statusBadge(op.status);
const executed = op.status === "executed" || op.status === "executed_partial";
return (
<div
key={op.id}
className="flex items-center gap-3 rounded-xl border border-ink-600/40 bg-ink-700/30 px-3 py-2"
>
<span className={clsx("w-[110px] shrink-0 rounded-md border px-2 py-1 text-center text-[10px] font-bold tracking-wider", badge.cls)}>
{badge.label}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 font-display text-sm">
<span className="text-accent">{op.buyExchange}</span>
<span className="text-slate-500"></span>
<span className="text-profit">{op.sellExchange}</span>
{op.demo && <span className="text-[10px] uppercase tracking-wide text-accent/70">demo</span>}
</div>
<div className="truncate font-mono text-xs text-slate-500">
buy {usd(op.buyVwap)} · sell {usd(op.sellVwap)} · {op.reason}
</div>
</div>
<div className="shrink-0 text-right font-mono text-xs tabular-nums">
<div className={clsx(executed ? "text-profit" : "text-slate-400")}>
{executed ? `+$${usd(op.netProfit)}` : pct(op.grossSpreadPct)}
</div>
<div className="text-slate-600">{timeOf(op.ts)}</div>
</div>
</div>
);
})}
</div>
</Card>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { PnlPoint } from "../types";
import { Card } from "./Card";
import { usd } from "../format";
interface Props {
series: PnlPoint[];
}
/**
* Lightweight inline SVG P&L curve — no chart library dependency (keeps the
* bundle small and the build dependency-free).
*/
export function PnlChart({ series }: Props): JSX.Element {
const width = 640;
const height = 220;
const pad = 28;
const content = (() => {
if (series.length < 2) {
return <p className="py-16 text-center text-sm text-slate-500">P&L curve builds as trades execute</p>;
}
const xs = series.map((p) => p.ts);
const ys = series.map((p) => p.pnl);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(0, ...ys);
const maxY = Math.max(0, ...ys);
const spanX = maxX - minX || 1;
const spanY = maxY - minY || 1;
const px = (x: number) => pad + ((x - minX) / spanX) * (width - pad * 2);
const py = (y: number) => height - pad - ((y - minY) / spanY) * (height - pad * 2);
const line = series.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.pnl).toFixed(1)}`).join(" ");
const area = `${line} L${px(maxX).toFixed(1)},${py(minY).toFixed(1)} L${px(minX).toFixed(1)},${py(minY).toFixed(1)} Z`;
const last = ys[ys.length - 1] ?? 0;
const positive = last >= 0;
const stroke = positive ? "#3ddc97" : "#ff5c7c";
const zeroY = py(0);
return (
<svg viewBox={`0 0 ${width} ${height}`} className="h-[220px] w-full" preserveAspectRatio="none">
<defs>
<linearGradient id="pnlfill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
</linearGradient>
</defs>
<line x1={pad} y1={zeroY} x2={width - pad} y2={zeroY} stroke="#2b3a4f" strokeWidth="1" strokeDasharray="4 4" />
<path d={area} fill="url(#pnlfill)" />
<path d={line} fill="none" stroke={stroke} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
})();
const last = series[series.length - 1]?.pnl ?? 0;
return (
<Card
title="Cumulative P&L"
subtitle="Realized, net of fees"
right={
<span className={`font-mono text-lg font-semibold tabular-nums ${last >= 0 ? "text-profit" : "text-loss"}`}>
${usd(last)}
</span>
}
>
{content}
</Card>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { BestQuote, ExchangeId } from "../types";
import { Card } from "./Card";
import { clsx, usd } from "../format";
interface Props {
quotes: BestQuote[];
}
const LABELS: Record<ExchangeId, string> = {
kraken: "Kraken",
bybit: "Bybit",
okx: "OKX",
binance: "Binance",
};
function statusDot(status: BestQuote["status"]): string {
switch (status) {
case "live":
return "bg-profit";
case "stale":
return "bg-warn";
case "down":
return "bg-loss";
case "connecting":
return "bg-slate-500";
default: {
const _exhaustive: never = status;
return _exhaustive;
}
}
}
export function PriceMatrix({ quotes }: Props): JSX.Element {
// Highlight the best (lowest) ask and best (highest) bid across venues.
const asks = quotes.map((q) => q.ask).filter((v): v is number => v !== null);
const bids = quotes.map((q) => q.bid).filter((v): v is number => v !== null);
const minAsk = asks.length ? Math.min(...asks) : null;
const maxBid = bids.length ? Math.max(...bids) : null;
const crossed = minAsk !== null && maxBid !== null && minAsk < maxBid;
return (
<Card
title="Order Book — Best Bid / Ask"
subtitle="BTC/USDT across venues"
right={
crossed ? (
<span className="rounded-full border border-profit/40 bg-profit/10 px-3 py-1 text-xs font-semibold text-profit">
DIVERGENCE
</span>
) : undefined
}
>
<div className="overflow-hidden rounded-xl border border-ink-600/50">
<table className="w-full text-sm">
<thead>
<tr className="bg-ink-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
<th className="px-4 py-2 font-medium">Exchange</th>
<th className="px-4 py-2 text-right font-medium">Bid</th>
<th className="px-4 py-2 text-right font-medium">Bid Qty</th>
<th className="px-4 py-2 text-right font-medium">Ask</th>
<th className="px-4 py-2 text-right font-medium">Ask Qty</th>
<th className="px-4 py-2 text-right font-medium">Spread</th>
</tr>
</thead>
<tbody className="font-mono tabular-nums">
{quotes.map((q) => {
const spread = q.ask !== null && q.bid !== null ? q.ask - q.bid : null;
return (
<tr key={q.exchange} className="border-t border-ink-600/40">
<td className="px-4 py-2.5">
<span className="flex items-center gap-2 font-display text-sm">
<span className={clsx("h-2 w-2 rounded-full", statusDot(q.status))} />
{LABELS[q.exchange]}
</span>
</td>
<td
className={clsx(
"px-4 py-2.5 text-right",
q.bid !== null && q.bid === maxBid ? "font-semibold text-profit" : "text-slate-300",
)}
>
{q.bid !== null ? usd(q.bid) : "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">{q.bidQty?.toFixed(3) ?? "—"}</td>
<td
className={clsx(
"px-4 py-2.5 text-right",
q.ask !== null && q.ask === minAsk ? "font-semibold text-accent" : "text-slate-300",
)}
>
{q.ask !== null ? usd(q.ask) : "—"}
</td>
<td className="px-4 py-2.5 text-right text-slate-500">{q.askQty?.toFixed(3) ?? "—"}</td>
<td className="px-4 py-2.5 text-right text-slate-400">{spread !== null ? usd(spread) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { EngineStats } from "../types";
import { clsx, usd } from "../format";
interface Props {
stats: EngineStats;
connected: boolean;
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: "profit" | "loss" | "warn" }): JSX.Element {
return (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-slate-500">{label}</span>
<span
className={clsx(
"font-mono text-lg font-semibold tabular-nums",
tone === "profit" && "text-profit",
tone === "loss" && "text-loss",
tone === "warn" && "text-warn",
!tone && "text-slate-100",
)}
>
{value}
</span>
</div>
);
}
function circuitTone(c: EngineStats["circuit"]): { label: string; cls: string } {
switch (c) {
case "running":
return { label: "RUNNING", cls: "bg-profit/15 text-profit border-profit/40" };
case "paused":
return { label: "PAUSED", cls: "bg-warn/15 text-warn border-warn/40" };
case "tripped":
return { label: "BREAKER TRIPPED", cls: "bg-loss/15 text-loss border-loss/40" };
default: {
const _exhaustive: never = c;
return { label: _exhaustive, cls: "" };
}
}
}
export function StatsBar({ stats, connected }: Props): JSX.Element {
const circuit = circuitTone(stats.circuit);
const pnlTone = stats.realizedPnl >= 0 ? "profit" : "loss";
return (
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 rounded-2xl border border-ink-600/60 bg-ink-800/60 px-5 py-4 backdrop-blur-sm">
<Stat label="Realized P&L" value={`$${usd(stats.realizedPnl)}`} tone={pnlTone} />
<Stat label="Trades" value={String(stats.tradesExecuted)} />
<Stat label="Opportunities" value={String(stats.opportunitiesDetected)} />
<Stat label="Rejected" value={String(stats.tradesRejected)} tone="warn" />
<Stat label="Ticks" value={stats.ticksProcessed.toLocaleString()} />
<Stat label="Engine /tick" value={`${stats.avgTickMs.toFixed(3)}ms`} />
<div className="ml-auto flex items-center gap-3">
{stats.demoMode && (
<span className="rounded-full border border-accent/40 bg-accent/15 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent">
Demo / Simulated Feed
</span>
)}
<span className={clsx("rounded-full border px-3 py-1 text-xs font-semibold tracking-wider", circuit.cls)}>
{circuit.label}
</span>
<span
className={clsx(
"flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wider",
connected ? "border-profit/40 bg-profit/10 text-profit" : "border-loss/40 bg-loss/10 text-loss",
)}
>
<span className={clsx("h-2 w-2 rounded-full", connected ? "bg-profit" : "bg-loss")} />
{connected ? "LIVE" : "OFFLINE"}
</span>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { Trade } from "../types";
import { Card } from "./Card";
import { btc, clsx, timeOf, usd } from "../format";
interface Props {
trades: Trade[];
}
export function TradeLog({ trades }: Props): JSX.Element {
return (
<Card title="Trade Log" subtitle="Simulated executions with real fills">
<div className="max-h-[360px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-ink-800">
<tr className="text-left text-xs uppercase tracking-wider text-slate-400">
<th className="px-2 py-2 font-medium">Time</th>
<th className="px-2 py-2 font-medium">Route</th>
<th className="px-2 py-2 text-right font-medium">Vol</th>
<th className="px-2 py-2 text-right font-medium">Buy</th>
<th className="px-2 py-2 text-right font-medium">Sell</th>
<th className="px-2 py-2 text-right font-medium">Fees</th>
<th className="px-2 py-2 text-right font-medium">Net P&L</th>
</tr>
</thead>
<tbody className="font-mono tabular-nums">
{trades.length === 0 && (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
No trades executed yet.
</td>
</tr>
)}
{trades.map((t) => (
<tr key={t.id} className="border-t border-ink-600/40">
<td className="px-2 py-2 text-slate-500">{timeOf(t.ts)}</td>
<td className="px-2 py-2">
<span className="font-display text-xs">
<span className="text-accent">{t.buyExchange}</span>
<span className="text-slate-600"> </span>
<span className="text-profit">{t.sellExchange}</span>
</span>
{t.partial && <span className="ml-1 text-[10px] uppercase text-warn">partial</span>}
</td>
<td className="px-2 py-2 text-right text-slate-400">{btc(t.volumeBtc)}</td>
<td className="px-2 py-2 text-right text-slate-400">{usd(t.execBuyVwap)}</td>
<td className="px-2 py-2 text-right text-slate-400">{usd(t.execSellVwap)}</td>
<td className="px-2 py-2 text-right text-slate-500">{usd(t.feeBuy + t.feeSell)}</td>
<td className={clsx("px-2 py-2 text-right font-semibold", t.netProfit >= 0 ? "text-profit" : "text-loss")}>
{t.netProfit >= 0 ? "+" : ""}
{usd(t.netProfit)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { RebalanceEvent, Wallet } from "../types";
import { Card } from "./Card";
import { btc, timeOf, usd } from "../format";
interface Props {
wallets: Wallet[];
rebalances: RebalanceEvent[];
}
export function Wallets({ wallets, rebalances }: Props): JSX.Element {
return (
<Card title="Wallets & Inventory" subtitle="Pre-positioned per venue">
<div className="space-y-2">
{wallets.map((w) => (
<div
key={w.exchange}
className="flex items-center justify-between rounded-xl border border-ink-600/40 bg-ink-700/30 px-3 py-2"
>
<span className="font-display text-sm capitalize">{w.exchange}</span>
<div className="flex gap-6 font-mono text-sm tabular-nums">
<span className="text-slate-300">
<span className="text-slate-500">$</span>
{usd(w.usdt, 0)}
</span>
<span className="text-warn">
{btc(w.btc, 4)} <span className="text-slate-500">BTC</span>
</span>
</div>
</div>
))}
</div>
{rebalances.length > 0 && (
<div className="mt-4">
<h3 className="mb-2 text-[10px] uppercase tracking-widest text-slate-500">Recent Rebalances</h3>
<div className="max-h-28 space-y-1 overflow-y-auto">
{rebalances.map((r) => (
<div key={r.id} className="flex items-center justify-between font-mono text-xs text-slate-500">
<span>
{r.fromExchange} {r.toExchange} · {r.asset === "BTC" ? btc(r.amount, 4) : usd(r.amount, 0)} {r.asset}
</span>
<span className="text-slate-600">{timeOf(r.ts)}</span>
</div>
))}
</div>
</div>
)}
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { PublicConfig, ExchangeId } from "./types";
export interface ConfigPatch {
minNetProfitPct?: number;
maxTradeBtc?: number;
flickerConfirmMs?: number;
activeExchanges?: Partial<Record<ExchangeId, boolean>>;
}
async function parseJson<T>(res: Response): Promise<T> {
const body = (await res.json()) as { success: boolean; data?: T; error?: string };
if (!body.success) throw new Error(body.error ?? "request failed");
return body.data as T;
}
export async function getConfig(): Promise<PublicConfig> {
const res = await fetch("/api/config");
return parseJson<PublicConfig>(res);
}
export async function patchConfig(patch: ConfigPatch): Promise<PublicConfig> {
const res = await fetch("/api/config", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return parseJson<PublicConfig>(res);
}
+26
View File
@@ -0,0 +1,26 @@
export function usd(n: number, digits = 2): string {
return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
export function pct(fraction: number, digits = 3): string {
return `${(fraction * 100).toFixed(digits)}%`;
}
export function btc(n: number, digits = 5): string {
return n.toFixed(digits);
}
export function ago(ts: number, now: number): string {
const s = Math.max(0, Math.round((now - ts) / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
export function timeOf(ts: number): string {
return new Date(ts).toLocaleTimeString("en-US", { hour12: false });
}
export function clsx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
+48
View File
@@ -0,0 +1,48 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(91, 140, 255, 0.12), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(61, 220, 151, 0.08), transparent 55%),
#0a0e14;
color: #d7e0ec;
font-family: "Sora", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #2b3a4f;
border-radius: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@keyframes flash {
from {
background-color: rgba(91, 140, 255, 0.18);
}
to {
background-color: transparent;
}
}
.flash {
animation: flash 0.6s ease-out;
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("root element not found");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+130
View File
@@ -0,0 +1,130 @@
// Mirror of the backend StateSnapshot contract (see src/domain/entities/index.ts).
export type ExchangeId = "kraken" | "bybit" | "okx" | "binance";
export type FeedStatus = "connecting" | "live" | "stale" | "down";
export type CircuitState = "running" | "paused" | "tripped";
export type OpportunityStatus =
| "executed"
| "executed_partial"
| "rejected_fees"
| "rejected_liquidity"
| "rejected_risk"
| "rejected_flicker"
| "rejected_stale"
| "pending_confirm";
export interface BestQuote {
exchange: ExchangeId;
bid: number | null;
bidQty: number | null;
ask: number | null;
askQty: number | null;
recvTs: number | null;
status: FeedStatus;
ageMs: number | null;
}
export interface Opportunity {
id: string;
ts: number;
buyExchange: ExchangeId;
sellExchange: ExchangeId;
topBuyAsk: number;
topSellBid: number;
volumeBtc: number;
buyVwap: number;
sellVwap: number;
grossSpread: number;
grossSpreadPct: number;
feeBuy: number;
feeSell: number;
netProfit: number;
netProfitPct: number;
status: OpportunityStatus;
reason: string;
demo: boolean;
}
export interface Trade {
id: string;
ts: number;
buyExchange: ExchangeId;
sellExchange: ExchangeId;
volumeBtc: number;
requestedBtc: number;
buyVwap: number;
sellVwap: number;
execBuyVwap: number;
execSellVwap: number;
feeBuy: number;
feeSell: number;
netProfit: number;
netProfitPct: number;
partial: boolean;
demo: boolean;
}
export interface Wallet {
exchange: ExchangeId;
usdt: number;
btc: number;
}
export interface RebalanceEvent {
id: string;
ts: number;
fromExchange: ExchangeId;
toExchange: ExchangeId;
asset: "BTC" | "USDT";
amount: number;
withdrawalFee: number;
reason: string;
}
export interface EngineStats {
uptimeMs: number;
ticksProcessed: number;
opportunitiesDetected: number;
tradesExecuted: number;
tradesRejected: number;
realizedPnl: number;
consecutiveLosses: number;
circuit: CircuitState;
demoMode: boolean;
avgTickMs: number;
}
export interface PnlPoint {
ts: number;
pnl: number;
}
export interface PublicConfig {
minNetProfitPct: number;
maxTradeBtc: number;
staleMs: number;
flickerConfirmMs: number;
latencyMs: number;
activeExchanges: Record<ExchangeId, boolean>;
defaults: {
minNetProfitPct: number;
maxTradeBtc: number;
flickerConfirmMs: number;
activeExchanges: Record<ExchangeId, boolean>;
};
takerFees: Record<ExchangeId, number>;
withdrawalFeesBtc: Record<ExchangeId, number>;
}
export interface StateSnapshot {
ts: number;
quotes: BestQuote[];
wallets: Wallet[];
stats: EngineStats;
recentOpportunities: Opportunity[];
recentTrades: Trade[];
rebalances: RebalanceEvent[];
pnlSeries: PnlPoint[];
config: PublicConfig;
}
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
mono: ["IBM Plex Mono", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
display: ["Sora", "ui-sans-serif", "system-ui", "sans-serif"],
},
colors: {
ink: {
900: "#0a0e14",
800: "#0f1620",
700: "#161f2c",
600: "#1f2a3a",
500: "#2b3a4f",
},
profit: "#3ddc97",
loss: "#ff5c7c",
warn: "#ffb454",
accent: "#5b8cff",
},
},
},
plugins: [],
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Dev server proxies API + SSE to the Node backend on :8080.
// In production the backend serves web/dist directly (same origin), so the
// relative /api paths used by the client work without any proxy.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});