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'); + }); +});