mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(seeds): gold-etf XLSX migration, IRFCL dataflow, imf-external BX/BM drop Three upstream-drift regressions caught from the market-backup + imf-extended bundle logs. Root causes validated by live API probes before coding. 1. seed-gold-etf-flows: SPDR /assets/dynamic/GLD/GLD_US_archive_EN.csv now silently returns a PDF (Content-Type: application/pdf) — site migrated to api.spdrgoldshares.com/api/v1/historical-archive which serves XLSX. Swapped the CSV parser for an exceljs-based XLSX parser. Adds browser-ish Origin/Referer headers (SPDR swaps payload for PDF without them) and a Content-Type guard. Column layout: Date | Closing | ... | Tonnes | Total NAV USD. 2. seed-gold-cb-reserves: PR #3038 shipped with IMF.STA/IFS dataflow and 3-segment key M..<indicator> — both wrong. IFS isn't exposed on api.imf.org (HTTP 204). Gold-reserves data lives under IMF.STA/IRFCL with 4 dimensions (COUNTRY.INDICATOR.SECTOR.FREQUENCY). Verified live: *.IRFCLDT1_IRFCL56_FTO.*.M returns 111 series. Switched to IRFCL + IRFCLDT1_IRFCL56_FTO (fine troy ounces) and fallbacks. The valueIsOunces flag now matches _FTO suffix (keeps legacy _OZT/OUNCE detection for backward compat). 3. seed-imf-external: BX/BM (export/import LEVELS, USD) WEO coverage collapsed to ~10 countries in late 2026 — the seeder's >=190-country validate floor was failing every run. Dropped BX/BM from fetch + join; kept BCA (~209) / TM_RPCH (~189) / TX_RPCH (~190). exportsUsd / importsUsd / tradeBalanceUsd fields kept as explicit null so consumers see a deliberate gap. validate floor lowered to 180 (BCA∪TM∪TX union). Tests: 32/32 pass. Rewrote gold-etf tests to use synthetic XLSX fixtures (exceljs resolved from scripts/package.json since repo root doesn't have it). Updated imf-external tests for the new indicator set + null BX/BM contract + 180-country validate threshold. * fix(mcp): update get_country_macro description after BX/BM drop Consumer-side catch during PR #3076 validation: the MCP tool description still promised 'exports, imports, trade balance' fields that the seeder fix nulls out. LLM consumers would be directed to exportsUsd/importsUsd/ tradeBalanceUsd fields that always return null since seed-imf-external dropped BX/BM (WEO coverage collapsed to ~10 countries). Updated description to list only the indicators actually populated (currentAccountUsd, importVolumePctChg, exportVolumePctChg) with an explicit note about the null trade-level fields so LLMs don't attempt to use them. * fix(gold-cb-reserves): compute real pctOfReserves + add exceljs to root Follow-up to #3076 review. 1. pctOfReserves was hardcoded to 0 with a "IFS doesn't give us total reserves" comment. That was a lazy limitation claim — IMF IRFCL DOES expose total official reserve assets as IRFCLDT1_IRFCL65_USD parallel to the gold USD series IRFCLDT1_IRFCL56_USD. fetchCbReserves now pulls all three indicators (primary FTO tonnage + the two USD series) via Promise.allSettled and passes the USD pair to buildReservesPayload so it can compute the true gold share per country. Falls back to 0 only when the denominator is genuinely missing for that country (IRFCL coverage: 114 gold_usd, 96 total_usd series; ~15% of holders have no matched-month denominator). 3-month lookback window absorbs per-country reporting lag. 2. CI fix: tests couldn't find exceljs because scripts/package.json is not workspace-linked to the repo root — CI runs `npm ci` at root only. Added exceljs@^4.4.0 as a root devDependency. Runtime seeder continues to resolve it from scripts/node_modules via Node's upward module resolution. 3 new tests cover pct computation, missing-denominator fallback, and the 3-month lookback window.
146 lines
5.6 KiB
JavaScript
146 lines
5.6 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createRequire } from 'node:module';
|
|
|
|
import { parseGldArchiveXlsx, computeFlows } from '../scripts/seed-gold-etf-flows.mjs';
|
|
|
|
// exceljs lives in scripts/node_modules (not the repo root) — resolve from
|
|
// the scripts package the seeder itself ships from.
|
|
const require = createRequire(new URL('../scripts/package.json', import.meta.url));
|
|
|
|
// Build a synthetic XLSX in memory that mirrors the real SPDR layout:
|
|
// sheet "US GLD Historical Archive"
|
|
// row 1 = headers; col 1=Date, 2=Close, 10=Tonnes, 11=AUM
|
|
async function buildSyntheticXlsx(rows, sheetName = 'US GLD Historical Archive') {
|
|
const ExcelJS = require('exceljs');
|
|
const wb = new ExcelJS.Workbook();
|
|
wb.addWorksheet('Disclaimer').addRow(['SPDR GOLD SHARES DISCLAIMER — synthetic test data']);
|
|
const ws = wb.addWorksheet(sheetName);
|
|
ws.addRow([
|
|
'Date', 'Closing Price', 'Ounces of Gold per Share', 'NAV/Share',
|
|
'IOPV', 'Mid', 'Premium/Discount', 'Volume',
|
|
'Total Ounces', 'Tonnes of Gold', 'Total Net Asset Value',
|
|
]);
|
|
for (const r of rows) {
|
|
ws.addRow([r.date, r.nav ?? 0, 0, 0, 0, 0, 0, 0, 0, r.tonnes ?? 0, r.aum ?? 0]);
|
|
}
|
|
return Buffer.from(await wb.xlsx.writeBuffer());
|
|
}
|
|
|
|
// Parser guards on rowCount < 10 to reject nearly-empty sheets; tests that
|
|
// want data back must supply ≥ 9 data rows.
|
|
function daysAgoIso(n) {
|
|
const d = new Date();
|
|
d.setUTCDate(d.getUTCDate() - n);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function spdrDate(iso) {
|
|
const d = new Date(iso + 'T00:00:00Z');
|
|
const mon = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getUTCMonth()];
|
|
return `${String(d.getUTCDate()).padStart(2,'0')}-${mon}-${d.getUTCFullYear()}`;
|
|
}
|
|
|
|
async function buildHistoricalXlsx(n, { tonnesBase = 900, aumBase = 90e9, navBase = 78 } = {}) {
|
|
const rows = [];
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
const iso = daysAgoIso(i);
|
|
rows.push({ date: spdrDate(iso), nav: navBase + i * 0.01, tonnes: tonnesBase + i, aum: aumBase + i * 1e6 });
|
|
}
|
|
return buildSyntheticXlsx(rows);
|
|
}
|
|
|
|
describe('seed-gold-etf-flows: parseGldArchiveXlsx', () => {
|
|
it('parses the real SPDR column layout (Date=1, Close=2, Tonnes=10, AUM=11)', async () => {
|
|
const buf = await buildHistoricalXlsx(15);
|
|
const rows = await parseGldArchiveXlsx(buf);
|
|
assert.equal(rows.length, 15);
|
|
// Sorted ascending
|
|
for (let i = 1; i < rows.length; i++) assert.ok(rows[i - 1].date <= rows[i].date);
|
|
// Column mapping sanity
|
|
assert.ok(rows[0].tonnes > 0);
|
|
assert.ok(rows[0].aum > 0);
|
|
assert.ok(rows[0].nav > 0);
|
|
});
|
|
|
|
it('accepts "DD-MMM-YYYY" dates (real SPDR format)', async () => {
|
|
const buf = await buildSyntheticXlsx(
|
|
Array.from({ length: 11 }, (_, i) => ({ date: `${String(i + 1).padStart(2,'0')}-Nov-2004`, tonnes: 8 + i * 0.1, aum: 1e8, nav: 44 })),
|
|
);
|
|
const rows = await parseGldArchiveXlsx(buf);
|
|
assert.ok(rows.length >= 11);
|
|
assert.ok(rows[0].date.startsWith('2004-11-'), `got ${rows[0].date}`);
|
|
});
|
|
|
|
it('skips rows with zero or negative tonnage', async () => {
|
|
const good = Array.from({ length: 11 }, (_, i) => ({ date: spdrDate(daysAgoIso(i + 3)), tonnes: 900 + i }));
|
|
const bad = [
|
|
{ date: spdrDate(daysAgoIso(1)), tonnes: 0 },
|
|
{ date: spdrDate(daysAgoIso(2)), tonnes: -5 },
|
|
];
|
|
const buf = await buildSyntheticXlsx([...good, ...bad]);
|
|
const rows = await parseGldArchiveXlsx(buf);
|
|
assert.equal(rows.length, 11, 'zero/negative tonnage rows dropped');
|
|
});
|
|
|
|
it('returns empty when the data sheet has fewer than 10 rows (too little to trust)', async () => {
|
|
const buf = await buildSyntheticXlsx([
|
|
{ date: '10-Apr-2026', tonnes: 905.20 },
|
|
{ date: '09-Apr-2026', tonnes: 904.10 },
|
|
]);
|
|
const rows = await parseGldArchiveXlsx(buf);
|
|
assert.equal(rows.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('seed-gold-etf-flows: computeFlows', () => {
|
|
const buildHistory = (tonnesFn) => {
|
|
const out = [];
|
|
const start = new Date('2025-04-15T00:00:00Z');
|
|
for (let i = 0; i < 260; i++) {
|
|
const d = new Date(start.getTime() + i * 86400000);
|
|
out.push({ date: d.toISOString().slice(0, 10), tonnes: tonnesFn(i), aum: 0, nav: 0 });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
it('returns null on empty history', () => {
|
|
assert.equal(computeFlows([]), null);
|
|
});
|
|
|
|
it('computes 1W / 1M / 1Y tonnage deltas correctly', () => {
|
|
const history = buildHistory(i => 800 + i);
|
|
const flows = computeFlows(history);
|
|
// latest = 800 + 259 = 1059; 5d ago = 1054 → +5 tonnes; 21d ago = 1038 → +21; 252d ago = 807 → +252
|
|
assert.equal(flows.tonnes, 1059);
|
|
assert.equal(flows.changeW1Tonnes, 5);
|
|
assert.equal(flows.changeM1Tonnes, 21);
|
|
assert.equal(flows.changeY1Tonnes, 252);
|
|
});
|
|
|
|
it('sparkline is last 90 days of tonnage', () => {
|
|
const history = buildHistory(i => 800 + i);
|
|
const flows = computeFlows(history);
|
|
assert.equal(flows.sparkline90d.length, 90);
|
|
assert.equal(flows.sparkline90d[0], 800 + 170);
|
|
assert.equal(flows.sparkline90d[89], 1059);
|
|
});
|
|
|
|
it('handles short histories (<252 days) without crashing', () => {
|
|
const history = buildHistory(i => 800 + i).slice(0, 10);
|
|
const flows = computeFlows(history);
|
|
assert.ok(flows !== null);
|
|
assert.ok(Number.isFinite(flows.changeW1Tonnes));
|
|
assert.ok(Number.isFinite(flows.changeY1Tonnes));
|
|
});
|
|
|
|
it('percent deltas are zero when baseline is zero', () => {
|
|
const history = [
|
|
{ date: '2026-04-09', tonnes: 0, aum: 0, nav: 0 },
|
|
{ date: '2026-04-10', tonnes: 900, aum: 0, nav: 0 },
|
|
];
|
|
const flows = computeFlows(history);
|
|
assert.equal(flows.changeW1Pct, 0);
|
|
});
|
|
});
|