217 lines
11 KiB
JavaScript
217 lines
11 KiB
JavaScript
// Generated, do not edit. sha256:d62e710a5bda41c8a7b1a7ff304738dfd9331056f2936a826970a6092995f68b
|
|||
|
|
import { CONFIG, SOURCE_CAPABILITIES } from '../../shared/config.js';
|
||
|
|
import { listAssets } from '../data/assets.js';
|
||
|
|
import { listPriceHistory, listPriceGaps, listIgnoredPriceGaps } from '../data/price-history.js';
|
||
|
|
import { listProviders, getProvider } from '../data/providers.js';
|
||
|
|
import { listPriceSource } from '../data/price-source.js';
|
||
|
|
import { apiMetricsTotals } from '../data/api-metrics.js';
|
||
|
|
import { dailyProvenance } from '../../shared/price-provenance.js';
|
||
|
|
import { diagnoseSources } from '../../shared/source-diagnosis.js';
|
||
|
|
import { serviceCoveredAssets } from './service-cover.mjs';
|
||
|
|
import { priceFromBase, priceUnitLabel } from '../../shared/units.js';
|
||
|
|
import { assertScope } from '../ai/gate.js';
|
||
|
|
import { limitedList } from '../ai/limits.js';
|
||
|
|
import { vertalerVoor } from './session.mjs';
|
||
|
|
import { getBaseCurrency } from '../data/portfolio.js';
|
||
|
|
|
||
|
|
const DAG = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
|
const DAG_MS = 86400000;
|
||
|
|
const plusDagen = (dag, n) => new Date(Date.parse(`${dag}T00:00:00Z`) + n * DAG_MS).toISOString().slice(0, 10);
|
||
|
|
const dagenTussen = (a, b) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAG_MS);
|
||
|
|
|
||
|
|
const STANDAARD_DAGEN = 14;
|
||
|
|
const MAX_DAGEN = 92;
|
||
|
|
|
||
|
|
const SOORT_TEKST = Object.freeze({
|
||
|
|
close: 'slotkoers', manual: 'met de hand', live: 'vandaag, beweegt nog', spot: 'afgeleid uit een spot',
|
||
|
|
carried: 'doorgetrokken', none: 'geen koers',
|
||
|
|
});
|
||
|
|
|
||
|
|
function getal(n) {
|
||
|
|
if (n == null || !Number.isFinite(n)) return '?';
|
||
|
|
const d = Math.abs(n) >= 100 ? 2 : Math.abs(n) >= 1 ? 4 : 6;
|
||
|
|
return n.toLocaleString('nl-NL', { maximumFractionDigits: d, useGrouping: false });
|
||
|
|
}
|
||
|
|
|
||
|
|
function assetVoorSymbool(gevraagd) {
|
||
|
|
const gezocht = String(gevraagd ?? '').trim().toUpperCase();
|
||
|
|
return gezocht ? listAssets().find(a => String(a.symbol).toUpperCase() === gezocht) || null : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const priceData = Object.freeze({
|
||
|
|
name: 'price_data',
|
||
|
|
title: 'Koersen van een asset',
|
||
|
|
description:
|
||
|
|
'Alle koersinformatie van één asset: per dag de gebruikte koers met herkomst en de koers per bron '
|
||
|
|
+ '(days), de gaten (gaps), of de bronnen met keuze, munt, limiet en verbruik (sources). '
|
||
|
|
+ 'Wijzigt niets.',
|
||
|
|
inputSchema: Object.freeze({
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
asset: { type: 'string', maxLength: 16, description: 'Symbool, zoals XAG.' },
|
||
|
|
view: { type: 'string', enum: ['days', 'gaps', 'sources'] },
|
||
|
|
from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||
|
|
until: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
||
|
|
},
|
||
|
|
required: ['asset'],
|
||
|
|
additionalProperties: false,
|
||
|
|
}),
|
||
|
|
scope: 'read',
|
||
|
|
|
||
|
|
async run(args, context) {
|
||
|
|
assertScope(priceData.scope);
|
||
|
|
const t = await vertalerVoor(context);
|
||
|
|
t.beginCall();
|
||
|
|
const asset = assetVoorSymbool(args?.asset);
|
||
|
|
if (!asset) {
|
||
|
|
const symbolen = listAssets().map(a => a.symbol).join(', ');
|
||
|
|
return { text: `Geen asset met symbool '${String(args?.asset ?? '')}'. Er zijn: ${symbolen}.`, fieldsShared: ['symbol'] };
|
||
|
|
}
|
||
|
|
const view = args?.view || 'days';
|
||
|
|
const sleutelVan = providerSleutels(t);
|
||
|
|
if (view === 'gaps') return gatenAntwoord(asset, t);
|
||
|
|
if (view === 'sources') return bronnenAntwoord(asset, t, sleutelVan, (await serviceCoveredAssets()).has(asset.id));
|
||
|
|
return dagenAntwoord(asset, args, t, sleutelVan);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
function providerSleutels(t) {
|
||
|
|
const m = new Map(listProviders().map(p => [p.id, t.toAi({ key: p.key }, { entity: 'provider' }).key]));
|
||
|
|
return id => m.get(id) ?? `bron ${id}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function dagenAntwoord(asset, args, t, sleutelVan) {
|
||
|
|
const vandaag = new Date().toISOString().slice(0, 10);
|
||
|
|
let until = DAG.test(String(args?.until ?? '')) ? args.until : vandaag;
|
||
|
|
let from = DAG.test(String(args?.from ?? '')) ? args.from : plusDagen(until, -(STANDAARD_DAGEN - 1));
|
||
|
|
if (from > until) [from, until] = [until, from];
|
||
|
|
let ingekort = false;
|
||
|
|
if (dagenTussen(from, until) + 1 > MAX_DAGEN) { from = plusDagen(until, -(MAX_DAGEN - 1)); ingekort = true; }
|
||
|
|
|
||
|
|
const rijen = listPriceHistory(asset.id, plusDagen(from, -31)).filter(r => r.observedAt.slice(0, 10) <= until);
|
||
|
|
const manual = listProviders().find(p => p.key === 'manual');
|
||
|
|
const dagen = dailyProvenance(rijen, { assetKind: asset.kind, manualProviderId: manual ? manual.id : null,
|
||
|
|
today: vandaag, from, until });
|
||
|
|
const eenheid = priceUnitLabel(asset);
|
||
|
|
const prijs = (fiatPrice) => getal(priceFromBase(t.toAi({ fiatPrice },{ entity: 'priceHistory' }).fiatPrice, asset));
|
||
|
|
const grens = CONFIG.HYGIENE.SOURCES_DISAGREE_SHARE;
|
||
|
|
|
||
|
|
const perDag = new Map();
|
||
|
|
for (const r of rijen) {
|
||
|
|
const dag = r.observedAt.slice(0, 10);
|
||
|
|
if (dag < from) continue;
|
||
|
|
if (!perDag.has(dag)) perDag.set(dag, []);
|
||
|
|
perDag.get(dag).push(r);
|
||
|
|
}
|
||
|
|
|
||
|
|
const regels = limitedList(dagen.length ? dagen : [], d => {
|
||
|
|
const gebruikt = d.kind === 'none'
|
||
|
|
? 'geen koers'
|
||
|
|
: `${prijs(d.fiatPrice)} (${SOORT_TEKST[d.kind] || d.kind}${d.row ? `, ${sleutelVan(d.row.providerId)}` : ''}`
|
||
|
|
+ `${d.kind === 'carried' ? ` van ${d.row.observedAt.slice(0, 10)}, ${d.ageDays} dagen` : ''})`;
|
||
|
|
const bronnen = (perDag.get(d.day) || []).map(r => {
|
||
|
|
const prijsTekst = r.fiatPrice != null && r.fiatPrice > 0 ? prijs(r.fiatPrice) : 'leeg';
|
||
|
|
const moment = r.resolution === 'timestamp' ? ` spot ${r.observedAt.slice(11, 16)} UTC` : '';
|
||
|
|
return `${sleutelVan(r.providerId)}${moment} ${prijsTekst}`;
|
||
|
|
});
|
||
|
|
const dagrijen = (perDag.get(d.day) || []).filter(r => r.resolution === 'day' && r.fiatPrice > 0);
|
||
|
|
let oneens = '';
|
||
|
|
if (new Set(dagrijen.map(r => r.providerId)).size > 1) {
|
||
|
|
const laag = Math.min(...dagrijen.map(r => r.fiatPrice)), hoog = Math.max(...dagrijen.map(r => r.fiatPrice));
|
||
|
|
const spreiding = hoog / laag - 1;
|
||
|
|
if (spreiding > grens) oneens = ` · BRONNEN ONEENS: ${getal(Math.round(spreiding * 1000) / 10)}%`;
|
||
|
|
}
|
||
|
|
return `${d.day}: ${gebruikt}${bronnen.length ? ` · ${bronnen.join('; ')}` : ''}${oneens}`;
|
||
|
|
}).lines;
|
||
|
|
|
||
|
|
return {
|
||
|
|
text: [
|
||
|
|
`Koersen van ${t.toAi(asset, { entity: 'asset' }).symbol} in ${getBaseCurrency()} per ${eenheid}, ${from} t/m ${until}`
|
||
|
|
+ `${ingekort ? ` (ingekort tot ${MAX_DAGEN} dagen)` : ''}. Per dag: de koers die de app gebruikt met`
|
||
|
|
+ ' herkomst en bron, daarna elke opgeslagen koers per bron. Een spot is een moment, geen slot.',
|
||
|
|
...(regels.length ? regels : ['Geen koersen in dit venster, en geen die ervoor doorgetrokken kan worden.']),
|
||
|
|
].join('\n'),
|
||
|
|
fieldsShared: [...new Set([...t.fieldsShared(), 'baseCurrency', 'symbol', 'kind', 'unit', 'observedAt', 'fiatPrice',
|
||
|
|
'resolution', 'key', 'updatedAt'])].sort(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function gatenAntwoord(asset, t) {
|
||
|
|
const open = listPriceGaps().rows.filter(g => g.assetId === asset.id);
|
||
|
|
const genegeerd = listIgnoredPriceGaps().filter(g => g.assetId === asset.id);
|
||
|
|
const symbool = t.toAi(asset, { entity: 'asset' }).symbol;
|
||
|
|
return {
|
||
|
|
text: [
|
||
|
|
`Gaten in de koershistorie van ${symbool} (dagen zonder koers, vanaf de eerste boeking t/m gisteren,`
|
||
|
|
+ ` langer dan ${CONFIG.HYGIENE.PRICE_GAP_DAYS - 1} dagen):`,
|
||
|
|
...(open.length ? limitedList(open, g => `- ${g.from} t/m ${g.until}, ${g.days} dagen`).lines : ['- geen']),
|
||
|
|
'Door de gebruiker genegeerd (telt als gedekt):',
|
||
|
|
...(genegeerd.length ? limitedList(genegeerd, g => `- ${g.fromDay} t/m ${g.untilDay}`).lines : ['- geen']),
|
||
|
|
].join('\n'),
|
||
|
|
fieldsShared: ['symbol', 'observedAt', 'fromDay', 'untilDay'],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function bronnenAntwoord(asset, t, sleutelVan, uitDienst = false) {
|
||
|
|
const maand = `${new Date().toISOString().slice(0, 7)}-01T00:00`;
|
||
|
|
const etmaal = new Date(Date.now() - DAG_MS).toISOString().slice(0, 16);
|
||
|
|
const providers = listProviders().map(p => getProvider(p.key)).filter(Boolean);
|
||
|
|
const usage = {};
|
||
|
|
for (const p of providers) if (p.callsPerMonth) usage[p.id] = apiMetricsTotals(p.id, maand).calls;
|
||
|
|
const cellen = listPriceSource().filter(c => c.assetId === asset.id);
|
||
|
|
const rijen = listPriceHistory(asset.id);
|
||
|
|
|
||
|
|
const keten = SOURCE_CAPABILITIES.map(cap => {
|
||
|
|
const k = cellen.filter(c => c.capability === cap && c.rank != null).sort((a, b) => a.rank - b.rank)
|
||
|
|
.map(c => { const v = t.toAi({ rank: c.rank, status: c.status }, { entity: 'priceSource' });
|
||
|
|
return `${v.rank}. ${sleutelVan(c.providerId)} (${v.status})`; });
|
||
|
|
return `${cap === 'spot' ? 'Spot' : 'Historie'}: ${k.length ? k.join(', ') : 'geen bron gekozen'}`;
|
||
|
|
});
|
||
|
|
|
||
|
|
const dekkend = providers.filter(p => (p.coverage || []).includes(asset.symbol) || p.key === 'manual');
|
||
|
|
const regels = limitedList(dekkend, p => {
|
||
|
|
const rauw = (p.symbols || []).find(s => !s.isCatchAll && s.symbol === asset.symbol)
|
||
|
|
|| (p.symbols || []).find(s => s.isCatchAll) || null;
|
||
|
|
const kaart = rauw ? t.toAi({ externalId: rauw.externalId, quoteUnit: rauw.quoteUnit }, { entity: 'provider' }) : null;
|
||
|
|
const eindpunten = (p.endpoints || []).map(e => {
|
||
|
|
const v = t.toAi({ capability: e.capability, quoteCurrency: e.quoteCurrency }, { entity: 'provider' });
|
||
|
|
return `${v.capability}${v.quoteCurrency ? ` in ${v.quoteCurrency}` : ''}`;
|
||
|
|
});
|
||
|
|
const eigen = rijen.filter(r => r.providerId === p.id);
|
||
|
|
const laatste = eigen[0] ? eigen[0].observedAt.slice(0, 10) : null;
|
||
|
|
const eerste = eigen.length ? eigen[eigen.length - 1].observedAt.slice(0, 10) : null;
|
||
|
|
const m = apiMetricsTotals(p.id, etmaal);
|
||
|
|
const delen = [
|
||
|
|
p.isActive ? 'actief' : 'uit',
|
||
|
|
eindpunten.length ? eindpunten.join(', ') : 'geen eindpunten',
|
||
|
|
...(kaart ? [`invoer ${kaart.externalId}${kaart.quoteUnit ? `, per ${kaart.quoteUnit}` : ''}`] : []),
|
||
|
|
...(p.callsPerMonth ? [`${usage[p.id] ?? 0} van ${p.callsPerMonth} verzoeken deze maand`] : []),
|
||
|
|
`${m.calls} aanroepen in 24 uur, ${m.fail} mislukt`,
|
||
|
|
eigen.length ? `${eigen.length} koersen, ${eerste} t/m ${laatste}` : 'geen koersen opgeslagen',
|
||
|
|
];
|
||
|
|
return `- ${sleutelVan(p.id)}: ${delen.join('; ')}`;
|
||
|
|
}).lines;
|
||
|
|
|
||
|
|
const historyChain = cellen.filter(c => c.capability === 'history' && c.rank != null)
|
||
|
|
.sort((a, b) => a.rank - b.rank).map(c => providers.find(p => p.id === c.providerId)?.key).filter(Boolean);
|
||
|
|
const bevindingen = diagnoseSources(asset, { providers, historyChain, usage, serviceCovers: uitDienst })
|
||
|
|
.map(b => `- ${b.code}${(b.names || []).length ? ` (${b.names.join(', ')})` : ''}`
|
||
|
|
+ `${b.proposal ? `, voorstel: ${b.proposal.providerKey}` : ''}`);
|
||
|
|
|
||
|
|
return {
|
||
|
|
text: [
|
||
|
|
`Bronnen van ${t.toAi(asset, { entity: 'asset' }).symbol} (koersen worden per gram opgeslagen bij metaal):`,
|
||
|
|
...keten,
|
||
|
|
...(uitDienst ? ['Dienst: historie uit Atreides Services (stand niet uit, symbool gedekt).'] : []),
|
||
|
|
'Bronnen die dit symbool dekken:',
|
||
|
|
...(regels.length ? regels : ['- geen']),
|
||
|
|
'Diagnose van de bronkeuze:',
|
||
|
|
...(bevindingen.length ? bevindingen : ['- in orde']),
|
||
|
|
].join('\n'),
|
||
|
|
fieldsShared: [...new Set([...t.fieldsShared(), 'symbol', 'key', 'isActive', 'capability', 'rank', 'status',
|
||
|
|
'quoteCurrency', 'quoteUnit', 'externalId', 'callsPerMonth', 'okCount', 'failCount', 'observedAt',
|
||
|
|
...(uitDienst ? ['value'] : [])])].sort(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export const PRICE_TOOLS = Object.freeze([priceData]);
|