mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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.
106 lines
3.8 KiB
JavaScript
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);
|
|
});
|
|
}
|