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:
co-authored by
Cursor
parent
b5ff33d716
commit
f4def0c214
@@ -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 };
|
||||
Reference in New Issue
Block a user