mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(economic): National Debt Clock — live ticking debt estimates for 180+ countries (#1923)
* 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.
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -56,6 +56,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
securityAdvisories: 'intelligence:advisories-bootstrap:v1',
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
sanctionsPressure: 'sanctions:pressure:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
};
|
||||
|
||||
const SLOW_KEYS = new Set([
|
||||
@@ -70,6 +71,7 @@ const SLOW_KEYS = new Set([
|
||||
'securityAdvisories',
|
||||
'customsRevenue',
|
||||
'sanctionsPressure',
|
||||
'nationalDebt',
|
||||
]);
|
||||
const FAST_KEYS = new Set([
|
||||
'earthquakes', 'outages', 'serviceStatuses', 'macroSignals', 'chokepoints', 'chokepointTransits',
|
||||
|
||||
@@ -37,6 +37,7 @@ const BOOTSTRAP_KEYS = {
|
||||
customsRevenue: 'trade:customs-revenue:v1',
|
||||
sanctionsPressure: 'sanctions:pressure:v1',
|
||||
radiationWatch: 'radiation:observations:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
};
|
||||
|
||||
const STANDALONE_KEYS = {
|
||||
@@ -136,6 +137,7 @@ const SEED_META = {
|
||||
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
|
||||
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
|
||||
thermalEscalation: { key: 'seed-meta:thermal:escalation', maxStaleMin: 240 },
|
||||
nationalDebt: { key: 'seed-meta:economic:national-debt', maxStaleMin: 10080 }, // 7 days — monthly seed
|
||||
tariffTrendsUs: { key: 'seed-meta:trade:tariffs:v1:840:all:10', maxStaleMin: 900 },
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -313,6 +313,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/economic/v1/get-national-debt:
|
||||
get:
|
||||
tags:
|
||||
- EconomicService
|
||||
summary: GetNationalDebt
|
||||
description: GetNationalDebt retrieves national debt clock data for all countries.
|
||||
operationId: GetNationalDebt
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetNationalDebtResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
@@ -886,3 +912,58 @@ components:
|
||||
type: string
|
||||
value:
|
||||
$ref: '#/components/schemas/FredSeries'
|
||||
GetNationalDebtRequest:
|
||||
type: object
|
||||
description: GetNationalDebtRequest requests national debt data for all countries.
|
||||
GetNationalDebtResponse:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NationalDebtEntry'
|
||||
seededAt:
|
||||
type: string
|
||||
description: ISO 8601 timestamp when seed data was written.
|
||||
unavailable:
|
||||
type: boolean
|
||||
description: True when upstream data is unavailable (fallback result).
|
||||
description: GetNationalDebtResponse wraps the full list of national debt entries.
|
||||
NationalDebtEntry:
|
||||
type: object
|
||||
properties:
|
||||
iso3:
|
||||
type: string
|
||||
description: ISO3 country code (e.g. "USA").
|
||||
debtUsd:
|
||||
type: number
|
||||
format: double
|
||||
description: Total debt in USD at baseline_ts.
|
||||
gdpUsd:
|
||||
type: number
|
||||
format: double
|
||||
description: GDP in USD (nominal, latest year).
|
||||
debtToGdp:
|
||||
type: number
|
||||
format: double
|
||||
description: Debt as % of GDP.
|
||||
annualGrowth:
|
||||
type: number
|
||||
format: double
|
||||
description: Year-over-year debt growth percent (2023->2024).
|
||||
perSecondRate:
|
||||
type: number
|
||||
format: double
|
||||
description: Deficit-derived accrual in USD per second.
|
||||
perDayRate:
|
||||
type: number
|
||||
format: double
|
||||
description: Deficit-derived accrual in USD per day.
|
||||
baselineTs:
|
||||
type: string
|
||||
format: int64
|
||||
description: UTC ms timestamp anchoring the debt_usd figure (2024-01-01T00:00:00Z).
|
||||
source:
|
||||
type: string
|
||||
description: Human-readable source string.
|
||||
description: NationalDebtEntry holds debt data for a single country.
|
||||
|
||||
37
proto/worldmonitor/economic/v1/get_national_debt.proto
Normal file
37
proto/worldmonitor/economic/v1/get_national_debt.proto
Normal file
@@ -0,0 +1,37 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.economic.v1;
|
||||
|
||||
// GetNationalDebtRequest requests national debt data for all countries.
|
||||
message GetNationalDebtRequest {}
|
||||
|
||||
// NationalDebtEntry holds debt data for a single country.
|
||||
message NationalDebtEntry {
|
||||
// ISO3 country code (e.g. "USA").
|
||||
string iso3 = 1;
|
||||
// Total debt in USD at baseline_ts.
|
||||
double debt_usd = 2;
|
||||
// GDP in USD (nominal, latest year).
|
||||
double gdp_usd = 3;
|
||||
// Debt as % of GDP.
|
||||
double debt_to_gdp = 4;
|
||||
// Year-over-year debt growth percent (2023->2024).
|
||||
double annual_growth = 5;
|
||||
// Deficit-derived accrual in USD per second.
|
||||
double per_second_rate = 6;
|
||||
// Deficit-derived accrual in USD per day.
|
||||
double per_day_rate = 7;
|
||||
// UTC ms timestamp anchoring the debt_usd figure (2024-01-01T00:00:00Z).
|
||||
int64 baseline_ts = 8;
|
||||
// Human-readable source string.
|
||||
string source = 9;
|
||||
}
|
||||
|
||||
// GetNationalDebtResponse wraps the full list of national debt entries.
|
||||
message GetNationalDebtResponse {
|
||||
repeated NationalDebtEntry entries = 1;
|
||||
// ISO 8601 timestamp when seed data was written.
|
||||
string seeded_at = 2;
|
||||
// True when upstream data is unavailable (fallback result).
|
||||
bool unavailable = 3;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import "worldmonitor/economic/v1/get_bis_policy_rates.proto";
|
||||
import "worldmonitor/economic/v1/get_bis_exchange_rates.proto";
|
||||
import "worldmonitor/economic/v1/get_bis_credit.proto";
|
||||
import "worldmonitor/economic/v1/get_fred_series_batch.proto";
|
||||
import "worldmonitor/economic/v1/get_national_debt.proto";
|
||||
|
||||
// EconomicService provides APIs for macroeconomic data from FRED, World Bank, and EIA.
|
||||
service EconomicService {
|
||||
@@ -61,4 +62,9 @@ service EconomicService {
|
||||
rpc GetFredSeriesBatch(GetFredSeriesBatchRequest) returns (GetFredSeriesBatchResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-fred-series-batch", method: HTTP_METHOD_POST};
|
||||
}
|
||||
|
||||
// GetNationalDebt retrieves national debt clock data for all countries.
|
||||
rpc GetNationalDebt(GetNationalDebtRequest) returns (GetNationalDebtResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-national-debt", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
150
scripts/seed-national-debt.mjs
Normal file
150
scripts/seed-national-debt.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
defiTokens: 'market:defi-tokens:v1',
|
||||
aiTokens: 'market:ai-tokens:v1',
|
||||
otherTokens: 'market:other-tokens:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
@@ -78,4 +79,5 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
defiTokens: 'slow',
|
||||
aiTokens: 'slow',
|
||||
otherTokens: 'slow',
|
||||
nationalDebt: 'slow',
|
||||
};
|
||||
|
||||
@@ -136,6 +136,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
|
||||
'/api/military/v1/list-military-bases': 'static',
|
||||
'/api/economic/v1/get-macro-signals': 'medium',
|
||||
'/api/economic/v1/get-national-debt': 'daily',
|
||||
'/api/prediction/v1/list-prediction-markets': 'medium',
|
||||
'/api/forecast/v1/get-forecasts': 'medium',
|
||||
'/api/supply-chain/v1/get-chokepoint-status': 'medium',
|
||||
|
||||
35
server/worldmonitor/economic/v1/get-national-debt.ts
Normal file
35
server/worldmonitor/economic/v1/get-national-debt.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* RPC: getNationalDebt -- reads seeded national debt data from Railway seed cache.
|
||||
* All external IMF/Treasury calls happen in seed-national-debt.mjs on Railway.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ServerContext,
|
||||
GetNationalDebtRequest,
|
||||
GetNationalDebtResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server';
|
||||
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const SEED_CACHE_KEY = 'economic:national-debt:v1';
|
||||
|
||||
function buildFallbackResult(): GetNationalDebtResponse {
|
||||
return {
|
||||
entries: [],
|
||||
seededAt: '',
|
||||
unavailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNationalDebt(
|
||||
_ctx: ServerContext,
|
||||
_req: GetNationalDebtRequest,
|
||||
): Promise<GetNationalDebtResponse> {
|
||||
try {
|
||||
const result = await getCachedJson(SEED_CACHE_KEY, true) as GetNationalDebtResponse | null;
|
||||
if (result && !result.unavailable && result.entries && result.entries.length > 0) return result;
|
||||
return buildFallbackResult();
|
||||
} catch {
|
||||
return buildFallbackResult();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getEnergyCapacity } from './get-energy-capacity';
|
||||
import { getBisPolicyRates } from './get-bis-policy-rates';
|
||||
import { getBisExchangeRates } from './get-bis-exchange-rates';
|
||||
import { getBisCredit } from './get-bis-credit';
|
||||
import { getNationalDebt } from './get-national-debt';
|
||||
|
||||
export const economicHandler: EconomicServiceHandler = {
|
||||
getFredSeries,
|
||||
@@ -20,4 +21,5 @@ export const economicHandler: EconomicServiceHandler = {
|
||||
getBisPolicyRates,
|
||||
getBisExchangeRates,
|
||||
getBisCredit,
|
||||
getNationalDebt,
|
||||
};
|
||||
|
||||
@@ -798,6 +798,14 @@ export class PanelLayoutManager implements AppModule {
|
||||
}),
|
||||
);
|
||||
|
||||
this.lazyPanel('national-debt', () =>
|
||||
import('@/components/NationalDebtPanel').then(m => {
|
||||
const p = new m.NationalDebtPanel();
|
||||
void p.refresh();
|
||||
return p;
|
||||
}),
|
||||
);
|
||||
|
||||
this.createPanel('macro-signals', () => new MacroSignalsPanel());
|
||||
this.createPanel('etf-flows', () => new ETFFlowsPanel());
|
||||
this.createPanel('stablecoins', () => new StablecoinPanel());
|
||||
|
||||
277
src/components/NationalDebtPanel.ts
Normal file
277
src/components/NationalDebtPanel.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { Panel } from './Panel';
|
||||
import { getNationalDebtData, type NationalDebtEntry } from '@/services/economic';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
|
||||
type SortMode = 'total' | 'gdp-ratio' | 'growth';
|
||||
|
||||
const COUNTRY_FLAGS: Record<string, string> = {
|
||||
AFG: '🇦🇫', ALB: '🇦🇱', DZA: '🇩🇿', AGO: '🇦🇴', ARG: '🇦🇷', ARM: '🇦🇲', AUS: '🇦🇺', AUT: '🇦🇹',
|
||||
AZE: '🇦🇿', BHS: '🇧🇸', BHR: '🇧🇭', BGD: '🇧🇩', BLR: '🇧🇾', BEL: '🇧🇪', BLZ: '🇧🇿', BEN: '🇧🇯',
|
||||
BTN: '🇧🇹', BOL: '🇧🇴', BIH: '🇧🇦', BWA: '🇧🇼', BRA: '🇧🇷', BRN: '🇧🇳', BGR: '🇧🇬', BFA: '🇧🇫',
|
||||
BDI: '🇧🇮', CPV: '🇨🇻', KHM: '🇰🇭', CMR: '🇨🇲', CAN: '🇨🇦', CAF: '🇨🇫', TCD: '🇹🇩', CHL: '🇨🇱',
|
||||
CHN: '🇨🇳', COL: '🇨🇴', COM: '🇰🇲', COD: '🇨🇩', COG: '🇨🇬', CRI: '🇨🇷', CIV: '🇨🇮', HRV: '🇭🇷',
|
||||
CYP: '🇨🇾', CZE: '🇨🇿', DNK: '🇩🇰', DJI: '🇩🇯', DOM: '🇩🇴', ECU: '🇪🇨', EGY: '🇪🇬', SLV: '🇸🇻',
|
||||
GNQ: '🇬🇶', ERI: '🇪🇷', EST: '🇪🇪', SWZ: '🇸🇿', ETH: '🇪🇹', FJI: '🇫🇯', FIN: '🇫🇮', FRA: '🇫🇷',
|
||||
GAB: '🇬🇦', GMB: '🇬🇲', GEO: '🇬🇪', DEU: '🇩🇪', GHA: '🇬🇭', GRC: '🇬🇷', GTM: '🇬🇹', GIN: '🇬🇳',
|
||||
GNB: '🇬🇼', GUY: '🇬🇾', HTI: '🇭🇹', HND: '🇭🇳', HKG: '🇭🇰', HUN: '🇭🇺', ISL: '🇮🇸', IND: '🇮🇳',
|
||||
IDN: '🇮🇩', IRN: '🇮🇷', IRQ: '🇮🇶', IRL: '🇮🇪', ISR: '🇮🇱', ITA: '🇮🇹', JAM: '🇯🇲', JPN: '🇯🇵',
|
||||
JOR: '🇯🇴', KAZ: '🇰🇿', KEN: '🇰🇪', KOR: '🇰🇷', KWT: '🇰🇼', KGZ: '🇰🇬', LAO: '🇱🇦',
|
||||
LVA: '🇱🇻', LBN: '🇱🇧', LSO: '🇱🇸', LBR: '🇱🇷', LBY: '🇱🇾', LTU: '🇱🇹', LUX: '🇱🇺', MAC: '🇲🇴',
|
||||
MDG: '🇲🇬', MWI: '🇲🇼', MYS: '🇲🇾', MDV: '🇲🇻', MLI: '🇲🇱', MLT: '🇲🇹', MRT: '🇲🇷', MUS: '🇲🇺',
|
||||
MEX: '🇲🇽', MDA: '🇲🇩', MNG: '🇲🇳', MNE: '🇲🇪', MAR: '🇲🇦', MOZ: '🇲🇿', MMR: '🇲🇲', NAM: '🇳🇦',
|
||||
NPL: '🇳🇵', NLD: '🇳🇱', NZL: '🇳🇿', NIC: '🇳🇮', NER: '🇳🇪', NGA: '🇳🇬', MKD: '🇲🇰', NOR: '🇳🇴',
|
||||
OMN: '🇴🇲', PAK: '🇵🇰', PAN: '🇵🇦', PNG: '🇵🇬', PRY: '🇵🇾', PER: '🇵🇪', PHL: '🇵🇭', POL: '🇵🇱',
|
||||
PRT: '🇵🇹', QAT: '🇶🇦', ROU: '🇷🇴', RUS: '🇷🇺', RWA: '🇷🇼', SAU: '🇸🇦', SEN: '🇸🇳', SRB: '🇷🇸',
|
||||
SLE: '🇸🇱', SGP: '🇸🇬', SVK: '🇸🇰', SVN: '🇸🇮', SOM: '🇸🇴', ZAF: '🇿🇦', SSD: '🇸🇸', ESP: '🇪🇸',
|
||||
LKA: '🇱🇰', SDN: '🇸🇩', SUR: '🇸🇷', SWE: '🇸🇪', CHE: '🇨🇭', TWN: '🇹🇼', TJK: '🇹🇯',
|
||||
TZA: '🇹🇿', THA: '🇹🇭', TLS: '🇹🇱', TGO: '🇹🇬', TTO: '🇹🇹', TUN: '🇹🇳', TUR: '🇹🇷', TKM: '🇹🇲',
|
||||
UGA: '🇺🇬', UKR: '🇺🇦', ARE: '🇦🇪', GBR: '🇬🇧', USA: '🇺🇸', URY: '🇺🇾', UZB: '🇺🇿', VEN: '🇻🇪',
|
||||
VNM: '🇻🇳', YEM: '🇾🇪', ZMB: '🇿🇲', ZWE: '🇿🇼',
|
||||
};
|
||||
|
||||
const COUNTRY_NAMES: Record<string, string> = {
|
||||
AFG: 'Afghanistan', ALB: 'Albania', DZA: 'Algeria', AGO: 'Angola', ARG: 'Argentina',
|
||||
ARM: 'Armenia', AUS: 'Australia', AUT: 'Austria', AZE: 'Azerbaijan', BHS: 'Bahamas',
|
||||
BHR: 'Bahrain', BGD: 'Bangladesh', BLR: 'Belarus', BEL: 'Belgium', BLZ: 'Belize',
|
||||
BEN: 'Benin', BTN: 'Bhutan', BOL: 'Bolivia', BIH: 'Bosnia & Herzegovina', BWA: 'Botswana',
|
||||
BRA: 'Brazil', BRN: 'Brunei', BGR: 'Bulgaria', BFA: 'Burkina Faso', BDI: 'Burundi',
|
||||
CPV: 'Cabo Verde', KHM: 'Cambodia', CMR: 'Cameroon', CAN: 'Canada', CAF: 'Central African Rep.',
|
||||
TCD: 'Chad', CHL: 'Chile', CHN: 'China', COL: 'Colombia', COM: 'Comoros',
|
||||
COD: 'Dem. Rep. Congo', COG: 'Congo', CRI: 'Costa Rica', CIV: "Cote d'Ivoire", HRV: 'Croatia',
|
||||
CYP: 'Cyprus', CZE: 'Czech Republic', DNK: 'Denmark', DJI: 'Djibouti', DOM: 'Dominican Rep.',
|
||||
ECU: 'Ecuador', EGY: 'Egypt', SLV: 'El Salvador', GNQ: 'Equatorial Guinea', ERI: 'Eritrea',
|
||||
EST: 'Estonia', SWZ: 'Eswatini', ETH: 'Ethiopia', FJI: 'Fiji', FIN: 'Finland',
|
||||
FRA: 'France', GAB: 'Gabon', GMB: 'Gambia', GEO: 'Georgia', DEU: 'Germany',
|
||||
GHA: 'Ghana', GRC: 'Greece', GTM: 'Guatemala', GIN: 'Guinea', GNB: 'Guinea-Bissau',
|
||||
GUY: 'Guyana', HTI: 'Haiti', HND: 'Honduras', HKG: 'Hong Kong SAR', HUN: 'Hungary',
|
||||
ISL: 'Iceland', IND: 'India', IDN: 'Indonesia', IRN: 'Iran', IRQ: 'Iraq',
|
||||
IRL: 'Ireland', ISR: 'Israel', ITA: 'Italy', JAM: 'Jamaica', JPN: 'Japan',
|
||||
JOR: 'Jordan', KAZ: 'Kazakhstan', KEN: 'Kenya', KOR: 'Korea (South)',
|
||||
KWT: 'Kuwait', KGZ: 'Kyrgyzstan', LAO: 'Laos', LVA: 'Latvia', LBN: 'Lebanon',
|
||||
LSO: 'Lesotho', LBR: 'Liberia', LBY: 'Libya', LTU: 'Lithuania', LUX: 'Luxembourg',
|
||||
MAC: 'Macao SAR', MDG: 'Madagascar', MWI: 'Malawi', MYS: 'Malaysia', MDV: 'Maldives',
|
||||
MLI: 'Mali', MLT: 'Malta', MRT: 'Mauritania', MUS: 'Mauritius', MEX: 'Mexico',
|
||||
MDA: 'Moldova', MNG: 'Mongolia', MNE: 'Montenegro', MAR: 'Morocco', MOZ: 'Mozambique',
|
||||
MMR: 'Myanmar', NAM: 'Namibia', NPL: 'Nepal', NLD: 'Netherlands', NZL: 'New Zealand',
|
||||
NIC: 'Nicaragua', NER: 'Niger', NGA: 'Nigeria', MKD: 'North Macedonia', NOR: 'Norway',
|
||||
OMN: 'Oman', PAK: 'Pakistan', PAN: 'Panama', PNG: 'Papua New Guinea', PRY: 'Paraguay',
|
||||
PER: 'Peru', PHL: 'Philippines', POL: 'Poland', PRT: 'Portugal', QAT: 'Qatar',
|
||||
ROU: 'Romania', RUS: 'Russia', RWA: 'Rwanda', SAU: 'Saudi Arabia', SEN: 'Senegal',
|
||||
SRB: 'Serbia', SLE: 'Sierra Leone', SGP: 'Singapore', SVK: 'Slovakia', SVN: 'Slovenia',
|
||||
SOM: 'Somalia', ZAF: 'South Africa', SSD: 'South Sudan', ESP: 'Spain', LKA: 'Sri Lanka',
|
||||
SDN: 'Sudan', SUR: 'Suriname', SWE: 'Sweden', CHE: 'Switzerland',
|
||||
TWN: 'Taiwan', TJK: 'Tajikistan', TZA: 'Tanzania', THA: 'Thailand', TLS: 'Timor-Leste',
|
||||
TGO: 'Togo', TTO: 'Trinidad & Tobago', TUN: 'Tunisia', TUR: 'Turkey', TKM: 'Turkmenistan',
|
||||
UGA: 'Uganda', UKR: 'Ukraine', ARE: 'United Arab Emirates', GBR: 'United Kingdom',
|
||||
USA: 'United States', URY: 'Uruguay', UZB: 'Uzbekistan', VEN: 'Venezuela',
|
||||
VNM: 'Vietnam', YEM: 'Yemen', ZMB: 'Zambia', ZWE: 'Zimbabwe',
|
||||
};
|
||||
|
||||
function getFlag(iso3: string): string {
|
||||
return COUNTRY_FLAGS[iso3] ?? '🌐';
|
||||
}
|
||||
|
||||
function getCountryName(iso3: string): string {
|
||||
return COUNTRY_NAMES[iso3] ?? iso3;
|
||||
}
|
||||
|
||||
function formatDebt(usd: number): string {
|
||||
if (!Number.isFinite(usd) || usd <= 0) return '$0';
|
||||
if (usd >= 1e12) return `$${(usd / 1e12).toFixed(1)}T`;
|
||||
if (usd >= 1e9) return `$${(usd / 1e9).toFixed(1)}B`;
|
||||
if (usd >= 1e6) return `$${(usd / 1e6).toFixed(1)}M`;
|
||||
return `$${Math.round(usd).toLocaleString()}`;
|
||||
}
|
||||
|
||||
function getCurrentDebt(entry: NationalDebtEntry): number {
|
||||
if (!entry.perSecondRate || !entry.baselineTs) return entry.debtUsd ?? 0;
|
||||
const secondsElapsed = (Date.now() - Number(entry.baselineTs)) / 1000;
|
||||
return (entry.debtUsd ?? 0) + entry.perSecondRate * secondsElapsed;
|
||||
}
|
||||
|
||||
function sortEntries(entries: NationalDebtEntry[], mode: SortMode): NationalDebtEntry[] {
|
||||
const sorted = [...entries];
|
||||
if (mode === 'total') {
|
||||
sorted.sort((a, b) => getCurrentDebt(b) - getCurrentDebt(a));
|
||||
} else if (mode === 'gdp-ratio') {
|
||||
sorted.sort((a, b) => (b.debtToGdp ?? 0) - (a.debtToGdp ?? 0));
|
||||
} else if (mode === 'growth') {
|
||||
sorted.sort((a, b) => (b.annualGrowth ?? 0) - (a.annualGrowth ?? 0));
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export class NationalDebtPanel extends Panel {
|
||||
private entries: NationalDebtEntry[] = [];
|
||||
private filteredEntries: NationalDebtEntry[] = [];
|
||||
private sortMode: SortMode = 'total';
|
||||
private searchQuery = '';
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private tickerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly REFRESH_INTERVAL = 6 * 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'national-debt',
|
||||
title: 'National Debt Clock',
|
||||
showCount: true,
|
||||
infoTooltip: 'Live national debt estimates for 150+ countries. Data anchored at 2024-01-01 and accruing using IMF deficit projections.',
|
||||
});
|
||||
|
||||
this.content.addEventListener('click', (e) => {
|
||||
const tab = (e.target as HTMLElement).closest('[data-sort]') as HTMLElement | null;
|
||||
if (tab?.dataset.sort) {
|
||||
this.sortMode = tab.dataset.sort as SortMode;
|
||||
this.applyFilters();
|
||||
this.render();
|
||||
this.restartTicker();
|
||||
}
|
||||
});
|
||||
|
||||
this.content.addEventListener('input', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.classList.contains('debt-search')) {
|
||||
this.searchQuery = target.value;
|
||||
this.applyFilters();
|
||||
this.render();
|
||||
this.restartTicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (Date.now() - this.lastFetch < this.REFRESH_INTERVAL && this.entries.length > 0) return;
|
||||
|
||||
this.loading = true;
|
||||
this.showLoadingState();
|
||||
|
||||
try {
|
||||
const data = await getNationalDebtData();
|
||||
if (!this.element?.isConnected) return;
|
||||
this.entries = data.entries ?? [];
|
||||
this.lastFetch = Date.now();
|
||||
this.applyFilters();
|
||||
this.setCount(this.filteredEntries.length);
|
||||
this.render();
|
||||
this.startTicker();
|
||||
} catch (err) {
|
||||
if (!this.element?.isConnected) return;
|
||||
console.error('[NationalDebtPanel] Error fetching data:', err);
|
||||
this.showError('Failed to load national debt data');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private showLoadingState(): void {
|
||||
this.setContent(`
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:80px;color:var(--text-dim);font-size:13px;">
|
||||
Loading debt data from IMF...
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
private applyFilters(): void {
|
||||
const q = this.searchQuery.toLowerCase().trim();
|
||||
const base = q
|
||||
? this.entries.filter(e =>
|
||||
e.iso3.toLowerCase().includes(q) ||
|
||||
getCountryName(e.iso3).toLowerCase().includes(q),
|
||||
)
|
||||
: this.entries;
|
||||
this.filteredEntries = sortEntries(base, this.sortMode);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.entries.length === 0) {
|
||||
this.showError('No data available');
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="debt-panel-container">
|
||||
<div class="debt-controls">
|
||||
<div class="debt-sort-tabs">
|
||||
<button class="debt-tab${this.sortMode === 'total' ? ' active' : ''}" data-sort="total">Total Debt</button>
|
||||
<button class="debt-tab${this.sortMode === 'gdp-ratio' ? ' active' : ''}" data-sort="gdp-ratio">Debt/GDP</button>
|
||||
<button class="debt-tab${this.sortMode === 'growth' ? ' active' : ''}" data-sort="growth">1Y Growth</button>
|
||||
</div>
|
||||
<input class="debt-search" type="text" placeholder="Search country..." value="${escapeHtml(this.searchQuery)}">
|
||||
</div>
|
||||
<div class="debt-list">
|
||||
${this.filteredEntries.slice(0, 100).map((entry, idx) => this.renderRow(entry, idx + 1)).join('')}
|
||||
</div>
|
||||
<div class="debt-footer">
|
||||
<span class="debt-source">Source: IMF WEO 2024 + US Treasury FiscalData</span>
|
||||
<span class="debt-updated">Updated: ${new Date(this.lastFetch).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setContent(html);
|
||||
}
|
||||
|
||||
private renderRow(entry: NationalDebtEntry, rank: number): string {
|
||||
const currentDebt = getCurrentDebt(entry);
|
||||
const name = escapeHtml(getCountryName(entry.iso3));
|
||||
const flag = getFlag(entry.iso3);
|
||||
const debtStr = formatDebt(currentDebt);
|
||||
const ratioStr = Number.isFinite(entry.debtToGdp) && entry.debtToGdp > 0
|
||||
? `${entry.debtToGdp.toFixed(1)}%`
|
||||
: '—';
|
||||
const growthStr = Number.isFinite(entry.annualGrowth) && entry.annualGrowth !== 0
|
||||
? `${entry.annualGrowth > 0 ? '+' : ''}${entry.annualGrowth.toFixed(1)}%`
|
||||
: '—';
|
||||
const growthClass = entry.annualGrowth > 5 ? 'debt-growth-high' : entry.annualGrowth > 0 ? 'debt-growth-mid' : '';
|
||||
|
||||
return `
|
||||
<div class="debt-row" data-iso3="${escapeHtml(entry.iso3)}">
|
||||
<div class="debt-rank">${rank}</div>
|
||||
<div class="debt-flag">${flag}</div>
|
||||
<div class="debt-info">
|
||||
<div class="debt-name">${name}</div>
|
||||
<div class="debt-meta">
|
||||
<span class="debt-ratio">${ratioStr} of GDP</span>
|
||||
<span class="debt-growth ${growthClass}">${growthStr} YoY</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debt-ticker" data-iso3="${escapeHtml(entry.iso3)}">${escapeHtml(debtStr)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startTicker(): void {
|
||||
this.stopTicker();
|
||||
if (this.filteredEntries.length === 0) return;
|
||||
|
||||
this.tickerInterval = setInterval(() => {
|
||||
const container = this.content.querySelector('.debt-list');
|
||||
if (!container) return;
|
||||
for (const entry of this.filteredEntries.slice(0, 100)) {
|
||||
const el = container.querySelector<HTMLElement>(`.debt-ticker[data-iso3="${entry.iso3}"]`);
|
||||
if (el) {
|
||||
el.textContent = formatDebt(getCurrentDebt(entry));
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopTicker(): void {
|
||||
if (this.tickerInterval !== null) {
|
||||
clearInterval(this.tickerInterval);
|
||||
this.tickerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private restartTicker(): void {
|
||||
this.stopTicker();
|
||||
this.startTicker();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.stopTicker();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,4 @@ export * from './MilitaryCorrelationPanel';
|
||||
export * from './EscalationCorrelationPanel';
|
||||
export * from './EconomicCorrelationPanel';
|
||||
export * from './DisasterCorrelationPanel';
|
||||
export { NationalDebtPanel } from './NationalDebtPanel';
|
||||
|
||||
@@ -72,6 +72,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 },
|
||||
'tech-readiness': { name: 'Tech Readiness Index', enabled: true, priority: 2 },
|
||||
'world-clock': { name: 'World Clock', enabled: true, priority: 2 },
|
||||
'national-debt': { name: 'National Debt Clock', enabled: true, priority: 2 },
|
||||
};
|
||||
|
||||
const FULL_MAP_LAYERS: MapLayers = {
|
||||
|
||||
@@ -221,6 +221,27 @@ export interface GetFredSeriesBatchResponse {
|
||||
requested: number;
|
||||
}
|
||||
|
||||
export interface GetNationalDebtRequest {
|
||||
}
|
||||
|
||||
export interface GetNationalDebtResponse {
|
||||
entries: NationalDebtEntry[];
|
||||
seededAt: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface NationalDebtEntry {
|
||||
iso3: string;
|
||||
debtUsd: number;
|
||||
gdpUsd: number;
|
||||
debtToGdp: number;
|
||||
annualGrowth: number;
|
||||
perSecondRate: number;
|
||||
perDayRate: number;
|
||||
baselineTs: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -491,6 +512,29 @@ export class EconomicServiceClient {
|
||||
return await resp.json() as GetFredSeriesBatchResponse;
|
||||
}
|
||||
|
||||
async getNationalDebt(req: GetNationalDebtRequest, options?: EconomicServiceCallOptions): Promise<GetNationalDebtResponse> {
|
||||
let path = "/api/economic/v1/get-national-debt";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetNationalDebtResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -221,6 +221,27 @@ export interface GetFredSeriesBatchResponse {
|
||||
requested: number;
|
||||
}
|
||||
|
||||
export interface GetNationalDebtRequest {
|
||||
}
|
||||
|
||||
export interface GetNationalDebtResponse {
|
||||
entries: NationalDebtEntry[];
|
||||
seededAt: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface NationalDebtEntry {
|
||||
iso3: string;
|
||||
debtUsd: number;
|
||||
gdpUsd: number;
|
||||
debtToGdp: number;
|
||||
annualGrowth: number;
|
||||
perSecondRate: number;
|
||||
perDayRate: number;
|
||||
baselineTs: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -275,6 +296,7 @@ export interface EconomicServiceHandler {
|
||||
getBisExchangeRates(ctx: ServerContext, req: GetBisExchangeRatesRequest): Promise<GetBisExchangeRatesResponse>;
|
||||
getBisCredit(ctx: ServerContext, req: GetBisCreditRequest): Promise<GetBisCreditResponse>;
|
||||
getFredSeriesBatch(ctx: ServerContext, req: GetFredSeriesBatchRequest): Promise<GetFredSeriesBatchResponse>;
|
||||
getNationalDebt(ctx: ServerContext, req: GetNationalDebtRequest): Promise<GetNationalDebtResponse>;
|
||||
}
|
||||
|
||||
export function createEconomicServiceRoutes(
|
||||
@@ -667,6 +689,43 @@ export function createEconomicServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/economic/v1/get-national-debt",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = {} as GetNationalDebtRequest;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getNationalDebt(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetNationalDebtResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
type BisPolicyRate,
|
||||
type BisExchangeRate,
|
||||
type BisCreditToGdp,
|
||||
type GetNationalDebtResponse,
|
||||
type NationalDebtEntry,
|
||||
} from '@/generated/client/worldmonitor/economic/v1/service_client';
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { getCSSColor } from '@/utils';
|
||||
@@ -581,6 +583,37 @@ export async function getCountryComparison(
|
||||
// ========================================================================
|
||||
|
||||
export type { BisPolicyRate, BisExchangeRate, BisCreditToGdp };
|
||||
export type { NationalDebtEntry };
|
||||
|
||||
// ========================================================================
|
||||
// National Debt Clock
|
||||
// ========================================================================
|
||||
|
||||
const nationalDebtBreaker = createCircuitBreaker<GetNationalDebtResponse>({ name: 'National Debt', cacheTtlMs: 6 * 60 * 60 * 1000, persistCache: true });
|
||||
const emptyNationalDebtFallback: GetNationalDebtResponse = { entries: [], seededAt: '', unavailable: true };
|
||||
|
||||
export async function getNationalDebtData(): Promise<GetNationalDebtResponse> {
|
||||
const hydrated = getHydratedData('nationalDebt') as GetNationalDebtResponse | undefined;
|
||||
if (hydrated?.entries?.length) return hydrated;
|
||||
|
||||
try {
|
||||
const resp = await fetch(toApiUrl('/api/bootstrap?keys=nationalDebt'), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const { data } = (await resp.json()) as { data: { nationalDebt?: GetNationalDebtResponse } };
|
||||
if (data.nationalDebt?.entries?.length) return data.nationalDebt;
|
||||
}
|
||||
} catch { /* fall through to RPC */ }
|
||||
|
||||
try {
|
||||
return await nationalDebtBreaker.execute(async () => {
|
||||
return client.getNationalDebt({}, { signal: AbortSignal.timeout(15_000) });
|
||||
}, emptyNationalDebtFallback);
|
||||
} catch {
|
||||
return emptyNationalDebtFallback;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BisData {
|
||||
policyRates: BisPolicyRate[];
|
||||
|
||||
@@ -20126,3 +20126,155 @@ body.has-breaking-alert .panels-grid {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* National Debt Clock Panel */
|
||||
.debt-panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.debt-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debt-sort-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.debt-tab {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.debt-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.debt-tab.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.debt-search {
|
||||
width: 110px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input, var(--bg-secondary));
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debt-search:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.debt-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.debt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.debt-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.debt-rank {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debt-flag {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.debt-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.debt-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.debt-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.debt-growth.debt-growth-high {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.debt-growth.debt-growth-mid {
|
||||
color: var(--yellow, #f5a623);
|
||||
}
|
||||
|
||||
.debt-ticker {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.debt-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0 0;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.debt-source,
|
||||
.debt-updated {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
168
tests/national-debt-seed.test.mjs
Normal file
168
tests/national-debt-seed.test.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
// Import only the pure compute function (no Redis, no fetch side-effects)
|
||||
import { computeEntries } from '../scripts/seed-national-debt.mjs';
|
||||
|
||||
const BASELINE_TS = Date.UTC(2024, 0, 1);
|
||||
|
||||
describe('computeEntries formula', () => {
|
||||
it('calculates debt_usd from IMF debt % and GDP billions', () => {
|
||||
const debtPct = { USA: { '2024': '120', '2023': '110' } };
|
||||
const gdp = { USA: { '2024': '28000' } }; // $28T in billions
|
||||
const deficit = { USA: { '2024': '-5' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, deficit, null);
|
||||
assert.equal(entries.length, 1);
|
||||
const usa = entries[0];
|
||||
assert.equal(usa.iso3, 'USA');
|
||||
|
||||
// debtUsd = (120/100) * 28000e9 = 33.6T
|
||||
assert.ok(Math.abs(usa.debtUsd - 33_600_000_000_000) < 1e6, `debtUsd=${usa.debtUsd}`);
|
||||
assert.ok(Math.abs(usa.gdpUsd - 28_000_000_000_000) < 1e6, `gdpUsd=${usa.gdpUsd}`);
|
||||
assert.ok(Math.abs(usa.debtToGdp - 120) < 0.001, `debtToGdp=${usa.debtToGdp}`);
|
||||
});
|
||||
|
||||
it('calculates per_second_rate from deficit %', () => {
|
||||
const debtPct = { JPN: { '2024': '260' } };
|
||||
const gdp = { JPN: { '2024': '4000' } }; // $4T
|
||||
const deficit = { JPN: { '2024': '-4' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, deficit, null);
|
||||
assert.equal(entries.length, 1);
|
||||
const jpn = entries[0];
|
||||
|
||||
const expectedPerSec = (0.04 * 4000e9) / (365.25 * 86400);
|
||||
assert.ok(Math.abs(jpn.perSecondRate - expectedPerSec) < 1, `perSecondRate=${jpn.perSecondRate}`);
|
||||
});
|
||||
|
||||
it('calculates annual_growth correctly', () => {
|
||||
const debtPct = { DEU: { '2024': '66', '2023': '60' } };
|
||||
const gdp = { DEU: { '2024': '4500' } };
|
||||
const deficit = {};
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, deficit, null);
|
||||
assert.equal(entries.length, 1);
|
||||
const deu = entries[0];
|
||||
|
||||
// annualGrowth = (66-60)/60 * 100 = 10%
|
||||
assert.ok(Math.abs(deu.annualGrowth - 10) < 0.01, `annualGrowth=${deu.annualGrowth}`);
|
||||
});
|
||||
|
||||
it('sets correct baseline_ts (2024-01-01 UTC)', () => {
|
||||
const debtPct = { GBR: { '2024': '100' } };
|
||||
const gdp = { GBR: { '2024': '3100' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
assert.equal(entries[0].baselineTs, BASELINE_TS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate filtering', () => {
|
||||
it('excludes regional aggregate codes', () => {
|
||||
const debtPct = {
|
||||
USA: { '2024': '120' },
|
||||
WEOWORLD: { '2024': '90' },
|
||||
EURO: { '2024': '85' },
|
||||
G20: { '2024': '100' },
|
||||
G7Q: { '2024': '100' }, // ends in Q
|
||||
};
|
||||
const gdp = {
|
||||
USA: { '2024': '28000' },
|
||||
WEOWORLD: { '2024': '100000' },
|
||||
EURO: { '2024': '15000' },
|
||||
G20: { '2024': '50000' },
|
||||
G7Q: { '2024': '30000' },
|
||||
};
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
const codes = entries.map(e => e.iso3);
|
||||
assert.ok(codes.includes('USA'), 'USA should be included');
|
||||
assert.ok(!codes.includes('WEOWORLD'), 'WEOWORLD should be excluded');
|
||||
assert.ok(!codes.includes('EURO'), 'EURO should be excluded');
|
||||
assert.ok(!codes.includes('G20'), 'G20 should be excluded');
|
||||
assert.ok(!codes.includes('G7Q'), 'G7Q (ends in Q) should be excluded');
|
||||
});
|
||||
|
||||
it('excludes territories (ABW, PRI, WBG)', () => {
|
||||
const debtPct = { ABW: { '2024': '50' }, PRI: { '2024': '50' }, WBG: { '2024': '50' }, BRA: { '2024': '90' } };
|
||||
const gdp = { ABW: { '2024': '3' }, PRI: { '2024': '100' }, WBG: { '2024': '10' }, BRA: { '2024': '2000' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
const codes = entries.map(e => e.iso3);
|
||||
assert.ok(!codes.includes('ABW'), 'ABW should be excluded');
|
||||
assert.ok(!codes.includes('PRI'), 'PRI should be excluded');
|
||||
assert.ok(!codes.includes('WBG'), 'WBG should be excluded');
|
||||
assert.ok(codes.includes('BRA'), 'BRA should be included');
|
||||
});
|
||||
|
||||
it('excludes non-3-char codes', () => {
|
||||
const debtPct = { US: { '2024': '120' }, USAA: { '2024': '120' }, USA: { '2024': '120' } };
|
||||
const gdp = { US: { '2024': '28000' }, USAA: { '2024': '28000' }, USA: { '2024': '28000' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
const codes = entries.map(e => e.iso3);
|
||||
assert.ok(!codes.includes('US'), '2-char code excluded');
|
||||
assert.ok(!codes.includes('USAA'), '4-char code excluded');
|
||||
assert.ok(codes.includes('USA'), '3-char code included');
|
||||
});
|
||||
});
|
||||
|
||||
describe('US Treasury override', () => {
|
||||
it('uses Treasury debtUsd for USA when provided', () => {
|
||||
const debtPct = { USA: { '2024': '120' } };
|
||||
const gdp = { USA: { '2024': '28000' } };
|
||||
const treasuryDebtUsd = 36_000_000_000_000; // $36T from Treasury
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, { debtUsd: treasuryDebtUsd, date: '2024-12-31' });
|
||||
assert.equal(entries.length, 1);
|
||||
assert.ok(Math.abs(entries[0].debtUsd - treasuryDebtUsd) < 1e6, `debtUsd should be Treasury value`);
|
||||
assert.ok(entries[0].source.includes('Treasury'), 'source should mention Treasury');
|
||||
});
|
||||
|
||||
it('falls back to IMF when Treasury returns null', () => {
|
||||
const debtPct = { USA: { '2024': '120' } };
|
||||
const gdp = { USA: { '2024': '28000' } };
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
const expectedDebt = (120 / 100) * 28000e9;
|
||||
assert.ok(Math.abs(entries[0].debtUsd - expectedDebt) < 1e6, 'fallback to IMF formula');
|
||||
assert.ok(!entries[0].source.includes('Treasury'), 'source should not mention Treasury');
|
||||
});
|
||||
});
|
||||
|
||||
describe('country count with realistic fixture', () => {
|
||||
it('produces at least 150 entries from realistic IMF data', () => {
|
||||
// Simulate 188 IMF WEO country entries (3-char codes, not aggregates)
|
||||
const SAMPLE_CODES = [
|
||||
'AFG','ALB','DZA','AGO','ARG','ARM','AUS','AUT','AZE','BHS',
|
||||
'BHR','BGD','BLR','BEL','BLZ','BEN','BTN','BOL','BIH','BWA',
|
||||
'BRA','BRN','BGR','BFA','BDI','CPV','KHM','CMR','CAN','CAF',
|
||||
'TCD','CHL','CHN','COL','COM','COD','COG','CRI','CIV','HRV',
|
||||
'CYP','CZE','DNK','DJI','DOM','ECU','EGY','SLV','GNQ','ERI',
|
||||
'EST','SWZ','ETH','FJI','FIN','FRA','GAB','GMB','GEO','DEU',
|
||||
'GHA','GRC','GTM','GIN','GNB','GUY','HTI','HND','HKG','HUN',
|
||||
'ISL','IND','IDN','IRN','IRQ','IRL','ISR','ITA','JAM','JPN',
|
||||
'JOR','KAZ','KEN','PRK','KOR','KWT','KGZ','LAO','LVA','LBN',
|
||||
'LSO','LBR','LBY','LTU','LUX','MAC','MDG','MWI','MYS','MDV',
|
||||
'MLI','MLT','MRT','MUS','MEX','MDA','MNG','MNE','MAR','MOZ',
|
||||
'MMR','NAM','NPL','NLD','NZL','NIC','NER','NGA','MKD','NOR',
|
||||
'OMN','PAK','PAN','PNG','PRY','PER','PHL','POL','PRT','QAT',
|
||||
'ROU','RUS','RWA','SAU','SEN','SRB','SLE','SGP','SVK','SVN',
|
||||
'SOM','ZAF','SSD','ESP','LKA','SDN','SUR','SWE','CHE','SYR',
|
||||
'TWN','TJK','TZA','THA','TLS','TGO','TTO','TUN','TUR','TKM',
|
||||
'UGA','UKR','ARE','GBR','USA','URY','UZB','VEN','VNM','YEM',
|
||||
'ZMB','ZWE',
|
||||
];
|
||||
|
||||
const debtPct = {};
|
||||
const gdp = {};
|
||||
for (const code of SAMPLE_CODES) {
|
||||
debtPct[code] = { '2024': '80' };
|
||||
gdp[code] = { '2024': '500' };
|
||||
}
|
||||
|
||||
const entries = computeEntries(debtPct, gdp, {}, null);
|
||||
assert.ok(entries.length >= 150, `Expected >=150 entries, got ${entries.length}`);
|
||||
});
|
||||
});
|
||||
78
tests/national-debt-ticker.test.mts
Normal file
78
tests/national-debt-ticker.test.mts
Normal file
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const BASELINE_TS = Date.UTC(2024, 0, 1);
|
||||
const SECONDS_PER_YEAR = 365.25 * 86400;
|
||||
|
||||
function getCurrentDebt(entry: { debtUsd: number; perSecondRate: number; baselineTs: number }, nowMs: number): number {
|
||||
const secondsElapsed = (nowMs - entry.baselineTs) / 1000;
|
||||
return entry.debtUsd + entry.perSecondRate * secondsElapsed;
|
||||
}
|
||||
|
||||
function formatDebt(usd: number): string {
|
||||
if (!Number.isFinite(usd) || usd <= 0) return '$0';
|
||||
if (usd >= 1e12) return `$${(usd / 1e12).toFixed(1)}T`;
|
||||
if (usd >= 1e9) return `$${(usd / 1e9).toFixed(1)}B`;
|
||||
if (usd >= 1e6) return `$${(usd / 1e6).toFixed(1)}M`;
|
||||
return `$${Math.round(usd).toLocaleString()}`;
|
||||
}
|
||||
|
||||
describe('getCurrentDebt ticking math', () => {
|
||||
it('returns base debt at baseline_ts', () => {
|
||||
const entry = { debtUsd: 33_600_000_000_000, perSecondRate: 10000, baselineTs: BASELINE_TS };
|
||||
const result = getCurrentDebt(entry, BASELINE_TS);
|
||||
assert.ok(Math.abs(result - 33_600_000_000_000) < 1, `Expected base debt, got ${result}`);
|
||||
});
|
||||
|
||||
it('accrues correctly after 1 hour', () => {
|
||||
const perSecondRate = 50_000;
|
||||
const entry = { debtUsd: 33_600_000_000_000, perSecondRate, baselineTs: BASELINE_TS };
|
||||
const oneHourLater = BASELINE_TS + 3600 * 1000;
|
||||
const result = getCurrentDebt(entry, oneHourLater);
|
||||
const expected = 33_600_000_000_000 + perSecondRate * 3600;
|
||||
assert.ok(Math.abs(result - expected) < 1, `Expected ${expected}, got ${result}`);
|
||||
});
|
||||
|
||||
it('accrues correctly after 1 year', () => {
|
||||
const deficitPct = 5;
|
||||
const gdpUsd = 28_000_000_000_000;
|
||||
const perSecondRate = (deficitPct / 100) * gdpUsd / SECONDS_PER_YEAR;
|
||||
const entry = { debtUsd: 33_600_000_000_000, perSecondRate, baselineTs: BASELINE_TS };
|
||||
const oneYearLater = BASELINE_TS + Math.round(SECONDS_PER_YEAR * 1000);
|
||||
const result = getCurrentDebt(entry, oneYearLater);
|
||||
const expectedAccrual = (deficitPct / 100) * gdpUsd;
|
||||
const accrued = result - entry.debtUsd;
|
||||
assert.ok(Math.abs(accrued - expectedAccrual) < 1000, `Accrued ${accrued}, expected ~${expectedAccrual}`);
|
||||
});
|
||||
|
||||
it('zero perSecondRate keeps debt flat', () => {
|
||||
const entry = { debtUsd: 1_000_000_000_000, perSecondRate: 0, baselineTs: BASELINE_TS };
|
||||
const later = BASELINE_TS + 86400_000;
|
||||
const result = getCurrentDebt(entry, later);
|
||||
assert.ok(Math.abs(result - 1_000_000_000_000) < 1, 'Debt should be flat with zero rate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDebt', () => {
|
||||
it('formats trillions', () => {
|
||||
assert.equal(formatDebt(33_600_000_000_000), '$33.6T');
|
||||
assert.equal(formatDebt(1_000_000_000_000), '$1.0T');
|
||||
assert.equal(formatDebt(100_000_000_000_000), '$100.0T');
|
||||
});
|
||||
|
||||
it('formats billions', () => {
|
||||
assert.equal(formatDebt(913_200_000_000), '$913.2B');
|
||||
assert.equal(formatDebt(1_000_000_000), '$1.0B');
|
||||
});
|
||||
|
||||
it('formats millions', () => {
|
||||
assert.equal(formatDebt(12_300_000), '$12.3M');
|
||||
assert.equal(formatDebt(1_000_000), '$1.0M');
|
||||
});
|
||||
|
||||
it('handles zero and non-finite', () => {
|
||||
assert.equal(formatDebt(0), '$0');
|
||||
assert.equal(formatDebt(NaN), '$0');
|
||||
assert.equal(formatDebt(-1), '$0');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user