Files
worldmonitor/tests/gold-etf-flows-seed.test.mjs
Elie Habib 30ddad28d7 fix(seeds): upstream API drift — SPDR XLSX + IMF IRFCL + IMF-External BX/BM drop (#3076)
* 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.
2026-04-14 08:19:47 +04:00

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);
});
});