mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-07-04 21:54:23 +03:00
Optional enrichment vendors (FRED macro, Polymarket events) raised on a bad LLM indicator, a missing key, or a network blip, which aborted the whole run. - Router: mark macro_data and prediction_markets optional; a sole-vendor failure returns a sentinel instead of re-raising. Core categories still raise. - FRED: reject a descriptive phrase up front and return guidance instead of 400ing the API; an unknown series returns a not-found message, not a crash.
238 lines
8.3 KiB
Python
238 lines
8.3 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.
|
|
|
|
Raises ``ValueError`` when the input is neither a known alias nor a plausible
|
|
series ID — typically a descriptive phrase the LLM passed instead (e.g.
|
|
"bank of japan rate"). FRED IDs are short and alphanumeric, so this rejects
|
|
it up front with guidance rather than letting it 400 the API.
|
|
"""
|
|
key = indicator.strip().lower().replace(" ", "_").replace("-", "_")
|
|
if key in MACRO_SERIES:
|
|
return MACRO_SERIES[key]
|
|
candidate = indicator.strip().upper()
|
|
# FRED series IDs never contain whitespace and are short; reject anything
|
|
# else (a descriptive phrase the LLM passed) rather than 400ing the API.
|
|
if not candidate or len(candidate) > 30 or any(c.isspace() for c in candidate):
|
|
raise ValueError(
|
|
f"'{indicator}' is not a known macro alias or a valid FRED series ID. "
|
|
f"Use an alias (e.g. 'cpi', 'unemployment', '10y_treasury') or a raw "
|
|
f"FRED series ID (e.g. 'CPIAUCSL')."
|
|
)
|
|
return candidate
|
|
|
|
|
|
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")
|
|
|
|
# Invalid LLM-supplied indicator: return guidance rather than raising, so a
|
|
# bad argument doesn't abort the run (the routing layer also degrades macro
|
|
# data, but a specific message is more useful to the analyst).
|
|
try:
|
|
series_id = _resolve_series_id(indicator)
|
|
except ValueError as e:
|
|
return f"FRED: {e}"
|
|
|
|
meta = _request("series", {"series_id": series_id}).get("seriess") or []
|
|
if not meta:
|
|
return (
|
|
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
|