Files
worldmonitor/scripts/seed-national-debt.mjs
Elie Habib 661bbe8f09 fix(health): nationalDebt threshold 7d → 60d — match monthly cron interval (#3237)
* fix(health): nationalDebt threshold 7d → 60d to match monthly cron cadence

User reported health showing:
  "nationalDebt": { status: "STALE_SEED", records: 187, seedAgeMin: 10469, maxStaleMin: 10080 }

Root cause: api/health.js had `maxStaleMin: 10080` (7 days) on a seeder
that runs every 30 days via seed-bundle-macro.mjs:
  { label: 'National-Debt', intervalMs: 30 * DAY, ... }

The threshold was narrower than the cron interval, so every month
between days 8–30 it guaranteed STALE_SEED. Original comment
"7 days — monthly seed" even spelled the mismatch out loud.

Data source cadence:
- US Treasury debt_to_penny API: updates daily but we only snapshot latest
- IMF WEO: quarterly/semi-annual release — no value in checking daily
- 30-day cron is appropriate; stale threshold should be ≥ 2× interval

Fix: bump maxStaleMin to 86400 (60 days). Matches the 2× pattern used
by faoFoodPriceIndex + recovery pillar (recoveryFiscalSpace, etc.)
which also run monthly.

Also fixes the same mismatch in scripts/regional-snapshot/freshness.mjs —
the 10080 ceiling there would exclude national-debt from capital_stress
axis scoring 23 days out of every 30 between seeds.

* fix(seed-national-debt): raise CACHE_TTL to 65d so health.js stale window is actually reachable

PR #3237 review was correct: my earlier fix set api/health.js
SEED_META.nationalDebt.maxStaleMin to 60d (86400min), but the seeder's
CACHE_TTL was still 35d. After a missed monthly cron, the canonical key
expired at day 35 — long before the 60d "stale" threshold. Result path:
  hasData=false → api/health.js:545-549 → status = EMPTY (crit)
Not STALE_SEED (warn) as my commit message claimed.

writeFreshnessMetadata() in scripts/_seed-utils.mjs:222 sets meta TTL to
max(7d, ttlSeconds), so bumping ttlSeconds alone propagates to both the
canonical payload AND the meta key.

Fix:
- CACHE_TTL 35d → 65d (5d past the 60d stale window so we get a clean
  STALE_SEED → EMPTY transition without keys vanishing mid-warn).
- runSeed opts.maxStaleMin 10080 (7d) → 86400 (60d) so the in-seeder
  declaration matches api/health.js. Field is only validated for
  presence by runSeed (scripts/_seed-utils.mjs:798), but the drift was
  what hid the TTL invariant in the first place.

Invariant this restores: for any SEED_META entry,
  seeder CACHE_TTL ≥ maxStaleMin + buffer
so the "warn before crit" gradient actually exists.

* fix(freshness): wire national-debt to seed-meta + teach extractTimestamp about seededAt

Reviewer P2 on PR #3237: my earlier freshness.mjs bump to 86400 was a
no-op. classifyInputs() (scripts/regional-snapshot/freshness.mjs:100-108,
122-132) uses the entry's metaKey or extractTimestamp()'s known field
list. national-debt had neither — payload carries only `seededAt`, and
extractTimestamp didn't know that field, so the "present but undated"
branch treated every call as fresh. The age window never mattered.

Two complementary fixes:

1. Add metaKey: 'seed-meta:economic:national-debt' to the freshness
   entry. Primary, authoritative source — seed-meta.fetchedAt is
   written by writeFreshnessMetadata() on every successful run, which is
   also what api/health.js reads, keeping both surfaces consistent.

2. Add `seededAt` to extractTimestamp()'s field list. Defense-in-depth:
   many other runSeed-based scripts (seed-iea-oil-stocks,
   seed-eurostat-country-data, etc.) wrap output as { ..., seededAt: ISO }
   with no metaKey in the freshness registry. Without this, they were
   also silently always-fresh. ISO strings parse via Date.parse.

Note: `economic:eu-gas-storage:v1` uses `seededAt: String(Date.now())` —
a stringified epoch number, which Date.parse does NOT handle. That seed's
freshness classification is still broken by this entry's lack of metaKey,
but it's a separate shape issue out of scope here. Flagged in PR body.
2026-04-20 19:03:47 +04:00

195 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});
}