Files
worldmonitor/tests/imf-country-data.test.mts
Elie Habib 71a6309503 feat(seeds): expand IMF WEO coverage — growth, labor, external themes (#3027) (#3046)
* feat(seeds): expand IMF WEO coverage — growth, labor, external themes (#3027)

Adds three new SDMX-3.0 seeders alongside the existing imf-macro seeder
to surface 15+ additional WEO indicators across ~210 countries at zero
incremental API cost. Bundled into seed-bundle-imf-extended.mjs on the
same monthly Railway cron cadence.

Seeders + Redis keys:
- seed-imf-growth.mjs    → economic:imf:growth:v1
  NGDP_RPCH, NGDPDPC, NGDP_R, PPPPC, PPPGDP, NID_NGDP, NGSD_NGDP
- seed-imf-labor.mjs     → economic:imf:labor:v1
  LUR (unemployment), LP (population)
- seed-imf-external.mjs  → economic:imf:external:v1
  BX, BM, BCA, TM_RPCH, TX_RPCH (+ derived trade balance)
- seed-imf-macro.mjs extended with PCPI, PCPIEPCH, GGX_NGDP, GGXONLB_NGDP

All four seeders share the 35-day TTL (monthly WEO release) and ~210
country coverage via the same imfSdmxFetchIndicator helper.

Wiring:
- api/bootstrap.js, api/health.js, server/_shared/cache-keys.ts —
  register new keys, mark them slow-tier, add SEED_META freshness
  thresholds matching the imfMacro entry (70d = 2× monthly cadence)
- server/worldmonitor/resilience/v1/_dimension-freshness.ts —
  override entries for the dash-vs-colon seed-meta key shape
- _indicator-registry.ts — add LUR as a 4th macroFiscal sub-metric
  (enrichment tier, weight 0.15); rebalance govRevenuePct (0.5→0.4)
  and currentAccountPct (0.3→0.25) so weights still sum to 1.0
- _dimension-scorers.ts — read economic:imf:labor:v1 in scoreMacroFiscal,
  normalize LUR with goalposts 3% (best) → 25% (worst); null-tolerant so
  weightedBlend redistributes when labor data is unavailable
- api/mcp.ts — new get_country_macro tool bundling all four IMF keys
  with a single freshness check; describes per-country fields including
  growth/inflation/labor/BOP for LLM-driven country screening
- src/services/imf-country-data.ts — bootstrap-cached client + pure
  buildImfEconomicIndicators helper
- src/app/country-intel.ts — async-fetch the IMF bundle on country
  selection and merge real GDP growth, CPI inflation, unemployment, and
  GDP/capita rows into the Economic Indicators card; bumps card cap
  from 3 → 6 rows to fit live signals + IMF context

Tests:
- tests/seed-imf-extended.test.mjs — 13 unit tests across the three new
  seeders' pure helpers (canonical keys, ISO3→ISO2 mapping, aggregate
  filtering, derived savings-investment gap & trade balance, validate
  thresholds)
- tests/imf-country-data.test.mts — 6 tests for the panel rendering
  helper, including stagflation flag and high-unemployment trend
- tests/resilience-dimension-scorers.test.mts — new LUR sub-metric test
  (tight vs slack labor); existing scoreMacroFiscal coverage assertions
  updated for the new 4-metric weight split
- tests/helpers/resilience-fixtures.mts — labor fixture for NO/US/YE so
  the existing macroFiscal ordering test still resolves the LUR weight
- tests/bootstrap.test.mjs — register imfGrowth/imfLabor/imfExternal as
  pending consumers (matching imfMacro)
- tests/mcp.test.mjs — bump tools/list count 28 → 29

https://claude.ai/code/session_018enRzZuRqaMudKsLD5RLZv

* fix(resilience): update macroFiscal goldens for LUR weight rebalance

Recompute pinned fixture values after adding labor-unemployment as
4th macroFiscal sub-metric (weight rebalance in _indicator-registry).
Also align seed-imf-external tradeBalance to a single reference year
to avoid mixing ex/im values from different WEO vintages.

* fix(seeds): tighten IMF coverage gates to reject partial snapshots

IMF WEO growth/labor/external indicators report ~210 countries for healthy
runs. Previous thresholds (150/100/150) let a bad IMF run overwrite a good
snapshot with dozens of missing countries and still pass validation.

Raise all three to >=190, matching the pattern of sibling seeders and
leaving a ~20-country margin for indicators with slightly narrower
reporting. Labor validator unions LUR + population (LP), so healthy
coverage tracks LP (~210), not LUR (~100) — the old 100 threshold was
based on a misread of the union logic.

* fix(seed-health): register imf-growth/labor/external seed-meta keys

Missing SEED_DOMAINS entries meant the 3 new IMF WEO seeders (growth, labor,
external) had no /api/seed-health visibility. intervalMin=50400 matches
health.js maxStaleMin/2 (100800/2) — same monthly WEO cadence as imf-macro.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 12:51:35 +04:00

104 lines
3.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildImfEconomicIndicators, type ImfCountryBundle } from '../src/services/imf-country-data.ts';
function bundle(overrides: Partial<ImfCountryBundle> = {}): ImfCountryBundle {
return {
macro: null,
growth: null,
labor: null,
external: null,
fetchedAt: 0,
...overrides,
};
}
describe('buildImfEconomicIndicators (panel rendering)', () => {
it('returns no rows when no IMF data is present', () => {
assert.deepEqual(buildImfEconomicIndicators(bundle()), []);
});
it('renders real GDP growth + inflation + unemployment + GDP/capita rows', () => {
const rows = buildImfEconomicIndicators(bundle({
macro: {
inflationPct: 3.4, currentAccountPct: -2.1, govRevenuePct: 30,
cpiIndex: null, cpiEopPct: null, govExpenditurePct: null, primaryBalancePct: null,
year: 2025,
},
growth: {
realGdpGrowthPct: 2.7, gdpPerCapitaUsd: 78500, realGdp: null,
gdpPerCapitaPpp: null, gdpPpp: null, investmentPct: null, savingsPct: null,
savingsInvestmentGap: null, year: 2025,
},
labor: {
unemploymentPct: 4.2, populationMillions: 333.3, year: 2025,
},
}));
assert.deepEqual(rows.map(r => r.label), [
'Real GDP Growth', 'CPI Inflation', 'Unemployment', 'GDP / Capita',
]);
assert.equal(rows[0].value, '+2.7%');
assert.equal(rows[0].trend, 'up');
assert.equal(rows[1].value, '+3.4%');
assert.equal(rows[1].trend, 'up'); // 3.4% inflation: warning but not crisis
assert.equal(rows[2].value, '4.2%');
assert.equal(rows[2].trend, 'up'); // <5% unemployment is good
assert.equal(rows[3].value, '$78.5k');
for (const row of rows) assert.equal(row.source, 'IMF WEO');
});
it('flags stagflation: rising inflation + contracting growth', () => {
const rows = buildImfEconomicIndicators(bundle({
macro: {
inflationPct: 12, currentAccountPct: null, govRevenuePct: null,
cpiIndex: null, cpiEopPct: null, govExpenditurePct: null, primaryBalancePct: null,
year: 2025,
},
growth: {
realGdpGrowthPct: -1.4, gdpPerCapitaUsd: null, realGdp: null,
gdpPerCapitaPpp: null, gdpPpp: null, investmentPct: null, savingsPct: null,
savingsInvestmentGap: null, year: 2025,
},
}));
const growth = rows.find(r => r.label === 'Real GDP Growth')!;
const infl = rows.find(r => r.label === 'CPI Inflation')!;
assert.equal(growth.value, '-1.4%');
assert.equal(growth.trend, 'down');
assert.equal(infl.value, '+12.0%');
assert.equal(infl.trend, 'down'); // >5% inflation flagged downward
});
it('marks high unemployment with a downward trend', () => {
const rows = buildImfEconomicIndicators(bundle({
labor: { unemploymentPct: 22.5, populationMillions: null, year: 2025 },
}));
const lur = rows.find(r => r.label === 'Unemployment')!;
assert.equal(lur.value, '22.5%');
assert.equal(lur.trend, 'down');
});
it('formats sub-$1k GDP/capita with the dollar prefix', () => {
const rows = buildImfEconomicIndicators(bundle({
growth: {
realGdpGrowthPct: null, gdpPerCapitaUsd: 850, realGdp: null,
gdpPerCapitaPpp: null, gdpPpp: null, investmentPct: null, savingsPct: null,
savingsInvestmentGap: null, year: 2025,
},
}));
const gdp = rows.find(r => r.label === 'GDP / Capita')!;
assert.equal(gdp.value, '$850');
});
it('skips rows whose values are null or non-finite', () => {
const rows = buildImfEconomicIndicators(bundle({
macro: {
inflationPct: NaN, currentAccountPct: null, govRevenuePct: null,
cpiIndex: null, cpiEopPct: null, govExpenditurePct: null, primaryBalancePct: null,
year: 2025,
},
}));
assert.equal(rows.length, 0);
});
});