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