Files
worldmonitor/scripts/seed-imf-macro.mjs
Elie Habib d64172e67e fix(resilience): calibrate scoring anchors — gov revenue, GPI, inflation cap (#2769)
* fix(resilience): calibrate scoring anchors — gov revenue, GPI, inflation cap

Three evidence-based anchor fixes resolving score compression and fragile-state
inflation (Haiti 56→expected ≤35, Somalia ~50→near-0 on next seed run):

1. Replace debt/GDP with gov revenue/GDP in scoreMacroFiscal (weight=0.5)
   Debt/GDP is gamed by HIPC relief: Somalia (5% debt post-cancellation) and Haiti
   (15%) scored near-100 on fiscal resilience despite being credit-excluded. Gov
   revenue as % GDP (IMF GGR_NGDP, anchor 5%=0 → 45%=100) directly measures fiscal
   capacity. seed-imf-macro.mjs now fetches GGR_NGDP alongside PCPIPCH + BCA_NGDPD.

2. Fix GPI anchor in scoreSocialCohesion: worst 4.0 → 3.6
   Empirical GPI range is 1.1–3.4 (2024). Yemen (3.4) was scoring 20/100 instead of
   near-0. With anchor 3.6, Yemen scores 8, Somalia scores 19, Haiti scores 30.

3. Tighten inflation proxy cap in scoreCurrencyExternal: 100% → 50%
   50%+ annual inflation is already catastrophic instability. Tighter cap better
   differentiates fragile states: Haiti 39% drops from score 61 → 22.

Research basis: INFORM Risk Index, FSI methodology, ND-GAIN, World Bank silent debt
crisis documentation, OECD States of Fragility 2022 normalization guidelines.

* fix(seed): use GGR_G01_GDP_PT for gov revenue — GGR_NGDP returns empty payload

GGR_NGDP is a WEO indicator not exposed in the DataMapper values API;
GGR_G01_GDP_PT (Fiscal Monitor) returns 212 countries and covers the
same concept: general government revenue as % of GDP.

* fix(resilience): bump imf-macro key to v2 — atomise govRevenuePct rollout

v1 was seeded 35-day TTL by PR #2766 without govRevenuePct; the seeder
would skip the stale-check and never write the new field until expiry.
v2 forces a clean seed on first Railway deploy, ensuring the scorer's
0.5-weight fiscal-capacity leg is always backed by real data.

* fix(health): bump imfMacro key to v2 in health.js

Missed in the key-bump commit; health endpoint was still checking the
old v1 key and would report imfMacro as permanently stale after v2 is
seeded.
2026-04-06 20:26:02 +04:00

106 lines
3.8 KiB
JavaScript

#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed, loadSharedConfig } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const IMF_BASE = 'https://www.imf.org/external/datamapper/api/v1';
const CANONICAL_KEY = 'economic:imf:macro:v2';
const CACHE_TTL = 35 * 24 * 3600; // 35 days — monthly IMF WEO release
// Invert iso2→iso3 map to convert IMF's ISO3 codes to our ISO2 keys.
// loadSharedConfig tries ../shared/ (local dev) then ./shared/ (Railway rootDirectory=scripts).
const ISO2_TO_ISO3 = loadSharedConfig('iso2-to-iso3.json');
const ISO3_TO_ISO2 = Object.fromEntries(Object.entries(ISO2_TO_ISO3).map(([k, v]) => [v, k]));
// IMF WEO regional aggregate and non-sovereign codes
const AGGREGATE_CODES = new Set([
'ADVEC', 'EMEDE', 'EURO', 'MECA', 'OEMDC', 'WEOWORLD', 'EU',
'AS5', 'DA', 'EDE', 'MAE', 'OAE', 'SSA', 'WE', 'EMDE', 'G20',
]);
function isAggregate(code) {
if (!code || code.length !== 3) return true;
return AGGREGATE_CODES.has(code) || code.endsWith('Q');
}
// Request the three most-recent years at call time so the monthly cron always picks up the
// latest WEO vintage without requiring a code edit (e.g. 2025,2024,2023 once 2025 publishes).
function weoYears() {
const y = new Date().getFullYear();
return [`${y}`, `${y - 1}`, `${y - 2}`];
}
async function fetchImfIndicator(indicator) {
const url = `${IMF_BASE}/${indicator}?periods=${weoYears().join(',')}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`IMF ${indicator}: HTTP ${resp.status}`);
const data = await resp.json();
return data?.values?.[indicator] ?? {};
}
// Pick the most recent year with a finite value, searching newest-first.
function latestValue(byYear) {
for (const year of weoYears()) {
const v = Number(byYear?.[year]);
if (Number.isFinite(v)) return { value: v, year: Number(year) };
}
return null;
}
async function fetchImfMacro() {
const [inflationData, currentAccountData, govRevenueData] = await Promise.all([
fetchImfIndicator('PCPIPCH'), // CPI inflation, annual % change
fetchImfIndicator('BCA_NGDPD'), // Current account balance, % of GDP
fetchImfIndicator('GGR_G01_GDP_PT'), // General government revenue, % of GDP (Fiscal Monitor)
]);
const countries = {};
const allIso3 = new Set([
...Object.keys(inflationData),
...Object.keys(currentAccountData),
...Object.keys(govRevenueData),
]);
for (const iso3 of allIso3) {
if (isAggregate(iso3)) continue;
const iso2 = ISO3_TO_ISO2[iso3];
if (!iso2) continue;
const infl = latestValue(inflationData[iso3]);
const ca = latestValue(currentAccountData[iso3]);
const rev = latestValue(govRevenueData[iso3]);
if (!infl && !ca && !rev) continue;
countries[iso2] = {
inflationPct: infl?.value ?? null,
currentAccountPct: ca?.value ?? null,
govRevenuePct: rev?.value ?? null,
year: infl?.year ?? ca?.year ?? rev?.year ?? null,
};
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 150;
}
// Guard: only run when executed directly, not when imported by tests
if (process.argv[1]?.endsWith('seed-imf-macro.mjs')) {
runSeed('economic', 'imf-macro', CANONICAL_KEY, fetchImfMacro, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `imf-weo-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}