Files
worldmonitor/tests/seed-imf-extended.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

196 lines
7.8 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildGrowthCountries,
isAggregate as isAggregateGrowth,
latestValue as latestValueGrowth,
validate as validateGrowth,
CANONICAL_KEY as GROWTH_KEY,
CACHE_TTL as GROWTH_TTL,
} from '../scripts/seed-imf-growth.mjs';
import {
buildLaborCountries,
validate as validateLabor,
CANONICAL_KEY as LABOR_KEY,
CACHE_TTL as LABOR_TTL,
} from '../scripts/seed-imf-labor.mjs';
import {
buildExternalCountries,
validate as validateExternal,
CANONICAL_KEY as EXTERNAL_KEY,
CACHE_TTL as EXTERNAL_TTL,
} from '../scripts/seed-imf-external.mjs';
const YEAR = String(new Date().getFullYear());
describe('seed-imf shared helpers', () => {
it('isAggregate flags WEO regional aggregates and rejects 2-letter codes', () => {
assert.equal(isAggregateGrowth('USA'), false);
assert.equal(isAggregateGrowth('GBR'), false);
assert.equal(isAggregateGrowth('EUROQ'), true); // ends with Q
assert.equal(isAggregateGrowth('WEOWORLD'), true);
assert.equal(isAggregateGrowth('EU'), true); // not 3-letter
assert.equal(isAggregateGrowth(''), true);
assert.equal(isAggregateGrowth('G20'), true);
});
it('latestValue picks the most recent finite year-keyed value', () => {
const y = Number(YEAR);
const series = { [`${y - 2}`]: 1.1, [`${y - 1}`]: 2.2 };
const result = latestValueGrowth(series);
assert.deepEqual(result, { value: 2.2, year: y - 1 });
assert.equal(latestValueGrowth({}), null);
assert.equal(latestValueGrowth({ [`${y}`]: 'NaN' }), null);
});
});
describe('seed-imf-growth', () => {
it('uses the v1 economic:imf:growth canonical key with 35-day TTL', () => {
assert.equal(GROWTH_KEY, 'economic:imf:growth:v1');
assert.equal(GROWTH_TTL, 35 * 24 * 3600);
});
it('buildGrowthCountries maps ISO3 → ISO2, drops aggregates, and computes savings-investment gap', () => {
const countries = buildGrowthCountries({
realGdpGrowth: { USA: { [YEAR]: 2.5 }, GBR: { [YEAR]: 1.1 }, WEOWORLD: { [YEAR]: 3 } },
nominalGdpPerCapita: { USA: { [YEAR]: 80000 }, GBR: { [YEAR]: 50000 } },
realGdp: { USA: { [YEAR]: 22000 } },
pppPerCapita: { USA: { [YEAR]: 80000 }, GBR: { [YEAR]: 55000 } },
pppGdp: { USA: { [YEAR]: 27000 } },
investmentPct: { USA: { [YEAR]: 21 }, GBR: { [YEAR]: 17.5 } },
savingsPct: { USA: { [YEAR]: 18 }, GBR: { [YEAR]: 14 } },
});
assert.ok(countries.US, 'USA → US');
assert.equal(countries.US.realGdpGrowthPct, 2.5);
assert.equal(countries.US.gdpPerCapitaUsd, 80000);
assert.equal(countries.US.savingsInvestmentGap, -3);
assert.equal(countries.US.year, Number(YEAR));
assert.ok(countries.GB, 'GBR → GB');
assert.equal(countries.GB.savingsInvestmentGap, -3.5);
// Aggregates dropped (no entry for WEOWORLD).
assert.ok(!('WEOWORLD' in countries));
assert.ok(!('WW' in countries));
});
it('buildGrowthCountries omits countries with no usable data', () => {
const countries = buildGrowthCountries({
realGdpGrowth: { USA: { '1970': 2 } }, // year falls outside weoYears window
});
assert.ok(!('US' in countries), 'no IMF series for current window → no entry');
});
it('validate accepts 190+ countries and rejects partial snapshots', () => {
const countries = {};
for (let i = 0; i < 200; i++) countries[`X${i}`] = { realGdpGrowthPct: 1, year: 2025 };
assert.equal(validateGrowth({ countries }), true);
const partial = {};
for (let i = 0; i < 170; i++) partial[`X${i}`] = { realGdpGrowthPct: 1, year: 2025 };
assert.equal(validateGrowth({ countries: partial }), false, 'rejects 170 countries (dozens missing)');
assert.equal(validateGrowth({ countries: {} }), false);
assert.equal(validateGrowth(null), false);
});
});
describe('seed-imf-labor', () => {
it('uses the v1 labor canonical key with 35-day TTL', () => {
assert.equal(LABOR_KEY, 'economic:imf:labor:v1');
assert.equal(LABOR_TTL, 35 * 24 * 3600);
});
it('buildLaborCountries surfaces unemployment and population per ISO2', () => {
const countries = buildLaborCountries({
unemployment: { USA: { [YEAR]: 4.1 }, FRA: { [YEAR]: 7.5 } },
population: { USA: { [YEAR]: 333.3 }, FRA: { [YEAR]: 67.9 }, ZAF: { [YEAR]: 60.2 } },
});
assert.deepEqual(countries.US, {
unemploymentPct: 4.1, populationMillions: 333.3, year: Number(YEAR),
});
assert.deepEqual(countries.FR, {
unemploymentPct: 7.5, populationMillions: 67.9, year: Number(YEAR),
});
// South Africa: only population (no LUR); still included.
assert.deepEqual(countries.ZA, {
unemploymentPct: null, populationMillions: 60.2, year: Number(YEAR),
});
});
it('validate accepts 190+ countries and rejects partial snapshots', () => {
const countries = {};
for (let i = 0; i < 200; i++) countries[`X${i}`] = { populationMillions: 10, year: 2025 };
assert.equal(validateLabor({ countries }), true);
const partial = {};
for (let i = 0; i < 170; i++) partial[`X${i}`] = { populationMillions: 10, year: 2025 };
assert.equal(validateLabor({ countries: partial }), false, 'rejects 170 countries (dozens missing)');
const sparse = {};
for (let i = 0; i < 50; i++) sparse[`X${i}`] = { unemploymentPct: 5, year: 2025 };
assert.equal(validateLabor({ countries: sparse }), false);
});
});
describe('seed-imf-external', () => {
it('uses the v1 external canonical key with 35-day TTL', () => {
assert.equal(EXTERNAL_KEY, 'economic:imf:external:v1');
assert.equal(EXTERNAL_TTL, 35 * 24 * 3600);
});
it('buildExternalCountries maps current account + volume changes and nulls out legacy BX/BM fields', () => {
// BX/BM (export/import levels in USD) were removed 2026-04 — WEO coverage
// dropped to ~10 countries on those indicators, collapsing the result
// below the validate floor. Fields remain on the output as explicit null
// so downstream consumers see a deliberate gap rather than a missing key.
const countries = buildExternalCountries({
currentAccount: { USA: { [YEAR]: -800 }, DEU: { [YEAR]: 250 } },
importVol: { USA: { [YEAR]: 4.2 } },
exportVol: { USA: { [YEAR]: 3.1 } },
});
assert.equal(countries.US.exportsUsd, null);
assert.equal(countries.US.importsUsd, null);
assert.equal(countries.US.tradeBalanceUsd, null);
assert.equal(countries.US.currentAccountUsd, -800);
assert.equal(countries.US.importVolumePctChg, 4.2);
assert.equal(countries.US.exportVolumePctChg, 3.1);
// Germany has only currentAccount — still included.
assert.equal(countries.DE.currentAccountUsd, 250);
assert.equal(countries.DE.importVolumePctChg, null);
});
it('buildExternalCountries drops countries with no usable indicator data', () => {
const countries = buildExternalCountries({
currentAccount: { USA: { [YEAR]: -800 } },
importVol: {},
exportVol: {},
});
assert.equal(Object.keys(countries).length, 1);
assert.ok(countries.US);
});
it('validate gates >=180 country coverage (relaxed from 190 after BX/BM removal)', () => {
const countries = {};
for (let i = 0; i < 200; i++) countries[`X${i}`] = { currentAccountUsd: 1, year: 2025 };
assert.equal(validateExternal({ countries }), true);
// 180 is the new minimum (BCA ~209 / TM ~189 / TX ~190; union floor).
const at180 = {};
for (let i = 0; i < 180; i++) at180[`X${i}`] = { currentAccountUsd: 1, year: 2025 };
assert.equal(validateExternal({ countries: at180 }), true, 'exactly 180 passes');
const partial = {};
for (let i = 0; i < 170; i++) partial[`X${i}`] = { currentAccountUsd: 1, year: 2025 };
assert.equal(validateExternal({ countries: partial }), false, 'rejects 170 countries');
assert.equal(validateExternal({ countries: {} }), false);
});
});