Repository Public
1// Routes of the derivatives module — the "API contract (wave 2)" table of docs/v4/DERIVATIVES-ARCHITECTURE.md, route for route.2// Authentication and CSRF are the global hook of server.ts. Decimals are strings. Read-only and paper only: the live3// trading routes are in live-routes.ts.4import type { FastifyInstance } from 'fastify';5import { z } from 'zod';6import { RiskPolicySchema, type VenueId } from '@spw/derivatives';7import { UserError } from '../config.ts';8import { CANDLE_INTERVALS, DATA_BOUNDS, FUNDING_MAX_HOURS } from './data.ts';9import { derivatives, type Derivatives } from './index.ts';10import { CloseBodySchema, CreateBodySchema, OrderBodySchema } from './paper.ts';11import { localizeCompare, localizeInstruments, localizeRoute, textLocale } from './market-texts.ts'; // v8 hardening: `locale` on the read routes1213const P = '/api/v1/derivatives';14/** Server-Sent Events connections served at once (one owner, a few tabs) */15export const MAX_STREAMS = 24;16const InstrumentQuery = z.string().min(3).max(120);17const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;18const IDEMPOTENCY = /^[A-Za-z0-9._:-]{8,100}$/;1920export function derivativesRoutes(app: FastifyInstance, d: Derivatives = derivatives()): void {21 const { registry: reg, data, paper } = d;22 let streams = 0;23 app.addHook('onClose', async () => { data.close(); });2425 const accountId = (req: { params: unknown }): string => z.uuid().parse((req.params as { id: string }).id);26 const idempotencyKey = (req: { headers: Record<string, unknown> }, required: boolean): string | undefined => {27 const k = req.headers['idempotency-key'];28 if (k === undefined && !required) return undefined;29 if (typeof k !== 'string' || !IDEMPOTENCY.test(k)) throw new UserError('idempotency', 'an Idempotency-Key header (8 to 100 characters: letters, digits, . _ : -) is required', 400);30 return k;31 };32 /** one venue, or all of them: a venue that cannot be read is listed in `errors`, the others are still answered */33 const perVenue = async <T>(venue: string | undefined, read: (id: VenueId) => Promise<{ value: T[]; ts: number }>): Promise<{ items: T[]; ts: number; errors: { venue: VenueId; message: string }[] }> => {34 if (venue !== undefined) { const r = await read(reg.get(venue).id); return { items: r.value, ts: r.ts, errors: [] }; }35 const ids = reg.list().map((v) => v.id), got = await Promise.allSettled(ids.map((id) => read(id)));36 const items: T[] = [], errors: { venue: VenueId; message: string }[] = [];37 let ts = data.now(), first: unknown = null;38 got.forEach((g, k) => {39 if (g.status === 'fulfilled') { items.push(...g.value.value); ts = Math.min(ts, g.value.ts); }40 else { first ??= g.reason; errors.push({ venue: ids[k]!, message: g.reason instanceof Error ? g.reason.message.slice(0, 200) : 'error' }); }41 });42 if (errors.length === ids.length && first) throw first;43 return { items, ts, errors };44 };4546 // ------------------------------------------------------------------ venues and market data4748 app.get(`${P}/venues`, async () => {49 // a venue that was never read has no health yet: its (cached) instrument list is read so that `status` means something50 await Promise.all(reg.list().map((v) => data.instruments(v.id).catch(() => null)));51 return {52 venues: reg.list().map((v) => {53 const h = reg.health(v.id);54 return { id: v.id, name: v.name, network: v.adapter.network, capabilities: v.adapter.capabilities, status: h.status, lastOkAt: h.lastOkAt, restriction: v.restriction,55 lastError: h.lastError, paper: v.restriction === null && v.facts.paperFees !== null && v.facts.hasOrderBook, orderBook: v.facts.hasOrderBook };56 }),57 bounds: DATA_BOUNDS, ts: data.now(),58 };59 });6061 app.get(`${P}/instruments`, async (req) => {62 const q = z.object({ venue: z.string().max(40).optional(), locale: z.string().max(8).optional() }).parse(req.query);63 const r = await perVenue(q.venue, (id) => data.instruments(id));64 return localizeInstruments({ instruments: r.items, ts: r.ts, errors: r.errors }, textLocale(q.locale)); // v8: `?locale=fr|en` (default en = the adapters' own strings)65 });6667 app.get(`${P}/tickers`, async (req) => {68 const q = z.object({ venue: z.string().max(40).optional() }).parse(req.query);69 const r = await perVenue(q.venue, (id) => data.tickers(id));70 return { tickers: r.items, ts: r.ts, source: 'venue' as const, errors: r.errors };71 });7273 app.get(`${P}/market`, async (req) => data.market(z.object({ instrument: InstrumentQuery }).parse(req.query).instrument));7475 app.get(`${P}/candles`, async (req) => {76 const q = z.object({ instrument: InstrumentQuery, interval: z.enum(CANDLE_INTERVALS).default('1h'), limit: z.coerce.number().int().min(1).max(1000).default(200) }).parse(req.query);77 const inst = await data.instrument(q.instrument), r = await data.candles(inst, q.interval, q.limit);78 return { instrument: inst.id, interval: q.interval, candles: r.value, ts: r.ts, source: 'venue' as const };79 });8081 app.get(`${P}/funding`, async (req) => {82 const q = z.object({ instrument: InstrumentQuery, hours: z.coerce.number().int().min(1).max(FUNDING_MAX_HOURS).default(24) }).parse(req.query);83 const inst = await data.instrument(q.instrument), r = await data.funding(inst, q.hours);84 return { instrument: inst.id, history: r.value, intervalSec: inst.fundingIntervalSec, ts: r.ts, source: 'venue' as const };85 });8687 // Server-Sent Events: `status`, `ticker`, `book`, `trades` (and `error`). One upstream per instrument, shared by every listener.88 app.get(`${P}/stream`, async (req, reply) => {89 const inst = await data.instrument(z.object({ instrument: InstrumentQuery }).parse(req.query).instrument);90 if (streams >= MAX_STREAMS) throw new UserError('too_many_streams', `at most ${MAX_STREAMS} live streams at once`, 429);91 streams += 1;92 reply.hijack();93 reply.raw.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });94 const write = (chunk: string): void => { if (!reply.raw.writableEnded && !reply.raw.destroyed) reply.raw.write(chunk); };95 const off = data.subscribe(inst, (ev) => write(`event: ${ev.event}\ndata: ${JSON.stringify(ev.data)}\n\n`));96 const beat = setInterval(() => write(': keep-alive\n\n'), 15_000);97 beat.unref();98 let done = false;99 const end = (): void => { if (done) return; done = true; streams -= 1; clearInterval(beat); off(); if (!reply.raw.writableEnded) reply.raw.end(); };100 req.raw.on('close', end); reply.raw.on('close', end); reply.raw.on('error', end);101 });102103 app.get(`${P}/compare`, async (req) => {104 const q = z.object({ base: z.string().regex(/^[A-Za-z0-9]{1,15}$/).default('BTC'), locale: z.string().max(8).optional() }).parse(req.query);105 return localizeCompare(await data.compare(q.base), textLocale(q.locale)); // v8: `?locale=fr|en` (default en = the same strings as before)106 });107108 app.get(`${P}/watch`, async (req) => {109 const q = z.object({ account: z.string().min(1).max(120), venue: z.string().max(40) }).parse(req.query);110 const v = reg.assertAccountAllowed(q.venue); // dYdX: 403 with the restriction text, before anything else111 if (!EVM_ADDRESS.test(q.account)) throw new UserError('bad_account', `an account on ${v.name} is a 20-byte hex address`, 400);112 return data.watch(v.id, q.account);113 });114115 // ------------------------------------------------------------------ router (simulation) — docs/v4/ROUTER.md116 // Compares the execution conditions of ONE order intent across the venues. Nothing is placed, signed or sent; the answer is117 // an estimate (`source: 'estimate'`) and its ranking is information, never a selection.118 const router = (): NonNullable<Derivatives['router']> => { if (!d.router) throw new UserError('router_unavailable', 'the router is not configured on this instance', 503); return d.router; };119 app.get(`${P}/route/assets`, async () => router().assets());120 app.post(`${P}/route`, async (req) => {121 // v8: `?locale=fr|en` or the body's `locale` selects the language of the engine messages and contract terms; it is not part of the order intent122 const body = req.body, own = body !== null && typeof body === 'object' && !Array.isArray(body) && 'locale' in body;123 const { locale: bodyLocale, ...intent } = own ? (body as Record<string, unknown>) : { locale: undefined };124 return localizeRoute(await router().route(own ? intent : body), textLocale((req.query as { locale?: unknown } | undefined)?.locale ?? bodyLocale));125 });126 // ------------------------------------------------------------------ /router127128 // ------------------------------------------------------------------ paper accounts129130 app.get(`${P}/accounts`, async () => ({ accounts: await paper.list(), ts: data.now() }));131 app.post(`${P}/accounts`, async (req) => paper.create(CreateBodySchema.parse(req.body)));132 app.get(`${P}/accounts/:id`, async (req) => paper.get(accountId(req)));133 app.post(`${P}/accounts/:id/policy`, async (req) => paper.setPolicy(accountId(req), RiskPolicySchema.strict().parse(req.body)));134 app.post(`${P}/accounts/:id/orders/preview`, async (req) => paper.preview(accountId(req), OrderBodySchema.parse(req.body)));135 app.post(`${P}/accounts/:id/orders`, async (req) => {136 const id = accountId(req), key = idempotencyKey(req, true)!;137 return paper.order(id, OrderBodySchema.parse(req.body), key);138 });139 app.post(`${P}/accounts/:id/orders/:clientId/cancel`, async (req) => {140 const clientId = z.string().min(1).max(100).parse((req.params as { clientId: string }).clientId);141 return paper.cancel(accountId(req), clientId);142 });143 app.post(`${P}/accounts/:id/close`, async (req) => paper.close(accountId(req), CloseBodySchema.parse(req.body), idempotencyKey(req, false)));144 app.post(`${P}/accounts/:id/reset`, async (req) => paper.reset(accountId(req)));145}146