mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-06 12:07:44 +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:
+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;
|
||||
}
|
||||
Reference in New Issue
Block a user