mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
Every condition where a vendor cannot return usable data now derives from a single VendorError base (errors.py): NoMarketDataError, VendorRateLimitError, and VendorNotConfiguredError (still a ValueError for back-compat). Vendor-named errors subclass the generic bases, and the router catches the base types, so a new vendor needs no new except clause. Not-configured now has explicit try-next-vendor handling instead of falling through the generic catch-all. The number of error types tracks the number of distinct router reactions, not the number of causes.
218 lines
7.4 KiB
Python
218 lines
7.4 KiB
Python
"""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
|