566 lines
24 KiB
JavaScript
566 lines
24 KiB
JavaScript
// 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 };
|
||
|
|
}
|