From c658b8eb943f3e5401cdb64c9295a67bac088c6e Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 20 Mar 2026 16:08:48 +0400 Subject: [PATCH] =?UTF-8?q?feat(economic):=20National=20Debt=20Clock=20?= =?UTF-8?q?=E2=80=94=20live=20ticking=20debt=20estimates=20for=20180+=20co?= =?UTF-8?q?untries=20(#1923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- api/bootstrap.js | 2 + api/health.js | 2 + docs/api/EconomicService.openapi.json | 2 +- docs/api/EconomicService.openapi.yaml | 81 +++++ .../economic/v1/get_national_debt.proto | 37 +++ proto/worldmonitor/economic/v1/service.proto | 6 + scripts/seed-national-debt.mjs | 150 ++++++++++ server/_shared/cache-keys.ts | 2 + server/gateway.ts | 1 + .../economic/v1/get-national-debt.ts | 35 +++ server/worldmonitor/economic/v1/handler.ts | 2 + src/app/panel-layout.ts | 8 + src/components/NationalDebtPanel.ts | 277 ++++++++++++++++++ src/components/index.ts | 1 + src/config/panels.ts | 1 + .../economic/v1/service_client.ts | 44 +++ .../economic/v1/service_server.ts | 59 ++++ src/services/economic/index.ts | 33 +++ src/styles/main.css | 152 ++++++++++ tests/national-debt-seed.test.mjs | 168 +++++++++++ tests/national-debt-ticker.test.mts | 78 +++++ 21 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 proto/worldmonitor/economic/v1/get_national_debt.proto create mode 100644 scripts/seed-national-debt.mjs create mode 100644 server/worldmonitor/economic/v1/get-national-debt.ts create mode 100644 src/components/NationalDebtPanel.ts create mode 100644 tests/national-debt-seed.test.mjs create mode 100644 tests/national-debt-ticker.test.mts diff --git a/api/bootstrap.js b/api/bootstrap.js index a63c05bcd..fc2b9f4b0 100644 --- a/api/bootstrap.js +++ b/api/bootstrap.js @@ -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', diff --git a/api/health.js b/api/health.js index 9fc630832..b2ab879b2 100644 --- a/api/health.js +++ b/api/health.js @@ -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 }, }; diff --git a/docs/api/EconomicService.openapi.json b/docs/api/EconomicService.openapi.json index 92ee57ee9..71b9ae3f3 100644 --- a/docs/api/EconomicService.openapi.json +++ b/docs/api/EconomicService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BisCreditToGdp":{"description":"BisCreditToGdp represents total credit as percentage of GDP from BIS.","properties":{"countryCode":{"description":"ISO 2-letter country code.","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"creditGdpRatio":{"description":"Total credit as percentage of GDP.","format":"double","type":"number"},"date":{"description":"Date as YYYY-QN.","type":"string"},"previousRatio":{"description":"Previous quarter ratio.","format":"double","type":"number"}},"type":"object"},"BisExchangeRate":{"description":"BisExchangeRate represents effective exchange rate indices from BIS.","properties":{"countryCode":{"description":"ISO 2-letter country code.","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"date":{"description":"Date as YYYY-MM.","type":"string"},"nominalEer":{"description":"Nominal effective exchange rate index.","format":"double","type":"number"},"realChange":{"description":"Percentage change from previous period (real).","format":"double","type":"number"},"realEer":{"description":"Real effective exchange rate index.","format":"double","type":"number"}},"type":"object"},"BisPolicyRate":{"description":"BisPolicyRate represents a central bank policy rate from BIS.","properties":{"centralBank":{"description":"Central bank name (e.g. \"Federal Reserve\").","type":"string"},"countryCode":{"description":"ISO 2-letter country code (US, GB, JP, etc.)","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"date":{"description":"Date as YYYY-MM.","type":"string"},"previousRate":{"description":"Previous period rate percentage.","format":"double","type":"number"},"rate":{"description":"Current policy rate percentage.","format":"double","type":"number"}},"type":"object"},"EnergyCapacitySeries":{"properties":{"data":{"items":{"$ref":"#/components/schemas/EnergyCapacityYear"},"type":"array"},"energySource":{"type":"string"},"name":{"type":"string"}},"type":"object"},"EnergyCapacityYear":{"properties":{"capacityMw":{"format":"double","type":"number"},"year":{"format":"int32","type":"integer"}},"type":"object"},"EnergyPrice":{"description":"EnergyPrice represents a current energy commodity price from EIA.","properties":{"change":{"description":"Percentage change from previous period.","format":"double","type":"number"},"commodity":{"description":"Energy commodity identifier.","minLength":1,"type":"string"},"name":{"description":"Human-readable name (e.g., \"WTI Crude Oil\", \"Henry Hub Natural Gas\").","type":"string"},"price":{"description":"Current price in USD.","format":"double","type":"number"},"priceAt":{"description":"Price date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"unit":{"description":"Unit of measurement (e.g., \"$/barrel\", \"$/MMBtu\").","type":"string"}},"required":["commodity"],"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FearGreedHistoryEntry":{"description":"FearGreedHistoryEntry is a single day's Fear \u0026 Greed index reading.","properties":{"date":{"description":"Date string (YYYY-MM-DD).","type":"string"},"value":{"description":"Index value (0-100).","format":"int32","maximum":100,"minimum":0,"type":"integer"}},"type":"object"},"FearGreedSignal":{"description":"FearGreedSignal tracks the Crypto Fear \u0026 Greed index.","properties":{"history":{"items":{"$ref":"#/components/schemas/FearGreedHistoryEntry"},"type":"array"},"status":{"description":"Classification label (e.g., \"Extreme Fear\", \"Greed\").","type":"string"},"value":{"description":"Current index value (0-100).","format":"int32","type":"integer"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowStructureSignal":{"description":"FlowStructureSignal compares BTC vs QQQ 5-day returns.","properties":{"btcReturn5":{"description":"BTC 5-day return percentage.","format":"double","type":"number"},"qqqReturn5":{"description":"QQQ 5-day return percentage.","format":"double","type":"number"},"status":{"description":"\"PASSIVE GAP\", \"ALIGNED\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"FredObservation":{"description":"FredObservation represents a single data point from a FRED economic series.","properties":{"date":{"description":"Observation date as YYYY-MM-DD string.","type":"string"},"value":{"description":"Observation value.","format":"double","type":"number"}},"type":"object"},"FredSeries":{"description":"FredSeries represents a FRED time series with metadata.","properties":{"frequency":{"description":"Data frequency (e.g., \"Monthly\", \"Quarterly\").","type":"string"},"observations":{"items":{"$ref":"#/components/schemas/FredObservation"},"type":"array"},"seriesId":{"description":"Series identifier (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","minLength":1,"type":"string"},"title":{"description":"Series title.","type":"string"},"units":{"description":"Unit of measurement.","type":"string"}},"required":["seriesId"],"type":"object"},"GetBisCreditRequest":{"description":"GetBisCreditRequest requests credit-to-GDP ratio data.","type":"object"},"GetBisCreditResponse":{"description":"GetBisCreditResponse contains BIS credit-to-GDP data.","properties":{"entries":{"items":{"$ref":"#/components/schemas/BisCreditToGdp"},"type":"array"}},"type":"object"},"GetBisExchangeRatesRequest":{"description":"GetBisExchangeRatesRequest requests effective exchange rates.","type":"object"},"GetBisExchangeRatesResponse":{"description":"GetBisExchangeRatesResponse contains BIS effective exchange rate data.","properties":{"rates":{"items":{"$ref":"#/components/schemas/BisExchangeRate"},"type":"array"}},"type":"object"},"GetBisPolicyRatesRequest":{"description":"GetBisPolicyRatesRequest requests central bank policy rates.","type":"object"},"GetBisPolicyRatesResponse":{"description":"GetBisPolicyRatesResponse contains BIS policy rate data.","properties":{"rates":{"items":{"$ref":"#/components/schemas/BisPolicyRate"},"type":"array"}},"type":"object"},"GetEnergyCapacityRequest":{"properties":{"energySources":{"items":{"description":"Energy source codes to query (e.g., \"SUN\", \"WND\", \"COL\").\n Empty returns all tracked sources (SUN, WND, COL).","type":"string"},"type":"array"},"years":{"description":"Number of years of historical data. Default 20 if not set.","format":"int32","type":"integer"}},"type":"object"},"GetEnergyCapacityResponse":{"properties":{"series":{"items":{"$ref":"#/components/schemas/EnergyCapacitySeries"},"type":"array"}},"type":"object"},"GetEnergyPricesRequest":{"description":"GetEnergyPricesRequest specifies which energy commodities to retrieve.","properties":{"commodities":{"items":{"description":"Optional commodity filter. Empty returns all tracked commodities.","type":"string"},"type":"array"}},"type":"object"},"GetEnergyPricesResponse":{"description":"GetEnergyPricesResponse contains energy price data.","properties":{"prices":{"items":{"$ref":"#/components/schemas/EnergyPrice"},"type":"array"}},"type":"object"},"GetFredSeriesBatchRequest":{"description":"GetFredSeriesBatchRequest looks up multiple FRED series in a single call.","properties":{"limit":{"description":"Maximum number of observations per series. Defaults to 120.","format":"int32","type":"integer"},"seriesIds":{"items":{"description":"FRED series IDs (e.g., \"WALCL\", \"FEDFUNDS\"). Max 10.","maxItems":10,"minItems":1,"type":"string"},"maxItems":10,"minItems":1,"type":"array"}},"type":"object"},"GetFredSeriesBatchResponse":{"description":"GetFredSeriesBatchResponse contains the requested FRED series data.","properties":{"fetched":{"description":"Number of series successfully fetched.","format":"int32","type":"integer"},"requested":{"description":"Number of series requested.","format":"int32","type":"integer"},"results":{"additionalProperties":{"$ref":"#/components/schemas/FredSeries"},"description":"Map of series_id -\u003e FRED series for found series.","type":"object"}},"type":"object"},"GetFredSeriesRequest":{"description":"GetFredSeriesRequest specifies which FRED series to retrieve.","properties":{"limit":{"description":"Maximum number of observations to return. Defaults to 120.","format":"int32","type":"integer"},"seriesId":{"description":"FRED series ID (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","minLength":1,"type":"string"}},"required":["seriesId"],"type":"object"},"GetFredSeriesResponse":{"description":"GetFredSeriesResponse contains the requested FRED series data.","properties":{"series":{"$ref":"#/components/schemas/FredSeries"}},"type":"object"},"GetMacroSignalsRequest":{"description":"GetMacroSignalsRequest requests the current macro signal dashboard.","type":"object"},"GetMacroSignalsResponse":{"description":"GetMacroSignalsResponse contains the full macro signal dashboard with 7 signals and verdict.","properties":{"bullishCount":{"description":"Number of bullish signals.","format":"int32","type":"integer"},"meta":{"$ref":"#/components/schemas/MacroMeta"},"signals":{"$ref":"#/components/schemas/MacroSignals"},"timestamp":{"description":"ISO 8601 timestamp of computation.","type":"string"},"totalCount":{"description":"Total number of evaluated signals (excluding UNKNOWN).","format":"int32","type":"integer"},"unavailable":{"description":"True when upstream data is unavailable (fallback result).","type":"boolean"},"verdict":{"description":"Overall verdict: \"BUY\", \"CASH\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"HashRateSignal":{"description":"HashRateSignal tracks Bitcoin hash rate momentum.","properties":{"change30d":{"description":"Hash rate change over 30 days as percentage.","format":"double","type":"number"},"status":{"description":"\"GROWING\", \"DECLINING\", \"STABLE\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"LiquiditySignal":{"description":"LiquiditySignal tracks JPY 30d rate of change as a liquidity proxy.","properties":{"sparkline":{"items":{"description":"Last 30 JPY close prices.","format":"double","type":"number"},"type":"array"},"status":{"description":"\"SQUEEZE\", \"NORMAL\", or \"UNKNOWN\".","type":"string"},"value":{"description":"JPY 30d ROC percentage, absent if unavailable.","format":"double","type":"number"}},"type":"object"},"ListWorldBankIndicatorsRequest":{"description":"ListWorldBankIndicatorsRequest specifies filters for retrieving World Bank data.","properties":{"countryCode":{"description":"Optional country filter (ISO 3166-1 alpha-2).","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"indicatorCode":{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","minLength":1,"type":"string"},"pageSize":{"description":"Maximum items per page.","format":"int32","type":"integer"},"year":{"description":"Optional year filter. Defaults to latest available.","format":"int32","type":"integer"}},"required":["indicatorCode"],"type":"object"},"ListWorldBankIndicatorsResponse":{"description":"ListWorldBankIndicatorsResponse contains World Bank indicator data.","properties":{"data":{"items":{"$ref":"#/components/schemas/WorldBankCountryData"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"MacroMeta":{"description":"MacroMeta contains supplementary chart data.","properties":{"qqqSparkline":{"items":{"description":"Last 30 QQQ close prices for sparkline.","format":"double","type":"number"},"type":"array"}},"type":"object"},"MacroRegimeSignal":{"description":"MacroRegimeSignal compares QQQ vs XLP 20-day rate of change.","properties":{"qqqRoc20":{"description":"QQQ 20d ROC percentage.","format":"double","type":"number"},"status":{"description":"\"RISK-ON\", \"DEFENSIVE\", or \"UNKNOWN\".","type":"string"},"xlpRoc20":{"description":"XLP 20d ROC percentage.","format":"double","type":"number"}},"type":"object"},"MacroSignals":{"description":"MacroSignals contains all 7 individual signal computations.","properties":{"fearGreed":{"$ref":"#/components/schemas/FearGreedSignal"},"flowStructure":{"$ref":"#/components/schemas/FlowStructureSignal"},"hashRate":{"$ref":"#/components/schemas/HashRateSignal"},"liquidity":{"$ref":"#/components/schemas/LiquiditySignal"},"macroRegime":{"$ref":"#/components/schemas/MacroRegimeSignal"},"priceMomentum":{"$ref":"#/components/schemas/PriceMomentumSignal"},"technicalTrend":{"$ref":"#/components/schemas/TechnicalTrendSignal"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"PriceMomentumSignal":{"description":"PriceMomentumSignal uses the Mayer Multiple (price/SMA200) as a market-adaptive signal.","properties":{"status":{"description":"\"STRONG\", \"MODERATE\", \"WEAK\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"ResultsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/FredSeries"}},"type":"object"},"TechnicalTrendSignal":{"description":"TechnicalTrendSignal evaluates BTC price vs moving averages and VWAP.","properties":{"btcPrice":{"description":"Current BTC price.","format":"double","type":"number"},"mayerMultiple":{"description":"Mayer multiple (BTC price / SMA200).","format":"double","type":"number"},"sma200":{"description":"200-day simple moving average.","format":"double","type":"number"},"sma50":{"description":"50-day simple moving average.","format":"double","type":"number"},"sparkline":{"items":{"description":"Last 30 BTC close prices.","format":"double","type":"number"},"type":"array"},"status":{"description":"\"BULLISH\", \"BEARISH\", \"NEUTRAL\", or \"UNKNOWN\".","type":"string"},"vwap30d":{"description":"30-day volume-weighted average price.","format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"},"WorldBankCountryData":{"description":"WorldBankCountryData represents a World Bank indicator value for a country.","properties":{"countryCode":{"description":"ISO 3166-1 alpha-2 country code.","minLength":1,"type":"string"},"countryName":{"description":"Country name.","type":"string"},"indicatorCode":{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","minLength":1,"type":"string"},"indicatorName":{"description":"Indicator name.","type":"string"},"value":{"description":"Indicator value.","format":"double","type":"number"},"year":{"description":"Data year.","format":"int32","type":"integer"}},"required":["countryCode","indicatorCode"],"type":"object"}}},"info":{"title":"EconomicService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/economic/v1/get-bis-credit":{"get":{"description":"GetBisCredit retrieves credit-to-GDP ratio data from BIS.","operationId":"GetBisCredit","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisCreditResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisCredit","tags":["EconomicService"]}},"/api/economic/v1/get-bis-exchange-rates":{"get":{"description":"GetBisExchangeRates retrieves effective exchange rates from BIS.","operationId":"GetBisExchangeRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisExchangeRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisExchangeRates","tags":["EconomicService"]}},"/api/economic/v1/get-bis-policy-rates":{"get":{"description":"GetBisPolicyRates retrieves central bank policy rates from BIS.","operationId":"GetBisPolicyRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisPolicyRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisPolicyRates","tags":["EconomicService"]}},"/api/economic/v1/get-energy-capacity":{"get":{"description":"GetEnergyCapacity retrieves installed capacity data (solar, wind, coal) from EIA.","operationId":"GetEnergyCapacity","parameters":[{"description":"Energy source codes to query (e.g., \"SUN\", \"WND\", \"COL\").\n Empty returns all tracked sources (SUN, WND, COL).","in":"query","name":"energy_sources","required":false,"schema":{"type":"string"}},{"description":"Number of years of historical data. Default 20 if not set.","in":"query","name":"years","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetEnergyCapacityResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetEnergyCapacity","tags":["EconomicService"]}},"/api/economic/v1/get-energy-prices":{"get":{"description":"GetEnergyPrices retrieves current energy commodity prices from EIA.","operationId":"GetEnergyPrices","parameters":[{"description":"Optional commodity filter. Empty returns all tracked commodities.","in":"query","name":"commodities","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetEnergyPricesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetEnergyPrices","tags":["EconomicService"]}},"/api/economic/v1/get-fred-series":{"get":{"description":"GetFredSeries retrieves time series data from the Federal Reserve Economic Data.","operationId":"GetFredSeries","parameters":[{"description":"FRED series ID (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","in":"query","name":"series_id","required":false,"schema":{"type":"string"}},{"description":"Maximum number of observations to return. Defaults to 120.","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetFredSeries","tags":["EconomicService"]}},"/api/economic/v1/get-fred-series-batch":{"post":{"description":"GetFredSeriesBatch retrieves multiple FRED series in a single call.","operationId":"GetFredSeriesBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesBatchRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesBatchResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetFredSeriesBatch","tags":["EconomicService"]}},"/api/economic/v1/get-macro-signals":{"get":{"description":"GetMacroSignals computes 7 macro signals from 6 upstream sources with BUY/CASH verdict.","operationId":"GetMacroSignals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMacroSignalsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetMacroSignals","tags":["EconomicService"]}},"/api/economic/v1/list-world-bank-indicators":{"get":{"description":"ListWorldBankIndicators retrieves development indicator data from the World Bank.","operationId":"ListWorldBankIndicators","parameters":[{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","in":"query","name":"indicator_code","required":false,"schema":{"type":"string"}},{"description":"Optional country filter (ISO 3166-1 alpha-2).","in":"query","name":"country_code","required":false,"schema":{"type":"string"}},{"description":"Optional year filter. Defaults to latest available.","in":"query","name":"year","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Maximum items per page.","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorldBankIndicatorsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListWorldBankIndicators","tags":["EconomicService"]}}}} \ No newline at end of file +{"components":{"schemas":{"BisCreditToGdp":{"description":"BisCreditToGdp represents total credit as percentage of GDP from BIS.","properties":{"countryCode":{"description":"ISO 2-letter country code.","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"creditGdpRatio":{"description":"Total credit as percentage of GDP.","format":"double","type":"number"},"date":{"description":"Date as YYYY-QN.","type":"string"},"previousRatio":{"description":"Previous quarter ratio.","format":"double","type":"number"}},"type":"object"},"BisExchangeRate":{"description":"BisExchangeRate represents effective exchange rate indices from BIS.","properties":{"countryCode":{"description":"ISO 2-letter country code.","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"date":{"description":"Date as YYYY-MM.","type":"string"},"nominalEer":{"description":"Nominal effective exchange rate index.","format":"double","type":"number"},"realChange":{"description":"Percentage change from previous period (real).","format":"double","type":"number"},"realEer":{"description":"Real effective exchange rate index.","format":"double","type":"number"}},"type":"object"},"BisPolicyRate":{"description":"BisPolicyRate represents a central bank policy rate from BIS.","properties":{"centralBank":{"description":"Central bank name (e.g. \"Federal Reserve\").","type":"string"},"countryCode":{"description":"ISO 2-letter country code (US, GB, JP, etc.)","type":"string"},"countryName":{"description":"Country or region name.","type":"string"},"date":{"description":"Date as YYYY-MM.","type":"string"},"previousRate":{"description":"Previous period rate percentage.","format":"double","type":"number"},"rate":{"description":"Current policy rate percentage.","format":"double","type":"number"}},"type":"object"},"EnergyCapacitySeries":{"properties":{"data":{"items":{"$ref":"#/components/schemas/EnergyCapacityYear"},"type":"array"},"energySource":{"type":"string"},"name":{"type":"string"}},"type":"object"},"EnergyCapacityYear":{"properties":{"capacityMw":{"format":"double","type":"number"},"year":{"format":"int32","type":"integer"}},"type":"object"},"EnergyPrice":{"description":"EnergyPrice represents a current energy commodity price from EIA.","properties":{"change":{"description":"Percentage change from previous period.","format":"double","type":"number"},"commodity":{"description":"Energy commodity identifier.","minLength":1,"type":"string"},"name":{"description":"Human-readable name (e.g., \"WTI Crude Oil\", \"Henry Hub Natural Gas\").","type":"string"},"price":{"description":"Current price in USD.","format":"double","type":"number"},"priceAt":{"description":"Price date, as Unix epoch milliseconds.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"unit":{"description":"Unit of measurement (e.g., \"$/barrel\", \"$/MMBtu\").","type":"string"}},"required":["commodity"],"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FearGreedHistoryEntry":{"description":"FearGreedHistoryEntry is a single day's Fear \u0026 Greed index reading.","properties":{"date":{"description":"Date string (YYYY-MM-DD).","type":"string"},"value":{"description":"Index value (0-100).","format":"int32","maximum":100,"minimum":0,"type":"integer"}},"type":"object"},"FearGreedSignal":{"description":"FearGreedSignal tracks the Crypto Fear \u0026 Greed index.","properties":{"history":{"items":{"$ref":"#/components/schemas/FearGreedHistoryEntry"},"type":"array"},"status":{"description":"Classification label (e.g., \"Extreme Fear\", \"Greed\").","type":"string"},"value":{"description":"Current index value (0-100).","format":"int32","type":"integer"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowStructureSignal":{"description":"FlowStructureSignal compares BTC vs QQQ 5-day returns.","properties":{"btcReturn5":{"description":"BTC 5-day return percentage.","format":"double","type":"number"},"qqqReturn5":{"description":"QQQ 5-day return percentage.","format":"double","type":"number"},"status":{"description":"\"PASSIVE GAP\", \"ALIGNED\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"FredObservation":{"description":"FredObservation represents a single data point from a FRED economic series.","properties":{"date":{"description":"Observation date as YYYY-MM-DD string.","type":"string"},"value":{"description":"Observation value.","format":"double","type":"number"}},"type":"object"},"FredSeries":{"description":"FredSeries represents a FRED time series with metadata.","properties":{"frequency":{"description":"Data frequency (e.g., \"Monthly\", \"Quarterly\").","type":"string"},"observations":{"items":{"$ref":"#/components/schemas/FredObservation"},"type":"array"},"seriesId":{"description":"Series identifier (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","minLength":1,"type":"string"},"title":{"description":"Series title.","type":"string"},"units":{"description":"Unit of measurement.","type":"string"}},"required":["seriesId"],"type":"object"},"GetBisCreditRequest":{"description":"GetBisCreditRequest requests credit-to-GDP ratio data.","type":"object"},"GetBisCreditResponse":{"description":"GetBisCreditResponse contains BIS credit-to-GDP data.","properties":{"entries":{"items":{"$ref":"#/components/schemas/BisCreditToGdp"},"type":"array"}},"type":"object"},"GetBisExchangeRatesRequest":{"description":"GetBisExchangeRatesRequest requests effective exchange rates.","type":"object"},"GetBisExchangeRatesResponse":{"description":"GetBisExchangeRatesResponse contains BIS effective exchange rate data.","properties":{"rates":{"items":{"$ref":"#/components/schemas/BisExchangeRate"},"type":"array"}},"type":"object"},"GetBisPolicyRatesRequest":{"description":"GetBisPolicyRatesRequest requests central bank policy rates.","type":"object"},"GetBisPolicyRatesResponse":{"description":"GetBisPolicyRatesResponse contains BIS policy rate data.","properties":{"rates":{"items":{"$ref":"#/components/schemas/BisPolicyRate"},"type":"array"}},"type":"object"},"GetEnergyCapacityRequest":{"properties":{"energySources":{"items":{"description":"Energy source codes to query (e.g., \"SUN\", \"WND\", \"COL\").\n Empty returns all tracked sources (SUN, WND, COL).","type":"string"},"type":"array"},"years":{"description":"Number of years of historical data. Default 20 if not set.","format":"int32","type":"integer"}},"type":"object"},"GetEnergyCapacityResponse":{"properties":{"series":{"items":{"$ref":"#/components/schemas/EnergyCapacitySeries"},"type":"array"}},"type":"object"},"GetEnergyPricesRequest":{"description":"GetEnergyPricesRequest specifies which energy commodities to retrieve.","properties":{"commodities":{"items":{"description":"Optional commodity filter. Empty returns all tracked commodities.","type":"string"},"type":"array"}},"type":"object"},"GetEnergyPricesResponse":{"description":"GetEnergyPricesResponse contains energy price data.","properties":{"prices":{"items":{"$ref":"#/components/schemas/EnergyPrice"},"type":"array"}},"type":"object"},"GetFredSeriesBatchRequest":{"description":"GetFredSeriesBatchRequest looks up multiple FRED series in a single call.","properties":{"limit":{"description":"Maximum number of observations per series. Defaults to 120.","format":"int32","type":"integer"},"seriesIds":{"items":{"description":"FRED series IDs (e.g., \"WALCL\", \"FEDFUNDS\"). Max 10.","maxItems":10,"minItems":1,"type":"string"},"maxItems":10,"minItems":1,"type":"array"}},"type":"object"},"GetFredSeriesBatchResponse":{"description":"GetFredSeriesBatchResponse contains the requested FRED series data.","properties":{"fetched":{"description":"Number of series successfully fetched.","format":"int32","type":"integer"},"requested":{"description":"Number of series requested.","format":"int32","type":"integer"},"results":{"additionalProperties":{"$ref":"#/components/schemas/FredSeries"},"description":"Map of series_id -\u003e FRED series for found series.","type":"object"}},"type":"object"},"GetFredSeriesRequest":{"description":"GetFredSeriesRequest specifies which FRED series to retrieve.","properties":{"limit":{"description":"Maximum number of observations to return. Defaults to 120.","format":"int32","type":"integer"},"seriesId":{"description":"FRED series ID (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","minLength":1,"type":"string"}},"required":["seriesId"],"type":"object"},"GetFredSeriesResponse":{"description":"GetFredSeriesResponse contains the requested FRED series data.","properties":{"series":{"$ref":"#/components/schemas/FredSeries"}},"type":"object"},"GetMacroSignalsRequest":{"description":"GetMacroSignalsRequest requests the current macro signal dashboard.","type":"object"},"GetMacroSignalsResponse":{"description":"GetMacroSignalsResponse contains the full macro signal dashboard with 7 signals and verdict.","properties":{"bullishCount":{"description":"Number of bullish signals.","format":"int32","type":"integer"},"meta":{"$ref":"#/components/schemas/MacroMeta"},"signals":{"$ref":"#/components/schemas/MacroSignals"},"timestamp":{"description":"ISO 8601 timestamp of computation.","type":"string"},"totalCount":{"description":"Total number of evaluated signals (excluding UNKNOWN).","format":"int32","type":"integer"},"unavailable":{"description":"True when upstream data is unavailable (fallback result).","type":"boolean"},"verdict":{"description":"Overall verdict: \"BUY\", \"CASH\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"GetNationalDebtRequest":{"description":"GetNationalDebtRequest requests national debt data for all countries.","type":"object"},"GetNationalDebtResponse":{"description":"GetNationalDebtResponse wraps the full list of national debt entries.","properties":{"entries":{"items":{"$ref":"#/components/schemas/NationalDebtEntry"},"type":"array"},"seededAt":{"description":"ISO 8601 timestamp when seed data was written.","type":"string"},"unavailable":{"description":"True when upstream data is unavailable (fallback result).","type":"boolean"}},"type":"object"},"HashRateSignal":{"description":"HashRateSignal tracks Bitcoin hash rate momentum.","properties":{"change30d":{"description":"Hash rate change over 30 days as percentage.","format":"double","type":"number"},"status":{"description":"\"GROWING\", \"DECLINING\", \"STABLE\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"LiquiditySignal":{"description":"LiquiditySignal tracks JPY 30d rate of change as a liquidity proxy.","properties":{"sparkline":{"items":{"description":"Last 30 JPY close prices.","format":"double","type":"number"},"type":"array"},"status":{"description":"\"SQUEEZE\", \"NORMAL\", or \"UNKNOWN\".","type":"string"},"value":{"description":"JPY 30d ROC percentage, absent if unavailable.","format":"double","type":"number"}},"type":"object"},"ListWorldBankIndicatorsRequest":{"description":"ListWorldBankIndicatorsRequest specifies filters for retrieving World Bank data.","properties":{"countryCode":{"description":"Optional country filter (ISO 3166-1 alpha-2).","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"indicatorCode":{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","minLength":1,"type":"string"},"pageSize":{"description":"Maximum items per page.","format":"int32","type":"integer"},"year":{"description":"Optional year filter. Defaults to latest available.","format":"int32","type":"integer"}},"required":["indicatorCode"],"type":"object"},"ListWorldBankIndicatorsResponse":{"description":"ListWorldBankIndicatorsResponse contains World Bank indicator data.","properties":{"data":{"items":{"$ref":"#/components/schemas/WorldBankCountryData"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"MacroMeta":{"description":"MacroMeta contains supplementary chart data.","properties":{"qqqSparkline":{"items":{"description":"Last 30 QQQ close prices for sparkline.","format":"double","type":"number"},"type":"array"}},"type":"object"},"MacroRegimeSignal":{"description":"MacroRegimeSignal compares QQQ vs XLP 20-day rate of change.","properties":{"qqqRoc20":{"description":"QQQ 20d ROC percentage.","format":"double","type":"number"},"status":{"description":"\"RISK-ON\", \"DEFENSIVE\", or \"UNKNOWN\".","type":"string"},"xlpRoc20":{"description":"XLP 20d ROC percentage.","format":"double","type":"number"}},"type":"object"},"MacroSignals":{"description":"MacroSignals contains all 7 individual signal computations.","properties":{"fearGreed":{"$ref":"#/components/schemas/FearGreedSignal"},"flowStructure":{"$ref":"#/components/schemas/FlowStructureSignal"},"hashRate":{"$ref":"#/components/schemas/HashRateSignal"},"liquidity":{"$ref":"#/components/schemas/LiquiditySignal"},"macroRegime":{"$ref":"#/components/schemas/MacroRegimeSignal"},"priceMomentum":{"$ref":"#/components/schemas/PriceMomentumSignal"},"technicalTrend":{"$ref":"#/components/schemas/TechnicalTrendSignal"}},"type":"object"},"NationalDebtEntry":{"description":"NationalDebtEntry holds debt data for a single country.","properties":{"annualGrowth":{"description":"Year-over-year debt growth percent (2023-\u003e2024).","format":"double","type":"number"},"baselineTs":{"description":"UTC ms timestamp anchoring the debt_usd figure (2024-01-01T00:00:00Z).","format":"int64","type":"string"},"debtToGdp":{"description":"Debt as % of GDP.","format":"double","type":"number"},"debtUsd":{"description":"Total debt in USD at baseline_ts.","format":"double","type":"number"},"gdpUsd":{"description":"GDP in USD (nominal, latest year).","format":"double","type":"number"},"iso3":{"description":"ISO3 country code (e.g. \"USA\").","type":"string"},"perDayRate":{"description":"Deficit-derived accrual in USD per day.","format":"double","type":"number"},"perSecondRate":{"description":"Deficit-derived accrual in USD per second.","format":"double","type":"number"},"source":{"description":"Human-readable source string.","type":"string"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"PriceMomentumSignal":{"description":"PriceMomentumSignal uses the Mayer Multiple (price/SMA200) as a market-adaptive signal.","properties":{"status":{"description":"\"STRONG\", \"MODERATE\", \"WEAK\", or \"UNKNOWN\".","type":"string"}},"type":"object"},"ResultsEntry":{"properties":{"key":{"type":"string"},"value":{"$ref":"#/components/schemas/FredSeries"}},"type":"object"},"TechnicalTrendSignal":{"description":"TechnicalTrendSignal evaluates BTC price vs moving averages and VWAP.","properties":{"btcPrice":{"description":"Current BTC price.","format":"double","type":"number"},"mayerMultiple":{"description":"Mayer multiple (BTC price / SMA200).","format":"double","type":"number"},"sma200":{"description":"200-day simple moving average.","format":"double","type":"number"},"sma50":{"description":"50-day simple moving average.","format":"double","type":"number"},"sparkline":{"items":{"description":"Last 30 BTC close prices.","format":"double","type":"number"},"type":"array"},"status":{"description":"\"BULLISH\", \"BEARISH\", \"NEUTRAL\", or \"UNKNOWN\".","type":"string"},"vwap30d":{"description":"30-day volume-weighted average price.","format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"},"WorldBankCountryData":{"description":"WorldBankCountryData represents a World Bank indicator value for a country.","properties":{"countryCode":{"description":"ISO 3166-1 alpha-2 country code.","minLength":1,"type":"string"},"countryName":{"description":"Country name.","type":"string"},"indicatorCode":{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","minLength":1,"type":"string"},"indicatorName":{"description":"Indicator name.","type":"string"},"value":{"description":"Indicator value.","format":"double","type":"number"},"year":{"description":"Data year.","format":"int32","type":"integer"}},"required":["countryCode","indicatorCode"],"type":"object"}}},"info":{"title":"EconomicService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/economic/v1/get-bis-credit":{"get":{"description":"GetBisCredit retrieves credit-to-GDP ratio data from BIS.","operationId":"GetBisCredit","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisCreditResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisCredit","tags":["EconomicService"]}},"/api/economic/v1/get-bis-exchange-rates":{"get":{"description":"GetBisExchangeRates retrieves effective exchange rates from BIS.","operationId":"GetBisExchangeRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisExchangeRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisExchangeRates","tags":["EconomicService"]}},"/api/economic/v1/get-bis-policy-rates":{"get":{"description":"GetBisPolicyRates retrieves central bank policy rates from BIS.","operationId":"GetBisPolicyRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBisPolicyRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBisPolicyRates","tags":["EconomicService"]}},"/api/economic/v1/get-energy-capacity":{"get":{"description":"GetEnergyCapacity retrieves installed capacity data (solar, wind, coal) from EIA.","operationId":"GetEnergyCapacity","parameters":[{"description":"Energy source codes to query (e.g., \"SUN\", \"WND\", \"COL\").\n Empty returns all tracked sources (SUN, WND, COL).","in":"query","name":"energy_sources","required":false,"schema":{"type":"string"}},{"description":"Number of years of historical data. Default 20 if not set.","in":"query","name":"years","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetEnergyCapacityResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetEnergyCapacity","tags":["EconomicService"]}},"/api/economic/v1/get-energy-prices":{"get":{"description":"GetEnergyPrices retrieves current energy commodity prices from EIA.","operationId":"GetEnergyPrices","parameters":[{"description":"Optional commodity filter. Empty returns all tracked commodities.","in":"query","name":"commodities","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetEnergyPricesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetEnergyPrices","tags":["EconomicService"]}},"/api/economic/v1/get-fred-series":{"get":{"description":"GetFredSeries retrieves time series data from the Federal Reserve Economic Data.","operationId":"GetFredSeries","parameters":[{"description":"FRED series ID (e.g., \"GDP\", \"UNRATE\", \"CPIAUCSL\").","in":"query","name":"series_id","required":false,"schema":{"type":"string"}},{"description":"Maximum number of observations to return. Defaults to 120.","in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetFredSeries","tags":["EconomicService"]}},"/api/economic/v1/get-fred-series-batch":{"post":{"description":"GetFredSeriesBatch retrieves multiple FRED series in a single call.","operationId":"GetFredSeriesBatch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesBatchRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetFredSeriesBatchResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetFredSeriesBatch","tags":["EconomicService"]}},"/api/economic/v1/get-macro-signals":{"get":{"description":"GetMacroSignals computes 7 macro signals from 6 upstream sources with BUY/CASH verdict.","operationId":"GetMacroSignals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetMacroSignalsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetMacroSignals","tags":["EconomicService"]}},"/api/economic/v1/get-national-debt":{"get":{"description":"GetNationalDebt retrieves national debt clock data for all countries.","operationId":"GetNationalDebt","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetNationalDebtResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetNationalDebt","tags":["EconomicService"]}},"/api/economic/v1/list-world-bank-indicators":{"get":{"description":"ListWorldBankIndicators retrieves development indicator data from the World Bank.","operationId":"ListWorldBankIndicators","parameters":[{"description":"World Bank indicator code (e.g., \"NY.GDP.MKTP.CD\").","in":"query","name":"indicator_code","required":false,"schema":{"type":"string"}},{"description":"Optional country filter (ISO 3166-1 alpha-2).","in":"query","name":"country_code","required":false,"schema":{"type":"string"}},{"description":"Optional year filter. Defaults to latest available.","in":"query","name":"year","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Maximum items per page.","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWorldBankIndicatorsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListWorldBankIndicators","tags":["EconomicService"]}}}} \ No newline at end of file diff --git a/docs/api/EconomicService.openapi.yaml b/docs/api/EconomicService.openapi.yaml index 970e43ba3..c17e32c5a 100644 --- a/docs/api/EconomicService.openapi.yaml +++ b/docs/api/EconomicService.openapi.yaml @@ -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. diff --git a/proto/worldmonitor/economic/v1/get_national_debt.proto b/proto/worldmonitor/economic/v1/get_national_debt.proto new file mode 100644 index 000000000..59266eff9 --- /dev/null +++ b/proto/worldmonitor/economic/v1/get_national_debt.proto @@ -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; +} diff --git a/proto/worldmonitor/economic/v1/service.proto b/proto/worldmonitor/economic/v1/service.proto index 268f48ad0..5a6c05d0c 100644 --- a/proto/worldmonitor/economic/v1/service.proto +++ b/proto/worldmonitor/economic/v1/service.proto @@ -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}; + } } diff --git a/scripts/seed-national-debt.mjs b/scripts/seed-national-debt.mjs new file mode 100644 index 000000000..a94661790 --- /dev/null +++ b/scripts/seed-national-debt.mjs @@ -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); + }); +} diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index 7a86ec5fa..8417dafe0 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -54,6 +54,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record = { 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 = { @@ -78,4 +79,5 @@ export const BOOTSTRAP_TIERS: Record = { defiTokens: 'slow', aiTokens: 'slow', otherTokens: 'slow', + nationalDebt: 'slow', }; diff --git a/server/gateway.ts b/server/gateway.ts index b35d696ff..65802594d 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -136,6 +136,7 @@ const RPC_CACHE_TIER: Record = { '/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', diff --git a/server/worldmonitor/economic/v1/get-national-debt.ts b/server/worldmonitor/economic/v1/get-national-debt.ts new file mode 100644 index 000000000..f1c8f7544 --- /dev/null +++ b/server/worldmonitor/economic/v1/get-national-debt.ts @@ -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 { + 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(); + } +} diff --git a/server/worldmonitor/economic/v1/handler.ts b/server/worldmonitor/economic/v1/handler.ts index 61d42bc0d..8de276ef5 100644 --- a/server/worldmonitor/economic/v1/handler.ts +++ b/server/worldmonitor/economic/v1/handler.ts @@ -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, }; diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 2e6907524..751efc33e 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -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()); diff --git a/src/components/NationalDebtPanel.ts b/src/components/NationalDebtPanel.ts new file mode 100644 index 000000000..f0605328b --- /dev/null +++ b/src/components/NationalDebtPanel.ts @@ -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 = { + 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 = { + 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 | 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 { + 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(` +
+ Loading debt data from IMF... +
+ `); + } + + 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 = ` +
+
+
+ + + +
+ +
+
+ ${this.filteredEntries.slice(0, 100).map((entry, idx) => this.renderRow(entry, idx + 1)).join('')} +
+ +
+ `; + + 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 ` +
+
${rank}
+
${flag}
+
+
${name}
+
+ ${ratioStr} of GDP + ${growthStr} YoY +
+
+
${escapeHtml(debtStr)}
+
+ `; + } + + 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(`.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(); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 2a28785da..6b078e025 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -63,3 +63,4 @@ export * from './MilitaryCorrelationPanel'; export * from './EscalationCorrelationPanel'; export * from './EconomicCorrelationPanel'; export * from './DisasterCorrelationPanel'; +export { NationalDebtPanel } from './NationalDebtPanel'; diff --git a/src/config/panels.ts b/src/config/panels.ts index 8cc0a80f1..476ed2bed 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -72,6 +72,7 @@ const FULL_PANELS: Record = { '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 = { diff --git a/src/generated/client/worldmonitor/economic/v1/service_client.ts b/src/generated/client/worldmonitor/economic/v1/service_client.ts index dd48e429c..97970e0b7 100644 --- a/src/generated/client/worldmonitor/economic/v1/service_client.ts +++ b/src/generated/client/worldmonitor/economic/v1/service_client.ts @@ -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 { + let path = "/api/economic/v1/get-national-debt"; + const url = this.baseURL + path; + + const headers: Record = { + "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 { const body = await resp.text(); if (resp.status === 400) { diff --git a/src/generated/server/worldmonitor/economic/v1/service_server.ts b/src/generated/server/worldmonitor/economic/v1/service_server.ts index a7b3a0024..d6a687eca 100644 --- a/src/generated/server/worldmonitor/economic/v1/service_server.ts +++ b/src/generated/server/worldmonitor/economic/v1/service_server.ts @@ -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; getBisCredit(ctx: ServerContext, req: GetBisCreditRequest): Promise; getFredSeriesBatch(ctx: ServerContext, req: GetFredSeriesBatchRequest): Promise; + getNationalDebt(ctx: ServerContext, req: GetNationalDebtRequest): Promise; } export function createEconomicServiceRoutes( @@ -667,6 +689,43 @@ export function createEconomicServiceRoutes( } }, }, + { + method: "GET", + path: "/api/economic/v1/get-national-debt", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + 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" }, + }); + } + }, + }, ]; } diff --git a/src/services/economic/index.ts b/src/services/economic/index.ts index b056ad43d..fa2e844a8 100644 --- a/src/services/economic/index.ts +++ b/src/services/economic/index.ts @@ -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({ name: 'National Debt', cacheTtlMs: 6 * 60 * 60 * 1000, persistCache: true }); +const emptyNationalDebtFallback: GetNationalDebtResponse = { entries: [], seededAt: '', unavailable: true }; + +export async function getNationalDebtData(): Promise { + 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[]; diff --git a/src/styles/main.css b/src/styles/main.css index e6f667f36..4c0107f7a 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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); +} diff --git a/tests/national-debt-seed.test.mjs b/tests/national-debt-seed.test.mjs new file mode 100644 index 000000000..0338f56a9 --- /dev/null +++ b/tests/national-debt-seed.test.mjs @@ -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}`); + }); +}); diff --git a/tests/national-debt-ticker.test.mts b/tests/national-debt-ticker.test.mts new file mode 100644 index 000000000..84f2365bd --- /dev/null +++ b/tests/national-debt-ticker.test.mts @@ -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'); + }); +});