Files
AtreidesAssetsFree/server/price/custom.js
T

267 lines
14 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:e3489a6085d47798380dafd4c4992473c5fdddb26fbfb6bf3b1a4ff191ab8437
import { AUTH_KINDS, CONFIG } from '../../shared/config.js';
import { AppError, fail } from '../../shared/app-error.js';
import { toLocalIso } from '../../shared/time.js';
const TIMEOUT_MS = 12000;
const CAPABILITIES = ['spot', 'daily_history', 'history_point'];
function tokenize(path, externalId) {
return path.split('.').map(seg => {
if (seg === '{external_id}') {
if (externalId == null) fail('integrations.price.externalIdMissing', null, 'The output path uses {external_id} but there is no external id.');
return externalId;
}
return /^\d+$/.test(seg) ? Number(seg) : seg;
});
}
export function resolveScalar(obj, path, externalId) {
let cur = obj;
for (const key of tokenize(path, externalId)) {
if (cur == null) return undefined;
cur = cur[key];
}
return cur;
}
export function resolveColumn(obj, path, externalId) {
const o = path.indexOf('{}');
if (o >= 0 && (path.indexOf('[]') < 0 || o < path.indexOf('[]'))) {
const before = path.slice(0, o);
const after = path.slice(o + 2).replace(/^\./, '');
const map = before ? resolveScalar(obj, before, externalId) : obj;
if (map == null || typeof map !== 'object' || Array.isArray(map)) {
fail('integrations.price.expectedObject', { pad: before || '(root)' }, `Expected an object at '${before || '(root)'}'.`);
}
return Object.entries(map).map(([key, value]) =>
(after === '@key' ? key : after ? resolveScalar(value, after, externalId) : value));
}
const i = path.indexOf('[]');
if (i < 0) fail('integrations.price.listPathWithoutBrackets', { pad: path }, `List path without []: '${path}'.`);
const before = path.slice(0, i);
const after = path.slice(i + 2).replace(/^\./, '');
const arr = before ? resolveScalar(obj, before, externalId) : obj;
if (!Array.isArray(arr)) fail('integrations.price.expectedArray', { pad: before || '(root)' }, `Expected an array at '${before || '(root)'}'.`);
return arr.map(el => (after ? resolveScalar(el, after, externalId) : el));
}
function applyScalarTransform(v, transform) {
switch (transform) {
case 'none': return v;
case 'to_number': { const n = Number(v); return Number.isFinite(n) ? n : null; }
case 'ms_to_s': { const n = Number(v); return Number.isFinite(n) ? Math.floor(n / 1000) : null; }
case 'last_per_day': return v;
case 'troy_ounce_to_gram': { const n = Number(v); const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
return (Number.isFinite(n) && f) ? n / f : null; }
case 'iso_date_to_s': {
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
const ms = Date.parse(`${v}T00:00:00Z`);
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
}
case 'ms_to_s_local_close': {
const n = Number(v);
if (!Number.isFinite(n)) return null;
const utc = new Date(n);
if (n % 86400000 === 0 && utc.getUTCDate() === 1) return null;
const lokaal = toLocalIso(utc.toISOString());
if (/T00:00:00(\.000)?$/.test(lokaal)) return Date.parse(`${lokaal.slice(0, 10)}T00:00:00Z`) / 1000;
return Math.floor(n / 1000);
}
default: fail('integrations.price.unknownTransform', { transform }, `Unknown transform: '${transform}'.`);
}
}
function outputsFor(provider, capability) {
const rows = provider.outputs.filter(o => o.capability === capability);
if (!rows.length) fail('integrations.price.noOutputMapping', { provider: provider.key, capability }, `Provider ${provider.key}: no output mapping for '${capability}'.`);
return rows;
}
function assertPriced(value, provider) {
if (Number.isFinite(value)) return;
fail('integrations.price.noValidPrice', { provider: provider.key }, `Provider ${provider.key}: no valid price in the response.`);
}
export function mapSpot(parsed, provider, externalId) {
const out = {};
for (const o of outputsFor(provider, 'spot'))
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
assertPriced(out.price, provider);
return { price: out.price };
}
export function mapHistoryPoint(parsed, provider, externalId, dateIso) {
const out = {};
for (const o of outputsFor(provider, 'history_point'))
out[o.internalField] = applyScalarTransform(resolveScalar(parsed, o.jsonPath, externalId), o.transform);
const time = Number.isFinite(out.time) ? out.time : Math.floor(Date.parse(`${dateIso}T00:00:00Z`) / 1000);
if (!Number.isFinite(time)) fail('integrations.price.invalidDate', { provider: provider.key, datum: dateIso }, `Provider ${provider.key}: invalid date '${dateIso}'.`);
assertPriced(out.price, provider);
return { time, price: out.price };
}
export function mapDailyHistory(parsed, provider, externalId) {
const cols = {}; let len = null; let lastPerDay = false;
for (const o of outputsFor(provider, 'daily_history')) {
if (o.transform === 'last_per_day') lastPerDay = true;
const vals = resolveColumn(parsed, o.jsonPath, externalId)
.map(v => applyScalarTransform(v, o.transform === 'last_per_day' ? 'none' : o.transform));
cols[o.internalField] = vals;
if (len == null) len = vals.length;
else if (vals.length !== len) fail('integrations.price.columnsUneven', { provider: provider.key }, `Provider ${provider.key}: the output columns have unequal length.`);
}
let rows = [];
for (let i = 0; i < (len || 0); i++) {
const time = cols.time ? cols.time[i] : null;
const price = cols.price?.[i];
if (Number.isFinite(time) && Number.isFinite(price)) rows.push({ time, price });
}
rows.sort((a, b) => a.time - b.time);
if (lastPerDay) {
const byDay = new Map();
for (const r of rows) byDay.set(new Date(r.time * 1000).toISOString().slice(0, 10), r);
rows = [...byDay.values()].sort((a, b) => a.time - b.time);
}
return rows;
}
function mapResult(parsed, provider, capability, externalId, dateIso) {
if (capability === 'spot') return mapSpot(parsed, provider, externalId);
if (capability === 'history_point') return mapHistoryPoint(parsed, provider, externalId, dateIso);
return mapDailyHistory(parsed, provider, externalId);
}
function endpointFor(provider, capability) {
const e = provider.endpoints.find(ep => ep.capability === capability);
if (!e) fail('integrations.price.capabilityNotConfigured', { provider: provider.key, capability }, `Provider ${provider.key}: capability '${capability}' is not configured.`);
return e;
}
function mapRowFor(provider, symbol) {
const rows = provider.symbols || [];
return rows.find(s => !s.isCatchAll && s.symbol === symbol) || rows.find(s => s.isCatchAll) || null;
}
function externalIdFor(provider, symbol) {
const row = mapRowFor(provider, symbol);
return row ? row.externalId : (symbol != null ? symbol : null);
}
function subst(tpl, externalId, symbol, dateIso, providerKey) {
if (tpl == null) return tpl;
let s = tpl;
if (s.includes('{external_id}')) {
if (externalId == null) fail('integrations.price.symbolMissing', { provider: providerKey }, `Provider ${providerKey}: {external_id} is used but no symbol was given.`);
s = s.replaceAll('{external_id}', encodeURIComponent(externalId));
}
if (s.includes('{symbol}')) s = s.replaceAll('{symbol}', encodeURIComponent(symbol ?? ''));
if (s.includes('{date}')) {
const ymd = String(dateIso || '').slice(0, 10).replace(/-/g, '');
if (ymd.length !== 8) fail('integrations.price.templateDateInvalid', { provider: providerKey, datum: dateIso }, `Provider ${providerKey}: the template uses {date} but date '${dateIso}' is invalid.`);
s = s.replaceAll('{date}', ymd);
}
return s;
}
const toHex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
export async function hmacHeaders(hmac, method, fullPath, body, creds) {
const ts = Date.now();
const tokens = { timestamp: String(ts), method, full_path: fullPath, body: body || '' };
const message = hmac.messageParts.split(',').map(tok => {
if (!(tok in tokens)) fail('integrations.price.unknownHmacToken', { token: tok }, `Unknown hmac message token: '${tok}'.`);
return tokens[tok];
}).join('');
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(creds.api_secret),
{ name: 'HMAC', hash: hmac.hashAlgorithm }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
const signature = hmac.encoding === 'hex' ? toHex(sig) : Buffer.from(sig).toString('base64');
const headers = { [hmac.keyHeader]: creds.api_key, [hmac.signatureHeader]: signature, [hmac.timestampHeader]: String(ts) };
if (hmac.windowHeader && hmac.windowMs != null) headers[hmac.windowHeader] = String(hmac.windowMs);
return headers;
}
export async function buildRequest(provider, capability, symbol, dateIso, creds) {
if (!CAPABILITIES.includes(capability)) fail('integrations.price.unknownCapability', { capability }, `Unknown capability: '${capability}'.`);
const e = endpointFor(provider, capability);
if (e.method !== 'GET') fail('integrations.price.onlyGet', { provider: provider.key }, `Provider ${provider.key}: only GET is supported.`);
const externalId = externalIdFor(provider, symbol);
const kind = provider.authenticationKind;
if (kind !== 'none' && !AUTH_KINDS[kind]) fail('integrations.price.unknownAuthKind', { soort: kind }, `Unknown authenticationKind: '${kind}'.`);
const fields = kind === 'none' ? [] : AUTH_KINDS[kind].secret_fields;
const hasCreds = kind !== 'none' && fields.every(f => creds && creds[f]);
if (provider.authenticationRequired && !hasCreds)
fail('integrations.price.keyMissing', { provider: provider.key }, `Provider ${provider.key}: a key is required but missing or incomplete.`);
const path = subst(e.pathTemplate, externalId, symbol, dateIso, provider.key);
let query = subst(e.queryTemplate, externalId, symbol, dateIso, provider.key) || '';
if (kind === 'query_key' && hasCreds) {
if (!provider.authenticationParameterName) fail('integrations.price.parameterNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationParameterName is missing for query_key.`);
const qp = `${encodeURIComponent(provider.authenticationParameterName)}=${encodeURIComponent(creds.api_key)}`;
query = query ? `${query}&${qp}` : qp;
}
const fullPath = path + (query ? `?${query}` : '');
const headers = {};
if (hasCreds) {
if (kind === 'header_key') {
if (!provider.authenticationHeaderName) fail('integrations.price.headerNameMissing', { provider: provider.key }, `Provider ${provider.key}: authenticationHeaderName is missing for header_key.`);
headers[provider.authenticationHeaderName] = creds.api_key;
} else if (kind === 'bearer') {
headers['Authorization'] = `Bearer ${creds.api_key}`;
} else if (kind === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(`${creds.api_key}:${creds.api_secret}`).toString('base64')}`;
} else if (kind === 'hmac') {
if (!provider.hmac) fail('integrations.price.hmacConfigMissing', { provider: provider.key }, `Provider ${provider.key}: the hmac config is missing.`);
Object.assign(headers, await hmacHeaders(provider.hmac, e.method, fullPath, '', creds));
}
}
return { url: provider.baseUrl + fullPath, method: e.method, headers, externalId };
}
function describeBody(res, text) {
const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim();
return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`;
}
function tagged(e, extra) { return Object.assign(e, extra); }
export async function priceCall(provider, capability, symbol, dateIso, creds, { withRaw = false } = {}) {
let built;
try { built = await buildRequest(provider, capability, symbol, dateIso, creds); } catch (e) { throw tagged(e, { notSent: true }); }
const { url, method, headers, externalId } = built;
let res;
try {
res = await fetch(url, { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (err) {
fail('integrations.price.networkError', { provider: provider.key, capability, reden: err.name === 'TimeoutError' ? 'timeout' : err.message },
`${provider.key} ${capability}: network error (${err.name === 'TimeoutError' ? 'timeout' : err.message}).`);
}
const text = await res.text().catch(() => '');
let parsed;
let notJson = null;
try { parsed = JSON.parse(text); } catch { notJson = describeBody(res, text); }
const httpStatus = res.status;
if (!res.ok) {
const m = parsed && (parsed.error || parsed.message);
const detail = typeof m === 'string' && m ? ` ${m}` : notJson ? ` ${notJson}` : '';
throw tagged(new AppError('integrations.price.httpFailed', { provider: provider.key, capability, status: res.status, detail },
`${provider.key} ${capability} failed (${res.status})${detail}.`), { httpStatus });
}
if (notJson) {
throw tagged(new AppError('integrations.price.notJson', { provider: provider.key, capability, detail: notJson },
`${provider.key} ${capability}: the answer is ${notJson}.`), { httpStatus });
}
let result;
try {
result = inStorageUnit(mapResult(parsed, provider, capability, externalId, dateIso), provider, symbol);
} catch (e) { throw tagged(e, { httpStatus }); }
return withRaw ? { result, raw: parsed } : result;
}
export function failureEntry(provider, capability, symbol, e, latencyMs) {
const outcome = e?.notSent ? 'refused' : e?.key === 'integrations.price.noValidPrice' ? 'empty' : 'fail';
return {
target: 'provider', providerKey: provider?.key, operation: capability,
subject: typeof symbol === 'string' && symbol ? symbol : null, outcome,
httpStatus: e?.httpStatus ?? null, errorKey: e?.key ?? null, detail: e?.message ?? String(e),
latencyMs: e?.notSent ? null : latencyMs,
};
}
function inStorageUnit(result, provider, symbol) {
const row = mapRowFor(provider, symbol);
if (!row || row.quoteUnit !== 'ozt') return result;
const f = CONFIG.METALS.GRAMS_PER_TROY_OUNCE;
const perGram = r => (Number.isFinite(r.price) ? { ...r, price: r.price / f } : r);
return Array.isArray(result) ? result.map(perGram) : perGram(result);
}