chore: initial import
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
tokens/*.json
|
||||
tokens/*.yml
|
||||
tokens/*.yaml
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY services/api/server.mjs /app/services/api/server.mjs
|
||||
RUN mkdir -p /app/tokens
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "services/api/server.mjs"]
|
||||
16
README.md
Normal file
16
README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# trade-api
|
||||
|
||||
Node.js API dla projektu `trade`.
|
||||
|
||||
## Uruchomienie lokalnie
|
||||
|
||||
```bash
|
||||
PORT=8787 HASURA_GRAPHQL_URL=http://localhost:8080/v1/graphql node services/api/server.mjs
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t trade-api .
|
||||
docker run --rm -p 8787:8787 trade-api
|
||||
```
|
||||
814
services/api/server.mjs
Normal file
814
services/api/server.mjs
Normal file
@@ -0,0 +1,814 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 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 fn = cfg.candlesFunction;
|
||||
const query = `
|
||||
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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const data = await hasuraRequest(cfg, { admin: true }, query, {
|
||||
symbol,
|
||||
bucket: bucketSeconds,
|
||||
limit,
|
||||
source: source || null,
|
||||
});
|
||||
|
||||
const 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));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user