#!/usr/bin/env node import { loadEnvFile, CHROME_UA, runSeed, imfSdmxFetchIndicator } from './_seed-utils.mjs'; loadEnvFile(import.meta.url); 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'; // 65 days — must exceed health.js SEED_META.nationalDebt.maxStaleMin (60d) so // a missed monthly cron keeps the canonical payload readable through the // STALE_SEED warn window instead of vanishing into EMPTY crit at day 35. // writeFreshnessMetadata() uses max(7d, ttlSeconds) → meta TTL tracks this. const CACHE_TTL = 65 * 24 * 3600; // 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 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), }; } function deriveWeoYear(debtPctByCountry) { let maxYear = 0; for (const byYear of Object.values(debtPctByCountry || {})) { for (const [yearStr, value] of Object.entries(byYear || {})) { const y = Number(yearStr); const v = Number(value); if (Number.isFinite(y) && y > maxYear && Number.isFinite(v) && v > 0) { maxYear = y; } } } return maxYear > 0 ? maxYear : null; } function latestYearWithValue(byYear) { if (!byYear) return null; let best = null; for (const [yearStr, value] of Object.entries(byYear)) { const y = Number(yearStr); const v = Number(value); if (Number.isFinite(y) && Number.isFinite(v) && v > 0 && (best === null || y > best)) { best = y; } } return best; } export function computeEntries(debtPctByCountry, gdpByCountry, deficitPctByCountry, treasuryOverride) { const SECONDS_PER_YEAR = 365.25 * 86400; const weoYear = deriveWeoYear(debtPctByCountry); const weoLabel = weoYear ? `IMF WEO ${weoYear}` : 'IMF WEO'; // Baseline = Jan 1 of the vintage year so the live ticker advances from a // sensible anchor once a newer WEO vintage lands. const BASELINE_TS = Date.UTC(weoYear ?? new Date().getUTCFullYear(), 0, 1); const entries = []; for (const [iso3, debtByYear] of Object.entries(debtPctByCountry)) { if (isAggregate(iso3)) continue; const gdpByYear = gdpByCountry[iso3]; if (!gdpByYear) continue; const latestDebtYear = latestYearWithValue(debtByYear); if (latestDebtYear === null) continue; const gdpYear = latestYearWithValue(gdpByYear) ?? latestDebtYear; const gdpLatest = Number(gdpByYear[String(gdpYear)]); if (!Number.isFinite(gdpLatest) || gdpLatest <= 0) continue; const effectiveDebtPct = Number(debtByYear[String(latestDebtYear)]); const prevYear = String(latestDebtYear - 1); const prevDebtPct = Number(debtByYear[prevYear]); const hasPrev = Number.isFinite(prevDebtPct) && prevDebtPct > 0; const gdpUsd = gdpLatest * 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 (hasPrev) { annualGrowth = ((effectiveDebtPct - prevDebtPct) / prevDebtPct) * 100; } const deficitByYear = deficitPctByCountry[iso3]; const deficitPct2024 = deficitByYear ? Number(deficitByYear[String(latestDebtYear)] ?? deficitByYear[prevYear]) : 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 ? `${weoLabel} + US Treasury FiscalData` : weoLabel, }); } entries.sort((a, b) => b.debtUsd - a.debtUsd); return entries; } // Rolling 4-year window: two historical, current, one forward. WEO publishes // the current-year vintage mid-year and forecasts forward — this keeps the // seed picking up newer vintages without manual edits. export function weoYearWindow(now = new Date()) { const y = now.getUTCFullYear(); return [String(y - 2), String(y - 1), String(y), String(y + 1)]; } async function fetchNationalDebt() { const years = weoYearWindow(); const [debtPctData, gdpData, deficitData, treasury] = await Promise.all([ imfSdmxFetchIndicator('GGXWDG_NGDP', { years }), imfSdmxFetchIndicator('NGDPD', { years }), imfSdmxFetchIndicator('GGXCNL_NGDP', { years }), 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 export function declareRecords(data) { return Array.isArray(data?.entries) ? data.entries.length : 0; } if (process.argv[1]?.endsWith('seed-national-debt.mjs')) { runSeed('economic', 'national-debt', CANONICAL_KEY, fetchNationalDebt, { validateFn: validate, ttlSeconds: CACHE_TTL, sourceVersion: 'imf-sdmx-weo-2024', recordCount: (data) => data?.entries?.length ?? 0, declareRecords, schemaVersion: 1, // Matches api/health.js SEED_META.nationalDebt (60d = 2× monthly interval). // runSeed only validates the field is present; health.js is the actual // alarm source, but keeping these in sync prevents future drift. maxStaleMin: 86400, }).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); }); }