mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-27 15:47:43 +00:00
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:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
Generated
+120
-47
@@ -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
@@ -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
@@ -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`).",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+269
-718
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+13
-5
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user