// Generated, do not edit. sha256:7aabe3b0d2c7bf04e76cf1fb822d83923d1b0287d69de575f4f58ac8cbd62381 import * as db from '../db.js'; import { ENUMS, CONFIG } from '../../shared/config.js'; import { assertEnum, addDaysIso, daysBetween } from './_shared.js'; import { fail } from '../../shared/app-error.js'; import { isMetal, metalFactor } from '../../shared/units.js'; function providerIdVoor(key) { const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [key]); if (!p) throw new Error(`Unknown price source '${key}': there is no provider with that key.`); return p.id; } const positief = v => (v != null && Number(v) > 0 ? v : null); export async function upsertPriceHistory(assetId, points, source, resolution) { assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution); const providerId = providerIdVoor(source); const now = new Date().toISOString(); for (const punt of points) { const prijs = positief(punt.price); if (prijs == null) continue; const iso = new Date(punt.time * 1000).toISOString(); const observedAt = resolution === 'day' ? `${iso.slice(0, 10)}T00:00:00Z` : iso; const sourceName = typeof punt.sourceName === 'string' && punt.sourceName ? punt.sourceName : null; db.run(`INSERT INTO PriceHistory (assetId, observedAt, resolution, fiatPrice, providerId, createdAt, updatedAt, sourceName) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(assetId, providerId, observedAt, resolution) DO UPDATE SET fiatPrice = excluded.fiatPrice, updatedAt = excluded.updatedAt, sourceName = excluded.sourceName`, [assetId, observedAt, resolution, prijs, providerId, now, now, sourceName]); } await db.persist(); } export async function addManualPricePoint(payload = {}) { const { assetId, observedAt, fiatPrice, resolution = 'day', unit = null } = payload; assertEnum('resolution', ENUMS.PRICE_RESOLUTION, resolution); const asset = db.one(`SELECT id, kind, unit, subunit, displayUnit FROM Asset WHERE id = ?`, [assetId]); if (!asset) { fail('priceHistory.manualUnknownAsset', {}, `Asset ${assetId} does not exist.`); } const ms = Date.parse(observedAt); if (!Number.isFinite(ms)) { fail('priceHistory.manualBadMoment', {}, `'${observedAt}' is not a valid moment.`); } if (ms > Date.now()) { fail('priceHistory.manualFutureMoment', {}, `'${observedAt}' lies in the future.`); } const koers = Number(fiatPrice); if (!Number.isFinite(koers) || koers <= 0) { fail('priceHistory.manualBadPrice', {}, `A price must be a number above zero, got '${fiatPrice}'.`); } const eenheid = unit || asset.unit; const naarBasis = p => (isMetal(asset) ? p / metalFactor(eenheid) : p); await upsertPriceHistory(assetId, [{ time: Math.floor(ms / 1000), price: naarBasis(koers) }], 'manual', resolution); const observed = resolution === 'day' ? `${new Date(ms).toISOString().slice(0, 10)}T00:00:00Z` : new Date(ms).toISOString(); const rij = db.one( `SELECT id FROM PriceHistory WHERE assetId = ? AND providerId = ? AND observedAt = ? AND resolution = ?`, [assetId, providerIdVoor('manual'), observed, resolution]); return rij ? rij.id : null; } const DEKKING = `AND EXISTS ( SELECT 1 FROM Provider WHERE Provider.id = PriceHistory.providerId AND (Provider.isSystem = 1 OR EXISTS (SELECT 1 FROM ProviderCoverage JOIN Asset ON Asset.id = PriceHistory.assetId WHERE ProviderCoverage.providerId = Provider.id AND ProviderCoverage.symbol = Asset.symbol)))`; export function listPriceHistory(assetId, fromDate) { return fromDate ? db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND observedAt >= ? ${DEKKING} ORDER BY observedAt DESC`, [assetId, `${fromDate}T00:00:00Z`]) : db.all(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC`, [assetId]); } export function latestPricePoint(assetId) { return db.one(`SELECT * FROM PriceHistory WHERE assetId = ? ${DEKKING} ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId]) || null; } export function latestSpotPoint(assetId, providerKey) { const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [providerKey]); if (!p) return null; return db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND fiatPrice > 0 ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId, p.id]) || null; } export function priceSeriesExport() { return db.all( `SELECT Asset.kind AS kind, Asset.symbol AS symbol, Provider.key AS source, PriceHistory.resolution AS resolution, PriceHistory.observedAt AS observedAt, PriceHistory.createdAt AS createdAt, PriceHistory.fiatPrice AS fiatPrice FROM PriceHistory JOIN Asset ON Asset.id = PriceHistory.assetId JOIN Provider ON Provider.id = PriceHistory.providerId WHERE Provider.key NOT IN ('manual', 'atreides_services', 'fixed') ${DEKKING} ORDER BY Asset.kind, Asset.symbol, Provider.key, PriceHistory.resolution, PriceHistory.observedAt`); } export function priceHistoryCount(assetId) { return db.one(`SELECT COUNT(*) AS n FROM PriceHistory WHERE assetId = ?`, [assetId]).n; } function laatsteSpotVanDag(assetId, dag, preferSource = null) { const cands = db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ? AND fiatPrice > 0 ${DEKKING}`, [assetId, dag]); const r = pickPreferredRow(cands, preferSource, (a, b) => (a.observedAt < b.observedAt ? 1 : a.observedAt > b.observedAt ? -1 : 0)); return r ? { ...r, derivedFrom: 'spot' } : null; } export function getPricePoint(assetId, onDate, { derived = true } = {}) { const dagrij = db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'day' AND observedAt = ? ${DEKKING} ORDER BY id DESC LIMIT 1`, [assetId, `${onDate}T00:00:00Z`]) || null; if (dagrij || !derived) return dagrij; return laatsteSpotVanDag(assetId, onDate); } function pickPreferredRow(cands, preferSource, cmp) { if (!cands.length) return null; const sorted = [...cands].sort(cmp); if (preferSource) { const p = db.one(`SELECT id FROM Provider WHERE key = ?`, [preferSource]); const pref = p ? sorted.filter(r => r.providerId === p.id) : []; if (pref.length) return pref[0]; } return sorted[0]; } export function getPriceAt(assetId, tsUnix, preferSource = null) { const iso = new Date(tsUnix * 1000).toISOString(); const dayKey = iso.slice(0, 10); const maxSec = CONFIG.PRICE_HISTORY.NEAREST_MAX_SECONDS; const tsCand = [ db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt <= ? ${DEKKING} ORDER BY observedAt DESC LIMIT 1`, [assetId, iso]), db.one(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'timestamp' AND observedAt >= ? ${DEKKING} ORDER BY observedAt ASC LIMIT 1`, [assetId, iso]), ].filter(Boolean) .map(r => ({ ...r, dist_seconds: Math.abs(Date.parse(r.observedAt) / 1000 - tsUnix) })) .filter(r => r.dist_seconds <= maxSec); const ts = pickPreferredRow(tsCand, preferSource, (a, b) => a.dist_seconds - b.dist_seconds); if (ts) return ts; const dayCand = db.all(`SELECT * FROM PriceHistory WHERE assetId = ? AND resolution = 'day' AND substr(observedAt, 1, 10) = ? ${DEKKING}`, [assetId, dayKey]) .map(r => ({ ...r, dist_seconds: null })); return pickPreferredRow(dayCand, preferSource, (a, b) => b.id - a.id) || laatsteSpotVanDag(assetId, dayKey, preferSource); } export function listPriceGaps({ today = new Date().toISOString().slice(0, 10), head = true, tail = true, limit = CONFIG.HYGIENE.MAX_ROWS_PER_FINDING } = {}) { const grens = CONFIG.HYGIENE.PRICE_GAP_DAYS; const naGisteren = today; const assets = db.all(`SELECT Asset.id, Asset.symbol, substr(MIN(Event.occurredAt), 1, 10) AS start FROM Asset JOIN Event ON Event.assetId = Asset.id AND Event.type <> 'reservation' WHERE Asset.kind <> 'fiat' GROUP BY Asset.id, Asset.symbol ORDER BY Asset.symbol`); const rows = []; for (const a of assets) { if (a.start >= today) continue; const dagen = db.all(`SELECT DISTINCT substr(observedAt, 1, 10) AS dag FROM PriceHistory WHERE assetId = ? AND observedAt >= ? AND fiatPrice > 0 ${DEKKING} ORDER BY dag`, [a.id, `${a.start}T00:00:00Z`]).map(r => r.dag); const genegeerd = db.all(`SELECT fromDay, untilDay FROM IgnoredPriceGap WHERE assetId = ? ORDER BY fromDay`, [a.id]); let vorige = addDaysIso(a.start, -1); if (!head) { const ervoor = db.one(`SELECT 1 AS x FROM PriceHistory WHERE assetId = ? AND observedAt < ? AND fiatPrice > 0 ${DEKKING}`, [a.id, `${a.start}T00:00:00Z`]); if (!ervoor) { if (!dagen.length) continue; vorige = dagen[0]; } } for (const dag of [...dagen.filter(d => d < naGisteren), ...(tail ? [naGisteren] : [])]) { if (daysBetween(vorige, dag) > grens) { for (const stuk of zonderGenegeerd(addDaysIso(vorige, 1), addDaysIso(dag, -1), genegeerd)) { const lengte = daysBetween(stuk.from, stuk.until) + 1; if (lengte > grens - 1) rows.push({ assetId: a.id, symbol: a.symbol, from: stuk.from, until: stuk.until, days: lengte }); } } vorige = dag; } } return { total: rows.length, rows: limit == null ? rows : rows.slice(0, limit) }; } function zonderGenegeerd(from, until, ranges) { let stukken = [{ from, until }]; for (const r of ranges) { stukken = stukken.flatMap(s => { if (r.untilDay < s.from || r.fromDay > s.until) return [s]; const rest = []; if (r.fromDay > s.from) rest.push({ from: s.from, until: addDaysIso(r.fromDay, -1) }); if (r.untilDay < s.until) rest.push({ from: addDaysIso(r.untilDay, 1), until: s.until }); return rest; }); } return stukken; } const DAG = /^\d{4}-\d{2}-\d{2}$/; export async function ignorePriceGap({ assetId, from, until } = {}) { if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`); if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) { throw new Error(`'${from}' to '${until}' is not a range of days.`); } db.run(`INSERT INTO IgnoredPriceGap (assetId, fromDay, untilDay, createdAt) VALUES (?, ?, ?, ?)`, [assetId, from, until, new Date().toISOString()]); const id = db.lastId(); await db.persist(); return id; } export function listIgnoredPriceGaps() { return db.all(`SELECT IgnoredPriceGap.*, Asset.symbol FROM IgnoredPriceGap JOIN Asset ON Asset.id = IgnoredPriceGap.assetId ORDER BY Asset.symbol, IgnoredPriceGap.fromDay`); } export async function unignorePriceGap(id) { db.run(`DELETE FROM IgnoredPriceGap WHERE id = ?`, [id]); await db.persist(); } export async function recordNoPriceDays(assetId, source, days) { if (!db.one(`SELECT id FROM Asset WHERE id = ?`, [assetId])) throw new Error(`Asset ${assetId} does not exist.`); const providerId = providerIdVoor(source); if (!Array.isArray(days) || days.some(d => !DAG.test(String(d)))) throw new Error(`Not a list of days: ${JSON.stringify(days)}.`); const now = new Date().toISOString(); for (const day of days) { db.run(`INSERT OR IGNORE INTO PriceAbsence (assetId, providerId, day, createdAt) VALUES (?, ?, ?, ?)`, [assetId, providerId, day, now]); } await db.persist(); } export function listPriceAbsences() { const rijen = db.all(`SELECT PriceAbsence.assetId, PriceAbsence.providerId, Provider.name AS providerName, PriceAbsence.day FROM PriceAbsence JOIN Provider ON Provider.id = PriceAbsence.providerId ORDER BY PriceAbsence.assetId, Provider.name, PriceAbsence.day`); const uit = []; for (const r of rijen) { const vorige = uit[uit.length - 1]; if (vorige && vorige.assetId === r.assetId && vorige.providerId === r.providerId && addDaysIso(vorige.untilDay, 1) === r.day) { vorige.untilDay = r.day; vorige.days++; } else { uit.push({ assetId: r.assetId, providerId: r.providerId, providerName: r.providerName, fromDay: r.day, untilDay: r.day, days: 1 }); } } return uit; } export async function deletePriceAbsences({ assetId, providerId, from, until } = {}) { if (!Number.isInteger(assetId) || !Number.isInteger(providerId)) throw new Error(`An asset id and a provider id are required.`); if (!DAG.test(String(from)) || !DAG.test(String(until)) || from > until) throw new Error(`'${from}' to '${until}' is not a range of days.`); db.run(`DELETE FROM PriceAbsence WHERE assetId = ? AND providerId = ? AND day BETWEEN ? AND ?`, [assetId, providerId, from, until]); await db.persist(); } export function listNoPriceDays(assetId, fromDay = '0000-01-01') { return db.all(`SELECT providerId, day FROM PriceAbsence WHERE assetId = ? AND day >= ? ORDER BY day, providerId`, [assetId, fromDay]); } export function listAllPriceHistory(filter = {}) { const where = ['1=1'], args = []; if (filter.assetId) { where.push('ph.assetId = ?'); args.push(filter.assetId); } if (filter.fromDate) { where.push('ph.observedAt >= ?'); args.push(`${filter.fromDate}T00:00:00Z`); } return db.all(`SELECT ph.*, a.symbol AS asset_symbol, p.key AS provider_key, p.name AS provider_name FROM PriceHistory ph JOIN Asset a ON a.id = ph.assetId JOIN Provider p ON p.id = ph.providerId WHERE ${where.join(' AND ')} ORDER BY ph.observedAt DESC, ph.id DESC`, args); } export async function recordCurrentSpot(assetId, source, spot) { if (!spot || !Number.isFinite(spot.price)) return; const today = new Date().toISOString().slice(0, 10); const existing = db.one( `SELECT id FROM PriceHistory WHERE assetId = ? AND providerId = ? AND resolution = 'timestamp' AND substr(observedAt, 1, 10) = ? ORDER BY observedAt DESC, id DESC LIMIT 1`, [assetId, providerIdVoor(source), today]); if (existing) { db.run(`UPDATE PriceHistory SET observedAt = ?, fiatPrice = ?, updatedAt = ? WHERE id = ?`, [new Date().toISOString(), spot.price, new Date().toISOString(), existing.id]); await db.persist(); return; } await upsertPriceHistory(assetId, [{ time: Math.floor(Date.now() / 1000), price: spot.price }], source, 'timestamp'); }