Files
AtreidesAssetsFree/server/data/price-history.js
T

296 lines
14 KiB
JavaScript
Raw Normal View History

2026-10-29 11:57:29 +01:00
// 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');
}