import { formatNumber } from "../../../utils/format"; import type { StatDef } from "./defs"; const DAY_MS = 24 * 60 * 60 * 1000; const DAILY = 6 * DAY_MS; const WEEKLY = 21 * DAY_MS; // FRED dates a print at the start of its period, so an on-time print is two // periods plus the release lag old just before the next one lands. Each limit // adds about two weeks to the slowest normal lag of its group. const MONTHLY_EARLY = 90 * DAY_MS; // CPI, PPI, jobs, output, retail, housing starts: lag up to ~19d const MONTHLY_LATE = 105 * DAY_MS; // PCE, income, new home sales, sentiment, M2: lag up to ~31d const MONTHLY_LAGGED = 115 * DAY_MS; // JOLTS, durables, factory orders, trade: lag up to ~40d const QUARTERLY = 235 * DAY_MS; // GDP, productivity: two quarters plus a lag up to ~38d const pct = (value: number) => `${formatNumber(value, 1)}%`; const pct2 = (value: number) => `${formatNumber(value, 2)}%`; const plain = (value: number) => formatNumber(value, 1); /** FRED reports payrolls and claims in thousands already. */ const thousands = (value: number) => `${formatNumber(value / 1000, value >= 10_000 ? 1 : 2)}M`; const signedThousands = (value: number) => `${value > 0 ? "+" : ""}${formatNumber(value, 0)}k`; const billions = (value: number) => `${formatNumber(value, 0)}B`; function stat(def: StatDef): StatDef { return def; } export const STATS: readonly StatDef[] = [ // ---- Inflation ------------------------------------------------------- stat({ id: "cpi-yoy", measurementBasis: "Not seasonally adjusted", label: "CPI", shortLabel: "CPI y/y", category: "inflation", seriesId: "CPIAUCNS", transform: "yoy", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: { value: 2, label: "2%" }, staleAfterMs: MONTHLY_EARLY, note: "Headline consumer prices against a year earlier, not seasonally adjusted, including food and energy.", limit: 900, }), stat({ id: "core-cpi-yoy", measurementBasis: "NSA · excludes food & energy", label: "Core CPI", shortLabel: "Core CPI y/y", category: "inflation", seriesId: "CPILFENS", transform: "yoy", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: { value: 2, label: "2%" }, staleAfterMs: MONTHLY_EARLY, note: "Consumer prices excluding food and energy against a year earlier, not seasonally adjusted.", limit: 900, }), stat({ id: "pce-yoy", label: "PCE Prices", shortLabel: "PCE y/y", category: "inflation", seriesId: "PCEPI", transform: "yoy", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: { value: 2, label: "2%" }, staleAfterMs: MONTHLY_LATE, note: "The price index for personal consumption, broader in scope than CPI.", limit: 900, }), stat({ id: "core-pce-yoy", measurementBasis: "Excludes food & energy", label: "Core PCE Prices", shortLabel: "Core PCE y/y", category: "inflation", seriesId: "PCEPILFE", transform: "yoy", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: { value: 2, label: "2% reference" }, staleAfterMs: MONTHLY_LATE, note: "Excludes food and energy to track underlying PCE inflation. The Fed target applies to overall PCE inflation.", limit: 900, }), stat({ id: "ppi-yoy", measurementBasis: "Final demand · not seasonally adjusted", label: "Producer Prices", shortLabel: "PPI y/y", category: "inflation", seriesId: "PPIFID", transform: "yoy", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: null, staleAfterMs: MONTHLY_EARLY, note: "Final-demand producer prices against a year earlier, not seasonally adjusted.", limit: 900, }), // ---- Labor ----------------------------------------------------------- stat({ id: "unemployment", label: "Unemployment Rate", shortLabel: "Unemployment", category: "labor", seriesId: "UNRATE", transform: "level", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: null, staleAfterMs: MONTHLY_EARLY, note: "Share of the labour force without work and looking for it.", limit: 900, }), stat({ id: "payrolls", label: "Nonfarm Payrolls", shortLabel: "Payrolls m/m", category: "labor", seriesId: "PAYEMS", transform: "change", direction: "higher-is-good", axisUnit: "", formatValue: signedThousands, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_EARLY, note: "Jobs added or lost last month, the single most watched release.", limit: 900, }), stat({ id: "initial-claims", measurementBasis: "Weekly", label: "Initial Jobless Claims", shortLabel: "Claims", category: "labor", seriesId: "ICSA", transform: "level", direction: "higher-is-bad", axisUnit: "", formatValue: (value) => `${formatNumber(value / 1000, 0)}k`, reference: null, staleAfterMs: WEEKLY, note: "Weekly filings for unemployment benefits, the fastest labour signal.", }), stat({ id: "job-openings", label: "Job Openings", shortLabel: "Openings", category: "labor", seriesId: "JTSJOL", transform: "level", direction: "higher-is-good", axisUnit: "", formatValue: thousands, reference: null, staleAfterMs: MONTHLY_LAGGED, note: "Unfilled positions employers report, a read on labour demand.", limit: 400, }), // ---- Growth ---------------------------------------------------------- stat({ id: "real-gdp", measurementBasis: "q/q annualized", label: "Real GDP", shortLabel: "Real GDP q/q", category: "growth", seriesId: "GDPC1", transform: "qoq-annualized", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: QUARTERLY, note: "Inflation-adjusted output, stated at the annual rate the quarter implies.", limit: 400, }), stat({ id: "industrial-production", measurementBasis: "y/y", label: "Industrial Production", shortLabel: "Ind. prod.", category: "growth", seriesId: "INDPRO", transform: "yoy", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_EARLY, note: "Output of factories, mines and utilities against a year earlier.", limit: 900, }), stat({ id: "capacity-utilization", label: "Capacity Utilization", shortLabel: "Capacity use", category: "growth", seriesId: "TCU", transform: "level", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: null, staleAfterMs: MONTHLY_EARLY, note: "How much of industry's capacity is in use, a slack and inflation gauge.", limit: 900, }), stat({ id: "durable-goods", label: "Durable Goods Orders", shortLabel: "Durables m/m", category: "growth", seriesId: "DGORDER", transform: "mom", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_LAGGED, note: "New orders for goods meant to last, a forward read on investment.", limit: 500, }), stat({ id: "factory-orders", label: "Factory Orders", shortLabel: "Factory m/m", category: "growth", seriesId: "AMTMNO", transform: "mom", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_LAGGED, note: "New orders across manufacturing, durable and not.", limit: 500, }), // ---- Consumer -------------------------------------------------------- stat({ id: "retail-sales", measurementBasis: "Not adjusted for prices", label: "Retail Sales", shortLabel: "Retail y/y", category: "consumer", seriesId: "RSAFS", transform: "yoy", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: null, staleAfterMs: MONTHLY_EARLY, note: "What households actually spent at retailers, not adjusted for prices.", limit: 500, }), stat({ id: "core-retail-sales", measurementBasis: "y/y · excludes motor vehicles", label: "Core Retail Sales", shortLabel: "Core retail", category: "consumer", seriesId: "RSFSXMV", transform: "yoy", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: null, staleAfterMs: MONTHLY_EARLY, note: "Retail sales excluding motor vehicles, which swing the headline around.", limit: 500, }), stat({ id: "consumer-sentiment", measurementBasis: "Michigan survey", label: "Consumer Sentiment", shortLabel: "Sentiment", category: "consumer", seriesId: "UMCSENT", transform: "level", direction: "higher-is-good", axisUnit: "", formatValue: plain, reference: null, staleAfterMs: MONTHLY_LATE, note: "Michigan's survey of how households feel about their finances.", limit: 900, }), stat({ id: "personal-income", measurementBasis: "Before tax", label: "Personal Income", shortLabel: "Income m/m", category: "consumer", seriesId: "PI", transform: "mom", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_LATE, note: "What households received before tax, month on month.", limit: 900, }), stat({ id: "personal-spending", label: "Personal Spending", shortLabel: "Spending m/m", category: "consumer", seriesId: "PCE", transform: "mom", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_LATE, note: "What households spent, the largest single component of GDP.", limit: 900, }), // ---- Housing --------------------------------------------------------- stat({ id: "housing-starts", measurementBasis: "Annual rate", label: "Housing Starts", shortLabel: "Starts", category: "housing", seriesId: "HOUST", transform: "level", direction: "higher-is-good", axisUnit: "", formatValue: thousands, reference: null, staleAfterMs: MONTHLY_EARLY, note: "Homes broken ground on, at an annual rate.", limit: 900, }), stat({ id: "building-permits", label: "Building Permits", shortLabel: "Permits", category: "housing", seriesId: "PERMIT", transform: "level", direction: "higher-is-good", axisUnit: "", formatValue: thousands, reference: null, staleAfterMs: MONTHLY_EARLY, note: "Permits issued, which lead starts by a month or two.", limit: 900, }), stat({ id: "new-home-sales", measurementBasis: "Annual rate", label: "New Home Sales", shortLabel: "New homes", category: "housing", seriesId: "HSN1F", transform: "level", direction: "higher-is-good", axisUnit: "", formatValue: thousands, reference: null, staleAfterMs: MONTHLY_LATE, note: "Newly built homes sold, at an annual rate.", limit: 900, }), // ---- Rates & money --------------------------------------------------- stat({ id: "fed-funds", measurementBasis: "Daily · overnight", label: "Effective Federal Funds Rate", shortLabel: "Fed funds", category: "rates", seriesId: "EFFR", transform: "level", direction: "neutral", axisUnit: "%", formatValue: pct2, reference: null, staleAfterMs: DAILY, note: "Daily effective overnight rate; the FOMC sets a separate target range.", }), stat({ id: "ten-year", label: "10-Year Treasury", shortLabel: "10Y yield", category: "rates", seriesId: "DGS10", transform: "level", direction: "neutral", axisUnit: "%", formatValue: pct2, reference: null, staleAfterMs: DAILY, note: "The benchmark long rate that discounts almost everything else.", }), stat({ id: "two-year", label: "2-Year Treasury", shortLabel: "2Y yield", category: "rates", seriesId: "DGS2", transform: "level", direction: "neutral", axisUnit: "%", formatValue: pct2, reference: null, staleAfterMs: DAILY, note: "The short end, which tracks where policy is expected to go.", }), stat({ id: "curve-spread", measurementBasis: "10Y minus 2Y", label: "10Y minus 2Y Spread", shortLabel: "2s10s", category: "rates", seriesId: "T10Y2Y", derivedFrom: ["ten-year", "two-year"], transform: "level", direction: "higher-is-good", axisUnit: "%", formatValue: pct2, reference: { value: 0, label: "inversion" }, staleAfterMs: DAILY, note: "The curve spread. Below zero it has preceded every modern recession.", }), stat({ id: "real-ten-year", measurementBasis: "Inflation-protected", label: "10-Year Real Yield", shortLabel: "10Y real", category: "rates", seriesId: "DFII10", transform: "level", direction: "neutral", axisUnit: "%", formatValue: pct2, reference: { value: 0, label: "zero" }, staleAfterMs: DAILY, note: "The inflation-protected long rate, the real cost of money.", }), stat({ id: "m2-yoy", label: "M2 Money Stock", shortLabel: "M2 y/y", category: "rates", seriesId: "M2SL", transform: "yoy", direction: "neutral", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: MONTHLY_LATE, note: "Money supply growth, which contracted outright for the first time in 2022.", limit: 900, }), // ---- Trade & costs --------------------------------------------------- stat({ id: "trade-balance", measurementBasis: "Goods & services · exports minus imports", label: "Trade Balance", shortLabel: "Trade balance", category: "trade", seriesId: "BOPGSTB", transform: "level", scale: 0.001, direction: "higher-is-good", axisUnit: "", formatValue: billions, reference: { value: 0, label: "balanced" }, staleAfterMs: MONTHLY_LAGGED, note: "Goods and services exported less imported, persistently negative.", limit: 500, }), stat({ id: "productivity", measurementBasis: "q/q annualized · output per hour", label: "Nonfarm Productivity", shortLabel: "Productivity", category: "trade", seriesId: "OPHNFB", transform: "qoq-annualized", direction: "higher-is-good", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: QUARTERLY, note: "Output per hour worked. It is what lets pay rise without inflation.", limit: 400, }), stat({ id: "unit-labor-costs", measurementBasis: "q/q annualized", label: "Unit Labor Costs", shortLabel: "Labor costs", category: "trade", seriesId: "ULCNFB", transform: "qoq-annualized", direction: "higher-is-bad", axisUnit: "%", formatValue: pct, reference: { value: 0, label: "flat" }, staleAfterMs: QUARTERLY, note: "What a unit of output costs in labour, pay growth net of productivity.", limit: 400, }), ]; export function findStat(id: string | null | undefined): StatDef { const normalized = id?.trim().toLowerCase(); return STATS.find((entry) => entry.id === normalized) ?? STATS[0]!; } export const DEFAULT_STAT_ID = STATS[0]!.id; /** Resolves shortcut arguments like `ECST cpi` onto a statistic. */ export function resolveStatArg(arg: string | null | undefined): StatDef | null { const query = arg?.trim().toLowerCase(); if (!query) return null; return STATS.find((entry) => entry.id === query) ?? STATS.find((entry) => entry.seriesId.toLowerCase() === query) ?? STATS.find((entry) => entry.id.startsWith(query) || entry.label.toLowerCase().startsWith(query) || entry.shortLabel.toLowerCase().startsWith(query)) ?? null; }