Files
AtreidesAssetsFree/client/price/providers.js
T

566 lines
24 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:1f114683952f59d1ca0874094fffe2f799e396366bc22e66b0b9cf051e48c0cd
import { recordApiCall, apiMetricsTotals } from '../data/api-metrics.js';
import { serverFoutTekst } from '../data/_rpc.js';
import { t } from '../i18n/index.js';
import { priceSourceChain, ownPriceSourceChain } from '../data/price-source.js';
import { upsertPriceHistory, listPriceHistory, getPricePoint, recordCurrentSpot, latestSpotPoint,
listNoPriceDays, recordNoPriceDays } from '../data/price-history.js';
import { getProvider } from '../data/providers.js';
import { listAssets } from '../data/assets.js';
import { CONFIG, sourceCapabilityOf } from '../../shared/config.js';
import { baseCurrency, appSetting } from '../app-settings.js';
import { recordSpot } from './momentum.js';
import { serviceSpot, serviceDayHistory, serviceCoverage } from '../data/market.js';
const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
function emitApiCall(detail) {
if (typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') {
document.dispatchEvent(new CustomEvent('api-call', { detail }));
}
}
function serviceCalled(asset, capability, ok, t0) {
emitApiCall({ provider_key: CONFIG.MARKET_SERVICE.PROVIDER_KEY, capability, symbol: asset.symbol, ok,
latency_ms: Math.round(perfNow() - t0), ts: Date.now() });
}
function vasteKoers(provider, capability, date) {
if (capability !== 'spot' && capability !== 'history_point') {
throw new Error(t('integrations.price.notFromFixedRate', { provider: provider.key, capability }));
}
const uit = { price: provider.fixedRate.fiatPrice };
if (capability !== 'history_point') return uit;
const time = Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
if (!Number.isFinite(time)) throw new Error(t('integrations.price.invalidDate', { provider: provider.key, datum: date }));
return { ...uit, time };
}
const usageThisMonth = new Map();
async function usageFor(provider) {
if (!provider.callsPerMonth || !Number.isInteger(provider.id)) return null;
const month = new Date().toISOString().slice(0, 7);
let u = usageThisMonth.get(provider.id);
if (!u || u.month !== month) {
let calls = 0;
try { calls = (await apiMetricsTotals(provider.id, `${month}-01T00:00`)).calls; } catch { }
u = { month, calls };
usageThisMonth.set(provider.id, u);
}
return u;
}
export function forgetUsage() { usageThisMonth.clear(); }
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('providers-changed', forgetUsage);
}
function intervalOf(provider, capability) {
const e = provider && Array.isArray(provider.endpoints) && provider.endpoints.find(x => x.capability === capability);
return e && e.minIntervalSeconds > 0 ? e.minIntervalSeconds : null;
}
const lastCallAt = new Map();
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) { reject(stoppedError()); return; }
const timer = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms);
const onAbort = () => { clearTimeout(timer); reject(stoppedError()); };
if (signal) signal.addEventListener('abort', onAbort, { once: true });
});
}
function stoppedError() {
const e = new Error('Stopped while waiting for the minimum interval of the source.');
e.stopped = true;
return e;
}
async function spacing(provider, capability, signal) {
const seconds = capability === 'spot' ? null : intervalOf(provider, capability);
if (!seconds) return false;
const last = lastCallAt.get(`${provider.id}|${capability}`);
const wait = last == null ? 0 : last + seconds * 1000 - Date.now();
if (wait > 0) await sleep(wait, signal);
return true;
}
export function forgetCallTimes() { lastCallAt.clear(); }
async function callPrice(provider, capability, symbol, date, { signal = null } = {}) {
if (provider.fixedRate) return vasteKoers(provider, capability, date);
const tegoed = await usageFor(provider);
if (tegoed && tegoed.calls >= provider.callsPerMonth) {
const e = new Error(t('integrations.price.limitReached', { provider: provider.name, limiet: provider.callsPerMonth }));
e.limitReached = true;
throw e;
}
if (await spacing(provider, capability, signal)) lastCallAt.set(`${provider.id}|${capability}`, Date.now());
if (tegoed) tegoed.calls++;
const t0 = perfNow();
let status = 0, ok = false, data = null, err = null;
try {
const res = await fetch('/api/price/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, capability, symbol, date }),
});
status = res.status;
data = await res.json().catch(() => ({}));
if (!res.ok || (data && data.error)) {
err = new Error(serverFoutTekst(data) || t('integrations.priceCallFailed', { status }));
if (data && data.key) { err.key = data.key; err.params = data.params || null; }
} else ok = true;
} catch (e) {
err = e;
}
const latency = Math.round(perfNow() - t0);
emitApiCall({ provider_key: provider.key, provider_name: provider.name, capability, symbol, ok, http_status: status, latency_ms: latency, ts: Date.now() });
try { await recordApiCall({ providerId: provider.id, capability, ok, latency_ms: latency }); }
catch { }
if (err) throw err;
return inBaseCurrency(provider, capability, symbol, date, data);
}
const dayOf = time => new Date(time * 1000).toISOString().slice(0, 10);
async function rateLookup(currency) {
const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat');
if (!asset) return () => null;
try { await ensurePriceHistoryFor(asset); } catch { }
const rows = (await listPriceHistory(asset.id))
.filter(r => r.resolution === 'day' && r.fiatPrice != null && r.fiatPrice > 0)
.map(r => ({ day: r.observedAt.slice(0, 10), fiatPrice: r.fiatPrice }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
const maxAge = CONFIG.PRICE_HISTORY.FX_MAX_AGE_DAYS;
return day => {
let lo = 0, hi = rows.length - 1, hit = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (rows[mid].day <= day) { hit = rows[mid]; lo = mid + 1; } else hi = mid - 1;
}
if (!hit) return null;
const age = (Date.parse(`${day}T00:00:00Z`) - Date.parse(`${hit.day}T00:00:00Z`)) / 86400000;
return age <= maxAge ? hit.fiatPrice : null;
};
}
export async function rateSourceFor(currency) {
if (!currency || currency === baseCurrency()) return null;
const asset = (await listAssets()).find(a => a.symbol === currency && a.kind === 'fiat');
if (!asset) return null;
return (await primaryProvider(asset, 'daily_history'))
|| (await primaryProvider(asset, 'spot'))
|| (await serviceRateSource(asset));
}
async function serviceRateSource(asset) {
if (marketSpotMode() === 'off') return null;
try {
const r = await serviceCoverage();
if (r.status !== 'ok' || !r.assetIds.includes(asset.id)) return null;
return await getProvider(CONFIG.MARKET_SERVICE.PROVIDER_KEY);
} catch { return null; }
}
export const basePriceOf = spot => (spot && Number.isFinite(spot.price) ? spot.price : null);
export async function currentRate(currency) {
if (currency === baseCurrency()) return 1;
return (await rateLookup(currency))(todayIso());
}
export async function rateOn(currency, day) {
if (currency === baseCurrency()) return 1;
return (await rateLookup(currency))(day);
}
async function inBaseCurrency(provider, capability, symbol, date, data) {
const endpoint = provider.endpoints.find(e => e.capability === capability);
const currency = endpoint && endpoint.quoteCurrency;
if (!currency || currency === baseCurrency()) return data;
if (symbol === currency) throw new Error(`Provider ${provider.key}: ${currency} cannot be quoted in ${currency}.`);
const rate = await rateLookup(currency);
const convert = (p, day) => {
const r = rate(day);
if (!Number.isFinite(p.price) || r == null) return null;
return { ...p, price: p.price * r };
};
if (capability === 'daily_history') {
return (Array.isArray(data) ? data : []).map(p => convert(p, dayOf(p.time))).filter(Boolean);
}
const day = capability === 'history_point' ? (date || dayOf(data.time)) : todayIso();
const uit = convert(data, day);
if (!uit) throw new Error(t('integrations.price.noExchangeRate', { valuta: currency, datum: day, munt: baseCurrency() }));
return uit;
}
function hasCapability(provider, capability) {
return !!(provider && provider.endpoints.some(e => e.capability === capability));
}
const todayIso = () => new Date().toISOString().slice(0, 10);
async function newestStoredDate(assetId) {
const rows = (await listPriceHistory(assetId)).filter(r => r.resolution === 'day');
return rows.length ? rows[0].observedAt.slice(0, 10) : null;
}
const seriesFetchedOn = new Map();
export function forgetSeriesFetches() { seriesFetchedOn.clear(); serviceAskAfter.clear(); }
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('providers-changed', forgetSeriesFetches);
}
async function ensureHistoryWith(provider, asset) {
if (!provider) return;
const newest = await newestStoredDate(asset.id);
if (newest && newest >= todayIso()) return;
if (hasCapability(provider, 'daily_history')) {
const sleutel = `${provider.key}|${asset.id}`;
if (seriesFetchedOn.get(sleutel) === todayIso()) return;
const points = await callPrice(provider, 'daily_history', asset.symbol);
if (Array.isArray(points) && points.length) await upsertPriceHistory(asset.id, points, provider.key, 'day');
seriesFetchedOn.set(sleutel, todayIso());
return;
}
if (hasCapability(provider, 'spot')) {
const p = await callPrice(provider, 'spot', asset.symbol);
if (p && Number.isFinite(p.price)) {
const time = Math.floor(Date.parse(`${todayIso()}T00:00:00Z`) / 1000);
await upsertPriceHistory(asset.id, [{ time, price: p.price }], provider.key, 'day');
}
}
}
async function serviceDaysAnswer(asset, sinceMs) {
const since = Math.max(sinceMs, Date.now() - CONFIG.MARKET_SERVICE.HISTORY_MAX_DAYS * 86_400_000);
let r;
const t0 = perfNow();
try { r = await serviceDayHistory(asset.id, since); } catch { serviceCalled(asset, 'daily_history', false, t0); return { failed: true, retryLater: true }; }
serviceCalled(asset, 'daily_history', r.status === 'ok', t0);
if (r.status !== 'ok') {
return { failed: true, retryLater: r.status === 'unavailable' && !!r.retryLater, refused: r.status === 'unavailable' && !r.retryLater };
}
const points = r.days.map(([ms, price, sourceName]) => ({ time: Math.floor(ms / 1000), price, ...(sourceName ? { sourceName } : {}) }));
if (r.currency === baseCurrency()) return { points };
if (asset.kind === 'fiat' && asset.symbol === r.currency) return { failed: true };
const rate = await rateLookup(r.currency);
return {
points: points.map((p) => {
const x = rate(dayOf(p.time));
return x == null ? null : { ...p, price: p.price * x };
}).filter(Boolean),
};
}
async function serviceDays(asset, sinceMs) {
return (await serviceDaysAnswer(asset, sinceMs)).points ?? null;
}
const serviceAskAfter = new Map();
async function ensureHistoryFromService(asset) {
const sleutel = `${CONFIG.MARKET_SERVICE.PROVIDER_KEY}|${asset.id}`;
if (seriesFetchedOn.get(sleutel) === todayIso()) return true;
if ((serviceAskAfter.get(sleutel) ?? 0) > Date.now()) return false;
const yesterday = new Date(Date.parse(`${todayIso()}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
const newest = (await listPriceHistory(asset.id))
.filter(row => row.resolution === 'day' && row.observedAt.slice(0, 10) < todayIso())
.map(row => row.observedAt.slice(0, 10)).sort().at(-1) ?? null;
if (newest && newest >= yesterday) return true;
const answer = await serviceDaysAnswer(asset, newest ? Date.parse(`${newest}T00:00:00Z`) + 86_400_000 : 0);
if (!answer.points) {
if (answer.refused) serviceAskAfter.set(sleutel, Date.parse(`${todayIso()}T00:00:00Z`) + 86_400_000);
else if (answer.retryLater) serviceAskAfter.set(sleutel, Date.now() + CONFIG.MARKET_SERVICE.RETRY_PAUSE_MS);
return false;
}
const points = answer.points;
if (points.length) await upsertPriceHistory(asset.id, points, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day');
seriesFetchedOn.set(sleutel, todayIso());
return true;
}
export async function ensurePriceHistoryFor(asset) {
const mode = marketSpotMode();
const grid = await primaryProvider(asset, 'daily_history');
const own = grid || await primaryProvider(asset, 'spot');
if (mode === 'first') {
const chosen = grid && (await ownPriceSourceChain(asset.id, sourceCapabilityOf('daily_history'))).includes(grid.key);
if (!chosen && await ensureHistoryFromService(asset)) return;
}
if (mode !== 'fallback') return ensureHistoryWith(own, asset);
if (own) {
try { return await ensureHistoryWith(own, asset); } catch (e) {
if (await ensureHistoryFromService(asset)) return;
throw e;
}
}
await ensureHistoryFromService(asset);
}
export async function resolveChain(asset, capability) {
if (!asset) return [];
const keuze = sourceCapabilityOf(capability);
if (!keuze) return [];
const keys = await priceSourceChain(asset.id, keuze);
const providers = await Promise.all(keys.map(key => getProvider(key)));
return providers.filter(p => p && p.isActive && hasCapability(p, capability) && covers(p, asset.symbol));
}
function covers(provider, symbol) {
return Array.isArray(provider.coverage) && provider.coverage.includes(symbol);
}
export async function primaryProvider(asset, capability) {
return (await resolveChain(asset, capability))[0] || null;
}
export async function primarySourceKey(asset, capability) {
const p = await primaryProvider(asset, capability);
return p ? p.key : null;
}
const spotAnswers = new Map();
async function recentSpot(provider, asset, seconds) {
const sleutel = `${provider.id}|${asset.symbol}`;
const houdbaar = at => Date.now() - at < seconds * 1000;
const m = spotAnswers.get(sleutel);
if (m && houdbaar(m.at)) return m.spot;
try {
const r = await latestSpotPoint(asset.id, provider.key);
const at = r ? Date.parse(r.observedAt) : NaN;
if (Number.isFinite(at) && houdbaar(at)) {
const spot = { price: r.fiatPrice };
spotAnswers.set(sleutel, { at, spot });
return spot;
}
} catch { }
return null;
}
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('providers-changed', () => spotAnswers.clear());
}
export function forgetSpotAnswers() { spotAnswers.clear(); }
async function spotVia(provider, asset) {
const seconds = intervalOf(provider, 'spot');
if (seconds) {
const vers = await recentSpot(provider, asset, seconds);
if (vers) return vers;
}
const spot = await callPrice(provider, 'spot', asset.symbol);
if (seconds) spotAnswers.set(`${provider.id}|${asset.symbol}`, { at: Date.now(), spot });
try { await recordCurrentSpot(asset.id, provider.key, spot); } catch { }
return spot;
}
const spotCache = new Map();
const spotSources = new Map();
export function clearSpotCache() { spotCache.clear(); spotSources.clear(); }
export function lastSpotSource(asset) {
return spotSources.get(asset && asset.id) ?? null;
}
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('tick:spot', clearSpotCache);
document.addEventListener('providers-changed', clearSpotCache);
}
function marketSpotMode() {
try { return appSetting('market_spot'); } catch { return 'off'; }
}
async function askService(asset) {
let r;
const t0 = perfNow();
try { r = await serviceSpot(asset.id); } catch { serviceCalled(asset, 'spot', false, t0); return null; }
serviceCalled(asset, 'spot', r.status === 'ok', t0);
if (r.status === 'unavailable') return { unavailable: true };
if (r.status !== 'ok') return null;
const rate = await currentRate(r.currency);
if (rate == null) return null;
const spot = { price: r.price * rate };
try { await recordCurrentSpot(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY, spot); } catch { }
return { spot, label: t('marketService.sourceLive', { source: r.sourceLabel || r.source || '?' }) };
}
async function cachedServiceSpot(asset) {
try {
const r = await latestSpotPoint(asset.id, CONFIG.MARKET_SERVICE.PROVIDER_KEY);
if (!r) return null;
return { spot: { price: r.fiatPrice }, label: t('marketService.sourceCached', { when: r.observedAt.slice(0, 16).replace('T', ' ') }) };
} catch { return null; }
}
function keep(sleutel, spot, label, fresh) {
spotCache.set(sleutel, spot);
spotSources.set(sleutel, label);
if (fresh) recordSpot(sleutel, basePriceOf(spot));
return spot;
}
async function firstSpot(sleutel, asset, providers) {
for (const provider of providers) {
try {
return keep(sleutel, await spotVia(provider, asset), provider.name, true);
} catch { }
}
return null;
}
export async function liveSpotFor(asset) {
const sleutel = asset && asset.id;
if (spotCache.has(sleutel)) return spotCache.get(sleutel);
const mode = marketSpotMode();
let chain = await resolveChain(asset, 'spot');
let dienst = null;
if (mode === 'first') {
const eigen = new Set(await ownPriceSourceChain(asset.id, sourceCapabilityOf('spot')));
const gekozen = await firstSpot(sleutel, asset, chain.filter(p => eigen.has(p.key)));
if (gekozen) return gekozen;
dienst = await askService(asset);
if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true);
chain = chain.filter(p => !eigen.has(p.key));
}
const lokaal = await firstSpot(sleutel, asset, chain);
if (lokaal) return lokaal;
if (mode === 'fallback') {
dienst = await askService(asset);
if (dienst?.spot) return keep(sleutel, dienst.spot, dienst.label, true);
}
if (dienst?.unavailable) {
const cached = await cachedServiceSpot(asset);
if (cached) return keep(sleutel, cached.spot, cached.label, false);
}
return null;
}
export async function liveSpotPriceFor(asset) {
return basePriceOf(await liveSpotFor(asset));
}
export async function fetchPriceVia(providerKey, asset, capability, dateKey) {
const provider = await getProvider(providerKey);
if (!provider || !provider.isActive || !hasCapability(provider, capability)) return null;
return callPrice(provider, capability, asset.symbol, dateKey);
}
export async function hasDailyGridFor(asset) {
return !!(await primaryProvider(asset, 'daily_history'));
}
async function historyPointWith(provider, asset, dateKey) {
if (!hasCapability(provider, 'history_point')) return null;
return callPrice(provider, 'history_point', asset.symbol, dateKey);
}
export async function historicalPointForVia(asset, dateKey) {
for (const provider of await resolveChain(asset, 'history_point')) {
try { const p = await historyPointWith(provider, asset, dateKey); if (p) return p; }
catch { }
}
return null;
}
async function ensurePointsWith(provider, asset, dateKeys) {
if (!hasCapability(provider, 'history_point')) return;
for (const key of dateKeys) {
if (await getPricePoint(asset.id, key, { derived: false })) continue;
try {
const p = await callPrice(provider, 'history_point', asset.symbol, key);
if (p && Number.isFinite(p.price)) await upsertPriceHistory(asset.id, [p], provider.key, 'day');
} catch { }
}
}
export async function ensurePointsAtFor(asset, dateKeys) {
return ensurePointsWith(await primaryProvider(asset, 'history_point'), asset, dateKeys);
}
const shiftDay = (dayKey, n) => new Date(Date.parse(`${dayKey}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10);
const isWeekend = dayKey => { const d = new Date(`${dayKey}T00:00:00Z`).getUTCDay(); return d === 0 || d === 6; };
const skipsWeekend = asset => CONFIG.PRICE_HISTORY.WEEKEND_PRICE[asset.kind] === false;
export function daysToAsk(asset, fromDay, untilDay) {
let n = 0;
for (let day = fromDay; day <= untilDay; day = shiftDay(day, 1)) if (!(skipsWeekend(asset) && isWeekend(day))) n++;
return n;
}
function isClose(row, manualId) {
if (row.resolution !== 'day') return false;
if (manualId != null && row.providerId === manualId) return true;
const written = row.updatedAt || row.createdAt;
return !!written && written.slice(0, 10) > row.observedAt.slice(0, 10);
}
export async function fillClosesFor(asset, fromDay, untilDay, { today = todayIso(), signal = null } = {}) {
const uit = { requested: 0, written: 0, limitReached: false, stopped: false };
if (!asset) return uit;
const series = await primaryProvider(asset, 'daily_history');
if (series) { try { await ensureHistoryWith(series, asset); } catch { } }
const last = untilDay < shiftDay(today, -1) ? untilDay : shiftDay(today, -1);
if (marketSpotMode() !== 'off' && fromDay <= last) {
const points = await serviceDays(asset, Date.parse(`${fromDay}T00:00:00Z`));
const inGap = (points || []).filter(p => dayOf(p.time) >= fromDay && dayOf(p.time) <= last);
if (inGap.length) {
await upsertPriceHistory(asset.id, inGap, CONFIG.MARKET_SERVICE.PROVIDER_KEY, 'day');
uit.written += inGap.length;
}
}
const chain = await resolveChain(asset, 'history_point');
if (!chain.length) return uit;
if (fromDay > last) return uit;
const manual = await getProvider('manual');
const closed = new Set((await listPriceHistory(asset.id, fromDay))
.filter(r => isClose(r, manual ? manual.id : null))
.map(r => r.observedAt.slice(0, 10)));
const op = new Set();
const skipWeekend = skipsWeekend(asset);
const noPrice = new Set((await listNoPriceDays(asset.id, fromDay)).map(r => `${r.providerId}|${r.day}`));
const found = new Map();
const delivered = new Set();
for (let day = fromDay; day <= last; day = shiftDay(day, 1)) {
if (closed.has(day)) continue;
if (skipWeekend && isWeekend(day)) continue;
if (signal && signal.aborted) { uit.stopped = true; break; }
if (op.size === chain.length) { uit.limitReached = true; break; }
const first = chain.find(p => !op.has(p.id));
if (noPrice.has(`${first.id}|${day}`)) continue;
uit.requested++;
for (const provider of chain) {
if (op.has(provider.id)) continue;
if (noPrice.has(`${provider.id}|${day}`)) break;
try {
const p = await callPrice(provider, 'history_point', asset.symbol, day, { signal });
if (p && Number.isFinite(p.price)) {
await upsertPriceHistory(asset.id, [p], provider.key, 'day');
uit.written++;
delivered.add(provider.id);
}
break;
} catch (e) {
if (e && e.limitReached) { op.add(provider.id); continue; }
if (isNoPrice(e)) { if (!found.has(provider.id)) found.set(provider.id, []); found.get(provider.id).push(day); }
break;
}
}
}
for (const [providerId, days] of found) {
if (!delivered.has(providerId)) continue;
const provider = chain.find(p => p.id === providerId);
try { await recordNoPriceDays(asset.id, provider.key, days); } catch { }
}
if (op.size === chain.length) uit.limitReached = true;
return uit;
}
function isNoPrice(e) {
if (!e) return false;
if (e.key === 'integrations.price.noValidPrice') return true;
return e.key === 'integrations.price.httpFailed' && e.params && Number(e.params.status) === 404;
}
export async function refreshRecentCloses(assets, { today = todayIso() } = {}) {
const until = shiftDay(today, -1);
const from = shiftDay(today, -CONFIG.PRICE_HISTORY.RECENT_CLOSE_DAYS);
let written = 0;
for (const asset of assets || []) {
try { written += (await fillClosesFor(asset, from, until, { today })).written; }
catch { }
}
return { written };
}