Files
AtreidesAssetsFree/server/mcp/price-tool.mjs
T
2026-10-29 11:57:29 +01:00

218 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]);