// 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 }; }