mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(economic): add National Debt Clock panel with IMF + Treasury data - Proto: GetNationalDebt RPC in EconomicService with NationalDebtEntry message - Seed: seed-national-debt.mjs fetches IMF WEO (debt%, GDP, deficit%) + US Treasury FiscalData in parallel; filters aggregates/territories; sorts by total debt; 35-day TTL for monthly Railway cron - Handler: get-national-debt.ts reads seeded Redis cache key economic:national-debt:v1 - Registry: nationalDebt key added to cache-keys.ts, bootstrap.js (SLOW tier), health.js (maxStaleMin=10080), gateway.ts (daily cache tier) - Service: getNationalDebtData() in economic/index.ts with bootstrap hydration + RPC fallback - Panel: NationalDebtPanel.ts with sort tabs (Total/Debt-GDP/1Y Growth), search, live ticking via direct DOM manipulation (avoids setContent debounce) - Tests: 10 seed formula tests + 8 ticker math tests; all 2064 suite tests green * fix(economic): address code review findings for national debt clock * fix(economic): guard runSeed() call to prevent process.exit in test imports seed-national-debt.mjs called runSeed() at module top-level. When imported by tests (to access computeEntries), the seed ran, hit missing Redis creds in CI, and called process.exit(1), failing the entire test suite. Guard with isMain check so runSeed() only fires on direct execution.
151 lines
5.2 KiB
JavaScript
151 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
|
|
|
|
loadEnvFile(import.meta.url);
|
|
|
|
const IMF_BASE = 'https://www.imf.org/external/datamapper/api/v1';
|
|
const TREASURY_URL = 'https://api.fiscaldata.treasury.gov/services/api/v1/accounting/od/debt_to_penny?fields=record_date,tot_pub_debt_out_amt&sort=-record_date&page[size]=1';
|
|
|
|
const CANONICAL_KEY = 'economic:national-debt:v1';
|
|
const CACHE_TTL = 35 * 24 * 3600; // 35 days — monthly cron with buffer
|
|
|
|
// IMF WEO regional aggregate codes (not real sovereign countries)
|
|
const AGGREGATE_CODES = new Set([
|
|
'ADVEC', 'EMEDE', 'EURO', 'MECA', 'OEMDC', 'WEOWORLD', 'EU',
|
|
'AS5', 'DA', 'EDE', 'MAE', 'OAE', 'SSA', 'WE', 'EMDE', 'G20',
|
|
]);
|
|
|
|
// Overseas territories / non-sovereign entities to exclude
|
|
const TERRITORY_CODES = new Set(['ABW', 'PRI', 'WBG']);
|
|
|
|
function isAggregate(code) {
|
|
if (!code || code.length !== 3) return true;
|
|
return AGGREGATE_CODES.has(code) || TERRITORY_CODES.has(code) || code.endsWith('Q');
|
|
}
|
|
|
|
async function fetchImfIndicator(indicator, periods, timeoutMs) {
|
|
const url = `${IMF_BASE}/${indicator}?periods=${periods}`;
|
|
const resp = await fetch(url, {
|
|
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!resp.ok) throw new Error(`IMF ${indicator}: HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
return data?.values?.[indicator] ?? {};
|
|
}
|
|
|
|
async function fetchTreasury() {
|
|
const resp = await fetch(TREASURY_URL, {
|
|
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!resp.ok) throw new Error(`Treasury API: HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
const record = data?.data?.[0];
|
|
if (!record) return null;
|
|
return {
|
|
date: record.record_date,
|
|
debtUsd: Number(record.tot_pub_debt_out_amt),
|
|
};
|
|
}
|
|
|
|
export function computeEntries(debtPctByCountry, gdpByCountry, deficitPctByCountry, treasuryOverride) {
|
|
const BASELINE_TS = Date.UTC(2024, 0, 1); // 2024-01-01T00:00:00Z
|
|
const SECONDS_PER_YEAR = 365.25 * 86400;
|
|
|
|
const entries = [];
|
|
|
|
for (const [iso3, debtByYear] of Object.entries(debtPctByCountry)) {
|
|
if (isAggregate(iso3)) continue;
|
|
|
|
const gdpByYear = gdpByCountry[iso3];
|
|
if (!gdpByYear) continue;
|
|
|
|
const gdp2024 = Number(gdpByYear['2024']);
|
|
if (!Number.isFinite(gdp2024) || gdp2024 <= 0) continue;
|
|
|
|
const debtPct2024 = Number(debtByYear['2024']);
|
|
const debtPct2023 = Number(debtByYear['2023']);
|
|
const hasDebt2024 = Number.isFinite(debtPct2024) && debtPct2024 > 0;
|
|
const hasDebt2023 = Number.isFinite(debtPct2023) && debtPct2023 > 0;
|
|
|
|
if (!hasDebt2024 && !hasDebt2023) continue;
|
|
|
|
const effectiveDebtPct = hasDebt2024 ? debtPct2024 : debtPct2023;
|
|
const gdpUsd = gdp2024 * 1e9;
|
|
let debtUsd = (effectiveDebtPct / 100) * gdpUsd;
|
|
|
|
// Override USA with live Treasury data when available
|
|
if (iso3 === 'USA' && treasuryOverride && treasuryOverride.debtUsd > 0) {
|
|
debtUsd = treasuryOverride.debtUsd;
|
|
}
|
|
|
|
let annualGrowth = 0;
|
|
if (hasDebt2024 && hasDebt2023) {
|
|
annualGrowth = ((debtPct2024 - debtPct2023) / debtPct2023) * 100;
|
|
}
|
|
|
|
const deficitByYear = deficitPctByCountry[iso3];
|
|
const deficitPct2024 = deficitByYear ? Number(deficitByYear['2024']) : NaN;
|
|
let perSecondRate = 0;
|
|
let perDayRate = 0;
|
|
// Only accrue when running a deficit (GGXCNL_NGDP < 0 = net borrower).
|
|
// Surplus countries (Norway, Kuwait, Singapore, etc.) tick at 0 — not upward.
|
|
if (Number.isFinite(deficitPct2024) && deficitPct2024 < 0) {
|
|
const deficitAbs = (Math.abs(deficitPct2024) / 100) * gdpUsd;
|
|
perSecondRate = deficitAbs / SECONDS_PER_YEAR;
|
|
perDayRate = deficitAbs / 365.25;
|
|
}
|
|
|
|
entries.push({
|
|
iso3,
|
|
debtUsd,
|
|
gdpUsd,
|
|
debtToGdp: effectiveDebtPct,
|
|
annualGrowth,
|
|
perSecondRate,
|
|
perDayRate,
|
|
baselineTs: BASELINE_TS,
|
|
source: iso3 === 'USA' && treasuryOverride ? 'IMF WEO + US Treasury FiscalData' : 'IMF WEO 2024',
|
|
});
|
|
}
|
|
|
|
entries.sort((a, b) => b.debtUsd - a.debtUsd);
|
|
return entries;
|
|
}
|
|
|
|
async function fetchNationalDebt() {
|
|
const [debtPctData, gdpData, deficitData, treasury] = await Promise.all([
|
|
fetchImfIndicator('GGXWDG_NGDP', '2023,2024', 30_000),
|
|
fetchImfIndicator('NGDPD', '2024', 30_000),
|
|
fetchImfIndicator('GGXCNL_NGDP', '2024', 30_000),
|
|
fetchTreasury().catch(() => null),
|
|
]);
|
|
|
|
const entries = computeEntries(debtPctData, gdpData, deficitData, treasury);
|
|
|
|
return {
|
|
entries,
|
|
seededAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function validate(data) {
|
|
return Array.isArray(data?.entries) && data.entries.length >= 100;
|
|
}
|
|
|
|
// Guard: only run seed when executed directly, not when imported by tests
|
|
if (process.argv[1]?.endsWith('seed-national-debt.mjs')) {
|
|
runSeed('economic', 'national-debt', CANONICAL_KEY, fetchNationalDebt, {
|
|
validateFn: validate,
|
|
ttlSeconds: CACHE_TTL,
|
|
sourceVersion: 'imf-weo-2024',
|
|
recordCount: (data) => data?.entries?.length ?? 0,
|
|
}).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);
|
|
});
|
|
}
|