"""FRED (Federal Reserve Economic Data) macro vendor. Fetches macroeconomic time series — policy rates, Treasury yields, inflation, labor, growth — from the St. Louis Fed's free API. Used by the news analyst to ground macro commentary in actual numbers rather than headlines alone. A free API key (https://fred.stlouisfed.org/docs/api/api_key.html) is read from ``FRED_API_KEY``; if it is unset the vendor raises ``FredNotConfiguredError`` so the routing layer treats it as "unavailable" rather than a hard crash. """ import logging import os from datetime import datetime, timedelta import requests from .errors import VendorNotConfiguredError logger = logging.getLogger(__name__) FRED_API_BASE = "https://api.stlouisfed.org/fred" # Network timeout (seconds) so a stalled request can't hang the agents, # mirroring the Alpha Vantage client. REQUEST_TIMEOUT = 30 # Default trailing window when the caller does not specify one. A year captures # the trend and the year-over-year base for most monthly/quarterly series. DEFAULT_LOOKBACK_DAYS = 365 # Rows cap for the rendered table: recent values matter most for a decision, and # daily series (yields, VIX) over a long window would otherwise flood context. MAX_ROWS = 40 # Curated human-friendly aliases -> FRED series IDs. Anything not listed is used # verbatim as a raw FRED series ID, so power users are never limited to this set. MACRO_SERIES = { # Policy rate & Treasury yields "fed_funds_rate": "FEDFUNDS", "federal_funds_rate": "FEDFUNDS", "fed_funds": "FEDFUNDS", "2y_treasury": "DGS2", "10y_treasury": "DGS10", "30y_treasury": "DGS30", "10y_2y_spread": "T10Y2Y", "yield_curve": "T10Y2Y", # Inflation "cpi": "CPIAUCSL", "core_cpi": "CPILFESL", "pce": "PCEPI", "core_pce": "PCEPILFE", "inflation_expectations": "T10YIE", # Growth & output "real_gdp": "GDPC1", "gdp": "GDP", "industrial_production": "INDPRO", # Labor "unemployment_rate": "UNRATE", "unemployment": "UNRATE", "nonfarm_payrolls": "PAYEMS", "payrolls": "PAYEMS", "initial_claims": "ICSA", # Money & markets "m2": "M2SL", "money_supply": "M2SL", "vix": "VIXCLS", "dollar_index": "DTWEXBGS", # Sentiment & housing "consumer_sentiment": "UMCSENT", "housing_starts": "HOUST", "retail_sales": "RSAFS", } class FredNotConfiguredError(VendorNotConfiguredError): """Raised when FRED is selected but no API key is configured. A VendorNotConfiguredError (and thus still a ValueError), so the routing layer's "vendor unavailable" handling and existing ValueError callers both keep working. """ def get_api_key() -> str: """Retrieve the FRED API key from the environment.""" api_key = os.getenv("FRED_API_KEY") if not api_key: raise FredNotConfiguredError( "FRED_API_KEY environment variable is not set. Get a free key at " "https://fred.stlouisfed.org/docs/api/api_key.html." ) return api_key def _resolve_series_id(indicator: str) -> str: """Map a friendly alias to a FRED series ID, or pass a raw ID through.""" key = indicator.strip().lower().replace(" ", "_").replace("-", "_") if key in MACRO_SERIES: return MACRO_SERIES[key] # Not a known alias: treat the input as a raw FRED series ID (FRED IDs are # conventionally uppercase, e.g. "DGS10", "CPIAUCSL"). return indicator.strip().upper() def _request(path: str, params: dict) -> dict: """GET a FRED endpoint, surfacing FRED's JSON error body on a bad request.""" api_params = {**params, "api_key": get_api_key(), "file_type": "json"} response = requests.get( f"{FRED_API_BASE}/{path}", params=api_params, timeout=REQUEST_TIMEOUT ) # FRED returns 400 with a JSON {"error_message": ...} for unknown series IDs # or malformed params; turn that into a clear, actionable error. if response.status_code == 400: try: message = response.json().get("error_message", response.text) except ValueError: message = response.text raise ValueError(f"FRED request failed: {message}") response.raise_for_status() return response.json() def get_macro_data( indicator: str, curr_date: str, look_back_days: int | None = None, ) -> str: """Fetch a FRED macroeconomic series as a formatted markdown report. Args: indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). curr_date: End of the window (yyyy-mm-dd); no later observations are returned, so a past date never leaks future data. look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS. Returns: A markdown report with the series title, units, frequency, the latest value, the change over the window, and a recent observation table. """ if look_back_days is None: look_back_days = DEFAULT_LOOKBACK_DAYS end_dt = datetime.strptime(curr_date, "%Y-%m-%d") start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") series_id = _resolve_series_id(indicator) meta = _request("series", {"series_id": series_id}).get("seriess") or [] if not meta: raise ValueError( f"FRED series '{series_id}' not found. Pass a known alias " f"(e.g. 'cpi', 'unemployment') or a valid FRED series ID." ) info = meta[0] title = info.get("title", series_id) units = info.get("units_short") or info.get("units", "") frequency = info.get("frequency", "") seasonal = info.get("seasonal_adjustment_short", "") observations = _request( "series/observations", { "series_id": series_id, "observation_start": start_date, "observation_end": curr_date, "sort_order": "asc", }, ).get("observations", []) # FRED encodes a missing observation as ".". points = [ (o["date"], o["value"]) for o in observations if o.get("value") not in (".", None, "") ] header = ( f"## FRED: {title} ({series_id})\n" f"- Units: {units}\n" f"- Frequency: {frequency}" f"{f' ({seasonal})' if seasonal else ''}\n" f"- Window: {start_date} to {curr_date}\n" ) if not points: return header + ( f"\nNo observations for {series_id} in this window. The series may " f"report less frequently than the window length; widen look_back_days." ) first_date, first_val = points[0] last_date, last_val = points[-1] try: delta = float(last_val) - float(first_val) base = float(first_val) pct = f" ({delta / base * 100:+.2f}%)" if base != 0 else "" summary = ( f"\n**Latest:** {last_val} ({last_date}) | " f"**Change over window:** {delta:+.2f}{pct} " f"from {first_val} ({first_date})\n" ) except ValueError: summary = f"\n**Latest:** {last_val} ({last_date})\n" shown = points note = "" if len(points) > MAX_ROWS: shown = points[-MAX_ROWS:] note = f"\n_(showing the most recent {MAX_ROWS} of {len(points)} observations)_\n" table = ( "\n| Date | Value |\n| --- | --- |\n" + "\n".join(f"| {d} | {v} |" for d, v in shown) + "\n" ) return header + summary + note + table