Refactor API contracts to Zod, zod-to-openapi, and Scalar.

Replace hand-written OpenAPI and Swagger UI with schema-driven docs at /api-docs and Zod validation on request bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mauricio Barragan
2026-06-11 00:04:12 -06:00
parent b5ff33d716
commit f4def0c214
13 changed files with 788 additions and 812 deletions
+2
View File
@@ -23,5 +23,7 @@ DEMO_MODE=false
# Feed recorder (NDJSON to data/)
RECORD_FEED=false
# API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required)
# Sentry — https://sentry.io (project: arbpulse)
SENTRY_DSN=
+3 -2
View File
@@ -301,9 +301,9 @@ Respuestas REST siguen la forma `{ success, data?, error? }`.
| `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 |
| `GET` | `/api-docs` | **Scalar** — documentación interactiva OpenAPI 3.0 |
Contrato completo y schemas en `src/interfaces/http/openapi.ts`.
Contrato generado desde Zod (`src/interfaces/http/schemas/`) con `@asteasolutions/zod-to-openapi`; registro de rutas en `src/interfaces/http/openapi.ts`.
---
@@ -346,6 +346,7 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e
## Production practices
- **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`.
- **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers.
- **Observability:** Sentry planned — set `SENTRY_DSN` in `.env` after creating the `arbpulse` project in Sentry.
---
+120 -47
View File
@@ -8,23 +8,23 @@
"name": "arb-pulse",
"version": "1.0.0",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@scalar/express-api-reference": "^0.8.48",
"express": "^4.21.2",
"swagger-ui-express": "^5.0.1",
"tsx": "^4.19.2",
"ws": "^8.18.0"
"ws": "^8.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/express": "^4.17.21",
"@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.5.13",
"eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"openapi-types": "^12.1.3",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.26.0"
@@ -33,6 +33,18 @@
"node": ">=20"
}
},
"node_modules/@asteasolutions/zod-to-openapi": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz",
"integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==",
"license": "MIT",
"dependencies": {
"openapi3-ts": "^4.1.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
@@ -722,12 +734,53 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
"node_modules/@scalar/core": {
"version": "0.3.45",
"resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.45.tgz",
"integrity": "sha512-f3jyzColUUcu3eYfjslgNjG2JABuFU8WR3P7a9GevMpisW3skWUqVVffyC1P4w4i9TVRk9FUjYEtGeP10Rw9lQ==",
"license": "MIT",
"dependencies": {
"@scalar/types": "0.6.10"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@scalar/express-api-reference": {
"version": "0.8.48",
"resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.8.48.tgz",
"integrity": "sha512-DtdWhpxSPYAEuWNa0BZ8uVgvmfXytD6jRRJUlFi9RBeVm2Gectbc5dDtHqekmllQ4iiwyvGm8RcEhY/TZm4KbA==",
"license": "MIT",
"dependencies": {
"@scalar/core": "0.3.45"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@scalar/helpers": {
"version": "0.2.18",
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.2.18.tgz",
"integrity": "sha512-w1d4tpNEVZ293oB2BAgLrS0kVPUtG3eByNmOCJA5eK9vcT4D3cmsGtWjUaaqit0BQCsBFHK51rasGvSWnApYTw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/@scalar/types": {
"version": "0.6.10",
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.6.10.tgz",
"integrity": "sha512-fZkelRwcEeAhsn4c0wjYXWrzSzLaEyfxTn/eazXJ4XfCIsgJTQyK0FD8mnOBZJ2vEIbtT2E1mBKnCbDxrJIlxA==",
"license": "MIT",
"dependencies": {
"@scalar/helpers": "0.2.18",
"nanoid": "^5.1.6",
"type-fest": "^5.3.1",
"zod": "^4.3.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
@@ -861,17 +914,6 @@
"@types/node": "*"
}
},
"node_modules/@types/swagger-ui-express": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz",
"integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2739,6 +2781,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -2795,12 +2855,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"dev": true,
"license": "MIT"
"node_modules/openapi3-ts": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz",
"integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==",
"license": "MIT",
"dependencies": {
"yaml": "^2.9.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
@@ -3341,28 +3403,16 @@
"node": ">=8"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.6",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz",
"integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
"node": ">=20"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyexec": {
@@ -3445,6 +3495,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
"integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -3640,7 +3705,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -3664,6 +3728,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+4 -4
View File
@@ -27,23 +27,23 @@
]
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@scalar/express-api-reference": "^0.8.48",
"express": "^4.21.2",
"swagger-ui-express": "^5.0.1",
"tsx": "^4.19.2",
"ws": "^8.18.0"
"ws": "^8.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/express": "^4.17.21",
"@types/node": "^22.10.5",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "^8.5.13",
"eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"openapi-types": "^12.1.3",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.26.0"
+12 -7
View File
@@ -1,5 +1,5 @@
import express from "express";
import swaggerUi from "swagger-ui-express";
import { apiReference } from "@scalar/express-api-reference";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
@@ -8,7 +8,7 @@ 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";
import { openapiDocument } from "./interfaces/http/openapi.js";
const log = createLogger("server");
@@ -26,10 +26,13 @@ function main(): void {
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 },
}));
server.use(
"/api-docs",
apiReference({
content: openapiDocument,
pageTitle: "Arb Pulse — API Docs",
}),
);
if (existsSync(webDist)) {
server.use(express.static(webDist));
@@ -40,7 +43,9 @@ function main(): void {
server.get("/", (_req, res) => {
res
.status(200)
.send("Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).");
.send(
"Backend running. Frontend not built yet — run `npm run build` (or `npm run dev:web`).",
);
});
}
+201 -650
View File
@@ -1,745 +1,296 @@
/**
* 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).
* OpenAPI 3.0 document generated from Zod schemas via @asteasolutions/zod-to-openapi.
* Served interactively at /api-docs through Scalar.
*/
import type { OpenAPIV3 } from "openapi-types";
import {
OpenAPIRegistry,
OpenApiGeneratorV3,
} from "@asteasolutions/zod-to-openapi";
import {
ErrorEnvelopeSchema,
SuccessEnvelopeNoDataSchema,
} from "./schemas/common.js";
import {
ConfigPatchSchema,
DemoControlBodySchema,
MaxTradeControlBodySchema,
RecordControlBodySchema,
ThresholdControlBodySchema,
} from "./schemas/requests.js";
import {
ConfigResponseSchema,
DemoModeResponseSchema,
HealthResponseSchema,
MaxTradeResponseSchema,
RecordFeedResponseSchema,
StateResponseSchema,
ThresholdResponseSchema,
} from "./schemas/responses.js";
import { StateSnapshotSchema } from "./schemas/domain.js";
// ---------------------------------------------------------------------------
// Reusable schema components
// ---------------------------------------------------------------------------
const registry = new OpenAPIRegistry();
const schemas: Record<string, OpenAPIV3.SchemaObject> = {
ExchangeId: {
type: "string",
enum: ["kraken", "bybit", "okx", "binance"],
description: "One of the supported exchanges.",
},
registry.register("ErrorEnvelope", ErrorEnvelopeSchema);
registry.register("SuccessEnvelopeNoData", SuccessEnvelopeNoDataSchema);
registry.register("StateSnapshot", StateSnapshotSchema);
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 = {
const jsonError = {
description: "Invalid request body or out-of-range value.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorEnvelope" },
},
},
content: { "application/json": { schema: ErrorEnvelopeSchema } },
};
const serverErrorResponse: OpenAPIV3.ResponseObject = {
const jsonServerError = {
description: "Unexpected server error.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorEnvelope" },
example: { success: false, error: "Internal server error" },
},
},
content: { "application/json": { schema: ErrorEnvelopeSchema } },
};
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: {
registry.registerPath({
method: "get",
path: "/api/health",
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.",
"Lightweight endpoint used by Fly.io (and any uptime monitor) to verify the server is alive.",
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,
},
200: {
description: "Server is healthy.",
content: { "application/json": { schema: HealthResponseSchema } },
},
500: jsonServerError,
},
});
"/api/state": {
get: {
registry.registerPath({
method: "get",
path: "/api/state",
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.",
"Returns the complete in-memory engine state as a single JSON document.",
operationId: "getState",
responses: {
"200": successResponse("Current engine state.", { $ref: "#/components/schemas/StateSnapshot" }),
"500": serverErrorResponse,
},
200: {
description: "Current engine state.",
content: { "application/json": { schema: StateResponseSchema } },
},
500: jsonServerError,
},
});
"/api/stream": {
get: {
registry.registerPath({
method: "get",
path: "/api/stream",
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.",
"Opens a persistent Server-Sent Events connection. Each event carries a JSON-encoded StateSnapshot.",
operationId: "getStream",
responses: {
"200": {
description: "SSE stream opened. Each `data:` event carries a JSON-encoded `StateSnapshot`.",
200: {
description: "SSE stream opened.",
content: {
"text/event-stream": {
schema: {
type: "string",
description: "Newline-delimited SSE events. Each `data:` line contains a serialized `StateSnapshot`.",
},
},
description: "Newline-delimited SSE events.",
},
},
},
},
},
});
"/api/config": {
get: {
registry.registerPath({
method: "get",
path: "/api/config",
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,
200: {
description: "Current configuration.",
content: { "application/json": { schema: ConfigResponseSchema } },
},
500: jsonServerError,
},
patch: {
});
registry.registerPath({
method: "patch",
path: "/api/config",
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: {
request: {
body: {
content: { "application/json": { schema: ConfigPatchSchema } },
required: true,
},
},
responses: {
200: {
description: "Updated configuration.",
content: { "application/json": { schema: ConfigResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
function registerControlPost(
path: string,
operationId: string,
summary: string,
description: string,
): void {
registry.registerPath({
method: "post",
path,
tags: ["Control"],
summary,
description,
operationId,
responses: {
200: {
description: summary,
content: {
"application/json": { schema: { $ref: "#/components/schemas/ConfigPatch" } },
},
},
responses: {
"200": successResponse("Updated configuration.", { $ref: "#/components/schemas/PublicConfig" }),
"400": badRequestResponse,
"500": serverErrorResponse,
"application/json": { schema: SuccessEnvelopeNoDataSchema },
},
},
500: jsonServerError,
},
});
}
"/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,
},
},
},
registerControlPost(
"/api/control/pause",
"controlPause",
"Pause the arbitrage engine",
"Manually pauses the engine until resume is called.",
);
registerControlPost(
"/api/control/resume",
"controlResume",
"Resume the arbitrage engine",
"Resumes opportunity evaluation after pause or circuit-breaker trip.",
);
registerControlPost(
"/api/control/reset",
"controlReset",
"Reset simulated state",
"Resets wallets, trade history, and cumulative P&L.",
);
"/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: {
registry.registerPath({
method: "post",
path: "/api/control/demo",
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: {
request: {
body: {
content: { "application/json": { schema: DemoControlBodySchema } },
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 } },
),
responses: {
200: {
description: "Demo mode updated.",
content: { "application/json": { schema: DemoModeResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
"/api/control/record": {
post: {
registry.registerPath({
method: "post",
path: "/api/control/record",
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: {
request: {
body: {
content: { "application/json": { schema: RecordControlBodySchema } },
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 } },
),
responses: {
200: {
description: "Recording state updated.",
content: { "application/json": { schema: RecordFeedResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
"/api/control/threshold": {
post: {
registry.registerPath({
method: "post",
path: "/api/control/threshold",
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: {
request: {
body: {
content: { "application/json": { schema: ThresholdControlBodySchema } },
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 } },
),
responses: {
200: {
description: "Threshold updated.",
content: { "application/json": { schema: ThresholdResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
});
"/api/control/max-trade": {
post: {
registry.registerPath({
method: "post",
path: "/api/control/max-trade",
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: {
request: {
body: {
content: { "application/json": { schema: MaxTradeControlBodySchema } },
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,
200: {
description: "Max trade volume updated.",
content: { "application/json": { schema: MaxTradeResponseSchema } },
},
400: jsonError,
500: jsonServerError,
},
};
});
const generator = new OpenApiGeneratorV3(registry.definitions);
export const openapiDocument = generator.generateDocument({
openapi: "3.0.3",
info: {
title: "Arb Pulse API",
version: "1.0.0",
description:
"Real-time BTC/USDT arbitrage detection and simulation engine. Simulated only — no real funds are committed.",
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." },
],
});
+32 -29
View File
@@ -1,6 +1,14 @@
import { Router, type Request, type Response } from "express";
import type { ApplicationService } from "../../composition/application-service.js";
import type { SseHub } from "../sse/sse.js";
import {
ConfigPatchSchema,
DemoControlBodySchema,
MaxTradeControlBodySchema,
RecordControlBodySchema,
ThresholdControlBodySchema,
} from "./schemas/requests.js";
import { parseBody } from "./validate.js";
export function createRouter(app: ApplicationService, sse: SseHub): Router {
const router = Router();
@@ -22,7 +30,10 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
});
router.patch("/config", (req: Request, res: Response) => {
const error = app.patchConfig(req.body ?? {});
const patch = parseBody(ConfigPatchSchema, req, res);
if (patch === null) return;
const error = app.patchConfig(patch);
if (error) {
res.status(400).json({ success: false, error });
return;
@@ -46,51 +57,43 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
});
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 } });
const body = parseBody(DemoControlBodySchema, req, res);
if (body === null) return;
app.setDemoMode(body.enabled);
res.json({ success: true, data: { demoMode: body.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 } });
const body = parseBody(RecordControlBodySchema, req, res);
if (body === null) return;
app.setRecordFeed(body.enabled);
res.json({ success: true, data: { recordFeed: body.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);
const body = parseBody(ThresholdControlBodySchema, req, res);
if (body === null) return;
const error = app.setThreshold(body.pct);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { minNetProfitPct: pct } });
res.json({ success: true, data: { minNetProfitPct: body.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);
const body = parseBody(MaxTradeControlBodySchema, req, res);
if (body === null) return;
const error = app.setMaxTradeBtc(body.btc);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { maxTradeBtc: btc } });
res.json({ success: true, data: { maxTradeBtc: body.btc } });
});
return router;
+59
View File
@@ -0,0 +1,59 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export { z };
export const ExchangeIdSchema = z
.enum(["kraken", "bybit", "okx", "binance"])
.openapi("ExchangeId");
export const FeedStatusSchema = z
.enum(["connecting", "live", "stale", "down"])
.openapi("FeedStatus");
export const CircuitStateSchema = z
.enum(["running", "paused", "tripped"])
.openapi("CircuitState");
export const OpportunityStatusSchema = z
.enum([
"executed",
"executed_partial",
"rejected_fees",
"rejected_liquidity",
"rejected_risk",
"rejected_flicker",
"rejected_stale",
"pending_confirm",
])
.openapi("OpportunityStatus");
export const ErrorEnvelopeSchema = z
.object({
success: z.literal(false),
error: z.string(),
})
.strict()
.openapi("ErrorEnvelope");
export const SuccessEnvelopeNoDataSchema = z
.object({
success: z.literal(true),
})
.strict()
.openapi("SuccessEnvelopeNoData");
export function successEnvelope<T extends z.ZodType>(
dataSchema: T,
name: string,
) {
return z
.object({
success: z.literal(true),
data: dataSchema,
})
.strict()
.openapi(name);
}
+161
View File
@@ -0,0 +1,161 @@
import {
CircuitStateSchema,
ExchangeIdSchema,
FeedStatusSchema,
OpportunityStatusSchema,
z,
} from "./common.js";
export const BestQuoteSchema = z
.object({
exchange: ExchangeIdSchema,
bid: z.number().nullable(),
bidQty: z.number().nullable(),
ask: z.number().nullable(),
askQty: z.number().nullable(),
recvTs: z.number().int().nullable(),
status: FeedStatusSchema,
ageMs: z.number().int().nullable(),
})
.strict()
.openapi("BestQuote");
export const WalletSchema = z
.object({
exchange: ExchangeIdSchema,
usdt: z.number(),
btc: z.number(),
})
.strict()
.openapi("Wallet");
export const OpportunitySchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
topBuyAsk: z.number(),
topSellBid: z.number(),
volumeBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
grossSpread: z.number(),
grossSpreadPct: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
status: OpportunityStatusSchema,
reason: z.string(),
demo: z.boolean(),
})
.strict()
.openapi("Opportunity");
export const TradeSchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
volumeBtc: z.number(),
requestedBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
execBuyVwap: z.number(),
execSellVwap: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
partial: z.boolean(),
demo: z.boolean(),
})
.strict()
.openapi("Trade");
export const RebalanceEventSchema = z
.object({
id: z.string(),
ts: z.number().int(),
fromExchange: ExchangeIdSchema,
toExchange: ExchangeIdSchema,
asset: z.enum(["BTC", "USDT"]),
amount: z.number(),
withdrawalFee: z.number(),
reason: z.string(),
})
.strict()
.openapi("RebalanceEvent");
export const PnlPointSchema = z
.object({
ts: z.number().int(),
pnl: z.number(),
})
.strict()
.openapi("PnlPoint");
export const EngineStatsSchema = z
.object({
uptimeMs: z.number().int(),
ticksProcessed: z.number().int(),
opportunitiesDetected: z.number().int(),
tradesExecuted: z.number().int(),
tradesRejected: z.number().int(),
realizedPnl: z.number(),
consecutiveLosses: z.number().int(),
circuit: CircuitStateSchema,
demoMode: z.boolean(),
avgTickMs: z.number(),
})
.strict()
.openapi("EngineStats");
const ActiveExchangesSchema = z.record(ExchangeIdSchema, z.boolean());
export const PublicConfigSchema = z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
staleMs: z.number().int(),
flickerConfirmMs: z.number().int(),
latencyMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
defaults: z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
flickerConfirmMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
})
.strict(),
takerFees: z.record(ExchangeIdSchema, z.number()),
withdrawalFeesBtc: z.record(ExchangeIdSchema, z.number()),
})
.strict()
.openapi("PublicConfig");
export const StateSnapshotSchema = z
.object({
ts: z.number().int(),
quotes: z.array(BestQuoteSchema),
wallets: z.array(WalletSchema),
stats: EngineStatsSchema,
recentOpportunities: z.array(OpportunitySchema),
recentTrades: z.array(TradeSchema),
rebalances: z.array(RebalanceEventSchema),
pnlSeries: z.array(PnlPointSchema),
config: PublicConfigSchema,
})
.strict()
.openapi("StateSnapshot");
export const HealthDataSchema = z
.object({
status: z.literal("ok"),
ts: z.number().int(),
})
.strict()
.openapi("HealthData");
+53
View File
@@ -0,0 +1,53 @@
import { ExchangeIdSchema, z } from "./common.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 const BooleanControlBodySchema = z
.object({
enabled: z.boolean(),
})
.strict()
.openapi("BooleanControlBody");
export const DemoControlBodySchema =
BooleanControlBodySchema.openapi("DemoControlBody");
export const RecordControlBodySchema =
BooleanControlBodySchema.openapi("RecordControlBody");
export const ThresholdControlBodySchema = z
.object({
pct: z.number().finite().min(MIN_PROFIT_PCT).max(MAX_PROFIT_PCT),
})
.strict()
.openapi("ThresholdControlBody");
export const MaxTradeControlBodySchema = z
.object({
btc: z.number().finite().min(MIN_TRADE_BTC).max(MAX_TRADE_BTC),
})
.strict()
.openapi("MaxTradeControlBody");
export const ConfigPatchSchema = z
.object({
minNetProfitPct: z
.number()
.finite()
.min(MIN_PROFIT_PCT)
.max(MAX_PROFIT_PCT)
.optional(),
maxTradeBtc: z
.number()
.finite()
.min(MIN_TRADE_BTC)
.max(MAX_TRADE_BTC)
.optional(),
flickerConfirmMs: z.number().int().min(0).max(MAX_FLICKER_MS).optional(),
activeExchanges: z.record(ExchangeIdSchema, z.boolean()).optional(),
})
.strict()
.openapi("ConfigPatch");
+42
View File
@@ -0,0 +1,42 @@
import { successEnvelope, SuccessEnvelopeNoDataSchema } from "./common.js";
import {
HealthDataSchema,
PublicConfigSchema,
StateSnapshotSchema,
} from "./domain.js";
import { z } from "./common.js";
export const HealthResponseSchema = successEnvelope(
HealthDataSchema,
"HealthResponse",
);
export const StateResponseSchema = successEnvelope(
StateSnapshotSchema,
"StateResponse",
);
export const ConfigResponseSchema = successEnvelope(
PublicConfigSchema,
"ConfigResponse",
);
export const DemoModeResponseSchema = successEnvelope(
z.object({ demoMode: z.boolean() }).strict(),
"DemoModeResponse",
);
export const RecordFeedResponseSchema = successEnvelope(
z.object({ recordFeed: z.boolean() }).strict(),
"RecordFeedResponse",
);
export const ThresholdResponseSchema = successEnvelope(
z.object({ minNetProfitPct: z.number() }).strict(),
"ThresholdResponse",
);
export const MaxTradeResponseSchema = successEnvelope(
z.object({ maxTradeBtc: z.number() }).strict(),
"MaxTradeResponse",
);
export { SuccessEnvelopeNoDataSchema };
+18
View File
@@ -0,0 +1,18 @@
import type { Request, Response } from "express";
import type { ZodType } from "zod";
export function parseBody<T>(
schema: ZodType<T>,
req: Request,
res: Response,
): T | null {
const result = schema.safeParse(req.body ?? {});
if (!result.success) {
const message = result.error.issues
.map((issue) => issue.message)
.join("; ");
res.status(400).json({ success: false, error: message });
return null;
}
return result.data;
}
+13 -5
View File
@@ -27,18 +27,26 @@
"name": "arb-pulse",
"version": "1.0.0",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@scalar/express-api-reference": "^0.8.48",
"express": "^4.21.2",
"swagger-ui-express": "^5.0.1",
"tsx": "^4.19.2",
"ws": "^8.18.0"
"ws": "^8.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@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"
"eslint": "^9.18.0",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.3",
"typescript": "^5.7.3",
"typescript-eslint": "^8.26.0"
},
"engines": {
"node": ">=20"