diff --git a/.env.example b/.env.example index 1aab52424..669b8962d 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ MOONSHOT_API_KEY= GROQ_API_KEY= NVIDIA_API_KEY= +# FRED (Federal Reserve macro data: rates, inflation, labor, growth). Free key: https://fred.stlouisfed.org/docs/api/api_key.html +#FRED_API_KEY= + # Optional: a custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp, # relay). Select provider "openai_compatible" and set the base URL; the key is # optional (local servers need none). diff --git a/tests/test_fred.py b/tests/test_fred.py new file mode 100644 index 000000000..f8a1f923a --- /dev/null +++ b/tests/test_fred.py @@ -0,0 +1,177 @@ +"""FRED macro vendor: alias resolution, configuration errors, output formatting, +missing-value handling, lookahead-safe windowing, and router integration. + +All API access is mocked, so these run without a network connection or a key. +""" +import copy +import unittest +from unittest import mock + +import pytest + +import tradingagents.dataflows.config as config_module +import tradingagents.default_config as default_config +from tradingagents.dataflows import fred, interface +from tradingagents.dataflows.config import set_config + +# A small, stable set of observations to format against. +_META = { + "seriess": [ + { + "title": "Unemployment Rate", + "units_short": "%", + "frequency": "Monthly", + "seasonal_adjustment_short": "SA", + } + ] +} +_OBS = { + "observations": [ + {"date": "2025-06-01", "value": "4.1"}, + {"date": "2025-07-01", "value": "4.3"}, + {"date": "2025-08-01", "value": "."}, # missing -> skipped + {"date": "2025-09-01", "value": "4.4"}, + ] +} + + +def _request_stub(meta=_META, obs=_OBS): + """Build a _request replacement that dispatches on the endpoint path.""" + def _impl(path, params): + if path == "series": + return meta + if path == "series/observations": + return obs + raise AssertionError(f"unexpected FRED path: {path}") + return _impl + + +@pytest.mark.unit +class FredResolutionTests(unittest.TestCase): + def test_alias_maps_to_series_id(self): + self.assertEqual(fred._resolve_series_id("cpi"), "CPIAUCSL") + self.assertEqual(fred._resolve_series_id("unemployment"), "UNRATE") + + def test_alias_is_case_and_separator_insensitive(self): + self.assertEqual(fred._resolve_series_id("Fed Funds Rate"), "FEDFUNDS") + self.assertEqual(fred._resolve_series_id("10y-treasury"), "DGS10") + + def test_unknown_alias_is_treated_as_raw_series_id(self): + # Power users can pass any FRED series ID; we uppercase by convention. + self.assertEqual(fred._resolve_series_id("dgs30"), "DGS30") + self.assertEqual(fred._resolve_series_id("MyCustomSeries"), "MYCUSTOMSERIES") + + +@pytest.mark.unit +class FredConfigTests(unittest.TestCase): + def test_missing_key_raises_not_configured(self): + with mock.patch.dict("os.environ", {}, clear=True), \ + self.assertRaises(fred.FredNotConfiguredError): + fred.get_api_key() + + def test_not_configured_is_a_value_error(self): + # Routing relies on this subclassing for "vendor unavailable" handling. + self.assertTrue(issubclass(fred.FredNotConfiguredError, ValueError)) + + +@pytest.mark.unit +class FredFormattingTests(unittest.TestCase): + def test_report_has_header_latest_change_and_table(self): + with mock.patch.object(fred, "_request", side_effect=_request_stub()): + out = fred.get_macro_data("unemployment", "2025-09-30", 365) + self.assertIn("## FRED: Unemployment Rate (UNRATE)", out) + self.assertIn("Units: %", out) + self.assertIn("Frequency: Monthly (SA)", out) + self.assertIn("**Latest:** 4.4 (2025-09-01)", out) + # change over the window: 4.4 - 4.1 = +0.30 + self.assertIn("+0.30", out) + self.assertIn("| 2025-06-01 | 4.1 |", out) + + def test_missing_value_is_skipped(self): + with mock.patch.object(fred, "_request", side_effect=_request_stub()): + out = fred.get_macro_data("unemployment", "2025-09-30", 365) + # the "." observation must not appear as a row + self.assertNotIn("2025-08-01", out) + + def test_empty_window_reports_no_observations(self): + empty = {"observations": []} + with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=empty)): + out = fred.get_macro_data("unemployment", "2025-09-30", 30) + self.assertIn("No observations", out) + + def test_unknown_series_raises(self): + no_series = {"seriess": []} + with mock.patch.object(fred, "_request", side_effect=_request_stub(meta=no_series)), \ + self.assertRaises(ValueError): + fred.get_macro_data("totally_unknown_xyz", "2025-09-30", 30) + + def test_long_series_is_truncated_but_change_uses_full_range(self): + # Build > MAX_ROWS observations deterministically. + obs = { + "observations": [ + {"date": f"2025-01-{(i % 28) + 1:02d}", "value": str(i)} + for i in range(fred.MAX_ROWS + 10) + ] + } + with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)): + out = fred.get_macro_data("unemployment", "2025-12-31", 365) + self.assertIn(f"most recent {fred.MAX_ROWS}", out) + # change-over-window must reference the true first (0) and last value + self.assertIn("from 0 ", out) + body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")] + self.assertEqual(len(body_rows), fred.MAX_ROWS) + + def test_window_is_lookahead_safe(self): + # observation_end must equal curr_date so a past date never pulls future data. + captured = {} + + def _capture(path, params): + captured[path] = params + return _META if path == "series" else _OBS + + with mock.patch.object(fred, "_request", side_effect=_capture): + fred.get_macro_data("unemployment", "2025-09-30", 90) + obs_params = captured["series/observations"] + self.assertEqual(obs_params["observation_end"], "2025-09-30") + self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back + + +@pytest.mark.unit +class FredRoutingTests(unittest.TestCase): + def setUp(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def tearDown(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def test_macro_category_routes_to_fred(self): + self.assertEqual( + interface.get_category_for_method("get_macro_indicators"), "macro_data" + ) + set_config({"data_vendors": {"macro_data": "fred"}}) + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_macro_indicators": {"fred": lambda *a, **k: "MACRO_OK"}}, + clear=False, + ): + out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365) + self.assertEqual(out, "MACRO_OK") + + def test_not_configured_surfaces_through_router(self): + # With only fred and no key, the router has no fallback and must surface + # the real "not configured" failure rather than masking it. + set_config({"data_vendors": {"macro_data": "fred"}}) + + def _unconfigured(*a, **k): + raise fred.FredNotConfiguredError("FRED_API_KEY not set") + + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_macro_indicators": {"fred": _unconfigured}}, + clear=False, + ), self.assertRaises(fred.FredNotConfiguredError): + interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index 64825058e..5f986df0a 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -3,6 +3,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_global_news, get_language_instruction, + get_macro_indicators, get_news, ) from tradingagents.dataflows.config import get_config @@ -18,10 +19,11 @@ def create_news_analyst(llm): tools = [ get_news, get_global_news, + get_macro_indicators, ] system_message = ( - f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for {asset_label}-specific or targeted news searches, and get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for {asset_label}-specific or targeted news searches, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, and get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" + get_language_instruction() ) diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 92113e5e5..5f2e46f2f 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -23,6 +23,9 @@ from tradingagents.agents.utils.news_data_tools import ( get_insider_transactions, get_global_news ) +from tradingagents.agents.utils.macro_data_tools import ( + get_macro_indicators +) from tradingagents.agents.utils.market_data_validation_tools import ( get_verified_market_snapshot ) diff --git a/tradingagents/agents/utils/macro_data_tools.py b/tradingagents/agents/utils/macro_data_tools.py new file mode 100644 index 000000000..3d567549b --- /dev/null +++ b/tradingagents/agents/utils/macro_data_tools.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_macro_indicators( + indicator: Annotated[ + str, + "Macro indicator: a friendly alias such as 'cpi', 'core_pce', " + "'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve', " + "'real_gdp', 'vix', or a raw FRED series ID such as 'CPIAUCSL'.", + ], + curr_date: Annotated[str, "Current date in yyyy-mm-dd format; the end of the window"], + look_back_days: Annotated[ + int | None, "Trailing window length in days; omit for a 1-year window" + ] = None, +) -> str: + """ + Retrieve a macroeconomic indicator time series from FRED (Federal Reserve + Economic Data): policy rates, Treasury yields, inflation, labor, and growth. + Returns the series title, units, frequency, the latest value, the change + over the window, and a recent observation table. Uses the configured + macro_data vendor. + + Args: + indicator (str): Friendly alias or raw FRED series ID + curr_date (str): Current date in yyyy-mm-dd format + look_back_days (int): Trailing window length; omit for a 1-year window + + Returns: + str: A formatted markdown report of the macro series + """ + return route_to_vendor("get_macro_indicators", indicator, curr_date, look_back_days) diff --git a/tradingagents/dataflows/fred.py b/tradingagents/dataflows/fred.py new file mode 100644 index 000000000..1d7ad1315 --- /dev/null +++ b/tradingagents/dataflows/fred.py @@ -0,0 +1,215 @@ +"""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 + +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(ValueError): + """Raised when FRED is selected but no API key is configured. + + Subclasses ValueError so callers already catching ValueError keep working, + while the routing layer can distinguish a "vendor unavailable" condition + from a genuine data error (same contract as AlphaVantageNotConfiguredError). + """ + + +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 diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 5897bbbb9..9729b94a7 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -24,6 +24,7 @@ from .alpha_vantage import ( get_global_news as get_alpha_vantage_global_news, ) from .alpha_vantage_common import AlphaVantageRateLimitError +from .fred import get_macro_data as get_fred_macro_data from .symbol_utils import NoMarketDataError # Configuration and routing logic @@ -61,11 +62,18 @@ TOOLS_CATEGORIES = { "get_global_news", "get_insider_transactions", ] + }, + "macro_data": { + "description": "Macroeconomic indicators (rates, inflation, labor, growth)", + "tools": [ + "get_macro_indicators", + ] } } VENDOR_LIST = [ "yfinance", + "fred", "alpha_vantage", ] @@ -111,6 +119,10 @@ VENDOR_METHODS = { "alpha_vantage": get_alpha_vantage_insider_transactions, "yfinance": get_yfinance_insider_transactions, }, + # macro_data + "get_macro_indicators": { + "fred": get_fred_macro_data, + }, } def get_category_for_method(method: str) -> str: diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index e195c62ee..9e9df9100 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -106,6 +106,7 @@ DEFAULT_CONFIG = _apply_env_overrides({ "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance "news_data": "yfinance", # Options: alpha_vantage, yfinance + "macro_data": "fred", # Options: fred (needs FRED_API_KEY) }, # Tool-level configuration (takes precedence over category-level) "tool_vendors": { diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 20dff4e91..8221f4e14 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -39,7 +39,8 @@ from tradingagents.agents.utils.agent_utils import ( get_income_statement, get_news, get_insider_transactions, - get_global_news + get_global_news, + get_macro_indicators ) from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id @@ -192,6 +193,7 @@ class TradingAgentsGraph: get_news, get_global_news, get_insider_transactions, + get_macro_indicators, ] ), "fundamentals": ToolNode(