Files
trade-deploy/kustomize/base/api/server.mjs

1662 lines
53 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
const TOKENS_DIR = path.join(PROJECT_ROOT, 'tokens');
function readJsonFile(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch {
return undefined;
}
}
function getIsoNow() {
return new Date().toISOString();
}
function base64Url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function sha256Hex(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function generateToken() {
return `alg_${base64Url(crypto.randomBytes(32))}`;
}
function getHeader(req, name) {
const v = req.headers[String(name).toLowerCase()];
return Array.isArray(v) ? v[0] : v;
}
function readBearerToken(req) {
const auth = getHeader(req, 'authorization');
if (auth && typeof auth === 'string') {
const m = auth.match(/^Bearer\s+(.+)$/i);
if (m && m[1]) return m[1].trim();
}
const apiKey = getHeader(req, 'x-api-key');
if (apiKey && typeof apiKey === 'string' && apiKey.trim()) return apiKey.trim();
return undefined;
}
function withCors(res, corsOrigin) {
res.setHeader('access-control-allow-origin', corsOrigin);
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
res.setHeader(
'access-control-allow-headers',
'content-type, authorization, x-api-key, x-admin-secret'
);
}
function sendJson(res, status, body, corsOrigin) {
withCors(res, corsOrigin);
res.statusCode = status;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify(body));
}
async function readBodyJson(req, { maxBytes }) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) throw new Error('payload_too_large');
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
if (!text.trim()) return {};
try {
return JSON.parse(text);
} catch {
throw new Error('invalid_json');
}
}
function normalizeGraphqlName(value, fallback, label) {
const v = String(value || fallback || '')
.trim()
.toLowerCase();
if (!v) return String(fallback || '');
if (!/^[a-z][a-z0-9_]*$/.test(v)) throw new Error(`Invalid ${label}: ${value}`);
return v;
}
function resolveConfig() {
const hasuraTokens = readJsonFile(path.join(TOKENS_DIR, 'hasura.json')) || {};
const apiTokens = readJsonFile(path.join(TOKENS_DIR, 'api.json')) || {};
const portRaw = process.env.PORT || process.env.API_PORT || apiTokens.port || '8787';
const port = Number.parseInt(String(portRaw), 10);
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${portRaw}`);
const hasuraUrl =
process.env.HASURA_GRAPHQL_URL ||
hasuraTokens.graphqlUrl ||
hasuraTokens.apiUrl ||
'http://localhost:8080/v1/graphql';
const hasuraAdminSecret =
process.env.HASURA_ADMIN_SECRET || hasuraTokens.adminSecret || hasuraTokens.hasuraAdminSecret;
const apiAdminSecret = process.env.API_ADMIN_SECRET || apiTokens.adminSecret;
const corsOrigin = process.env.CORS_ORIGIN || apiTokens.corsOrigin || '*';
const appVersion = String(process.env.APP_VERSION || 'v1').trim() || 'v1';
const buildTimestamp = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined;
const startedAt = getIsoNow();
const ticksTable = normalizeGraphqlName(process.env.TICKS_TABLE, 'drift_ticks', 'TICKS_TABLE');
const candlesFunction = normalizeGraphqlName(
process.env.CANDLES_FUNCTION,
'get_drift_candles',
'CANDLES_FUNCTION'
);
return {
port,
hasuraUrl,
hasuraAdminSecret,
apiAdminSecret,
corsOrigin,
appVersion,
buildTimestamp,
startedAt,
ticksTable,
candlesFunction,
};
}
async function hasuraRequest(cfg, { admin }, query, variables) {
const headers = { 'content-type': 'application/json' };
if (admin) {
if (!cfg.hasuraAdminSecret) throw new Error('Missing HASURA_ADMIN_SECRET (or tokens/hasura.json adminSecret)');
headers['x-hasura-admin-secret'] = cfg.hasuraAdminSecret;
}
const res = await fetch(cfg.hasuraUrl, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Hasura invalid JSON: ${text}`);
}
if (json.errors?.length) {
throw new Error(json.errors.map((e) => e.message).join(' | '));
}
return json.data;
}
async function readSolPriceUsd(cfg) {
// Best-effort: use DLOB stats for SOL-PERP if available, else latest tick mark/oracle.
try {
const q = `
query SolPrice {
dlob_stats_latest(where: {market_name: {_eq: "SOL-PERP"}}, limit: 1) {
mid_price
mark_price
oracle_price
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, q, {});
const row = data?.dlob_stats_latest?.[0];
const p = parseNumeric(row?.mid_price) ?? parseNumeric(row?.mark_price) ?? parseNumeric(row?.oracle_price);
if (p != null && p > 0) return p;
} catch {
// ignore
}
try {
const table = cfg.ticksTable;
const q = `
query SolTick($limit: Int!) {
${table}(where: {symbol: {_eq: "SOL-PERP"}}, order_by: {ts: desc}, limit: $limit) {
mark_price
oracle_price
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, q, { limit: 1 });
const row = data?.[table]?.[0];
const p = parseNumeric(row?.mark_price) ?? parseNumeric(row?.oracle_price);
if (p != null && p > 0) return p;
} catch {
// ignore
}
return null;
}
function normalizeScopes(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
if (typeof value === 'string') {
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
return [];
}
async function requireValidToken(cfg, req, requiredScope) {
const token = readBearerToken(req);
if (!token) return { ok: false, status: 401, error: 'missing_token' };
const hash = sha256Hex(token);
const query = `
query ValidToken($hash: String!) {
api_tokens(where: {token_hash: {_eq: $hash}, revoked_at: {_is_null: true}}, limit: 1) {
id
name
scopes
}
}
`;
let data;
try {
data = await hasuraRequest(cfg, { admin: true }, query, { hash });
} catch (err) {
return { ok: false, status: 500, error: String(err?.message || err) };
}
const row = data?.api_tokens?.[0];
if (!row?.id) return { ok: false, status: 401, error: 'invalid_or_revoked_token' };
const scopes = normalizeScopes(row.scopes);
if (requiredScope && !scopes.includes(requiredScope)) {
return { ok: false, status: 403, error: 'missing_scope' };
}
// best-effort touch
const touch = `
mutation TouchToken($id: uuid!, $ts: timestamptz!) {
update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {last_used_at: $ts}) { id }
}
`;
hasuraRequest(cfg, { admin: true }, touch, { id: row.id, ts: new Date().toISOString() }).catch(() => {});
return { ok: true, token: { id: row.id, name: row.name } };
}
function toNumericString(value, fieldName) {
if (value == null) throw new Error(`invalid_${fieldName}`);
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error(`invalid_${fieldName}`);
return String(value);
}
if (typeof value === 'string') {
const s = value.trim();
if (!s) throw new Error(`invalid_${fieldName}`);
const n = Number(s);
if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`);
return s;
}
// best-effort: allow bigint-like or BN-like objects
if (typeof value?.toString === 'function') {
const s = String(value.toString()).trim();
if (!s) throw new Error(`invalid_${fieldName}`);
const n = Number(s);
if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`);
return s;
}
throw new Error(`invalid_${fieldName}`);
}
function normalizeTick(input, tokenInfo) {
const ts = (input?.ts || input?.timestamp || getIsoNow())?.toString?.();
const market_index = input?.market_index ?? input?.marketIndex;
const symbol = input?.symbol;
const oracle_price = input?.oracle_price ?? input?.oraclePrice ?? input?.price;
const mark_price = input?.mark_price ?? input?.markPrice ?? input?.mark;
const oracle_slot = input?.oracle_slot ?? input?.oracleSlot ?? input?.slot;
const source = (input?.source || 'api')?.toString?.();
const raw = input?.raw && typeof input.raw === 'object' ? input.raw : undefined;
if (!ts || Number.isNaN(Date.parse(ts))) throw new Error('invalid_ts');
if (!Number.isInteger(market_index)) throw new Error('invalid_market_index');
if (typeof symbol !== 'string' || !symbol.trim()) throw new Error('invalid_symbol');
const oracleStr = toNumericString(oracle_price, 'oracle_price');
const markStr = mark_price == null ? undefined : toNumericString(mark_price, 'mark_price');
const slotNum =
oracle_slot == null ? undefined : Number.isFinite(Number(oracle_slot)) ? Number(oracle_slot) : undefined;
const mergedRaw =
raw || tokenInfo
? {
...(raw || {}),
ingestedBy: tokenInfo ? { tokenId: tokenInfo.id, name: tokenInfo.name } : undefined,
}
: undefined;
return {
ts,
market_index,
symbol: symbol.trim(),
// Postgres columns are NUMERIC; Hasura `numeric` scalar returns strings and expects string inputs.
oracle_price: oracleStr,
mark_price: markStr,
oracle_slot: slotNum,
source,
raw: mergedRaw,
};
}
async function insertTick(cfg, tick) {
const table = cfg.ticksTable;
const insertField = `insert_${table}_one`;
const mutation = `
mutation InsertTick($object: ${table}_insert_input!) {
${insertField}(object: $object) {
id
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, mutation, { object: tick });
return data?.[insertField]?.id;
}
async function createApiToken(cfg, name, scopes, meta) {
const mutation = `
mutation CreateToken($name: String!, $hash: String!, $scopes: [String!]!, $meta: jsonb) {
insert_api_tokens_one(object: {name: $name, token_hash: $hash, scopes: $scopes, meta: $meta}) {
id
name
created_at
}
}
`;
for (let attempt = 0; attempt < 5; attempt++) {
const token = generateToken();
const hash = sha256Hex(token);
try {
const data = await hasuraRequest(cfg, { admin: true }, mutation, { name, hash, scopes, meta });
const row = data?.insert_api_tokens_one;
if (!row?.id) throw new Error('token_insert_failed');
return { token, row };
} catch (err) {
const msg = String(err?.message || err);
const isUniqueConflict = msg.toLowerCase().includes('unique') || msg.toLowerCase().includes('constraint');
if (!isUniqueConflict) throw err;
}
}
throw new Error('token_generation_failed');
}
async function revokeApiToken(cfg, id) {
const mutation = `
mutation RevokeToken($id: uuid!, $ts: timestamptz!) {
update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {revoked_at: $ts}) {
id
revoked_at
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, mutation, { id, ts: new Date().toISOString() });
return data?.update_api_tokens_by_pk?.id;
}
function clampInt(value, min, max) {
const n = Number.parseInt(String(value), 10);
if (!Number.isFinite(n) || !Number.isInteger(n)) return min;
return Math.min(max, Math.max(min, n));
}
function clampNumber(value, min, max, fallback) {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function parseNumeric(value) {
if (value == null) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const s = value.trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
return null;
}
function tsToUnixSeconds(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string') return null;
const ms = Date.parse(value);
if (!Number.isFinite(ms)) return null;
return Math.floor(ms / 1000);
}
function isUuid(value) {
const s = String(value || '').trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s);
}
function getByPath(obj, pathStr) {
if (!obj || typeof obj !== 'object') return undefined;
const parts = String(pathStr || '').split('.').filter(Boolean);
let cur = obj;
for (const p of parts) {
if (!cur || typeof cur !== 'object') return undefined;
cur = cur[p];
}
return cur;
}
function readNumberFromPayload(payload, paths) {
for (const p of paths) {
const v = getByPath(payload, p);
const n = parseNumeric(v);
if (n != null) return n;
}
return null;
}
function readTextFromPayload(payload, paths) {
for (const p of paths) {
const v = getByPath(payload, p);
if (typeof v === 'string' && v.trim()) return v.trim();
}
return null;
}
function inferContractSizeUsd(contract) {
return (
readNumberFromPayload(contract, [
'desired.size_usd',
'desired.sizeUsd',
'desired.notional_usd',
'desired.notionalUsd',
'entry.size_usd',
'entry.sizeUsd',
'entry.notional_usd',
'entry.notionalUsd',
'entry.order_intent.size_usd',
'entry.order_intent.sizeUsd',
'desired.order_intent.size_usd',
'desired.order_intent.sizeUsd',
]) || null
);
}
function inferContractSide(contract) {
const raw =
readTextFromPayload(contract, [
'desired.side',
'entry.side',
'entry.order_intent.side',
'desired.order_intent.side',
'desired.direction',
'entry.direction',
]) || '';
const v = raw.toLowerCase();
if (v === 'long' || v === 'buy') return 'long';
if (v === 'short' || v === 'sell') return 'short';
return null;
}
function sumCostsFromEvents(events) {
const totals = {
tradeFeeUsd: 0,
txFeeUsd: 0,
slippageUsd: 0,
fundingUsd: 0,
realizedPnlUsd: 0,
txCount: 0,
fillCount: 0,
cancelCount: 0,
modifyCount: 0,
errorCount: 0,
};
for (const ev of events || []) {
const t = String(ev?.event_type || '').toLowerCase();
const payload = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
const tradeFeeUsd =
readNumberFromPayload(payload, ['realized_fee_usd', 'trade_fee_usd', 'fee_usd', 'fees.trade_fee_usd', 'fees.usd']) || 0;
const txFeeUsd =
readNumberFromPayload(payload, ['realized_tx_usd', 'tx_fee_usd', 'network_fee_usd', 'fees.tx_fee_usd', 'fees.network_usd']) || 0;
const slippageUsd =
readNumberFromPayload(payload, ['slippage_usd', 'realized_slippage_usd', 'execution_usd', 'realized_execution_usd']) || 0;
const fundingUsd = readNumberFromPayload(payload, ['funding_usd', 'realized_funding_usd']) || 0;
const pnlUsd = readNumberFromPayload(payload, ['realized_pnl_usd', 'pnl_usd']) || 0;
const txCount = readNumberFromPayload(payload, ['tx_count', 'txCount']) || 0;
totals.tradeFeeUsd += tradeFeeUsd;
totals.txFeeUsd += txFeeUsd;
totals.slippageUsd += slippageUsd;
totals.fundingUsd += fundingUsd;
totals.realizedPnlUsd += pnlUsd;
totals.txCount += txCount;
if (t.includes('fill')) totals.fillCount += 1;
if (t.includes('cancel')) totals.cancelCount += 1;
if (t.includes('modify') || t.includes('reprice')) totals.modifyCount += 1;
if (t.includes('error') || String(ev?.severity || '').toLowerCase() === 'error') totals.errorCount += 1;
}
const totalCostsUsd = totals.tradeFeeUsd + totals.txFeeUsd + totals.slippageUsd + totals.fundingUsd;
return {
...totals,
totalCostsUsd,
netPnlUsd: totals.realizedPnlUsd - totalCostsUsd,
};
}
function buildCostSeriesFromEvents(events, { maxPoints }) {
const points = [];
const totals = {
tradeFeeUsd: 0,
txFeeUsd: 0,
slippageUsd: 0,
fundingUsd: 0,
realizedPnlUsd: 0,
};
for (const ev of events || []) {
const ts = ev?.ts;
if (!ts) continue;
const payload = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
const tradeFeeUsd =
readNumberFromPayload(payload, ['realized_fee_usd', 'trade_fee_usd', 'fee_usd', 'fees.trade_fee_usd', 'fees.usd']) || 0;
const txFeeUsd =
readNumberFromPayload(payload, ['realized_tx_usd', 'tx_fee_usd', 'network_fee_usd', 'fees.tx_fee_usd', 'fees.network_usd']) || 0;
const slippageUsd =
readNumberFromPayload(payload, ['slippage_usd', 'realized_slippage_usd', 'execution_usd', 'realized_execution_usd']) || 0;
const fundingUsd = readNumberFromPayload(payload, ['funding_usd', 'realized_funding_usd']) || 0;
const pnlUsd = readNumberFromPayload(payload, ['realized_pnl_usd', 'pnl_usd']) || 0;
totals.tradeFeeUsd += tradeFeeUsd;
totals.txFeeUsd += txFeeUsd;
totals.slippageUsd += slippageUsd;
totals.fundingUsd += fundingUsd;
totals.realizedPnlUsd += pnlUsd;
const totalCostsUsd = totals.tradeFeeUsd + totals.txFeeUsd + totals.slippageUsd + totals.fundingUsd;
points.push({
ts,
tradeFeeUsd: totals.tradeFeeUsd,
txFeeUsd: totals.txFeeUsd,
slippageUsd: totals.slippageUsd,
fundingUsd: totals.fundingUsd,
totalCostsUsd,
realizedPnlUsd: totals.realizedPnlUsd,
netPnlUsd: totals.realizedPnlUsd - totalCostsUsd,
});
}
const cap = Math.max(50, Math.min(10_000, Number(maxPoints) || 600));
if (points.length <= cap) return points;
const step = Math.ceil(points.length / cap);
const sampled = [];
for (let i = 0; i < points.length; i += step) sampled.push(points[i]);
const last = points[points.length - 1];
if (sampled[sampled.length - 1] !== last) sampled.push(last);
return sampled;
}
function flowFromDelta(delta) {
if (delta > 0) return { up: 1, down: 0, flat: 0 };
if (delta < 0) return { up: 0, down: 1, flat: 0 };
return { up: 0, down: 0, flat: 1 };
}
function computeCandleFlowFromTicks({ candle, bucketSeconds, points, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) return flowFromDelta(candle.close - candle.open);
let up = 0;
let down = 0;
let flat = 0;
let prevT = start;
let prevP = candle.open;
for (const pt of points || []) {
const t = clampNumber(pt.t, prevT, progressEnd, prevT);
const dt = t - prevT;
if (dt > 0) {
const delta = pt.p - prevP;
if (delta > 0) up += dt;
else if (delta < 0) down += dt;
else flat += dt;
prevT = t;
}
prevP = pt.p;
if (prevT >= progressEnd) break;
}
const tail = progressEnd - prevT;
if (tail > 0) flat += tail;
const sum = up + down + flat;
if (!(sum > 0)) return flowFromDelta(candle.close - candle.open);
return { up: up / sum, down: down / sum, flat: flat / sum };
}
function computeCandleFlowRowsFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) {
const overall = candle.close - candle.open;
const dir = overall > 0 ? 1 : overall < 0 ? -1 : 0;
return new Array(rows).fill(dir);
}
const rowDirs = new Array(rows).fill(0);
const pts = [{ t: start, p: candle.open }, ...(points || []), { t: progressEnd, p: candle.close }];
for (let i = 1; i < pts.length; i += 1) {
const a = pts[i - 1];
const b = pts[i];
const t1 = clampNumber(a.t, start, progressEnd, start);
const t2 = clampNumber(b.t, start, progressEnd, start);
if (!(t2 > t1)) continue;
const delta = b.p - a.p;
const dir = delta > 0 ? 1 : delta < 0 ? -1 : 0;
const from = Math.max(0, Math.min(rows - 1, Math.floor(((t1 - start) / bucketSeconds) * rows)));
const to = Math.max(0, Math.min(rows - 1, Math.floor(((t2 - start) / bucketSeconds) * rows)));
for (let r = from; r <= to; r += 1) rowDirs[r] = dir;
}
return rowDirs;
}
function computeCandleFlowSlicesFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const dirs = new Array(rows).fill(0);
const moves = new Array(rows).fill(0);
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) {
const overall = candle.close - candle.open;
const dir = overall > 0 ? 1 : overall < 0 ? -1 : 0;
dirs.fill(dir);
return { dirs, moves };
}
const pts = [...(points || []), { t: progressEnd, p: candle.close }];
let idx = 0;
let lastP = candle.open;
for (let r = 0; r < rows; r += 1) {
const sliceStart = start + (r / rows) * bucketSeconds;
if (!(sliceStart < progressEnd)) break;
const sliceEnd = Math.min(progressEnd, start + ((r + 1) / rows) * bucketSeconds);
while (idx < pts.length && pts[idx].t <= sliceStart) {
lastP = pts[idx].p;
idx += 1;
}
const pStart = lastP;
while (idx < pts.length && pts[idx].t <= sliceEnd) {
lastP = pts[idx].p;
idx += 1;
}
const pEnd = lastP;
const delta = pEnd - pStart;
const dir = delta > 0 ? 1 : delta < 0 ? -1 : 0;
const move = Math.abs(delta);
dirs[r] = dir;
moves[r] = Number.isFinite(move) ? move : 0;
}
return { dirs, moves };
}
function computeCandleFlowMovesFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
const out = new Array(rows).fill(0);
if (!(totalWindow > 0)) return out;
const pts = [...(points || []), { t: progressEnd, p: candle.close }];
let idx = 0;
let lastP = candle.open;
for (let r = 0; r < rows; r += 1) {
const sliceStart = start + (r / rows) * bucketSeconds;
if (!(sliceStart < progressEnd)) break;
const sliceEnd = Math.min(progressEnd, start + ((r + 1) / rows) * bucketSeconds);
while (idx < pts.length && pts[idx].t <= sliceStart) {
lastP = pts[idx].p;
idx += 1;
}
const pStart = lastP;
while (idx < pts.length && pts[idx].t <= sliceEnd) {
lastP = pts[idx].p;
idx += 1;
}
const pEnd = lastP;
const move = Math.abs(pEnd - pStart);
out[r] = Number.isFinite(move) ? move : 0;
}
return out;
}
function parseTimeframeToSeconds(tf) {
const v = String(tf || '').trim().toLowerCase();
if (!v) return 60;
const m = v.match(/^(\d+)(s|m|h|d)$/);
if (!m) throw new Error(`invalid_tf`);
const num = Number.parseInt(m[1], 10);
if (!Number.isInteger(num) || num <= 0) throw new Error(`invalid_tf`);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return num * mult;
}
function pickFlowPointBucketSeconds(bucketSeconds, rowsPerCandle) {
// We want a point step that is:
// - small enough to capture intra-candle direction,
// - but derived from already-cached candle buckets (1s/3s/5s/...).
const targetStep = Math.max(1, Math.floor(bucketSeconds / Math.max(1, rowsPerCandle)));
const candidates = [1, 3, 5, 15, 30, 60, 180, 300, 900, 1800, 3600, 14_400, 43_200, 86_400];
let best = candidates[0];
for (const c of candidates) {
if (c <= targetStep) best = c;
}
return best;
}
function mean(values) {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
function stddev(values) {
if (!values.length) return 0;
const m = mean(values);
const v = values.reduce((acc, x) => acc + (x - m) * (x - m), 0) / values.length;
return Math.sqrt(v);
}
function sma(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += values[i];
if (i >= period) sum -= values[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
function ema(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
const k = 2 / (period + 1);
if (values.length < period) return out;
const first = mean(values.slice(0, period));
out[period - 1] = first;
let prev = first;
for (let i = period; i < values.length; i++) {
const next = values[i] * k + prev * (1 - k);
out[i] = next;
prev = next;
}
return out;
}
function rsi(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
if (values.length <= period) return out;
let gains = 0;
let losses = 0;
for (let i = 1; i <= period; i++) {
const change = values[i] - values[i - 1];
if (change >= 0) gains += change;
else losses -= change;
}
let avgGain = gains / period;
let avgLoss = losses / period;
const rs = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[period] = 100 - 100 / (1 + rs);
for (let i = period + 1; i < values.length; i++) {
const change = values[i] - values[i - 1];
const gain = Math.max(change, 0);
const loss = Math.max(-change, 0);
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs2 = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[i] = 100 - 100 / (1 + rs2);
}
return out;
}
function bollingerBands(values, period, stdDevMult) {
if (period <= 0) throw new Error('period must be > 0');
const upper = new Array(values.length).fill(null);
const lower = new Array(values.length).fill(null);
const mid = sma(values, period);
for (let i = period - 1; i < values.length; i++) {
const window = values.slice(i - period + 1, i + 1);
const sd = stddev(window);
const m = mid[i];
if (m == null) continue;
upper[i] = m + stdDevMult * sd;
lower[i] = m - stdDevMult * sd;
}
return { upper, lower, mid };
}
function macd(values, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
const fast = ema(values, fastPeriod);
const slow = ema(values, slowPeriod);
const macdLine = values.map((_, i) => {
const f = fast[i];
const s = slow[i];
return f == null || s == null ? null : f - s;
});
const signal = new Array(values.length).fill(null);
const k = 2 / (signalPeriod + 1);
let seeded = false;
let prev = 0;
const buf = [];
for (let i = 0; i < macdLine.length; i++) {
const v = macdLine[i];
if (v == null) continue;
if (!seeded) {
buf.push(v);
if (buf.length === signalPeriod) {
const first = mean(buf);
signal[i] = first;
prev = first;
seeded = true;
}
continue;
}
const next = v * k + prev * (1 - k);
signal[i] = next;
prev = next;
}
return { macd: macdLine, signal };
}
function toSeries(times, values) {
return times.map((t, i) => ({ time: t, value: values[i] ?? null }));
}
function isAdmin(cfg, req) {
if (!cfg.apiAdminSecret) return false;
const provided = getHeader(req, 'x-admin-secret');
return typeof provided === 'string' && provided === cfg.apiAdminSecret;
}
async function handler(cfg, req, res) {
if (req.method === 'OPTIONS') {
withCors(res, cfg.corsOrigin);
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/healthz') {
sendJson(
res,
200,
{
ok: true,
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
startedAt: cfg.startedAt,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
},
cfg.corsOrigin
);
return;
}
if (req.method === 'GET' && pathname === '/v1/chart') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const symbol = (url.searchParams.get('symbol') || '').trim();
const source = (url.searchParams.get('source') || '').trim();
const tf = (url.searchParams.get('tf') || url.searchParams.get('timeframe') || '1m').trim();
const limit = clampInt(url.searchParams.get('limit') || '300', 10, 2000);
if (!symbol) {
sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin);
return;
}
let bucketSeconds;
try {
bucketSeconds = parseTimeframeToSeconds(tf);
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_tf' }, cfg.corsOrigin);
return;
}
const sourceKey = source || '';
try {
// Cache-first: read precomputed candles from `drift_candles_cache`.
// Fallback: compute on-demand via `get_drift_candles()` if cache not warmed yet.
const qCache = `
query CachedCandles($symbol: String!, $bucket: Int!, $limit: Int!, $source: String!) {
drift_candles_cache(
where: {symbol: {_eq: $symbol}, bucket_seconds: {_eq: $bucket}, source: {_eq: $source}}
order_by: {bucket: desc}
limit: $limit
) {
bucket
open
high
low
close
oracle_close
ticks
}
}
`;
const cacheData = await hasuraRequest(cfg, { admin: true }, qCache, {
symbol,
bucket: bucketSeconds,
limit,
source: sourceKey,
});
let rows = cacheData?.drift_candles_cache || [];
if (!rows.length) {
const fn = cfg.candlesFunction;
const qFn = `
query Candles($symbol: String!, $bucket: Int!, $limit: Int!, $source: String) {
${fn}(args: { p_symbol: $symbol, p_bucket_seconds: $bucket, p_limit: $limit, p_source: $source }) {
bucket
open
high
low
close
oracle_close
ticks
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, qFn, {
symbol,
bucket: bucketSeconds,
limit,
source: source || null,
});
rows = data?.[fn] || [];
}
const candles = rows
.slice()
.reverse()
.map((r) => {
const time = Math.floor(Date.parse(r.bucket) / 1000);
const open = Number(r.open);
const high = Number(r.high);
const low = Number(r.low);
const close = Number(r.close);
const oracle = r.oracle_close == null ? null : Number(r.oracle_close);
const volume = Number(r.ticks || 0);
return { time, open, high, low, close, volume, oracle };
})
.filter((c) => Number.isFinite(c.time) && Number.isFinite(c.open) && Number.isFinite(c.close));
// Flow = share of time spent moving up/down/flat inside each bucket.
// Used by the UI to render stacked volume bars describing microstructure.
const nowSec = Math.floor(Date.now() / 1000);
const windowSeconds = bucketSeconds * candles.length;
const canComputeFlow = candles.length > 0 && windowSeconds > 0 && windowSeconds <= 86_400; // cap at 24h
const rowsPerCandle = Math.min(60, Math.max(12, Math.floor(bucketSeconds)));
const flowPointBucketSeconds = pickFlowPointBucketSeconds(bucketSeconds, rowsPerCandle);
if (canComputeFlow) {
const firstStart = candles[0].time;
const lastStart = candles[candles.length - 1].time;
const fromIso = new Date(firstStart * 1000).toISOString();
const toIso = new Date((lastStart + bucketSeconds) * 1000).toISOString();
try {
// Prefer flow computed from cached candles (fast, no raw tick scan).
// If cache is missing, fall back to a simple delta-based approximation.
const maxPoints = Math.min(86_400, Math.max(1_000, candles.length * rowsPerCandle * 2));
const q1s = `
query FlowPts($symbol: String!, $source: String!, $bucketSeconds: Int!, $from: timestamptz!, $to: timestamptz!, $limit: Int!) {
drift_candles_cache(
where: {
symbol: {_eq: $symbol}
source: {_eq: $source}
bucket_seconds: {_eq: $bucketSeconds}
bucket: {_gte: $from, _lt: $to}
}
order_by: {bucket: asc}
limit: $limit
) {
bucket
close
}
}
`;
const pData = await hasuraRequest(cfg, { admin: true }, q1s, {
symbol,
source: sourceKey,
bucketSeconds: flowPointBucketSeconds,
from: fromIso,
to: toIso,
limit: maxPoints,
});
const ptsRows = pData?.drift_candles_cache || [];
const visibleStarts = new Set(candles.map((c) => c.time));
const pointsByCandle = new Map();
for (const r of ptsRows) {
const t = tsToUnixSeconds(r.bucket);
if (t == null) continue;
const p = parseNumeric(r.close);
if (p == null) continue;
const idx = Math.floor((t - firstStart) / bucketSeconds);
const start = firstStart + idx * bucketSeconds;
if (!visibleStarts.has(start)) continue;
const list = pointsByCandle.get(start) || [];
list.push({ t, p });
pointsByCandle.set(start, list);
}
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
for (const c of candles) {
const pts = pointsByCandle.get(c.time) || [];
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
c.flow = computeCandleFlowFromTicks({ candle: c, bucketSeconds, points: pts, nowSec, isCurrent });
const slices = computeCandleFlowSlicesFromTicks({
candle: c,
bucketSeconds,
points: pts,
rows: rowsPerCandle,
nowSec,
isCurrent,
});
c.flowRows = slices.dirs;
c.flowMoves = slices.moves;
}
} catch {
for (const c of candles) {
const fallback = flowFromDelta(c.close - c.open);
c.flow = fallback;
const dir = fallback.up ? 1 : fallback.down ? -1 : 0;
c.flowRows = new Array(rowsPerCandle).fill(dir);
c.flowMoves = new Array(rowsPerCandle).fill(0);
}
}
} else {
for (const c of candles) {
const fallback = flowFromDelta(c.close - c.open);
c.flow = fallback;
const dir = fallback.up ? 1 : fallback.down ? -1 : 0;
c.flowRows = new Array(rowsPerCandle).fill(dir);
c.flowMoves = new Array(rowsPerCandle).fill(0);
}
}
const times = candles.map((c) => c.time);
const closes = candles.map((c) => c.close);
const oracleSeries = toSeries(times, candles.map((c) => (c.oracle == null ? null : c.oracle)));
const sma20 = toSeries(times, sma(closes, 20));
const ema20 = toSeries(times, ema(closes, 20));
const bb = bollingerBands(closes, 20, 2);
const bbUpper = toSeries(times, bb.upper);
const bbLower = toSeries(times, bb.lower);
const bbMid = toSeries(times, bb.mid);
const rsi14 = toSeries(times, rsi(closes, 14));
const macdOut = macd(closes, 12, 26, 9);
const macdLine = toSeries(times, macdOut.macd);
const macdSignal = toSeries(times, macdOut.signal);
sendJson(
res,
200,
{
ok: true,
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
symbol,
source: source || null,
tf,
bucketSeconds,
candles,
indicators: {
oracle: oracleSeries,
sma20,
ema20,
bb20: { upper: bbUpper, lower: bbLower, mid: bbMid },
rsi14,
macd: { macd: macdLine, signal: macdSignal },
},
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/ingest/tick') {
const auth = await requireValidToken(cfg, req, 'write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch (err) {
const msg = String(err?.message || err);
if (msg === 'payload_too_large') {
sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin);
return;
}
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
let tick;
try {
tick = normalizeTick(body, auth.token);
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
return;
}
try {
const id = await insertTick(cfg, tick);
sendJson(res, 200, { ok: true, id }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'GET' && pathname === '/v1/ticks') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const symbol = url.searchParams.get('symbol') || '';
const source = url.searchParams.get('source');
const limitRaw = url.searchParams.get('limit') || '1000';
const limit = Math.min(5000, Math.max(1, Number.parseInt(limitRaw, 10) || 1000));
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
if (!symbol.trim()) {
sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin);
return;
}
const where = { symbol: { _eq: symbol.trim() } };
if (source && source.trim()) where.source = { _eq: source.trim() };
if (from || to) {
where.ts = {};
if (from) where.ts._gte = from;
if (to) where.ts._lte = to;
}
const table = cfg.ticksTable;
const query = `
query Ticks($where: ${table}_bool_exp!, $limit: Int!) {
${table}(where: $where, order_by: {ts: desc}, limit: $limit) {
ts
market_index
symbol
oracle_price
mark_price
oracle_slot
source
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { where, limit });
const ticks = (data?.[table] || []).slice().reverse();
sendJson(res, 200, { ok: true, version: cfg.appVersion, ticksTable: cfg.ticksTable, ticks }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// Contract monitoring + cost compute (read scope).
// This endpoint is meant for "live UI" polling/subscription aggregation on the backend.
// It is intentionally resilient to varying bot_events payload schemas.
if (req.method === 'GET' && pathname.startsWith('/v1/contracts/') && pathname.endsWith('/monitor')) {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const parts = pathname.split('/').filter(Boolean);
const contractId = parts[2];
if (!isUuid(contractId)) {
sendJson(res, 400, { ok: false, error: 'invalid_contract_id' }, cfg.corsOrigin);
return;
}
const limit = clampInt(url.searchParams.get('eventsLimit') || '2000', 10, 50_000);
const wantSeries = (url.searchParams.get('series') || '').trim() === '1';
const seriesMax = clampInt(url.searchParams.get('seriesMax') || '600', 50, 10_000);
const qContract = `
query ContractByPk($id: uuid!) {
bot_contracts_by_pk(id: $id) {
id
decision_id
bot_id
model_version
market_name
subaccount_id
status
desired
entry
manage
exit
gates
created_at
updated_at
last_heartbeat_at
ended_at
reason
}
}
`;
const qEvents = `
query ContractEvents($id: uuid!, $limit: Int!) {
bot_events(where: {contract_id: {_eq: $id}}, order_by: {ts: asc}, limit: $limit) {
ts
contract_id
decision_id
bot_id
market_name
event_type
severity
payload
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, qContract, { id: contractId });
const contract = data?.bot_contracts_by_pk;
if (!contract?.id) {
sendJson(res, 404, { ok: false, error: 'contract_not_found' }, cfg.corsOrigin);
return;
}
const evData = await hasuraRequest(cfg, { admin: true }, qEvents, { id: contractId, limit });
const events = evData?.bot_events || [];
const costs = sumCostsFromEvents(events);
const series = wantSeries ? buildCostSeriesFromEvents(events, { maxPoints: seriesMax }) : null;
const sizeUsd = inferContractSizeUsd(contract);
const side = inferContractSide(contract);
let closeEst = null;
if (contract.market_name && sizeUsd != null) {
const qSlip = `
query Slippage($market: String!) {
dlob_slippage_latest_v2(where: {market_name: {_eq: $market}}) {
market_name
side
size_usd
mid_price
vwap_price
worst_price
impact_bps
fill_pct
updated_at
}
dlob_slippage_latest(where: {market_name: {_eq: $market}}) {
market_name
side
size_usd
mid_price
vwap_price
worst_price
impact_bps
fill_pct
updated_at
}
}
`;
const slipData = await hasuraRequest(cfg, { admin: true }, qSlip, { market: contract.market_name });
const rowsV2 = slipData?.dlob_slippage_latest_v2 || [];
const rowsV1 = slipData?.dlob_slippage_latest || [];
const rows = rowsV2.length ? rowsV2 : rowsV1;
const pickNearest = (wantedSide) => {
const candidates = rows.filter((r) => String(r.side || '').toLowerCase() === wantedSide);
if (!candidates.length) return null;
let best = null;
let bestD = Infinity;
for (const r of candidates) {
const s = parseNumeric(r.size_usd);
if (s == null) continue;
const d = Math.abs(s - sizeUsd);
if (d < bestD) {
bestD = d;
best = r;
}
}
return best;
};
const buy = pickNearest('buy');
const sell = pickNearest('sell');
closeEst = {
requestedSizeUsd: sizeUsd,
entrySide: side,
suggestedCloseSide: side === 'long' ? 'sell' : side === 'short' ? 'buy' : null,
buy,
sell,
};
}
sendJson(
res,
200,
{
ok: true,
contract,
eventsCount: events.length,
costs,
series,
closeEstimate: closeEst,
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// Estimate costs for a new contract (read scope).
// Uses DLOB slippage table and simple fee/tx estimates (inputs).
if (req.method === 'POST' && pathname === '/v1/contracts/costs/estimate') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 256 * 1024 });
} catch (err) {
const msg = String(err?.message || err);
if (msg === 'payload_too_large') {
sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin);
return;
}
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const market = String(body?.market_name || body?.market || '').trim();
if (!market) {
sendJson(res, 400, { ok: false, error: 'missing_market' }, cfg.corsOrigin);
return;
}
const notionalUsd = parseNumeric(body?.notional_usd ?? body?.notionalUsd ?? body?.size_usd ?? body?.sizeUsd);
if (notionalUsd == null || !(notionalUsd > 0)) {
sendJson(res, 400, { ok: false, error: 'invalid_notional_usd' }, cfg.corsOrigin);
return;
}
const sideRaw = String(body?.side || 'long').trim().toLowerCase();
const entrySide = sideRaw === 'short' || sideRaw === 'sell' ? 'short' : 'long';
const defaultTakerBps = parseNumeric(process.env.FEE_TAKER_BPS_DEFAULT) ?? 5;
const defaultMakerBps = parseNumeric(process.env.FEE_MAKER_BPS_DEFAULT) ?? 0;
const takerBps = clampNumber(parseNumeric(body?.fee_taker_bps) ?? defaultTakerBps, 0, 1000, defaultTakerBps);
const makerBps = clampNumber(parseNumeric(body?.fee_maker_bps) ?? defaultMakerBps, -1000, 1000, defaultMakerBps);
const orderType = String(body?.order_type || body?.orderType || 'market').trim().toLowerCase();
const isMarket = orderType === 'market' || orderType === 'taker';
const feeBps = isMarket ? takerBps : makerBps;
let txFeeUsdEst = parseNumeric(body?.tx_fee_usd_est);
if (txFeeUsdEst == null) {
const baseLamports = parseNumeric(process.env.TX_BASE_FEE_LAMPORTS_EST) ?? 5000;
const sigs = parseNumeric(process.env.TX_SIGNATURES_EST) ?? 1;
const priorityLamports = parseNumeric(process.env.TX_PRIORITY_FEE_LAMPORTS_EST) ?? 0;
const lamports = Math.max(0, baseLamports) * Math.max(1, sigs) + Math.max(0, priorityLamports);
const sol = lamports / 1_000_000_000;
const solUsd = await readSolPriceUsd(cfg);
txFeeUsdEst = solUsd != null ? sol * solUsd : 0;
}
txFeeUsdEst = clampNumber(txFeeUsdEst, 0, 100, 0);
const defaultReprices = parseNumeric(process.env.EXPECTED_REPRICES_PER_ENTRY_DEFAULT) ?? 0;
const expectedReprices = clampInt(body?.expected_reprices_per_entry ?? body?.expectedReprices ?? String(defaultReprices), 0, 500);
const modifyTxCount = clampInt(body?.modify_tx_count ?? body?.modifyTxCount ?? '2', 0, 10);
try {
const qSlip = `
query Slippage($market: String!) {
dlob_slippage_latest_v2(where: {market_name: {_eq: $market}}) {
market_name
side
size_usd
mid_price
vwap_price
worst_price
impact_bps
fill_pct
updated_at
}
dlob_slippage_latest(where: {market_name: {_eq: $market}}) {
market_name
side
size_usd
mid_price
vwap_price
worst_price
impact_bps
fill_pct
updated_at
}
}
`;
const slipData = await hasuraRequest(cfg, { admin: true }, qSlip, { market });
const rowsV2 = slipData?.dlob_slippage_latest_v2 || [];
const rowsV1 = slipData?.dlob_slippage_latest || [];
const rows = rowsV2.length ? rowsV2 : rowsV1;
const wantedSide = entrySide === 'long' ? 'buy' : 'sell';
const candidates = rows.filter((r) => String(r.side || '').toLowerCase() === wantedSide);
let best = null;
let bestD = Infinity;
for (const r of candidates) {
const s = parseNumeric(r.size_usd);
if (s == null) continue;
const d = Math.abs(s - notionalUsd);
if (d < bestD) {
bestD = d;
best = r;
}
}
const impactBps = parseNumeric(best?.impact_bps) ?? 0;
const slippageUsd = (notionalUsd * impactBps) / 10_000;
const tradeFeeUsd = (notionalUsd * feeBps) / 10_000;
const modifyCostUsd = expectedReprices * modifyTxCount * txFeeUsdEst;
const totalUsd = tradeFeeUsd + slippageUsd + txFeeUsdEst + modifyCostUsd;
const totalBps = (totalUsd / notionalUsd) * 10_000;
sendJson(
res,
200,
{
ok: true,
input: {
market_name: market,
notional_usd: notionalUsd,
side: entrySide,
order_type: orderType,
fee_bps: feeBps,
tx_fee_usd_est: txFeeUsdEst,
expected_reprices_per_entry: expectedReprices,
},
dlob: best
? {
size_usd: best.size_usd,
side: best.side,
mid_price: best.mid_price,
vwap_price: best.vwap_price,
impact_bps: best.impact_bps,
fill_pct: best.fill_pct,
updated_at: best.updated_at,
}
: null,
breakdown: {
trade_fee_usd: tradeFeeUsd,
slippage_usd: slippageUsd,
tx_fee_usd: txFeeUsdEst,
expected_modify_usd: modifyCostUsd,
total_usd: totalUsd,
total_bps: totalBps,
breakeven_bps: totalBps,
},
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/admin/tokens') {
if (!isAdmin(cfg, req)) {
sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const name = (body?.name || 'algo')?.toString?.().trim();
if (!name) {
sendJson(res, 400, { ok: false, error: 'missing_name' }, cfg.corsOrigin);
return;
}
const scopes = normalizeScopes(body?.scopes);
const resolvedScopes = scopes.length ? scopes : ['write'];
try {
const { token, row } = await createApiToken(cfg, name, resolvedScopes, body?.meta);
sendJson(res, 200, { ok: true, token, id: row.id, name: row.name, created_at: row.created_at }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/admin/tokens/revoke') {
if (!isAdmin(cfg, req)) {
sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const id = (body?.id || '')?.toString?.().trim();
if (!id) {
sendJson(res, 400, { ok: false, error: 'missing_id' }, cfg.corsOrigin);
return;
}
try {
const revokedId = await revokeApiToken(cfg, id);
sendJson(res, 200, { ok: true, id: revokedId }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
sendJson(res, 404, { ok: false, error: 'not_found' }, cfg.corsOrigin);
}
function main() {
const cfg = resolveConfig();
const server = http.createServer((req, res) => void handler(cfg, req, res));
server.listen(cfg.port, () => {
console.log(
JSON.stringify(
{
service: 'trade-api',
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
port: cfg.port,
hasuraUrl: cfg.hasuraUrl,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
hasuraAdminSecret: cfg.hasuraAdminSecret ? '***' : undefined,
apiAdminSecret: cfg.apiAdminSecret ? '***' : undefined,
},
null,
2
)
);
});
}
main();