From ecd3404213acbd2d2592e62d1711cfefb227f5ce Mon Sep 17 00:00:00 2001 From: olivergpt <132330344+olivergpt@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:45:29 +0800 Subject: [PATCH 01/63] fix(llm): treat deepseek-flash as a thinking model (#1389) --- tests/test_capabilities.py | 18 ++++++++++++++++++ tradingagents/llm_clients/capabilities.py | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 2ab28dc2d..1cd396eaa 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -25,6 +25,17 @@ class TestExactIdMatches: assert caps.supports_tool_choice is False assert caps.requires_reasoning_content_roundtrip is True + def test_deepseek_flash_alias_rejects_tool_choice(self): + """``deepseek-flash`` is what the model picker offers for V4.1 Flash. + + It was falling through to _DEFAULT (tool_choice on), so every + structured-output call 400'd with "Thinking mode does not support + this tool_choice" and burned a retry as free text. + """ + caps = get_capabilities("deepseek-flash") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + def test_deepseek_v4_pro_rejects_tool_choice(self): caps = get_capabilities("deepseek-v4-pro") assert caps.supports_tool_choice is False @@ -48,6 +59,10 @@ class TestPatternMatches: caps = get_capabilities("deepseek-reasoner-pro") assert caps.supports_tool_choice is False + def test_future_flash_variant_inherits_thinking_quirks(self): + caps = get_capabilities("deepseek-flash-lite") + assert caps.supports_tool_choice is False + def test_minimax_m3_inherits_thinking_quirks(self): caps = get_capabilities("MiniMax-M3") assert caps.supports_tool_choice is False @@ -128,6 +143,9 @@ class TestOpenRouterDeepSeekNamespace: def test_prefixed_reasoner_suppresses_tool_choice(self): assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False + def test_prefixed_flash_alias_suppresses_tool_choice(self): + assert get_capabilities("deepseek/deepseek-flash").supports_tool_choice is False + def test_prefixed_chat_selects_deepseek_chat_not_default(self): # Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False # is what distinguishes them (both keep tool_choice). diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py index a2df7100a..e693bb5df 100644 --- a/tradingagents/llm_clients/capabilities.py +++ b/tradingagents/llm_clients/capabilities.py @@ -94,6 +94,7 @@ _DEFAULT = ModelCapabilities( _BY_ID: dict[str, ModelCapabilities] = { "deepseek-chat": _DEEPSEEK_CHAT, "deepseek-reasoner": _DEEPSEEK_THINKING, + "deepseek-flash": _DEEPSEEK_THINKING, "deepseek-v4-flash": _DEEPSEEK_THINKING, "deepseek-v4-pro": _DEEPSEEK_THINKING, # MiniMax — full official model lineup per @@ -108,10 +109,14 @@ _BY_ID: dict[str, ModelCapabilities] = { } # Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` -# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. +# / ``deepseek-flash-*`` or ``MiniMax-M3*`` variants inherit the thinking-mode +# quirks automatically. ``deepseek-flash`` is the unversioned alias the model +# picker offers for V4.1 Flash; it serves thinking mode and so rejects +# ``tool_choice`` exactly like the versioned ``deepseek-v4-flash`` ID does. _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), + (re.compile(r"^deepseek-flash"), _DEEPSEEK_THINKING), (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), ] From 6ac7c6f017a2c3251683f9b767091adeee53630d Mon Sep 17 00:00:00 2001 From: Chaoqi <136579807+Chaoqi31@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:45:39 +0800 Subject: [PATCH 02/63] fix(dataflows): report a Yahoo rate limit as a rate limit (#1387) --- tests/test_date_boundaries.py | 6 +- tests/test_no_data_handling.py | 6 +- tests/test_ohlcv_cache_freshness.py | 6 +- tests/test_ohlcv_latest_bar.py | 7 +- tests/test_yahoo_rate_limit.py | 130 ++++++++++++++++++ tradingagents/agents/tools.py | 9 +- tradingagents/dataflows/router.py | 15 +- .../dataflows/vendors/yahoo/fundamentals.py | 3 +- tradingagents/dataflows/vendors/yahoo/news.py | 6 +- .../dataflows/vendors/yahoo/ohlcv.py | 36 +++-- 10 files changed, 193 insertions(+), 31 deletions(-) create mode 100644 tests/test_yahoo_rate_limit.py diff --git a/tests/test_date_boundaries.py b/tests/test_date_boundaries.py index 80d0f08e1..9d5300882 100644 --- a/tests/test_date_boundaries.py +++ b/tests/test_date_boundaries.py @@ -4,6 +4,8 @@ requested end_date (and the current day) is actually included. Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date row omitted). """ +from types import SimpleNamespace + import pandas as pd import pytest @@ -44,7 +46,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path): set_config({"data_cache_dir": str(tmp_path)}) captured = {} - def fake_download(symbol, start, end, **kwargs): + def fake_history(start, end, **kwargs): captured["end"] = end idx = pd.to_datetime([pd.Timestamp.today().normalize()]) return pd.DataFrame( @@ -53,7 +55,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path): index=idx, ) - monkeypatch.setattr(ohlcv.yf, "download", fake_download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=fake_history)) today = pd.Timestamp.today().strftime("%Y-%m-%d") ohlcv.load_ohlcv("AAPL", today) diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index b70ff3354..f1eadd439 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -33,19 +33,19 @@ class TestLoadOhlcvNoPoison(unittest.TestCase): os.rmdir(self._tmp) def test_empty_download_raises_and_does_not_cache(self): - empty = pd.DataFrame() + empty = mock.Mock(history=mock.Mock(return_value=pd.DataFrame())) # Yahoo answers, so an empty download means the symbol has no data. reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True) reachable.start() self.addCleanup(reachable.stop) - with mock.patch.object(ohlcv.yf, "download", return_value=empty), \ + with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \ self.assertRaises(NoMarketDataError): ohlcv.load_ohlcv("FAKE", "2026-01-01") # Nothing should have been written to the cache. self.assertEqual(os.listdir(self._tmp), []) # A second call must re-attempt the fetch (no poisoned cache served). - with mock.patch.object(ohlcv.yf, "download", return_value=empty) as dl2: + with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty) as dl2: with self.assertRaises(NoMarketDataError): ohlcv.load_ohlcv("FAKE", "2026-01-01") self.assertTrue(dl2.called) diff --git a/tests/test_ohlcv_cache_freshness.py b/tests/test_ohlcv_cache_freshness.py index c7718b41c..da905e383 100644 --- a/tests/test_ohlcv_cache_freshness.py +++ b/tests/test_ohlcv_cache_freshness.py @@ -8,6 +8,7 @@ day (#1330). from __future__ import annotations import os +from types import SimpleNamespace import pandas as pd import pytest @@ -35,7 +36,7 @@ def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026 def _load(tmp_path, monkeypatch, curr_date, download): monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW)) - monkeypatch.setattr(ohlcv.yf, "download", download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=download)) return ohlcv.load_ohlcv("AAPL", curr_date) @@ -98,7 +99,8 @@ def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch): monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]}) downloads = [] - monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date")) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace( + history=lambda *a, **k: downloads.append(1) or frame.set_index("Date"))) for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"): now = pd.Timestamp(day) diff --git a/tests/test_ohlcv_latest_bar.py b/tests/test_ohlcv_latest_bar.py index 1fb82d866..7b48b5894 100644 --- a/tests/test_ohlcv_latest_bar.py +++ b/tests/test_ohlcv_latest_bar.py @@ -14,6 +14,7 @@ anywhere counts as no data and the staleness check judges the rest. from __future__ import annotations import os +from types import SimpleNamespace import pandas as pd import pytest @@ -105,7 +106,7 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date): def _fail_download(*a, **k): raise AssertionError("should use the seeded cache, not download") - monkeypatch.setattr(ohlcv.yf, "download", _fail_download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=_fail_download)) return ohlcv.load_ohlcv("AAPL", curr_date) @@ -198,8 +199,8 @@ def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, t cache = tmp_path / "AAPL-YFin-data.csv" cache.write_text(frame.to_csv(index=False)) _stamp(cache, today) - monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: (_ for _ in ()).throw( - AssertionError("should read the seeded cache"))) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace( + history=lambda *a, **k: (_ for _ in ()).throw(AssertionError("should read the seeded cache")))) out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py new file mode 100644 index 000000000..97a6882ad --- /dev/null +++ b/tests/test_yahoo_rate_limit.py @@ -0,0 +1,130 @@ +"""A Yahoo rate limit is retried, then reported as a rate limit. + +When the limit outlasts the retries, the agent must hear that the vendor is +throttled. "The symbol may be invalid or delisted" is a claim about the company +that nobody checked, and an exception out of a tool ends the run. +""" + +import pandas as pd +import pytest +import yfinance as yf +from yfinance.data import YfData +from yfinance.exceptions import YFRateLimitError + +from tradingagents.agents.tools import ( + get_global_news, + get_indicators, + get_insider_transactions, + get_news, + get_stock_data, + get_verified_market_snapshot, +) +from tradingagents.dataflows import router +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv + +DAY = "2026-09-18" + + +def _rate_limited(*args, **kwargs): + raise YFRateLimitError() + + +@pytest.fixture +def yahoo(monkeypatch, tmp_path): + set_config({"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(ohlcv.time, "sleep", lambda seconds: None) + for module in (ohlcv, fundamentals): + monkeypatch.setattr(module, "vendor_reachable", lambda url: True) + return monkeypatch + + +@pytest.mark.unit +@pytest.mark.parametrize("tool, args", [ + pytest.param(get_stock_data, ("AAPL", "2026-09-10", DAY), id="stock_data"), + pytest.param(get_indicators, ("AAPL", "rsi", DAY, 5), id="indicators"), + pytest.param(get_verified_market_snapshot, ("AAPL", DAY), id="snapshot"), + pytest.param(get_news, ("AAPL", "2026-09-10", DAY), id="news"), + pytest.param(get_global_news, (DAY, 7, 5), id="global_news"), + pytest.param(get_insider_transactions, ("AAPL",), id="insider"), +]) +def test_a_rate_limit_that_outlasts_the_retries_is_reported_as_one(yahoo, tool, args): + for name in ("history", "get_news"): + yahoo.setattr(yf.Ticker, name, _rate_limited) + yahoo.setattr(yf.Ticker, "insider_transactions", property(_rate_limited)) + yahoo.setattr(yf, "Search", _rate_limited) + + out = tool.func(*args, trade_date=DAY) + + assert out.startswith("DATA_UNAVAILABLE"), out + assert "delisted" not in out + + +@pytest.mark.unit +def test_the_indicator_path_retries_a_rate_limit(yahoo): + """The prices behind every indicator were fetched with ``yf.download``, which + returns an empty frame for a 429, so they were never retried.""" + bar = pd.DataFrame({"Open": [1.0], "High": [1.0], "Low": [1.0], "Close": [1.0], + "Volume": [100]}, index=pd.DatetimeIndex([DAY], name="Date")) + answers = [YFRateLimitError(), bar] + + def history(self, **kwargs): + answer = answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + yahoo.setattr(yf.Ticker, "history", history) + + assert ohlcv.load_ohlcv("AAPL", DAY)["Close"].tolist() == [1.0] + + +@pytest.mark.unit +def test_yfinance_raises_the_rate_limit_from_history(yahoo): + yahoo.setattr(YfData, "_make_request", _rate_limited) + + with pytest.raises(VendorRateLimitError, match="rate limited"): + ohlcv.load_ohlcv("AAPL", DAY) + + +@pytest.mark.unit +def test_fundamentals_ask_a_new_ticker_after_a_rate_limit(yahoo): + """A Ticker keeps a failed ``info`` fetch as done, so asking the same one + again reads an empty profile, which looks like a symbol with no data.""" + + class Ticker: + def __init__(self, symbol): + self.fetched = False + + @property + def info(self): + if self.fetched: + return {} + self.fetched = True + raise YFRateLimitError() + + yahoo.setattr(yf, "Ticker", Ticker) + + out = router.route_to_vendor("get_fundamentals", "AAPL", None) + + assert out.startswith("DATA_UNAVAILABLE"), out + + +@pytest.mark.unit +def test_another_price_fetch_error_still_tells_an_outage_from_an_unknown_symbol(yahoo): + """``Ticker.history`` lets some errors through that ``yf.download`` turned + into an empty frame. Whether Yahoo answers still decides which one it is.""" + + def refused(self, **kwargs): + raise ConnectionError("curl: (7) Failed to connect to query2.finance.yahoo.com") + + yahoo.setattr(yf.Ticker, "history", refused) + + yahoo.setattr(ohlcv, "vendor_reachable", lambda url: False) + with pytest.raises(VendorRateLimitError, match="unreachable"): + ohlcv.load_ohlcv("AAPL", DAY) + + yahoo.setattr(ohlcv, "vendor_reachable", lambda url: True) + with pytest.raises(NoMarketDataError): + ohlcv.load_ohlcv("AAPL", DAY) diff --git a/tradingagents/agents/tools.py b/tradingagents/agents/tools.py index 0ea4667e2..712dd90f3 100644 --- a/tradingagents/agents/tools.py +++ b/tradingagents/agents/tools.py @@ -10,7 +10,8 @@ from langchain_core.tools import tool from langgraph.prebuilt import InjectedState from tradingagents.dataflows.date_window import as_of, as_of_window -from tradingagents.dataflows.router import route_to_vendor +from tradingagents.dataflows.errors import VendorRateLimitError +from tradingagents.dataflows.router import route_to_vendor, vendor_unavailable from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot @@ -83,7 +84,11 @@ def get_verified_market_snapshot( price levels, Bollinger bands, RSI, MACD, moving averages, support / resistance, or historical comparisons, and treat it as the source of truth. """ - return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) + # An exception out of a tool would end the run. + try: + return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) + except VendorRateLimitError as exc: + return vendor_unavailable("get_verified_market_snapshot", exc) @tool diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index 33d7df866..7bfb1eefb 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -181,6 +181,15 @@ def get_vendor(category: str, method: str = None) -> str: return config.get("data_vendors", {}).get(category, "default") +def vendor_unavailable(method: str, error: VendorRateLimitError) -> str: + """What a call returns when every vendor was throttled or unreachable.""" + return ( + f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " + f"({error}). This says nothing about the instrument; report the " + f"data as unavailable and do not estimate or fabricate values." + ) + + def route_to_vendor(method: str, *args, **kwargs): """Route method calls to appropriate vendor implementation with fallback support.""" category = get_category_for_method(method) @@ -273,11 +282,7 @@ def route_to_vendor(method: str, *args, **kwargs): # Every vendor was throttled or unreachable: that is a fact about the # vendors, not about the instrument, and it must not end the run. if last_unavailable is not None: - return ( - f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " - f"({last_unavailable}). This says nothing about the instrument; report the " - f"data as unavailable and do not estimate or fabricate values." - ) + return vendor_unavailable(method, last_unavailable) if first_error is not None: if category in OPTIONAL_CATEGORIES: diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 72974f633..8c8e108b9 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -33,8 +33,7 @@ def get_fundamentals( return withheld try: - ticker_obj = yf.Ticker(canonical) - info = yf_retry(lambda: ticker_obj.info) + info = yf_retry(lambda: yf.Ticker(canonical).info) if not info: raise_for_empty(ticker, canonical, "fundamentals") diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 9f0498b9a..9561e9fad 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -8,7 +8,7 @@ from dateutil.relativedelta import relativedelta from tradingagents.dataflows.config import get_config from tradingagents.dataflows.date_window import coverage_gap, in_window -from tradingagents.dataflows.errors import NoMarketDataError +from tradingagents.dataflows.errors import NoMarketDataError, VendorError from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry @@ -114,6 +114,8 @@ def get_news_yfinance( return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" + except VendorError: + raise except Exception as e: raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e @@ -192,5 +194,7 @@ def get_global_news_yfinance( return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" + except VendorError: + raise except Exception as e: raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index 6325d6172..e5001aeb1 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -43,18 +43,24 @@ def yf_retry(func, max_retries=3, base_delay=2.0): yfinance raises YFRateLimitError on HTTP 429 responses but does not retry them internally. This wrapper adds retry logic specifically - for rate limits. Other exceptions propagate immediately. + for rate limits. Other exceptions propagate immediately. A rate limit + that outlasts the retries is raised as VendorRateLimitError, so the + router reports a throttled vendor rather than a symbol with no data. + ``func`` should build its own Ticker: a Ticker keeps a failed ``info`` + fetch as done, so asking the same one again reads an empty profile. """ for attempt in range(max_retries + 1): try: return func() - except YFRateLimitError: + except YFRateLimitError as exc: if attempt < max_retries: delay = base_delay * (2 ** attempt) logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") time.sleep(delay) else: - raise + raise VendorRateLimitError( + f"Yahoo Finance rate limited after {max_retries} retries: {exc}" + ) from exc def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: @@ -240,14 +246,22 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr data = cached if data is None: - downloaded = yf_retry(lambda: yf.download( - canonical, - start=start_str, - end=end_str, - multi_level_index=False, - progress=False, - auto_adjust=True, - )) + # yf.download catches every error, a rate limit included, and returns + # an empty frame. Ticker.history raises the rate limit, so it is retried. + try: + downloaded = yf_retry(lambda: yf.Ticker(canonical).history( + start=start_str, + end=end_str, + auto_adjust=True, + actions=False, + )) + except VendorRateLimitError: + raise + except Exception as exc: + # Any other failure is an outage or an unknown symbol, which + # raise_for_empty tells apart by whether Yahoo answers at all. + logger.warning("Yahoo Finance price request for %s failed: %s", canonical, exc) + raise_for_empty(symbol, canonical, "price rows") downloaded = _ensure_date_column(downloaded.reset_index()) # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: From 73e0aa0fa69b537211180757d9a9d13cc9d62914 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:10:54 +0000 Subject: [PATCH 03/63] chore: start 0.5.2 development (0.5.2.dev0) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f084d002e..b72cd3b39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents" -version = "0.5.1" +version = "0.5.2.dev0" description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.10" From 51066935b83cd031d0cce890d880c8d41dc32bf2 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:10:33 +0000 Subject: [PATCH 04/63] refactor: name the decision log module tradingagents/memory.py - TradingMemoryLog imports from tradingagents.memory, matching the class, the memory_log_path key and graph.memory_log --- tests/test_backtest.py | 2 +- tests/test_cli_decision_log.py | 2 +- tests/test_memory_log.py | 10 +++++----- tests/test_memory_pointintime.py | 2 +- tests/test_portfolio_context.py | 2 +- tests/test_rating_integrity.py | 6 +++--- tradingagents/backtest.py | 2 +- tradingagents/graph/trading_graph.py | 2 +- tradingagents/{decision_log.py => memory.py} | 0 9 files changed, 14 insertions(+), 14 deletions(-) rename tradingagents/{decision_log.py => memory.py} (100%) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index e86d40973..e9aab6c20 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest from tradingagents.backtest import iter_grid, run_backtest, summarize -from tradingagents.decision_log import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog DECISION = "Rating: Buy\n\nbuy it" diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_decision_log.py index be695b095..d8cf89da3 100644 --- a/tests/test_cli_decision_log.py +++ b/tests/test_cli_decision_log.py @@ -12,8 +12,8 @@ from __future__ import annotations import pytest import cli.run as cli_run -from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog def _bare_graph(tmp_path): diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 42dc34b11..807335b4a 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -7,11 +7,11 @@ import pytest from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating -from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph import settlement from tradingagents.graph.propagation import Propagator from tradingagents.graph.reflection import Reflector from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog _SEP = TradingMemoryLog._SEPARATOR @@ -863,12 +863,12 @@ class TestLegacyRemoval: def test_financial_situation_memory_removed(self): """FinancialSituationMemory must not be importable from the memory module.""" - import tradingagents.decision_log as m + import tradingagents.memory as m assert not hasattr(m, "FinancialSituationMemory") def test_bm25_not_imported(self): """rank_bm25 must not be present in the memory module namespace.""" - import tradingagents.decision_log as m + import tradingagents.memory as m assert not hasattr(m, "BM25Okapi") def test_reflect_and_remember_removed(self): @@ -934,8 +934,8 @@ class TestLegacyRemoval: def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch): """Settling past decisions happens on the way into a new run, and reflection calls an LLM. A transient failure there must not stop the new analysis.""" - from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md")} @@ -967,8 +967,8 @@ def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp def test_the_holding_window_is_configurable(tmp_path, monkeypatch): """A decision written for months should not be graded at a week without the operator choosing that window.""" - from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21} diff --git a/tests/test_memory_pointintime.py b/tests/test_memory_pointintime.py index 6adaf082d..c61a1d707 100644 --- a/tests/test_memory_pointintime.py +++ b/tests/test_memory_pointintime.py @@ -10,7 +10,7 @@ from __future__ import annotations import pytest -from tradingagents.decision_log import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog def _log(tmp_path): diff --git a/tests/test_portfolio_context.py b/tests/test_portfolio_context.py index b48481e39..3da81b5fe 100644 --- a/tests/test_portfolio_context.py +++ b/tests/test_portfolio_context.py @@ -76,9 +76,9 @@ def test_load_reads_a_valid_file(tmp_path): # --- threading through the graph -------------------------------------------- def _bare_graph(tmp_path): - from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.propagation import Propagator from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1, diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 1ee0a4c87..692ad918a 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -64,7 +64,7 @@ def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating(): @pytest.mark.unit def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log.store_decision("NVDA", "2026-01-05", REFUSAL) @@ -76,7 +76,7 @@ def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): @pytest.mark.unit def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): from tradingagents.agents.rating import parse_rating - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."): @@ -91,7 +91,7 @@ def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path): """REVIEW has no direction, so it cannot count for or against the system.""" from tradingagents.backtest import summarize - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index 65c4d9a6a..b249b4e96 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -24,8 +24,8 @@ from pathlib import Path from tradingagents.agents.rating import RATING_REVIEW from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component -from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog logger = logging.getLogger(__name__) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8169decc4..6717ef397 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -11,9 +11,9 @@ from tradingagents.agents.rating import parse_rating from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component -from tradingagents.decision_log import TradingMemoryLog from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import build_llm_kwargs, create_llm_client +from tradingagents.memory import TradingMemoryLog from tradingagents.reporting import write_report_tree from . import settlement diff --git a/tradingagents/decision_log.py b/tradingagents/memory.py similarity index 100% rename from tradingagents/decision_log.py rename to tradingagents/memory.py From 47ac1f2d1eee5d3de7bb9d5bd676542513739201 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:12:14 +0000 Subject: [PATCH 05/63] feat(config): benchmark Taiwan, Korea, Singapore and the main European exchanges (#1392) - .TW, .TWO, .KS, .KQ, .SI, .DE, .PA, .AS, .SW and .MI tickers are measured against their regional index instead of SPY --- tests/test_memory_log.py | 10 ++++++++++ tradingagents/default_config.py | 10 ++++++++++ tradingagents/graph/settlement.py | 7 +++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 807335b4a..090c3af9a 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -628,6 +628,16 @@ class TestDeferredReflection: "benchmark_map": DEFAULT_CONFIG["benchmark_map"]} assert settlement.resolve_benchmark("PETR4.SA", config) == "^BVSP" + @pytest.mark.parametrize(("ticker", "index"), [ + ("2330.TW", "^TWII"), ("6488.TWO", "^TWII"), ("005930.KS", "^KS11"), ("247540.KQ", "^KQ11"), ("D05.SI", "^STI"), ("SAP.DE", "^GDAXI"), + ("MC.PA", "^FCHI"), ("ASML.AS", "^AEX"), ("NESN.SW", "^SSMI"), ("ENI.MI", "FTSEMIB.MI"), + ]) + def test_resolve_benchmark_regional_indexes(self, ticker, index): + """Taiwan, Korea, Singapore and the main European exchanges were measured against SPY.""" + from tradingagents.default_config import DEFAULT_CONFIG + config = {"benchmark_ticker": None, "benchmark_map": DEFAULT_CONFIG["benchmark_map"]} + assert settlement.resolve_benchmark(ticker, config) == index + def test_resolve_benchmark_us_ticker_defaults_to_spy(self): """US tickers (no dotted suffix) take the empty-suffix entry.""" config = { diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 3bfaf3db9..8c59c76e3 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -161,8 +161,18 @@ DEFAULT_CONFIG = _apply_env_overrides({ ".NS": "^NSEI", # NSE India (Nifty 50) ".BO": "^BSESN", # BSE India (Sensex) ".T": "^N225", # Tokyo (Nikkei 225) + ".TW": "^TWII", # Taiwan (TAIEX) + ".TWO": "^TWII", # Taipei OTC (TPEx has no Yahoo index; TAIEX) + ".KS": "^KS11", # Korea (KOSPI) + ".KQ": "^KQ11", # Korea (KOSDAQ) ".HK": "^HSI", # Hong Kong (Hang Seng) + ".SI": "^STI", # Singapore (Straits Times) ".L": "^FTSE", # London (FTSE 100) + ".DE": "^GDAXI", # Germany (DAX) + ".PA": "^FCHI", # Paris (CAC 40) + ".AS": "^AEX", # Amsterdam (AEX) + ".SW": "^SSMI", # Switzerland (SMI) + ".MI": "FTSEMIB.MI", # Milan (FTSE MIB) ".TO": "^GSPTSE", # Toronto (TSX Composite) ".AX": "^AXJO", # Australia (ASX 200) ".SS": "000001.SS", # Shanghai (SSE Composite) diff --git a/tradingagents/graph/settlement.py b/tradingagents/graph/settlement.py index 794b987ee..51e658834 100644 --- a/tradingagents/graph/settlement.py +++ b/tradingagents/graph/settlement.py @@ -16,10 +16,9 @@ def resolve_benchmark(ticker: str, config: dict) -> str: ``config["benchmark_ticker"]`` overrides everything when set; otherwise the suffix map matches the ticker's exchange suffix (e.g. ``.T`` for Tokyo). US-listed tickers without a dotted suffix fall through to the - empty-suffix entry (SPY by default). Unrecognised suffixes (including - US tickers with dots like ``BRK.B``) also fall back to the empty-suffix - entry, which is the right default because the alpha calculation works - in USD. + empty-suffix entry (SPY by default). Unrecognised suffixes, including + US tickers with dots like ``BRK.B``, also take the empty-suffix entry. + Returns are compared as percentages, each in its own currency. """ explicit = config.get("benchmark_ticker") From f4269bf19324cf99892dee55950afd1777945b37 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:12:59 +0000 Subject: [PATCH 06/63] test: keep the CLI's saved selections out of the user's home (#1395) - every test saves and reads CLI selections under its own tmp_path --- tests/conftest.py | 6 ++++++ tests/test_cli_prefs.py | 14 ++++---------- tests/test_suite_isolation.py | 14 +++++++++++++- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index acbd85276..52d1c185b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,12 @@ def _no_network(request, monkeypatch): monkeypatch.setattr(socket.socket, "connect_ex", refuse) +@pytest.fixture(autouse=True) +def _own_cli_prefs(tmp_path, monkeypatch): + """The CLI keeps the last run's selections in the user's home; tests keep theirs apart.""" + monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json") + + _API_KEY_ENV_VARS = ( "OPENAI_API_KEY", "GOOGLE_API_KEY", diff --git a/tests/test_cli_prefs.py b/tests/test_cli_prefs.py index a833c52e1..1dbb8a149 100644 --- a/tests/test_cli_prefs.py +++ b/tests/test_cli_prefs.py @@ -27,12 +27,6 @@ SAVED = { } -@pytest.fixture(autouse=True) -def _home(tmp_path, monkeypatch): - monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json") - return tmp_path - - @pytest.mark.unit def test_round_trip(): save_last_run(SAVED) @@ -45,18 +39,18 @@ def test_missing_file_is_not_an_error(): @pytest.mark.unit -def test_a_corrupt_file_degrades_to_no_memory(_home): - (_home / "cli_prefs.json").write_text("{not json") +def test_a_corrupt_file_degrades_to_no_memory(tmp_path): + (tmp_path / "cli_prefs.json").write_text("{not json") assert load_last_run() == {} @pytest.mark.unit -def test_a_half_written_file_cannot_be_observed(_home): +def test_a_half_written_file_cannot_be_observed(tmp_path): """Two runs finishing together must never leave a torn file behind.""" save_last_run(SAVED) save_last_run({**SAVED, "research_depth": 5}) assert load_last_run()["research_depth"] == 5 - assert list((_home).glob("*.tmp*")) == [] + assert list(tmp_path.glob("*.tmp*")) == [] # --- validation against the current choices --------------------------------- diff --git a/tests/test_suite_isolation.py b/tests/test_suite_isolation.py index dd325c984..7337e232e 100644 --- a/tests/test_suite_isolation.py +++ b/tests/test_suite_isolation.py @@ -1,4 +1,4 @@ -"""The suite runs the same on any machine: no test reaches the network.""" +"""The suite runs the same on any machine: no test reaches the network or the user's files.""" import socket @@ -15,3 +15,15 @@ def test_a_test_cannot_reach_the_network(connect): machine it runs on; conftest refuses the connection instead.""" with pytest.raises(OSError, match="reach the network"): connect() + + +@pytest.mark.unit +def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path): + """The CLI remembers the last run's selections in the user's home; a test + that runs the selection flow would otherwise overwrite them.""" + from cli import prefs + + prefs.save_last_run({"analysts": ["market"]}) + + assert prefs._PREFS_PATH.is_relative_to(tmp_path) + assert prefs._PREFS_PATH.exists() From f9d6495dcaea24f4a3eeec278d6b8a8183ee434a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:14:53 +0000 Subject: [PATCH 07/63] fix(fred): state the dates a macro change spans (#1397) - the change line reads "Change from to " instead of "Change over window", so a partial-year move no longer reads as year on year --- tests/test_fred.py | 10 ++++++---- tradingagents/dataflows/vendors/fred.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_fred.py b/tests/test_fred.py index 6bcb6bbfc..c28c29891 100644 --- a/tests/test_fred.py +++ b/tests/test_fred.py @@ -98,8 +98,10 @@ class FredFormattingTests(unittest.TestCase): 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) + # The change names the observations it spans, not the lookback window, + # so a 3-month move on a monthly series cannot read as year on year. + self.assertIn("**Change from 2025-06-01 to 2025-09-01:** +0.30 (+7.32%), from 4.1", out) + self.assertNotIn("Change over window", out) self.assertIn("| 2025-06-01 | 4.1 |", out) def test_missing_value_is_skipped(self): @@ -133,8 +135,8 @@ class FredFormattingTests(unittest.TestCase): 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) + # the change must reference the true first (0) and last value + self.assertIn("+49.00, from 0\n", out) body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")] self.assertEqual(len(body_rows), fred.MAX_ROWS) diff --git a/tradingagents/dataflows/vendors/fred.py b/tradingagents/dataflows/vendors/fred.py index 611d8d919..e5a130664 100644 --- a/tradingagents/dataflows/vendors/fred.py +++ b/tradingagents/dataflows/vendors/fred.py @@ -254,8 +254,8 @@ def get_macro_data( 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" + f"**Change from {first_date} to {last_date}:** {delta:+.2f}{pct}, " + f"from {first_val}\n" ) except ValueError: summary = f"\n**Latest:** {last_val} ({last_date})\n" From 05878c96a8eda4f1808f4f2eea9d83caeb36191a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:15:35 +0000 Subject: [PATCH 08/63] test(net): pin that get_scrubbed keeps the vendor key out of errors (#1398) - the message and args are masked; no request, response or chained original travels with the error; a passthrough status is returned --- tests/test_get_scrubbed.py | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_get_scrubbed.py diff --git a/tests/test_get_scrubbed.py b/tests/test_get_scrubbed.py new file mode 100644 index 000000000..d15418020 --- /dev/null +++ b/tests/test_get_scrubbed.py @@ -0,0 +1,65 @@ +"""``get_scrubbed``: the vendor key must not survive into an error. + +FRED and Alpha Vantage authenticate with a query parameter, so requests quotes +the key back in the text of HTTP, connection and timeout errors, and in the +``request`` and ``response`` it attaches (#1324). +""" + +from unittest import mock + +import pytest +import requests + +from tradingagents.dataflows.net import get_scrubbed + +# A fabricated value in the shape of a FRED key. +_KEY = "deadbeefdeadbeefdeadbeefdeadbeef" +_LEAK = ( + "HTTPSConnectionPool(host='api.stlouisfed.org', port=443): Max retries " + f"exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}&file_type=json" +) + + +def _response(status_code): + response = mock.Mock(spec=requests.Response) + response.status_code = status_code + response.raise_for_status.side_effect = requests.HTTPError(_LEAK) if status_code >= 400 else None + return response + + +def _call(**kwargs): + return get_scrubbed("https://api.stlouisfed.org/fred/series", + params={"api_key": _KEY}, timeout=5, secret=_KEY, **kwargs) + + +@pytest.mark.unit +@pytest.mark.parametrize("failure", [ + {"side_effect": requests.ConnectionError(_LEAK)}, + {"return_value": _response(500)}, +], ids=["transport", "status"]) +def test_the_key_is_masked_in_the_error(failure): + with mock.patch.object(requests, "get", **failure), pytest.raises(requests.RequestException) as caught: + _call() + assert _KEY not in str(caught.value) and _KEY not in repr(caught.value.args) + assert "api_key=***" in str(caught.value) + + +@pytest.mark.unit +def test_nothing_that_holds_the_url_travels_with_the_error(): + """The request, the response and a chained original each carry the full URL.""" + leaky = requests.ConnectionError(_LEAK) + leaky.request = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}") + leaky.response = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}") + with mock.patch.object(requests, "get", side_effect=leaky), pytest.raises(requests.ConnectionError) as caught: + _call() + error = caught.value + assert error.request is None and error.response is None + assert error.__cause__ is None and error.__context__ is None + + +@pytest.mark.unit +def test_a_passthrough_status_is_returned_for_the_caller(): + response = _response(400) + with mock.patch.object(requests, "get", return_value=response): + assert _call(passthrough=(400,)) is response + response.raise_for_status.assert_not_called() From a94a411b0e404e49833c4aada1616495f1696e62 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:22:23 +0000 Subject: [PATCH 09/63] fix(agents): carry the Portfolio Manager's rating through the run (#1383) - the typed rating is the state's final_rating; propagate returns it, and the memory log tag, the state log and the CLI review check read it - the decision text is parsed only when the Portfolio Manager answered in free text - TradingAgentsGraph.process_signal is removed --- cli/run.py | 4 +- tests/test_cli_decision_log.py | 6 +-- tests/test_cli_display.py | 2 +- tests/test_graph_end_to_end.py | 6 ++- tests/test_memory_log.py | 2 +- tests/test_portfolio_context.py | 3 +- tests/test_rating_integrity.py | 18 +++++-- tests/test_signal_processing.py | 17 ------ .../agents/managers/portfolio_manager.py | 34 ++++++------ tradingagents/agents/rating.py | 12 ++++- tradingagents/agents/state.py | 1 + tradingagents/agents/structured.py | 54 ++++++++++--------- tradingagents/graph/trading_graph.py | 12 ++--- tradingagents/memory.py | 9 +++- 14 files changed, 93 insertions(+), 87 deletions(-) diff --git a/cli/run.py b/cli/run.py index 08896cf2e..7d0bda236 100644 --- a/cli/run.py +++ b/cli/run.py @@ -23,7 +23,7 @@ from cli.display import ( ) from cli.selections import get_user_selections from cli.stats_handler import StatsCallbackHandler -from tradingagents.agents.rating import is_review +from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.graph.analyst_execution import ( @@ -358,7 +358,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): # A decision nobody can read is not a position. Say so here rather than # leaving the run to look like a normal result. - if is_review(graph.process_signal(final_state.get("final_trade_decision", ""))): + if is_review(run_rating(final_state)): console.print( "[yellow]No rating could be read from the final decision, so this run " "is recorded for review rather than as a position. Re-run, or read the " diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_decision_log.py index d8cf89da3..d6d01e784 100644 --- a/tests/test_cli_decision_log.py +++ b/tests/test_cli_decision_log.py @@ -76,10 +76,6 @@ class _FakeGraph: self.calls.append(("create_run_state", ticker, trade_date)) return {"messages": [], "company_of_interest": ticker} - def process_signal(self, text): - from tradingagents.agents.rating import parse_rating - return parse_rating(text) - def record_decision(self, ticker, trade_date, final_state): self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision"))) @@ -101,7 +97,7 @@ class _FakeGraph: def stream(self, graph_input, **kwargs): yield {"messages": [], "market_report": "M"} - yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."} + yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"} class _NullLive: diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 52d419e07..8adb45ea1 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -46,7 +46,7 @@ def _state(ticker, final="评级: 买入"): "company_of_interest": ticker, "trade_date": "2026-09-01", "market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻", "fundamentals_report": "基本面", "investment_plan": "计划", - "trader_investment_plan": "交易计划", "final_trade_decision": final, + "trader_investment_plan": "交易计划", "final_trade_decision": final, "final_rating": "REVIEW", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", "current_response": "", "judge_decision": "", "count": 0}, "risk_debate_state": {"aggressive_history": "", "conservative_history": "", diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 2bd846b89..7b791220c 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -34,8 +34,10 @@ STRUCTURED = { schemas.ResearchPlan: schemas.ResearchPlan( recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"), schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"), + # The thesis quotes another party's rating; the decision is still the PM's own. schemas.PortfolioDecision: schemas.PortfolioDecision( - rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", investment_thesis="t"), + rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", + investment_thesis="Street consensus rating: Buy (28 of 35 analysts)."), schemas.SentimentReport: schemas.SentimentReport( overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"), } @@ -126,7 +128,7 @@ def test_a_full_run_reaches_a_logged_decision(tmp_path, monkeypatch, offline, st state, signal = graph.propagate("NVDA", TRADE_DATE) - assert signal == "Overweight" + assert signal == state["final_rating"] == "Overweight" for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report", "investment_plan", "trader_investment_plan", "final_trade_decision"): assert state[key].strip(), key diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 090c3af9a..5ceb01ac8 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -898,6 +898,7 @@ class TestLegacyRemoval: fake_state = { "final_trade_decision": "Rating: Buy\nBuy NVDA.", + "final_rating": "Buy", "company_of_interest": "NVDA", "trade_date": "2026-01-10", "market_report": "", @@ -924,7 +925,6 @@ class TestLegacyRemoval: mock_graph.graph.invoke.return_value = fake_state mock_graph.propagator.create_initial_state.return_value = fake_state mock_graph.propagator.get_graph_args.return_value = {} - mock_graph.process_signal.return_value = "Buy" # Bind the real _run_graph so propagate's call to self._run_graph executes # the actual write path instead of the auto-MagicMock. mock_graph._run_graph = functools.partial( diff --git a/tests/test_portfolio_context.py b/tests/test_portfolio_context.py index 3da81b5fe..228489671 100644 --- a/tests/test_portfolio_context.py +++ b/tests/test_portfolio_context.py @@ -174,9 +174,8 @@ def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch): graph.debug = False graph._resuming = False graph.propagator.get_graph_args = lambda callbacks=None: {} - graph.process_signal = lambda d: "Hold" graph._log_state = lambda *a, **k: None - graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})() + graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx", "final_rating": "Hold"}})() book = PortfolioContext.model_validate(HOLDING) written = graph._run_signature("stock", book) # what begin_checkpoint keys on diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 692ad918a..8fcadac4a 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -121,10 +121,6 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path def record_decision(self, *a, **k): pass - def process_signal(self, text): - from tradingagents.agents.rating import parse_rating - return parse_rating(text) - def get_graph_args(self, callbacks=None): return {} @@ -141,7 +137,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path pass def stream(self, *a, **k): - yield {"messages": [], "final_trade_decision": REFUSAL} + yield {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW} fake = _Graph() fake.graph = fake @@ -210,3 +206,15 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_ assert "## Output" in prompt, "no output-format section in the prompt" section = prompt.split("## Output", 1)[1] assert f"**{must_name}**" in section, section[:300] + + +@pytest.mark.unit +def test_a_state_without_the_typed_rating_reads_it_from_the_decision(): + """A run finished by an older version and resumed from its checkpoint has + no final_rating; every reader falls back the same way instead of one + raising and another reporting REVIEW.""" + from tradingagents.agents.rating import run_rating + + assert run_rating({"final_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold" + assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell" + assert run_rating({}) == RATING_REVIEW diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index 4a0440c6f..7976b8239 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -75,20 +75,3 @@ class TestExtractRating: # The memory log tags an unreadable decision REVIEW, never a tradeable rating. assert parse_rating("No rating here.") == RATING_REVIEW assert parse_rating("No rating here.", default="Underweight") == "Underweight" - - -@pytest.mark.unit -class TestGraphSignalContract: - """The graph-facing signal (TradingAgentsGraph.process_signal) honors the - documented "5-tier or REVIEW" contract, not just the parser in isolation.""" - - def _bare_graph(self): - from tradingagents.graph.trading_graph import TradingAgentsGraph - g = object.__new__(TradingAgentsGraph) - return g - - def test_graph_surfaces_review(self): - assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW - - def test_graph_returns_rating(self): - assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell" diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py index 442fbb5d6..228758221 100644 --- a/tradingagents/agents/managers/portfolio_manager.py +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -1,11 +1,11 @@ """Portfolio Manager: synthesises the risk-analyst debate into the final decision. Uses LangChain's ``with_structured_output`` so the LLM produces a typed -``PortfolioDecision`` directly, in a single call. The result is rendered -back to markdown for storage in ``final_trade_decision`` so memory log, -CLI display, and saved reports continue to consume the same shape they do -today. When a provider does not expose structured output, the agent falls -back gracefully to free-text generation. +``PortfolioDecision`` directly, in a single call. Its rating is the run's +``final_rating``, and the decision is rendered to markdown as +``final_trade_decision`` for the memory log, CLI display and saved reports. +When a provider does not expose structured output, the agent falls back to +free-text generation and the rating is read from that text. """ from __future__ import annotations @@ -15,12 +15,9 @@ from tradingagents.agents.context import ( get_language_instruction, get_portfolio_context_from_state, ) +from tradingagents.agents.rating import parse_rating from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision -from tradingagents.agents.structured import ( - NO_EXTERNAL_TOOLS, - bind_structured, - invoke_structured_or_freetext, -) +from tradingagents.agents.structured import NO_EXTERNAL_TOOLS, bind_structured, invoke_structured def create_portfolio_manager(llm): @@ -78,13 +75,15 @@ Write these sections, in this order, starting with the rating on its own line: {NO_EXTERNAL_TOOLS}{get_language_instruction()}""" - final_trade_decision = invoke_structured_or_freetext( - structured_llm, - llm, - prompt, - render_pm_decision, - "Portfolio Manager", - ) + # The typed rating is the decision; the rendered text only carries it. + # Read back from text, a rating the thesis quotes could replace it. + decision = invoke_structured(structured_llm, prompt, "Portfolio Manager") + if decision is not None: + final_trade_decision = render_pm_decision(decision) + final_rating = decision.rating.value + else: + final_trade_decision = llm.invoke(prompt).content + final_rating = parse_rating(final_trade_decision) new_risk_debate_state = { "judge_decision": final_trade_decision, @@ -102,6 +101,7 @@ Write these sections, in this order, starting with the rating on its own line: return { "risk_debate_state": new_risk_debate_state, "final_trade_decision": final_trade_decision, + "final_rating": final_rating, } return portfolio_manager_node diff --git a/tradingagents/agents/rating.py b/tradingagents/agents/rating.py index daaa3a492..37a6db8d6 100644 --- a/tradingagents/agents/rating.py +++ b/tradingagents/agents/rating.py @@ -2,8 +2,7 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: - The Research Manager (investment plan recommendation) -- The Portfolio Manager (final position decision) -- The signal processor (rating extracted for downstream consumers) +- The Portfolio Manager (final position decision; its free-text fallback is read here) - The memory log (rating tag stored alongside each decision entry) Centralising it here avoids drift between those call sites. @@ -88,6 +87,15 @@ def parse_rating(text: str, default: str = RATING_REVIEW) -> str: return rating if rating is not None else default +def run_rating(final_state: dict) -> str: + """A finished run's rating: the Portfolio Manager's own, else read from its decision. + + The fallback serves a state without ``final_rating``, such as a run an older + version completed and a checkpoint hands back unchanged. + """ + return final_state.get("final_rating") or parse_rating(final_state.get("final_trade_decision", "")) + + def is_review(signal: str) -> bool: """Whether a signal is the non-tradeable REVIEW sentinel (#1170).""" return signal == RATING_REVIEW diff --git a/tradingagents/agents/state.py b/tradingagents/agents/state.py index 0d6df58cc..7faa545e5 100644 --- a/tradingagents/agents/state.py +++ b/tradingagents/agents/state.py @@ -73,5 +73,6 @@ class AgentState(MessagesState): RiskDebateState, "Current state of the debate on evaluating risk" ] final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] + final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"] past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"] diff --git a/tradingagents/agents/structured.py b/tradingagents/agents/structured.py index 8e94132c9..8992e5351 100644 --- a/tradingagents/agents/structured.py +++ b/tradingagents/agents/structured.py @@ -56,6 +56,31 @@ def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None: return None +def invoke_structured(structured_llm: Any | None, prompt: Any, agent_name: str) -> T | None: + """Run the structured call; ``None`` when there is none or it fails. + + ``prompt`` is whatever the underlying LLM accepts (a string for chat + invocations, a list of message dicts for chat models that take that + shape), so a caller can forward the same value to its free-text fallback. + """ + if structured_llm is None: + return None + try: + result = structured_llm.invoke(prompt) + if result is None: + # A thinking model can answer in plain text instead of calling + # the tool, leaving the parser with nothing to return. Treat it + # as a structured miss and fall back, with a clear reason. + raise ValueError("structured output returned no parsed result") + return result + except Exception as exc: + logger.warning( + "%s: structured-output invocation failed (%s); retrying once as free text", + agent_name, exc, + ) + return None + + def invoke_structured_or_freetext( structured_llm: Any | None, plain_llm: Any, @@ -63,27 +88,8 @@ def invoke_structured_or_freetext( render: Callable[[T], str], agent_name: str, ) -> str: - """Run the structured call and render to markdown; fall back to free-text on any failure. - - ``prompt`` is whatever the underlying LLM accepts (a string for chat - invocations, a list of message dicts for chat models that take that - shape). The same value is forwarded to the free-text path so the - fallback sees the same input the structured call did. - """ - if structured_llm is not None: - try: - result = structured_llm.invoke(prompt) - if result is None: - # A thinking model can answer in plain text instead of calling - # the tool, leaving the parser with nothing to return. Treat it - # as a structured miss and fall back, with a clear reason. - raise ValueError("structured output returned no parsed result") - return render(result) - except Exception as exc: - logger.warning( - "%s: structured-output invocation failed (%s); retrying once as free text", - agent_name, exc, - ) - - response = plain_llm.invoke(prompt) - return response.content + """Run the structured call and render to markdown; fall back to free-text on any failure.""" + result = invoke_structured(structured_llm, prompt, agent_name) + if result is not None: + return render(result) + return plain_llm.invoke(prompt).content diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 6717ef397..9fe826977 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity -from tradingagents.agents.rating import parse_rating +from tradingagents.agents.rating import run_rating from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component @@ -295,7 +295,8 @@ class TradingAgentsGraph: logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) return self.memory_log.store_decision( - ticker=company_name, trade_date=trade_date, final_trade_decision=decision + ticker=company_name, trade_date=trade_date, final_trade_decision=decision, + rating=run_rating(final_state), ) def _run_graph(self, company_name, trade_date, asset_type: str = "stock", @@ -341,7 +342,7 @@ class TradingAgentsGraph: # Clear checkpoint on successful completion to avoid stale state. self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio) - return final_state, self.process_signal(final_state["final_trade_decision"]) + return final_state, run_rating(final_state) def _log_state(self, trade_date, final_state): """Write a run's final state to JSON under the run's own ticker.""" @@ -373,6 +374,7 @@ class TradingAgentsGraph: }, "investment_plan": final_state["investment_plan"], "final_trade_decision": final_state["final_trade_decision"], + "final_rating": run_rating(final_state), } # A ticker that would escape the results directory is rejected. @@ -384,7 +386,3 @@ class TradingAgentsGraph: with open(log_path, "w", encoding="utf-8") as f: # Reports can be in any language and this file is read by a person. json.dump(entry, f, indent=4, ensure_ascii=False) - - def process_signal(self, full_signal): - """The decision's 5-tier rating, or REVIEW when it has none.""" - return parse_rating(full_signal) diff --git a/tradingagents/memory.py b/tradingagents/memory.py index ca5afb47f..1b5ab6039 100644 --- a/tradingagents/memory.py +++ b/tradingagents/memory.py @@ -32,8 +32,13 @@ class TradingMemoryLog: ticker: str, trade_date: str, final_trade_decision: str, + rating: str | None = None, ) -> None: - """Append pending entry at end of propagate(). No LLM call.""" + """Append pending entry at end of propagate(). No LLM call. + + ``rating`` is the decision's own rating when the caller has it; without + one it is read from the decision text. + """ if not self._log_path: return # Idempotency guard: fast raw-text scan instead of full parse. Any entry @@ -45,7 +50,7 @@ class TradingMemoryLog: for line in raw.splitlines(): if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): return - rating = parse_rating(final_trade_decision) + rating = rating or parse_rating(final_trade_decision) tag = f"[{trade_date} | {ticker} | {rating} | pending]" entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" with open(self._log_path, "a", encoding="utf-8") as f: From b690dc79883f45a2a8a1fe6668869be1219d40e6 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:23:14 +0000 Subject: [PATCH 10/63] fix(rating): read a free-text rating from the decision's own rating line (#1383) - the first line opening with a rating label is the call, not a rating the text quotes later ("Consensus rating: Buy") - "rating" must start a word, so "Operating margin: Sell-side" is not a label --- tests/test_rating_integrity.py | 26 +++++++++++++++++++++++ tradingagents/agents/rating.py | 38 ++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 8fcadac4a..dc3c4373d 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -218,3 +218,29 @@ def test_a_state_without_the_typed_rating_reads_it_from_the_decision(): assert run_rating({"final_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold" assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell" assert run_rating({}) == RATING_REVIEW + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "Street consensus rating: Buy (28 of 35 analysts).", + "Moody's affirmed the credit rating: Buy-side demand for the bonds stayed firm.", + "Operating margin: Sell-side estimates sit below guidance.", +]) +def test_a_rating_the_text_quotes_does_not_replace_the_decision(quoted): + """A free-text decision opens with its own rating line; a rating it quotes + as evidence, or a word merely ending in 'rating', is not the call.""" + text = f"**Rating**: Hold\n\n**Investment Thesis**: {quoted} We wait for margins." + assert extract_rating(text) == "Hold" + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "- Rating: Buy (Goldman Sachs, 12m target 180)", + "| Rating: Buy | Morgan Stanley |", + "> Rating: Buy, per the sell-side note", + "Street consensus rating: Buy", + "Consensus rating: Buy (28 of 35 analysts)", +]) +def test_a_quoted_rating_in_a_list_table_or_quote_is_not_the_decision(quoted): + text = f"Our rating: Hold\n\nWhat others say:\n{quoted}\n\nWe wait for margins." + assert extract_rating(text) == "Hold" diff --git a/tradingagents/agents/rating.py b/tradingagents/agents/rating.py index 37a6db8d6..16d7689dd 100644 --- a/tradingagents/agents/rating.py +++ b/tradingagents/agents/rating.py @@ -32,10 +32,20 @@ RATING_REVIEW = "REVIEW" _RATING_SET = {r.lower() for r in RATINGS_5_TIER} # Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown -# bold wrappers and any dash or colon a model writes as the separator. -_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)", +# bold wrappers and any dash or colon a model writes as the separator. "rating" +# must start a word, so "Operating margin: Sell-side" is not a label. +_RATING_LABEL_RE = re.compile(r"(? str | None: Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like ``Rating:Overweight`` is matched the same as ASCII): - 1. An explicit "Rating: X" label (tolerant of markdown bold). - 2. The first standalone 5-tier rating word found anywhere. + 1. An explicit "Rating: X" label (tolerant of markdown bold): the first one + opening its own line, else the last one anywhere. + 2. A single 5-tier rating word, when the text names only one. """ if not text: return None norm = unicodedata.normalize("NFKC", text) - # The labelled rating, taking the last one written: a decision states its - # rating after discussing the alternatives. Lines presenting the scale - # itself are a legend the model echoed, not a call. - labelled = None + # A decision is asked to open with its rating on its own line, so the first + # such line is the call; later ones may quote someone else's ("Consensus + # rating: Buy"). Without one, the last label anywhere wins: prose states its + # rating after discussing the alternatives. Lines presenting the scale itself + # are a legend the model echoed, not a call. + on_own_line = anywhere = None for line in norm.splitlines(): if _RATING_SCALE_RE.search(line): continue + m = _RATING_LINE_RE.match(line) + if on_own_line is None and m and m.group(1).lower() in _RATING_SET: + on_own_line = m.group(1).capitalize() m = _RATING_LABEL_RE.search(line) if m and m.group(1).lower() in _RATING_SET: - labelled = m.group(1).capitalize() - if labelled: - return labelled + anywhere = m.group(1).capitalize() + if on_own_line or anywhere: + return on_own_line or anywhere # No label. A single rating word in the text is the call; several are an # argument, and picking one of them reports a direction nobody decided -- From c50420fc5ce53e090ab10495da004d97eb37447c Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:51:08 +0000 Subject: [PATCH 11/63] refactor: gather the memory log, settlement and reflection in tradingagents/memory - memory/log.py holds TradingMemoryLog, still imported from tradingagents.memory - graph/settlement.py and graph/reflection.py move to memory/; Reflector leaves tradingagents.graph's exports --- tests/test_dataflows_config.py | 2 +- tests/test_memory_log.py | 7 +++---- tests/test_symbol_normalization_paths.py | 2 +- tradingagents/graph/__init__.py | 2 -- tradingagents/graph/trading_graph.py | 5 ++--- tradingagents/memory/__init__.py | 10 ++++++++++ tradingagents/{memory.py => memory/log.py} | 0 tradingagents/{graph => memory}/reflection.py | 0 tradingagents/{graph => memory}/settlement.py | 0 9 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 tradingagents/memory/__init__.py rename tradingagents/{memory.py => memory/log.py} (100%) rename tradingagents/{graph => memory}/reflection.py (100%) rename tradingagents/{graph => memory}/settlement.py (100%) diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py index fd1ef37e5..dd0393c27 100644 --- a/tests/test_dataflows_config.py +++ b/tests/test_dataflows_config.py @@ -148,7 +148,7 @@ def test_settling_reads_the_graphs_own_config(monkeypatch): graph = _graph(config) graph.memory_log = graph.reflector = None # the settlement below is a stand-in seen = [] - from tradingagents.graph import settlement + from tradingagents.memory import settlement monkeypatch.setattr(settlement, "settle_pending", lambda *a: seen.append(get_vendor("core_stock_apis", "get_stock_data"))) diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 5ceb01ac8..7652aafde 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -7,11 +7,10 @@ import pytest from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating -from tradingagents.graph import settlement from tradingagents.graph.propagation import Propagator -from tradingagents.graph.reflection import Reflector from tradingagents.graph.trading_graph import TradingAgentsGraph -from tradingagents.memory import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog, settlement +from tradingagents.memory.reflection import Reflector _SEP = TradingMemoryLog._SEPARATOR @@ -1004,7 +1003,7 @@ def test_the_holding_window_is_configurable(tmp_path, monkeypatch): def test_the_reflection_states_the_window_it_judges(): """Judging a months-long thesis on a week's alpha, without saying so, turns a scope mismatch into a lesson that the call was wrong.""" - from tradingagents.graph.reflection import Reflector + from tradingagents.memory.reflection import Reflector prompt = Reflector(None)._system_prompt(holding_days=5) assert "5" in prompt and "trading day" in prompt diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index f802ae53b..f3ed1b21d 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -10,7 +10,7 @@ import pandas as pd import tradingagents.agents.context as au import tradingagents.dataflows.vendors.yahoo.market as yahoo_market import tradingagents.dataflows.vendors.yahoo.news as ynews -from tradingagents.graph import settlement +from tradingagents.memory import settlement def test_identity_lookup_normalizes_symbol(monkeypatch): diff --git a/tradingagents/graph/__init__.py b/tradingagents/graph/__init__.py index 54f3037fc..e3715701b 100644 --- a/tradingagents/graph/__init__.py +++ b/tradingagents/graph/__init__.py @@ -1,6 +1,5 @@ from .conditional_logic import ConditionalLogic from .propagation import Propagator -from .reflection import Reflector from .setup import GraphSetup from .trading_graph import TradingAgentsGraph @@ -9,5 +8,4 @@ __all__ = [ "ConditionalLogic", "GraphSetup", "Propagator", - "Reflector", ] diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 9fe826977..1795907db 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -13,14 +13,13 @@ from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import build_llm_kwargs, create_llm_client -from tradingagents.memory import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog, settlement +from tradingagents.memory.reflection import Reflector from tradingagents.reporting import write_report_tree -from . import settlement from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id from .conditional_logic import ConditionalLogic from .propagation import Propagator -from .reflection import Reflector from .setup import GraphSetup logger = logging.getLogger(__name__) diff --git a/tradingagents/memory/__init__.py b/tradingagents/memory/__init__.py new file mode 100644 index 000000000..04c764230 --- /dev/null +++ b/tradingagents/memory/__init__.py @@ -0,0 +1,10 @@ +"""The memory log: each decision recorded as made, settled against the market, and reflected on. + +``log`` keeps the entries, ``settlement`` measures a decision's return once its +holding window has traded, and ``reflection`` turns that outcome into a lesson +the next run of the same ticker reads. +""" + +from tradingagents.memory.log import TradingMemoryLog + +__all__ = ["TradingMemoryLog"] diff --git a/tradingagents/memory.py b/tradingagents/memory/log.py similarity index 100% rename from tradingagents/memory.py rename to tradingagents/memory/log.py diff --git a/tradingagents/graph/reflection.py b/tradingagents/memory/reflection.py similarity index 100% rename from tradingagents/graph/reflection.py rename to tradingagents/memory/reflection.py diff --git a/tradingagents/graph/settlement.py b/tradingagents/memory/settlement.py similarity index 100% rename from tradingagents/graph/settlement.py rename to tradingagents/memory/settlement.py From 5ac5786d0d2a021bf915bff19cafb901ab994666 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:51:45 +0000 Subject: [PATCH 12/63] docs: call it the memory log throughout - docstrings, comments, messages and the README section use "memory log", matching TradingMemoryLog and memory_log_path - the write, read and settle paths are named for what they do, not by design phase --- README.md | 8 ++++---- cli/run.py | 2 +- tests/test_backtest.py | 4 ++-- ...test_cli_decision_log.py => test_cli_memory_log.py} | 4 ++-- tradingagents/backtest.py | 8 ++++---- tradingagents/graph/trading_graph.py | 2 +- tradingagents/memory/log.py | 10 +++++----- tradingagents/memory/reflection.py | 8 +++++--- tradingagents/memory/settlement.py | 2 +- 9 files changed, 25 insertions(+), 23 deletions(-) rename tests/{test_cli_decision_log.py => test_cli_memory_log.py} (97%) diff --git a/README.md b/README.md index 82ca5e24d..2fd419e7d 100644 --- a/README.md +++ b/README.md @@ -304,9 +304,9 @@ An empty `positions` list means a flat book, which is different from passing not TradingAgents persists two kinds of state across runs. -### Decision log +### Memory log -The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. +The memory log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`. @@ -330,7 +330,7 @@ _, decision = ta.propagate("NVDA", "2026-09-01") ## Evaluating decisions over time -One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a decision log of its own, and scores the decisions whose holding window has since traded. +One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a memory log of its own, and scores the decisions whose holding window has since traded. ```python from tradingagents.backtest import iter_grid, run_backtest, summarize @@ -346,7 +346,7 @@ From the CLI: tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7 ``` -Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own decision log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. +Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. ## Reproducibility diff --git a/cli/run.py b/cli/run.py index 7d0bda236..01f6a6acb 100644 --- a/cli/run.py +++ b/cli/run.py @@ -199,7 +199,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) - # The same initial state propagate() builds: settled decision log, past + # The same initial state propagate() builds: settled memory log, past # context and resolved instrument identity. init_agent_state = graph.create_run_state( selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio diff --git a/tests/test_backtest.py b/tests/test_backtest.py index e9aab6c20..f029dee56 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -1,4 +1,4 @@ -"""Backtesting: many single-shot decisions, scored by the decision log. +"""Backtesting: many single-shot decisions, scored by the memory log. A run already records its rating and later settles it with realized and alpha return against the regional benchmark. A backtest is that machinery over a grid @@ -76,7 +76,7 @@ def _config(tmp_path): @pytest.mark.unit -def test_the_live_decision_log_is_never_written(tmp_path): +def test_the_live_memory_log_is_never_written(tmp_path): config = _config(tmp_path) result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config) diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_memory_log.py similarity index 97% rename from tests/test_cli_decision_log.py rename to tests/test_cli_memory_log.py index d6d01e784..9e266fbfb 100644 --- a/tests/test_cli_decision_log.py +++ b/tests/test_cli_memory_log.py @@ -1,4 +1,4 @@ -"""The CLI must use the decision log the same way propagate() does. +"""The CLI must use the memory log the same way propagate() does. The CLI streams the graph itself instead of calling propagate(), so memory steps that lived only in propagate() never ran on the primary entry point: pending @@ -160,7 +160,7 @@ def _run_cli(monkeypatch, tmp_path, fake): @pytest.mark.unit -def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch): +def test_cli_run_uses_the_memory_log_like_propagate(tmp_path, monkeypatch): fake = _FakeGraph() _run_cli(monkeypatch, tmp_path, fake) diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index b249b4e96..49afb06d6 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -2,7 +2,7 @@ One run yields one decision, so it cannot say whether the system decides well. This runs the same machinery over many (ticker, date) cells and reads the -aggregate. The decision log is the results table: every run already records its +aggregate. The memory log is the results table: every run already records its rating and later settles it with realized and alpha return against the instrument's regional benchmark, so there is nothing to record separately. @@ -134,7 +134,7 @@ def run_backtest( selected_analysts=("market", "social", "news", "fundamentals"), run_id: str | None = None, ) -> BacktestResult: - """Analyze every ticker on every date, into a decision log of this run's own. + """Analyze every ticker on every date, into a memory log of this run's own. The live log stays untouched: a sweep would otherwise flood the context that real runs read back. Cells already in this run's log are skipped, so an @@ -176,13 +176,13 @@ def run_backtest( def summarize(source: BacktestResult | str | Path) -> BacktestSummary: - """Score the settled decisions of a backtest, or of a decision log at a path, by rating.""" + """Score the settled decisions of a backtest, or of a memory log at a path, by rating.""" if isinstance(source, BacktestResult): path = source.log_path # a run whose cells all failed wrote no log: nothing to score elif Path(source).is_file(): path = Path(source) else: - raise FileNotFoundError(f"no decision log at {source}") + raise FileNotFoundError(f"no memory log at {source}") entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries() # A decision with no readable rating has no direction, so it can neither # count for nor against the system; it is reported as unscored instead. diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 1795907db..cd0296429 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -262,7 +262,7 @@ class TradingAgentsGraph: Settles this ticker's pending decisions first, then injects the lessons known by the trade date for the Portfolio Manager (#1251) and the resolved instrument identity for every agent (#814). An entry point that - assembled the state itself would skip the decision log. + assembled the state itself would skip the memory log. """ self.settle_pending(company_name) return self.propagator.create_initial_state( diff --git a/tradingagents/memory/log.py b/tradingagents/memory/log.py index 1b5ab6039..e06a1afa7 100644 --- a/tradingagents/memory/log.py +++ b/tradingagents/memory/log.py @@ -1,4 +1,4 @@ -"""Append-only markdown decision log for TradingAgents.""" +"""The memory log: an append-only markdown record of each decision and, once settled, its outcome.""" import re from pathlib import Path @@ -25,7 +25,7 @@ class TradingMemoryLog: # Optional cap on resolved entries. None disables rotation. self._max_entries = cfg.get("memory_log_max_entries") - # --- Write path (Phase A) --- + # --- Write: a run records its decision --- def store_decision( self, @@ -56,7 +56,7 @@ class TradingMemoryLog: with open(self._log_path, "a", encoding="utf-8") as f: f.write(entry) - # --- Read path (Phase A) --- + # --- Read --- def load_entries(self) -> list[dict]: """Parse all entries from log. Returns list of dicts.""" @@ -72,7 +72,7 @@ class TradingMemoryLog: return entries def get_pending_entries(self) -> list[dict]: - """Return entries with outcome:pending (for Phase B).""" + """Return entries with outcome:pending, for settlement.""" return [e for e in self.load_entries() if e.get("pending")] def get_past_context( @@ -114,7 +114,7 @@ class TradingMemoryLog: parts.extend(self._format_reflection_only(e) for e in cross) return "\n\n".join(parts) - # --- Update path (Phase B) --- + # --- Settle: record a decision's outcome and reflection --- def update_with_outcome( self, diff --git a/tradingagents/memory/reflection.py b/tradingagents/memory/reflection.py index 198333ffe..ba8bf5f31 100644 --- a/tradingagents/memory/reflection.py +++ b/tradingagents/memory/reflection.py @@ -1,3 +1,5 @@ +"""Reflection: a settled decision's outcome turned into a short lesson for later runs.""" + from typing import Any @@ -9,7 +11,7 @@ class Reflector: self.quick_thinking_llm = quick_thinking_llm def _system_prompt(self, holding_days: int) -> str: - """Concise prompt for reflect_on_final_decision (Phase B log entries). + """Concise prompt for reflect_on_final_decision (settled memory log entries). Produces 2-4 sentences of plain prose, compact enough to be re-injected into future agent prompts without bloating the context window. The @@ -27,7 +29,7 @@ class Reflector: "and say so plainly if the window is too short to judge the thesis.\n" "2. Which part of the investment thesis this window supports or undercuts.\n" "3. One concrete lesson to apply to the next similar analysis.\n\n" - "Be specific and terse. Your output will be stored verbatim in a decision log " + "Be specific and terse. Your output will be stored verbatim in a memory log " "and re-read by future analysts, so every word must earn its place." ) @@ -41,7 +43,7 @@ class Reflector: ) -> str: """Single reflection call on the final trade decision with outcome context. - Used by Phase B deferred reflection. The final_trade_decision already + Used when a pending decision is settled. The final_trade_decision already synthesises all analyst insights, so no separate market context is needed. ``benchmark_name`` is the label used for the alpha line (e.g. ``"SPY"`` for US tickers, ``"^N225"`` for ``.T`` listings); defaults to SPY for diff --git a/tradingagents/memory/settlement.py b/tradingagents/memory/settlement.py index 51e658834..6f8354de8 100644 --- a/tradingagents/memory/settlement.py +++ b/tradingagents/memory/settlement.py @@ -1,5 +1,5 @@ """Settling past decisions: once a decision's holding window has traded, score -it against its benchmark and record a reflection on it in the decision log.""" +it against its benchmark and record a reflection on it in the memory log.""" import logging from datetime import datetime, timedelta From 674f1087d15f50a098a22a8d779763c6337844c5 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:53:30 +0000 Subject: [PATCH 13/63] refactor: name the data layer's date argument as_of_date - vendor functions and date_window take as_of_date, the date data is served as of; the model-facing tool arguments are unchanged - build_instrument_context and resolve_instrument_context take trade_date, which is what they receive --- tests/test_alpha_vantage_hardening.py | 4 +- tests/test_fred.py | 12 ++--- tests/test_fundamentals_lookahead.py | 10 ++--- tests/test_no_data_handling.py | 2 +- tests/test_undated_tools_as_of.py | 10 ++--- tradingagents/agents/context.py | 6 +-- tradingagents/dataflows/date_window.py | 14 +++--- .../vendors/alpha_vantage/fundamentals.py | 30 ++++++------- .../vendors/alpha_vantage/indicator.py | 12 ++--- .../dataflows/vendors/alpha_vantage/news.py | 16 +++---- tradingagents/dataflows/vendors/fred.py | 20 ++++----- tradingagents/dataflows/vendors/polymarket.py | 10 ++--- tradingagents/dataflows/vendors/sec_edgar.py | 34 +++++++------- .../dataflows/vendors/yahoo/fundamentals.py | 42 +++++++++--------- .../dataflows/vendors/yahoo/market.py | 44 +++++++++---------- tradingagents/dataflows/vendors/yahoo/news.py | 12 ++--- .../dataflows/vendors/yahoo/ohlcv.py | 32 +++++++------- .../dataflows/vendors/yahoo/snapshot.py | 16 +++---- tradingagents/graph/trading_graph.py | 4 +- 19 files changed, 165 insertions(+), 165 deletions(-) diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index 0d15bf12b..d82989875 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -82,7 +82,7 @@ def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch): # #1115: the payload arrives as a JSON *string*; the old dict-only guard let # future-dated fiscal periods leak into historical runs. monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) - out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01") + out = avf.get_balance_sheet("AAPL", as_of_date="2024-01-01") assert isinstance(out, str) # callers still receive a str parsed = json.loads(out) assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"] @@ -98,7 +98,7 @@ def test_fundamentals_no_curr_date_passes_through(monkeypatch): @pytest.mark.unit def test_fundamentals_non_json_body_unchanged(monkeypatch): monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json") - assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json" + assert avf.get_cashflow("AAPL", as_of_date="2024-01-01") == "not-json" # --------------------------------------------------------------------------- diff --git a/tests/test_fred.py b/tests/test_fred.py index c28c29891..b27ba680d 100644 --- a/tests/test_fred.py +++ b/tests/test_fred.py @@ -141,7 +141,7 @@ class FredFormattingTests(unittest.TestCase): 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. + # observation_end must equal as_of_date so a past date never pulls future data. captured = {} def _capture(path, params): @@ -156,9 +156,9 @@ class FredFormattingTests(unittest.TestCase): def test_requests_pin_the_data_vintage(self): # #1275: both the metadata and observations requests must pin the vintage - # to curr_date (clamped to FRED's today), or FRED serves the latest + # to as_of_date (clamped to FRED's today), or FRED serves the latest # revision and revision-prone series leak future information. A past - # curr_date sits below FRED's today, so it pins through unchanged. + # as_of_date sits below FRED's today, so it pins through unchanged. captured = {} def _capture(path, params): @@ -174,11 +174,11 @@ class FredFormattingTests(unittest.TestCase): self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path) def test_future_curr_date_clamps_vintage_to_fred_today(self): - # #1275 regression: on a live run curr_date is the caller's LOCAL date, + # #1275 regression: on a live run as_of_date is the caller's LOCAL date, # which can be a day ahead of FRED's US-Central clock. Pinning the vintage # to that future date 400s, and the routing layer then drops macro data # silently. The pin must clamp to FRED's today; the observation window - # (future bars can't exist yet) stays at curr_date. + # (future bars can't exist yet) stays at as_of_date. captured = {} def _capture(path, params): @@ -192,7 +192,7 @@ class FredFormattingTests(unittest.TestCase): for path in ("series", "series/observations"): self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path) self.assertEqual(captured[path]["realtime_end"], "2026-08-31", path) - # the observation window still tracks curr_date, not the clamped vintage + # the observation window still tracks as_of_date, not the clamped vintage self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01") diff --git a/tests/test_fundamentals_lookahead.py b/tests/test_fundamentals_lookahead.py index d604c330e..d91d4390c 100644 --- a/tests/test_fundamentals_lookahead.py +++ b/tests/test_fundamentals_lookahead.py @@ -10,7 +10,7 @@ FRED (#1275), social (#1220) and memory (#1251) leaks. Both vendors withhold on one shared rule (``date_window.withhold_live_profile``) so switching ``fundamental_data`` between them cannot reintroduce the leak. The -statement tools stay point-in-time by filtering on ``curr_date``, and a live run +statement tools stay point-in-time by filtering on ``as_of_date``, and a live run is unchanged. All API access is mocked. """ from __future__ import annotations @@ -45,19 +45,19 @@ _LEAKY = ("3500000000000", "34.2", "260.1", "391000000000", "Apple Inc.", "Technology", "Consumer Electronics") -def _yf(curr_date, info=_INFO, today=_TODAY): +def _yf(as_of_date, info=_INFO, today=_TODAY): with mock.patch.object(date_window, "get_current_date", return_value=today), \ mock.patch.object(yahoo_fundamentals, "yf_retry", lambda fn: info), \ mock.patch.object(yahoo_market.yf, "Ticker"): - return yahoo_fundamentals.get_fundamentals("AAPL", curr_date) + return yahoo_fundamentals.get_fundamentals("AAPL", as_of_date) -def _av(curr_date, today=_TODAY): +def _av(as_of_date, today=_TODAY): """Alpha Vantage path; the API call is mocked so a leak would be visible.""" with mock.patch.object(date_window, "get_current_date", return_value=today), \ mock.patch.object(av, "_make_api_request", return_value="MarketCapitalization: 3500000000000") as req: - return av.get_fundamentals("AAPL", curr_date), req + return av.get_fundamentals("AAPL", as_of_date), req @pytest.mark.unit diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index f1eadd439..355bc76bf 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -102,4 +102,4 @@ def test_an_unreachable_yahoo_is_not_reported_as_a_symbol_without_insider_data() with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \ mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \ pytest.raises(VendorRateLimitError): - fundamentals.get_insider_transactions("AAPL", curr_date="2026-09-21") + fundamentals.get_insider_transactions("AAPL", as_of_date="2026-09-21") diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index 2ece8366d..ea3450083 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -70,14 +70,14 @@ def test_alpha_vantage_insider_filings_after_the_date_are_dropped(): @pytest.mark.unit def test_polymarket_withholds_live_odds_from_a_historical_run(): with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")): - out = polymarket.get_prediction_markets("Fed rate cut", curr_date="2025-06-01") + out = polymarket.get_prediction_markets("Fed rate cut", as_of_date="2025-06-01") assert "withheld" in out @pytest.mark.unit def test_polymarket_serves_a_current_run(): with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req: - polymarket.get_prediction_markets("Fed rate cut", curr_date=polymarket.get_current_date()) + polymarket.get_prediction_markets("Fed rate cut", as_of_date=polymarket.get_current_date()) req.assert_called_once() @@ -103,7 +103,7 @@ def test_a_historical_run_is_told_the_identity_is_current(monkeypatch): identity = {"company_name": "Example Corp", "sector": "Technology", "industry": "Software", "exchange": "NMS"} - historical = build_instrument_context("EXMP", "stock", identity, curr_date="2024-03-14") + historical = build_instrument_context("EXMP", "stock", identity, trade_date="2024-03-14") assert "Example Corp" in historical assert "2024-03-14" in historical and "today" in historical.lower() @@ -114,7 +114,7 @@ def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch): from tradingagents.dataflows.date_window import get_current_date today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"}, - curr_date=get_current_date()) + trade_date=get_current_date()) assert "Example Corp" in today assert "resolved today" not in today.lower() @@ -297,7 +297,7 @@ def test_an_unavailable_notice_names_no_date_after_the_run(): coverage_gap([pd.Timestamp(today, tz="UTC")], "2025-01-01", "2025-01-07", "Feed", "news"), withhold_live_profile("2025-01-07", "AAPL"), _yf_insider(_insider_frame(today), "2025-01-07"), - build_instrument_context("EXMP", "stock", {"company_name": "Example"}, curr_date="2025-01-07"), + build_instrument_context("EXMP", "stock", {"company_name": "Example"}, trade_date="2025-01-07"), ] for notice in notices: assert _dates_after(notice, "2025-01-07") == [], notice diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index 69276fd61..9842f2d5b 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -100,7 +100,7 @@ def build_instrument_context( ticker: str, asset_type: str = "stock", identity: Mapping[str, str] | None = None, - curr_date: str | None = None, + trade_date: str | None = None, ) -> str: """Describe the exact instrument so agents preserve identity and ticker. @@ -144,10 +144,10 @@ def build_instrument_context( "result explicitly disproves this resolved identity." ) today = get_current_date() - if curr_date and str(curr_date) < today: + if trade_date and str(trade_date) < today: context += ( f" This identity is how the vendor describes the instrument today, " - f"not necessarily on {curr_date}: a name or classification changed " + f"not necessarily on {trade_date}: a name or classification changed " f"since then would read as the current one." ) diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index d84e43887..aeca0f121 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -95,7 +95,7 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, return f"{_parse(end) - span:%Y-%m-%d}", end -def withhold_live_profile(curr_date: str | None, label: str) -> str | None: +def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: """Notice to serve instead of a live-only company profile, or None to serve it. Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage @@ -105,20 +105,20 @@ def withhold_live_profile(curr_date: str | None, label: str) -> str | None: Every fundamentals vendor withholds on this rule, so switching between them cannot reintroduce the leak. """ - if not curr_date: + if not as_of_date: return None today = get_current_date() - if curr_date >= today: + if as_of_date >= today: return None return ( f"# Company Fundamentals for {label}\n" - f"# Point-in-time as of: {curr_date}\n\n" + f"# Point-in-time as of: {as_of_date}\n\n" f"Profile fundamentals are withheld for this date. This vendor serves " f"only present-day values with no historical vintage: market " f"cap, valuation multiples, the 52-week range and TTM income move with " f"today's quote, and even the name, sector and industry reflect today " - f"rather than {curr_date} (companies rename and get reclassified). " - f"Serving them would put post-decision information into a {curr_date} " - f"analysis. Point-in-time fundamentals for {curr_date} are available " + f"rather than {as_of_date} (companies rename and get reclassified). " + f"Serving them would put post-decision information into a {as_of_date} " + f"analysis. Point-in-time fundamentals for {as_of_date} are available " f"from the balance sheet, income statement, and cash flow tools." ) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py index a53698190..17a304ed5 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py @@ -4,14 +4,14 @@ from tradingagents.dataflows.date_window import withhold_live_profile from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request -def _filter_reports_by_date(result, curr_date: str): - """Drop annual/quarterly reports dated after curr_date to prevent look-ahead. +def _filter_reports_by_date(result, as_of_date: str): + """Drop annual/quarterly reports dated after as_of_date to prevent look-ahead. ``_make_api_request`` returns the fundamentals payload as a JSON string, so - parse, filter, and re-serialize. A non-JSON body or an unset ``curr_date`` is + parse, filter, and re-serialize. A non-JSON body or an unset ``as_of_date`` is returned unchanged. """ - if not curr_date or not isinstance(result, str): + if not as_of_date or not isinstance(result, str): return result try: payload = json.loads(result) @@ -23,28 +23,28 @@ def _filter_reports_by_date(result, curr_date: str): if isinstance(payload.get(key), list): payload[key] = [ r for r in payload[key] - if r.get("fiscalDateEnding", "") <= curr_date + if r.get("fiscalDateEnding", "") <= as_of_date ] return json.dumps(payload) -def get_fundamentals(ticker: str, curr_date: str = None) -> str: +def get_fundamentals(ticker: str, as_of_date: str = None) -> str: """ Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. OVERVIEW serves only present-day values and carries no historical vintage, so - a past ``curr_date`` withholds it rather than leaking post-decision figures + a past ``as_of_date`` withholds it rather than leaking post-decision figures into a backtest (#1300); the statement endpoints below stay point-in-time via ``_filter_reports_by_date``. Args: ticker (str): Ticker symbol of the company - curr_date (str): Analysis date, yyyy-mm-dd + as_of_date (str): Analysis date, yyyy-mm-dd Returns: str: Company overview data including financial ratios and key metrics """ - withheld = withhold_live_profile(curr_date, ticker) + withheld = withhold_live_profile(as_of_date, ticker) if withheld: return withheld @@ -55,20 +55,20 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str: return _make_api_request("OVERVIEW", params) -def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + return _filter_reports_by_date(result, as_of_date) -def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" result = _make_api_request("CASH_FLOW", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + return _filter_reports_by_date(result, as_of_date) -def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + return _filter_reports_by_date(result, as_of_date) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/indicator.py b/tradingagents/dataflows/vendors/alpha_vantage/indicator.py index 20ab45e9b..2408be428 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/indicator.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/indicator.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) def get_indicator( symbol: str, indicator: str, - curr_date: str, + as_of_date: str, look_back_days: int, interval: str = "daily", time_period: int = 14, @@ -21,7 +21,7 @@ def get_indicator( Args: symbol: ticker symbol of the company indicator: technical indicator to get the analysis and report of - curr_date: The current trading date you are trading on, YYYY-mm-dd + as_of_date: The current trading date you are trading on, YYYY-mm-dd look_back_days: how many days to look back interval: Time interval (daily, weekly, monthly) time_period: Number of data points for calculation @@ -72,8 +72,8 @@ def get_indicator( f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}" ) - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - before = curr_date_dt - relativedelta(days=look_back_days) + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + before = as_of_dt - relativedelta(days=look_back_days) # Get the full data for the period instead of making individual calls _, required_series_type = supported_indicators[indicator] @@ -184,7 +184,7 @@ def get_indicator( date_str = values[date_col_idx].strip() date_dt = datetime.strptime(date_str, "%Y-%m-%d") - if before <= date_dt <= curr_date_dt: + if before <= date_dt <= as_of_dt: value = values[value_col_idx].strip() result_data.append((date_dt, value)) except (ValueError, IndexError): @@ -201,7 +201,7 @@ def get_indicator( ind_string = "No data available for the specified date range.\n" result_str = ( - f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {as_of_date}:\n\n" + ind_string + "\n\n" + indicator_descriptions.get(indicator, "No description available.") diff --git a/tradingagents/dataflows/vendors/alpha_vantage/news.py b/tradingagents/dataflows/vendors/alpha_vantage/news.py index 299264da7..164768f47 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/news.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/news.py @@ -33,13 +33,13 @@ def get_news(ticker, start_date, end_date) -> dict[str, str] | str: return _make_api_request("NEWS_SENTIMENT", params) -def get_global_news(curr_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str: +def get_global_news(as_of_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str: """Returns global market news & sentiment data without ticker-specific filtering. Covers broad market topics like financial markets, economy, and more. Args: - curr_date: Current date in yyyy-mm-dd format. + as_of_date: Current date in yyyy-mm-dd format. look_back_days: Number of days to look back; ``None`` uses ``global_news_lookback_days`` from the active config. limit: Maximum number of articles; ``None`` uses @@ -56,28 +56,28 @@ def get_global_news(curr_date, look_back_days: int | None = None, limit: int | N if limit is None: limit = config["global_news_article_limit"] - curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_dt = curr_dt - timedelta(days=look_back_days) start_date = start_dt.strftime("%Y-%m-%d") params = { "topics": "financial_markets,economy_macro,economy_monetary", "time_from": format_datetime_for_api(start_date), - "time_to": format_datetime_for_api(curr_date, end_of_day=True), + "time_to": format_datetime_for_api(as_of_date, end_of_day=True), "limit": str(limit), } return _make_api_request("NEWS_SENTIMENT", params) -def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[str, str] | str: +def get_insider_transactions(symbol: str, as_of_date: str | None = None) -> dict[str, str] | str: """Returns latest and historical insider transactions by key stakeholders. Covers transactions by founders, executives, board members, etc. Args: symbol: Ticker symbol. Example: "IBM". - curr_date: When given, only transactions on or before it (yyyy-mm-dd). + as_of_date: When given, only transactions on or before it (yyyy-mm-dd). Returns: Dictionary containing insider transaction data or JSON string. @@ -88,8 +88,8 @@ def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[ } response = _make_api_request("INSIDER_TRANSACTIONS", params) - if not curr_date: + if not as_of_date: return response payload = json.loads(response) - payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= curr_date] + payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= as_of_date] return json.dumps(payload) diff --git a/tradingagents/dataflows/vendors/fred.py b/tradingagents/dataflows/vendors/fred.py index e5a130664..86fbea43b 100644 --- a/tradingagents/dataflows/vendors/fred.py +++ b/tradingagents/dataflows/vendors/fred.py @@ -126,7 +126,7 @@ def _fred_today() -> str: """FRED's current calendar date (US Central) as ``yyyy-mm-dd``. The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after - its own today with a 400, and ``curr_date`` on a live run comes from the + its own today with a 400, and ``as_of_date`` on a live run comes from the caller's local clock, which can already be tomorrow in Chicago. """ return datetime.now(FRED_TZ).strftime("%Y-%m-%d") @@ -155,7 +155,7 @@ def _request(path: str, params: dict) -> dict: def get_macro_data( indicator: str, - curr_date: str, + as_of_date: str, look_back_days: int | None = None, ) -> str: """Fetch a FRED macroeconomic series as a formatted markdown report. @@ -163,9 +163,9 @@ def get_macro_data( Args: indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). - curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window + as_of_date: The as-of date (yyyy-mm-dd). It bounds the observation window AND pins the data vintage: FRED is queried with the realtime bounds - set to ``curr_date`` (clamped to FRED's own today) so a historical + set to ``as_of_date`` (clamped to FRED's own today) so a historical run sees the values that were actually published by that date, not later revisions. Without this, revision-prone series (CPI, GDP, ...) would leak future information into a backtest (#1275). @@ -178,18 +178,18 @@ def get_macro_data( if look_back_days is None: look_back_days = DEFAULT_LOOKBACK_DAYS - end_dt = datetime.strptime(curr_date, "%Y-%m-%d") + end_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") # Pin the data vintage. FRED defaults both realtime bounds to today, serving # the LATEST revision of every observation; a single-day realtime interval # asks for the values known as of the pin instead, on both the metadata and # observations requests (#1275). Clamp to FRED's today: on a live run - # curr_date is the caller's local date, which can be a day ahead of Chicago, + # as_of_date is the caller's local date, which can be a day ahead of Chicago, # and a realtime date in FRED's future 400s -> the routing layer would then - # drop macro data silently. A past curr_date is unaffected, so historical + # drop macro data silently. A past as_of_date is unaffected, so historical # point-in-time behaviour is preserved. - pit = min(curr_date, _fred_today()) + pit = min(as_of_date, _fred_today()) realtime = {"realtime_start": pit, "realtime_end": pit} # Invalid LLM-supplied indicator: return guidance rather than raising, so a @@ -217,7 +217,7 @@ def get_macro_data( { "series_id": series_id, "observation_start": start_date, - "observation_end": curr_date, + "observation_end": as_of_date, "sort_order": "asc", **realtime, }, @@ -235,7 +235,7 @@ def get_macro_data( f"- Units: {units}\n" f"- Frequency: {frequency}" f"{f' ({seasonal})' if seasonal else ''}\n" - f"- Window: {start_date} to {curr_date}\n" + f"- Window: {start_date} to {as_of_date}\n" ) if not points: diff --git a/tradingagents/dataflows/vendors/polymarket.py b/tradingagents/dataflows/vendors/polymarket.py index 96a352b0b..0e5303dd1 100644 --- a/tradingagents/dataflows/vendors/polymarket.py +++ b/tradingagents/dataflows/vendors/polymarket.py @@ -67,7 +67,7 @@ def _is_forward_looking(market: dict, now: datetime) -> bool: ) -def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str | None = None) -> str: +def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str | None = None) -> str: """Return live prediction-market probabilities for an event topic. Args: @@ -75,7 +75,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str "US election", or a sector/company event. limit: Max markets to return (ranked by traded volume); ``None`` uses DEFAULT_LIMIT. - curr_date: The analysis date. Polymarket serves only live odds, so a + as_of_date: The analysis date. Polymarket serves only live odds, so a date before today withholds them. Returns: @@ -83,11 +83,11 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str each with its implied probability, traded volume, resolution date, and recent (1-week) move. """ - if curr_date and curr_date < get_current_date(): + if as_of_date and as_of_date < get_current_date(): return ( - f"Prediction-market odds are withheld for {curr_date}. Polymarket serves " + f"Prediction-market odds are withheld for {as_of_date}. Polymarket serves " f"only live odds on open markets, with no historical vintage, so serving " - f"them would put post-decision information into a {curr_date} analysis." + f"them would put post-decision information into a {as_of_date} analysis." ) if limit is None: limit = DEFAULT_LIMIT diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index e096748d4..9b7315ecc 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -5,7 +5,7 @@ statement at the fiscal period end. That is two claims a run should not make: a period that has ended is not public until the company files, weeks later, and a figure that was later restated is not what investors saw at the time. -EDGAR reports every fact with the date it was filed, so a run dated ``curr_date`` +EDGAR reports every fact with the date it was filed, so a run dated ``as_of_date`` serves exactly what was on file by then, restatements included at the vintage that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment restated them to 36.2B. @@ -144,7 +144,7 @@ def cik_for(ticker: str) -> str | None: return None -def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int], +def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, span: tuple[int, int], forms: tuple[str, ...] = ()) -> tuple[dict, str]: """({period end: value}, unit) for the first tag the filer reports, as known then. @@ -164,7 +164,7 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, latest: dict[str, dict] = {} covered: set[str] = set() # period ends a filing of ``forms`` reports for fact in unit_values: - if fact["filed"] > curr_date or fact["end"] in values: + if fact["filed"] > as_of_date or fact["end"] in values: continue # A duration fact (revenue, cash flow) must cover the span asked # for. An instant fact (a balance) has no span and serves both. @@ -184,8 +184,8 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, return dict(sorted(values.items())), chosen_unit -def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> str: - curr_date = curr_date or datetime.now().strftime("%Y-%m-%d") +def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -> str: + as_of_date = as_of_date or datetime.now().strftime("%Y-%m-%d") cik = cik_for(ticker) if cik is None: raise NoMarketDataError(ticker, ticker, "not a US SEC filer") @@ -198,14 +198,14 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> quarterly = freq.lower() == "quarterly" span = _SPANS["quarterly" if quarterly else "annual"] forms = () if quarterly else _ANNUAL_FORMS - lines = {label: _as_of(us_gaap, tags, curr_date, span, forms) for label, tags in _STATEMENTS[kind]} + lines = {label: _as_of(us_gaap, tags, as_of_date, span, forms) for label, tags in _STATEMENTS[kind]} periods = sorted({end for values, _ in lines.values() for end in values}) if not periods: - raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_date}") + raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}") header = ( f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n" - f"# SEC EDGAR facts filed on or before {curr_date}, at the values filed then\n\n" + f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n" ) rows = [",".join([""] + periods)] for label, (values, unit) in lines.items(): @@ -223,21 +223,21 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> return header + "\n".join(rows) + "\n" -def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Balance sheet as filed on or before ``curr_date``.""" - return _statement("balance_sheet", ticker, freq, curr_date, "Balance Sheet") +def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Balance sheet as filed on or before ``as_of_date``.""" + return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet") -def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Income statement as filed on or before ``curr_date``. +def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Income statement as filed on or before ``as_of_date``. A fourth quarter is never derived: filers report it only inside the annual figure, and subtracting three separately filed quarters would invent a number with no filing date behind it. """ - return _statement("income_statement", ticker, freq, curr_date, "Income Statement") + return _statement("income_statement", ticker, freq, as_of_date, "Income Statement") -def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Cash flow statement as filed on or before ``curr_date``.""" - return _statement("cashflow", ticker, freq, curr_date, "Cash Flow Statement") +def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Cash flow statement as filed on or before ``as_of_date``.""" + return _statement("cashflow", ticker, freq, as_of_date, "Cash Flow Statement") diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 8c8e108b9..84d0e55c4 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -16,19 +16,19 @@ from tradingagents.dataflows.vendors.yahoo.ohlcv import ( def get_fundamentals( ticker: Annotated[str, "ticker symbol of the company"], - curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None ): """Get company fundamentals overview from yfinance. ``Ticker.info`` is a present-day snapshot with no historical vintage, so a - past ``curr_date`` withholds it through the shared point-in-time guard + past ``as_of_date`` withholds it through the shared point-in-time guard (``date_window.withhold_live_profile``, #1300). """ canonical = normalize_symbol(ticker) # Guard before the request: the response would only be discarded, and the # answer does not depend on it. - withheld = withhold_live_profile(curr_date, canonical) + withheld = withhold_live_profile(as_of_date, canonical) if withheld: return withheld @@ -99,14 +99,14 @@ _PERIOD_END_VINTAGE = ( ) -def _statement(ticker, freq, curr_date, title, quarterly_attr, annual_attr) -> str: - """One financial statement as CSV, cut at ``curr_date`` by period end.""" +def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> str: + """One financial statement as CSV, cut at ``as_of_date`` by period end.""" canonical = normalize_symbol(ticker) what = title.lower() try: ticker_obj = yf.Ticker(canonical) attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr - data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), curr_date) + data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), as_of_date) if data.empty: raise_for_empty(ticker, canonical, f"{what} data") return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() @@ -119,28 +119,28 @@ def _statement(ticker, freq, curr_date, title, quarterly_attr, annual_attr) -> s def get_balance_sheet( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get balance sheet data from yfinance.""" - return _statement(ticker, freq, curr_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet") + return _statement(ticker, freq, as_of_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet") def get_cashflow( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get cash flow data from yfinance.""" - return _statement(ticker, freq, curr_date, "Cash Flow", "quarterly_cashflow", "cashflow") + return _statement(ticker, freq, as_of_date, "Cash Flow", "quarterly_cashflow", "cashflow") def get_income_statement( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get income statement data from yfinance.""" - return _statement(ticker, freq, curr_date, "Income Statement", "quarterly_income_stmt", "income_stmt") + return _statement(ticker, freq, as_of_date, "Income Statement", "quarterly_income_stmt", "income_stmt") # Rows are dated by the transaction, which is when the insider traded, not when @@ -156,7 +156,7 @@ _TRANSACTION_DATE_VINTAGE = ( def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"], - curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, + as_of_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, ): """Get insider transactions data from yfinance.""" canonical = normalize_symbol(ticker) @@ -171,12 +171,12 @@ def get_insider_transactions( raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") return f"No insider transactions reported for symbol '{canonical}'" - if curr_date: + if as_of_date: traded = data["Start Date"] - kept = data[traded <= pd.Timestamp(curr_date)] + kept = data[traded <= pd.Timestamp(as_of_date)] if kept.empty: return ( - f"" ) data = kept @@ -198,15 +198,15 @@ def get_company_profile(ticker: str) -> dict: raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e -def filter_financials_by_date(data: pd.DataFrame, curr_date: str) -> pd.DataFrame: - """Drop financial statement columns (fiscal period timestamps) after curr_date. +def filter_financials_by_date(data: pd.DataFrame, as_of_date: str) -> pd.DataFrame: + """Drop financial statement columns (fiscal period timestamps) after as_of_date. yfinance financial statements use fiscal period end dates as columns. - Columns after curr_date represent future data and are removed to + Columns after as_of_date represent future data and are removed to prevent look-ahead bias. """ - if not curr_date or data.empty: + if not as_of_date or data.empty: return data - cutoff = pd.Timestamp(curr_date) + cutoff = pd.Timestamp(as_of_date) mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff return data.loc[:, mask] diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index 5e4d5a9c2..6965e54d2 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -73,7 +73,7 @@ def get_YFin_data_online( def get_stock_stats_indicators_window( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], - curr_date: Annotated[ + as_of_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], look_back_days: Annotated[int, "how many days to look back"], @@ -157,16 +157,16 @@ def get_stock_stats_indicators_window( f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" ) - end_date = curr_date - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - before = curr_date_dt - relativedelta(days=look_back_days) + end_date = as_of_date + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + before = as_of_dt - relativedelta(days=look_back_days) # Optimized: Get stock data once and calculate indicators for all dates try: - indicator_data = _get_stock_stats_bulk(symbol, indicator, curr_date) + indicator_data = _get_stock_stats_bulk(symbol, indicator, as_of_date) # Generate the date range we need - current_dt = curr_date_dt + current_dt = as_of_dt date_values = [] while current_dt >= before: @@ -191,13 +191,13 @@ def get_stock_stats_indicators_window( logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e) # Fallback to original implementation if bulk method fails ind_string = "" - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - while curr_date_dt >= before: + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + while as_of_dt >= before: indicator_value = get_stockstats_indicator( - symbol, indicator, curr_date_dt.strftime("%Y-%m-%d") + symbol, indicator, as_of_dt.strftime("%Y-%m-%d") ) - ind_string += f"{curr_date_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" - curr_date_dt = curr_date_dt - relativedelta(days=1) + ind_string += f"{as_of_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" + as_of_dt = as_of_dt - relativedelta(days=1) result_str = ( f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n" @@ -212,7 +212,7 @@ def get_stock_stats_indicators_window( def _get_stock_stats_bulk( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to calculate"], - curr_date: Annotated[str, "current date for reference"] + as_of_date: Annotated[str, "current date for reference"] ) -> dict: """ Optimized bulk calculation of stock stats indicators. @@ -221,7 +221,7 @@ def _get_stock_stats_bulk( """ from stockstats import wrap - data = load_ohlcv(symbol, curr_date) + data = load_ohlcv(symbol, as_of_date) df = wrap(data) df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") @@ -243,19 +243,19 @@ def _get_stock_stats_bulk( def get_stockstats_indicator( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], - curr_date: Annotated[ + as_of_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], ) -> str: - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - curr_date = curr_date_dt.strftime("%Y-%m-%d") + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + as_of_date = as_of_dt.strftime("%Y-%m-%d") try: indicator_value = get_stock_stats( symbol, indicator, - curr_date, + as_of_date, ) except VendorError: raise # Unknown/delisted symbol — let the router emit the sentinel @@ -264,7 +264,7 @@ def get_stockstats_indicator( # reads as no value that day rather than a read that failed. Raise so the # router can try the next vendor or report the series unavailable. raise NoMarketDataError( - symbol, symbol, f"{indicator} could not be read for {curr_date}: {e}" + symbol, symbol, f"{indicator} could not be read for {as_of_date}: {e}" ) from e return str(indicator_value) @@ -285,17 +285,17 @@ def get_stock_stats( indicator: Annotated[ str, "quantitative indicators based off of the stock data for the company" ], - curr_date: Annotated[ + as_of_date: Annotated[ str, "curr date for retrieving stock price data, YYYY-mm-dd" ], ): - data = load_ohlcv(symbol, curr_date) + data = load_ohlcv(symbol, as_of_date) df = wrap(data) df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") - curr_date_str = pd.to_datetime(curr_date).strftime("%Y-%m-%d") + as_of_str = pd.to_datetime(as_of_date).strftime("%Y-%m-%d") df[indicator] # trigger stockstats to calculate the indicator - matching_rows = df[df["Date"].str.startswith(curr_date_str)] + matching_rows = df[df["Date"].str.startswith(as_of_str)] if not matching_rows.empty: indicator_value = matching_rows[indicator].values[0] diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 9561e9fad..04fe18f8a 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -121,7 +121,7 @@ def get_news_yfinance( def get_global_news_yfinance( - curr_date: str, + as_of_date: str, look_back_days: int | None = None, limit: int | None = None, ) -> str: @@ -129,7 +129,7 @@ def get_global_news_yfinance( Retrieve global/macro economic news using yfinance Search. Args: - curr_date: Current date in yyyy-mm-dd format + as_of_date: Current date in yyyy-mm-dd format look_back_days: Number of days to look back. ``None`` falls back to ``global_news_lookback_days`` from the active config. limit: Maximum number of articles to return. ``None`` falls back to @@ -145,7 +145,7 @@ def get_global_news_yfinance( limit = config["global_news_article_limit"] search_queries = config["global_news_queries"] - curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_dt = curr_dt - relativedelta(days=look_back_days) start_date = start_dt.strftime("%Y-%m-%d") @@ -189,10 +189,10 @@ def get_global_news_yfinance( if not news_str: # Results merge several fuzzy searches, so their timestamps prove no # continuous coverage; judge the window against the present only. - gap = coverage_gap((), start_date, curr_date, "Yahoo Finance global news", "market news") - return gap or f"No global news found between {start_date} and {curr_date}" + gap = coverage_gap((), start_date, as_of_date, "Yahoo Finance global news", "market news") + return gap or f"No global news found between {start_date} and {as_of_date}" - return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" + return f"## Global Market News, from {start_date} to {as_of_date}:\n\n{news_str}" except VendorError: raise diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index e5001aeb1..87029e777 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -93,7 +93,7 @@ def _local_midnight(value) -> pd.Timestamp: def _normalize_dates(dates) -> pd.Series: """Parse to naive, midnight-normalized dates so tz-aware or intraday - timestamps compare correctly against the naive ``curr_date`` cutoff (#1201). + timestamps compare correctly against the naive ``as_of_date`` cutoff (#1201). Normalized per element: 5 years of yfinance bars span daylight-saving changes (and cache CSVs round-trip the offsets as strings), so the series can @@ -147,13 +147,13 @@ def _coerce_ohlcv_dates(data: pd.DataFrame) -> pd.Series: def _assert_ohlcv_not_stale( data: pd.DataFrame, - curr_date: str, + as_of_date: str, symbol: str, canonical: str | None = None, *, max_stale_days: int = MAX_OHLCV_STALE_DAYS, ) -> None: - """Reject OHLCV whose latest row is far older than curr_date. + """Reject OHLCV whose latest row is far older than as_of_date. Raises NoMarketDataError (with a stale-specific detail) so the router treats it like any other "no usable data from this vendor" — try the next vendor, @@ -164,7 +164,7 @@ def _assert_ohlcv_not_stale( """ if data is None or data.empty: return - requested = pd.to_datetime(curr_date, errors="coerce") + requested = pd.to_datetime(as_of_date, errors="coerce") if pd.isna(requested): return requested = requested.normalize() @@ -182,7 +182,7 @@ def _assert_ohlcv_not_stale( ) -def _cache_is_fresh(data_file, curr_date_dt, now) -> bool: +def _cache_is_fresh(data_file, as_of_dt, now) -> bool: """Whether the symbol's cached download can serve this request. The file holds the download made on the day it was written, so it serves @@ -194,14 +194,14 @@ def _cache_is_fresh(data_file, curr_date_dt, now) -> bool: written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file)) if written.date() != now.date(): return False - return curr_date_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS + return as_of_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS -def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFrame: +def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataFrame: """Fetch OHLCV data with caching, filtered to prevent look-ahead bias. Downloads 5 years of data up to today and caches per symbol. On - subsequent calls the cache is reused. Rows after curr_date are + subsequent calls the cache is reused. Rows after as_of_date are filtered out so backtests never see future prices. ``fill_gaps`` carries prices forward over gaps so indicators compute on a @@ -215,15 +215,15 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr safe_symbol = safe_ticker_component(canonical) config = get_config() - curr_date_dt = pd.to_datetime(curr_date).normalize() + as_of_dt = pd.to_datetime(as_of_date).normalize() # One cache file per symbol, holding the latest 5y-to-today download. now = pd.Timestamp.today() start_date = now - pd.DateOffset(years=5) start_str = start_date.strftime("%Y-%m-%d") # yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included - # when curr_date is the current day (#986). Look-ahead is still prevented by - # the curr_date filter below. + # when as_of_date is the current day (#986). Look-ahead is still prevented by + # the as_of_date filter below. end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d") os.makedirs(config["data_cache_dir"], exist_ok=True) @@ -241,7 +241,7 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr if ( not cached.empty and "Close" in cached.columns - and _cache_is_fresh(data_file, curr_date_dt, now) + and _cache_is_fresh(data_file, as_of_dt, now) ): data = cached @@ -271,8 +271,8 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr data = _clean_dataframe(data) - # Filter to curr_date to prevent look-ahead bias in backtesting. - data = data[data["Date"] <= curr_date_dt] + # Filter to as_of_date to prevent look-ahead bias in backtesting. + data = data[data["Date"] <= as_of_dt] # A closeless newest bar is an unsettled session, not a symbol without data. # _fill_price_gaps below drops it, here and mid-series alike, so the frame @@ -295,9 +295,9 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr # a filled cell is the previous session's price under this session's date. data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy() - # Reject a stale frame (latest row far older than curr_date) rather than + # Reject a stale frame (latest row far older than as_of_date) rather than # feeding year-old prices into indicators (#1021). - _assert_ohlcv_not_stale(data, curr_date, symbol, canonical) + _assert_ohlcv_not_stale(data, as_of_date, symbol, canonical) return data diff --git a/tradingagents/dataflows/vendors/yahoo/snapshot.py b/tradingagents/dataflows/vendors/yahoo/snapshot.py index 6c490ff71..06c16bd3c 100644 --- a/tradingagents/dataflows/vendors/yahoo/snapshot.py +++ b/tradingagents/dataflows/vendors/yahoo/snapshot.py @@ -25,8 +25,8 @@ DEFAULT_SNAPSHOT_INDICATORS: tuple[str, ...] = ( ) -def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: - """OHLCV on or before curr_date, date-sorted. Raises if nothing usable. +def _verified_rows(symbol: str, as_of_date: str) -> pd.DataFrame: + """OHLCV on or before as_of_date, date-sorted. Raises if nothing usable. ``load_ohlcv`` already normalizes the Date column and filters out look-ahead rows, but we re-apply the cutoff defensively — this is a @@ -34,16 +34,16 @@ def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: """ # As reported: this snapshot is quoted by the agents as exact prices, so a # gap-filled cell would put the previous session's number under this date. - data = load_ohlcv(symbol, curr_date, fill_gaps=False) + data = load_ohlcv(symbol, as_of_date, fill_gaps=False) if data is None or data.empty: raise ValueError(f"No OHLCV data available for {symbol}.") df = data.copy() df["Date"] = pd.to_datetime(df["Date"], errors="coerce") df = df.dropna(subset=["Date"]) - df = df[df["Date"] <= pd.to_datetime(curr_date)].sort_values("Date") + df = df[df["Date"] <= pd.to_datetime(as_of_date)].sort_values("Date") if df.empty: - raise ValueError(f"No OHLCV rows on or before {curr_date} for {symbol}.") + raise ValueError(f"No OHLCV rows on or before {as_of_date} for {symbol}.") return df @@ -63,7 +63,7 @@ def _fmt(value) -> str: def build_verified_market_snapshot( symbol: str, - curr_date: str, + as_of_date: str, look_back_days: int = 30, indicators: Iterable[str] | None = None, ) -> str: @@ -71,7 +71,7 @@ def build_verified_market_snapshot( # `df` keeps the original capitalized OHLCV columns (Open/High/Low/Close/ # Volume); stockstats `wrap()` lowercases columns and adds indicator # columns, so read raw prices from `df` and indicators from `stock_df`. - df = _verified_rows(symbol, curr_date) + df = _verified_rows(symbol, as_of_date) stock_df = wrap(df.copy()) selected = tuple(indicators or DEFAULT_SNAPSHOT_INDICATORS) @@ -91,7 +91,7 @@ def build_verified_market_snapshot( lines = [ f"## Verified market data snapshot for {symbol.upper()}", "", - f"- Requested analysis date: {curr_date}", + f"- Requested analysis date: {as_of_date}", f"- Latest trading row used: {latest_date}", "- Rows after the requested analysis date are excluded before verification.", "", diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index cd0296429..c3b7387ea 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -114,7 +114,7 @@ class TradingAgentsGraph: self._resuming = False def resolve_instrument_context(self, ticker: str, asset_type: str = "stock", - curr_date: str | None = None) -> str: + trade_date: str | None = None) -> str: """Resolve ticker identity once and return the full instrument context. Deterministic yfinance lookup (cached, fail-open) injected into a @@ -124,7 +124,7 @@ class TradingAgentsGraph: graph regardless of entry point. """ identity = resolve_instrument_identity(ticker) - return build_instrument_context(ticker, asset_type, identity, curr_date) + return build_instrument_context(ticker, asset_type, identity, trade_date) def _memory_as_of(self, trade_date) -> str | None: """Point-in-time cutoff for past-context lessons (#1251). From 3f6ad5dc12e65777b51cb385c468e9c4b318ac56 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:54:19 +0000 Subject: [PATCH 14/63] docs: say which agent writes each state field, and what the debate limits count - state field descriptions name the Research Manager, the News and Fundamentals Analysts and the Portfolio Manager; comments that only repeat them are gone - the debate-limit comments count turns per max_debate_rounds and max_risk_discuss_rounds - date_window's docstring covers all the point-in-time rules it holds --- tradingagents/agents/state.py | 42 +++++++++++------------- tradingagents/dataflows/date_window.py | 17 +++++----- tradingagents/graph/conditional_logic.py | 4 +-- 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/tradingagents/agents/state.py b/tradingagents/agents/state.py index 7faa545e5..e0eab07ce 100644 --- a/tradingagents/agents/state.py +++ b/tradingagents/agents/state.py @@ -8,71 +8,69 @@ from typing_extensions import TypedDict class InvestDebateState(TypedDict): bull_history: Annotated[ str, "Bullish Conversation history" - ] # Bullish Conversation history + ] bear_history: Annotated[ str, "Bearish Conversation history" - ] # Bullish Conversation history - history: Annotated[str, "Conversation history"] # Conversation history - current_response: Annotated[str, "Latest response"] # Last response - judge_decision: Annotated[str, "Final judge decision"] # Last response - count: Annotated[int, "Length of the current conversation"] # Conversation length + ] + history: Annotated[str, "Conversation history"] + current_response: Annotated[str, "Latest response"] + judge_decision: Annotated[str, "Final judge decision"] + count: Annotated[int, "Length of the current conversation"] # Risk management team state class RiskDebateState(TypedDict): aggressive_history: Annotated[ str, "Aggressive Agent's Conversation history" - ] # Conversation history + ] conservative_history: Annotated[ str, "Conservative Agent's Conversation history" - ] # Conversation history + ] neutral_history: Annotated[ str, "Neutral Agent's Conversation history" - ] # Conversation history - history: Annotated[str, "Conversation history"] # Conversation history + ] + history: Annotated[str, "Conversation history"] latest_speaker: Annotated[str, "Analyst that spoke last"] current_aggressive_response: Annotated[ str, "Latest response by the aggressive analyst" - ] # Last response + ] current_conservative_response: Annotated[ str, "Latest response by the conservative analyst" - ] # Last response + ] current_neutral_response: Annotated[ str, "Latest response by the neutral analyst" - ] # Last response + ] judge_decision: Annotated[str, "Judge's decision"] - count: Annotated[int, "Length of the current conversation"] # Conversation length + count: Annotated[int, "Length of the current conversation"] class AgentState(MessagesState): company_of_interest: Annotated[str, "Company that we are interested in trading"] asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"] instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"] - trade_date: Annotated[str, "What date we are trading at"] + trade_date: Annotated[str, "The analysis date; data is served as of it"] sender: Annotated[str, "Agent that sent this message"] # research step market_report: Annotated[str, "Report from the Market Analyst"] sentiment_report: Annotated[str, "Report from the Sentiment Analyst"] - news_report: Annotated[ - str, "Report from the News Researcher of current world affairs" - ] - fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"] + news_report: Annotated[str, "Report from the News Analyst on company and world news"] + fundamentals_report: Annotated[str, "Report from the Fundamentals Analyst"] # researcher team discussion step investment_debate_state: Annotated[ InvestDebateState, "Current state of the debate on if to invest or not" ] - investment_plan: Annotated[str, "Plan generated by the Analyst"] + investment_plan: Annotated[str, "Investment plan from the Research Manager"] - trader_investment_plan: Annotated[str, "Plan generated by the Trader"] + trader_investment_plan: Annotated[str, "Transaction proposal from the Trader"] # risk management team discussion step risk_debate_state: Annotated[ RiskDebateState, "Current state of the debate on evaluating risk" ] - final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] + final_trade_decision: Annotated[str, "Final decision from the Portfolio Manager"] final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"] past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"] diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index aeca0f121..b74202d14 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -1,12 +1,13 @@ -"""Shared look-ahead-safe date-window filtering for dated content. +"""Point-in-time rules shared by every dated path: data is served as of the run's date. -News, StockTwits, and Reddit all pull recent items that must be trimmed to the -analysis window so a historical/backtest run never sees content published after -its as-of date. Centralizing the rule keeps every source consistent (#1126, -#1220): every timestamp is normalized to UTC, the upper bound is exclusive at -midnight after ``end`` (so an item stamped exactly then can't leak), and an -undated item is kept only when the window reaches the present (a live run), since -in a backtest we can't prove it isn't future. +- ``as_of`` / ``as_of_window`` clamp a date or window the model asks for to the + trade date, so no tool reaches a vendor with a later one. +- ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis + window: timestamps normalized to UTC, the upper bound exclusive at midnight + after ``end``, and an undated item kept only when the window reaches the + present, since a backtest cannot prove it is not from the future (#1126, #1220). +- ``coverage_gap`` reports a window a feed cannot reach as unavailable, not empty. +- ``withhold_live_profile`` withholds present-day snapshots from historical runs. """ from __future__ import annotations diff --git a/tradingagents/graph/conditional_logic.py b/tradingagents/graph/conditional_logic.py index 9d41160a0..2c6caca2c 100644 --- a/tradingagents/graph/conditional_logic.py +++ b/tradingagents/graph/conditional_logic.py @@ -14,7 +14,7 @@ class ConditionalLogic: if ( state["investment_debate_state"]["count"] >= 2 * self.max_debate_rounds - ): # 3 rounds of back-and-forth between 2 agents + ): # max_debate_rounds turns each for bull and bear return "Research Manager" if state["investment_debate_state"]["current_response"].startswith("Bull"): return "Bear Researcher" @@ -24,7 +24,7 @@ class ConditionalLogic: """Determine if risk analysis should continue.""" if ( state["risk_debate_state"]["count"] >= 3 * self.max_risk_discuss_rounds - ): # 3 rounds of back-and-forth between 3 agents + ): # max_risk_discuss_rounds turns each for the three risk analysts return "Portfolio Manager" if state["risk_debate_state"]["latest_speaker"].startswith("Aggressive"): return "Conservative Analyst" From 1d2078009538157d978f8c72dfe1b9317e21a0f9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 18:55:26 +0000 Subject: [PATCH 15/63] chore: remove code nothing reads - router.VENDOR_LIST, and the state's sender field with the Trader's partial that only set it --- tradingagents/agents/state.py | 2 -- tradingagents/agents/trader/trader.py | 7 ++----- tradingagents/dataflows/router.py | 8 -------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/tradingagents/agents/state.py b/tradingagents/agents/state.py index e0eab07ce..ba83f5100 100644 --- a/tradingagents/agents/state.py +++ b/tradingagents/agents/state.py @@ -50,8 +50,6 @@ class AgentState(MessagesState): instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"] trade_date: Annotated[str, "The analysis date; data is served as of it"] - sender: Annotated[str, "Agent that sent this message"] - # research step market_report: Annotated[str, "Report from the Market Analyst"] sentiment_report: Annotated[str, "Report from the Sentiment Analyst"] diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py index 1b85ae404..8993095ec 100644 --- a/tradingagents/agents/trader/trader.py +++ b/tradingagents/agents/trader/trader.py @@ -2,8 +2,6 @@ from __future__ import annotations -import functools - from langchain_core.messages import AIMessage from tradingagents.agents.context import ( @@ -22,7 +20,7 @@ from tradingagents.agents.structured import ( def create_trader(llm): structured_llm = bind_structured(llm, TraderProposal, "Trader") - def trader_node(state, name): + def trader_node(state): company_name = state["company_of_interest"] instrument_context = get_instrument_context_from_state(state) investment_plan = state["investment_plan"] @@ -95,7 +93,6 @@ def create_trader(llm): return { "messages": [AIMessage(content=trader_plan)], "trader_investment_plan": trader_plan, - "sender": name, } - return functools.partial(trader_node, name="Trader") + return trader_node diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index 7bfb1eefb..72f95e5cf 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -86,14 +86,6 @@ TOOLS_CATEGORIES = { } } -VENDOR_LIST = [ - "yfinance", - "sec_edgar", - "fred", - "polymarket", - "alpha_vantage", -] - # Optional enrichment categories. These add macro/event context to the news # analyst but are not core to a decision, so a vendor failure here degrades to a # sentinel instead of aborting the run (a bad LLM-supplied indicator, a missing From cf960d638222331cf33c0f06778f2698fed2321f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:04:37 +0000 Subject: [PATCH 16/63] refactor: one name per decision in the state and its log - the Research Manager's plan and the Portfolio Manager's decision live only in investment_plan and final_trade_decision; the debate states no longer copy them as judge_decision - the saved state log writes the Trader's plan as trader_investment_plan, its state key - the report tree, the final display and the live CLI read the decisions from those fields --- cli/display.py | 12 ++++++------ cli/run.py | 4 ++-- tests/test_cli_display.py | 17 +++++++++++++++-- tests/test_memory_log.py | 5 ++--- tests/test_portfolio_context.py | 4 ++-- tests/test_prompt_integrity.py | 4 ++-- tests/test_rating_integrity.py | 4 ++-- tests/test_reporting.py | 6 ++++-- tests/test_structured_agent_prompts.py | 2 +- tests/test_structured_agents.py | 1 - .../agents/managers/portfolio_manager.py | 1 - .../agents/managers/research_manager.py | 1 - tradingagents/agents/state.py | 2 -- tradingagents/graph/propagation.py | 2 -- tradingagents/graph/trading_graph.py | 6 +----- tradingagents/reporting.py | 18 +++++++++--------- 16 files changed, 46 insertions(+), 43 deletions(-) diff --git a/cli/display.py b/cli/display.py index cba992cc9..1510e9581 100644 --- a/cli/display.py +++ b/cli/display.py @@ -404,8 +404,8 @@ def display_complete_report(final_state): research.append(("Bull Researcher", debate["bull_history"])) if debate.get("bear_history"): research.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): - research.append(("Research Manager", debate["judge_decision"])) + if final_state.get("investment_plan"): + research.append(("Research Manager", final_state["investment_plan"])) if research: console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta")) for title, content in research: @@ -431,10 +431,10 @@ def display_complete_report(final_state): for title, content in risk_reports: console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) - # V. Portfolio Manager Decision - if risk.get("judge_decision"): - console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) - console.print(Panel(Markdown(risk["judge_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) + # V. Portfolio Manager Decision + if final_state.get("final_trade_decision"): + console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) + console.print(Panel(Markdown(final_state["final_trade_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) def update_research_team_status(status): diff --git a/cli/run.py b/cli/run.py index 01f6a6acb..60a2ffff2 100644 --- a/cli/run.py +++ b/cli/run.py @@ -253,7 +253,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): debate_state = chunk["investment_debate_state"] bull_hist = debate_state.get("bull_history", "").strip() bear_hist = debate_state.get("bear_history", "").strip() - judge = debate_state.get("judge_decision", "").strip() + judge = (chunk.get("investment_plan") or "").strip() # Only update status when there's actual content if bull_hist or bear_hist: @@ -288,7 +288,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): agg_hist = risk_state.get("aggressive_history", "").strip() con_hist = risk_state.get("conservative_history", "").strip() neu_hist = risk_state.get("neutral_history", "").strip() - judge = risk_state.get("judge_decision", "").strip() + judge = (chunk.get("final_trade_decision") or "").strip() if agg_hist: if message_buffer.agent_status.get("Aggressive Analyst") != "completed": diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 8adb45ea1..674e668bd 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -48,9 +48,9 @@ def _state(ticker, final="评级: 买入"): "fundamentals_report": "基本面", "investment_plan": "计划", "trader_investment_plan": "交易计划", "final_trade_decision": final, "final_rating": "REVIEW", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", - "current_response": "", "judge_decision": "", "count": 0}, + "current_response": "", "count": 0}, "risk_debate_state": {"aggressive_history": "", "conservative_history": "", - "neutral_history": "", "history": "", "judge_decision": "", + "neutral_history": "", "history": "", "latest_speaker": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", "count": 0}, @@ -76,6 +76,19 @@ def test_the_state_log_keeps_non_ascii_readable(tmp_path): assert json.loads(written) # still valid JSON +@pytest.mark.unit +def test_the_state_log_names_each_field_as_the_state_does(tmp_path): + """One name per field: the Trader's plan under its state key, and no second + copy of the managers' decisions under the debate states.""" + _bare_graph(tmp_path)._log_state("2026-09-01", _state("NVDA")) + + logged = json.loads(next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8")) + assert logged["trader_investment_plan"] == "交易计划" + assert logged["investment_plan"] == "计划" + assert "trader_investment_decision" not in logged + assert "judge_decision" not in json.dumps(logged) + + @pytest.mark.unit def test_the_live_display_does_not_scroll_the_terminal(): """A layout taller than the window makes rich redraw by scrolling, which diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 7652aafde..bcd955eb7 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -74,7 +74,6 @@ def _make_pm_state(past_context=""): "aggressive_history": "", "conservative_history": "", "neutral_history": "", - "judge_decision": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", @@ -906,13 +905,13 @@ class TestLegacyRemoval: "fundamentals_report": "", "investment_debate_state": { "bull_history": "", "bear_history": "", "history": "", - "current_response": "", "judge_decision": "", + "current_response": "", }, "investment_plan": "", "trader_investment_plan": "", "risk_debate_state": { "aggressive_history": "", "conservative_history": "", - "neutral_history": "", "history": "", "judge_decision": "", + "neutral_history": "", "history": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", "count": 1, "latest_speaker": "", }, diff --git a/tests/test_portfolio_context.py b/tests/test_portfolio_context.py index 228489671..4b89637d2 100644 --- a/tests/test_portfolio_context.py +++ b/tests/test_portfolio_context.py @@ -139,11 +139,11 @@ def test_decision_agents_see_the_portfolio(module, factory, monkeypatch): "news_report": "N", "fundamentals_report": "F", "investment_plan": "P", "trader_investment_plan": "T", "past_context": "", "portfolio_context": "PORTFOLIO_BLOCK_MARKER", - "investment_debate_state": {"history": "", "judge_decision": "", "count": 0}, + "investment_debate_state": {"history": "", "count": 0}, "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, "aggressive_history": "", "conservative_history": "", "neutral_history": "", "current_aggressive_response": "", "current_conservative_response": "", - "current_neutral_response": "", "judge_decision": ""}, + "current_neutral_response": ""}, } node = getattr(mod, factory)(_LLM()) node(state) diff --git a/tests/test_prompt_integrity.py b/tests/test_prompt_integrity.py index e26e72185..db9f38a27 100644 --- a/tests/test_prompt_integrity.py +++ b/tests/test_prompt_integrity.py @@ -74,11 +74,11 @@ def test_a_report_that_was_never_produced_says_so(module, factory): "market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "", "fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", - "current_response": "", "judge_decision": "", "count": 0}, + "current_response": "", "count": 0}, "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, "aggressive_history": "", "conservative_history": "", "neutral_history": "", "current_aggressive_response": "", "current_conservative_response": "", - "current_neutral_response": "", "judge_decision": ""}, + "current_neutral_response": ""}, } getattr(mod, factory)(_LLM())(state) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index dc3c4373d..8d6523e08 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -194,11 +194,11 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_ "news_report": "N", "fundamentals_report": "F", "investment_plan": "P", "trader_investment_plan": "T", "past_context": "", "portfolio_context": "", "investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h", - "current_response": "", "judge_decision": "", "count": 2}, + "current_response": "", "count": 2}, "risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3, "aggressive_history": "", "conservative_history": "", "neutral_history": "", "current_aggressive_response": "", "current_conservative_response": "", - "current_neutral_response": "", "judge_decision": ""}, + "current_neutral_response": ""}, } getattr(mod, factory)(_LLM())(state) diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 04a1d5a72..dd93e4edb 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -13,9 +13,11 @@ def _state(): return { "market_report": "MKT", "news_report": "NEWS", - "investment_debate_state": {"judge_decision": "RM PLAN"}, + "investment_debate_state": {"bull_history": "BULL"}, + "investment_plan": "RM PLAN", "trader_investment_plan": "TRADE", - "risk_debate_state": {"judge_decision": "PM DECISION"}, + "risk_debate_state": {"neutral_history": "NEUTRAL"}, + "final_trade_decision": "PM DECISION", } diff --git a/tests/test_structured_agent_prompts.py b/tests/test_structured_agent_prompts.py index c89fae767..47b8559ad 100644 --- a/tests/test_structured_agent_prompts.py +++ b/tests/test_structured_agent_prompts.py @@ -72,7 +72,7 @@ def test_research_manager_prompt_states_constraint(): "company_of_interest": "NVDA", "investment_debate_state": { "history": "h", "bull_history": "b", "bear_history": "r", - "current_response": "", "judge_decision": "", "count": 1, + "current_response": "", "count": 1, }, }) assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) diff --git a/tests/test_structured_agents.py b/tests/test_structured_agents.py index d385e0ea3..e168c12cd 100644 --- a/tests/test_structured_agents.py +++ b/tests/test_structured_agents.py @@ -292,7 +292,6 @@ def _make_rm_state(): "bull_history": "Bull says...", "bear_history": "Bear says...", "current_response": "", - "judge_decision": "", "count": 1, }, } diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py index 228758221..0537b5e80 100644 --- a/tradingagents/agents/managers/portfolio_manager.py +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -86,7 +86,6 @@ Write these sections, in this order, starting with the rating on its own line: final_rating = parse_rating(final_trade_decision) new_risk_debate_state = { - "judge_decision": final_trade_decision, "history": risk_debate_state["history"], "aggressive_history": risk_debate_state["aggressive_history"], "conservative_history": risk_debate_state["conservative_history"], diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py index f1bb2c4fc..3bf288c60 100644 --- a/tradingagents/agents/managers/research_manager.py +++ b/tradingagents/agents/managers/research_manager.py @@ -59,7 +59,6 @@ Write these sections, in this order, starting with the recommendation on its own ) new_investment_debate_state = { - "judge_decision": investment_plan, "history": investment_debate_state.get("history", ""), "bear_history": investment_debate_state.get("bear_history", ""), "bull_history": investment_debate_state.get("bull_history", ""), diff --git a/tradingagents/agents/state.py b/tradingagents/agents/state.py index ba83f5100..5eea76703 100644 --- a/tradingagents/agents/state.py +++ b/tradingagents/agents/state.py @@ -14,7 +14,6 @@ class InvestDebateState(TypedDict): ] history: Annotated[str, "Conversation history"] current_response: Annotated[str, "Latest response"] - judge_decision: Annotated[str, "Final judge decision"] count: Annotated[int, "Length of the current conversation"] @@ -40,7 +39,6 @@ class RiskDebateState(TypedDict): current_neutral_response: Annotated[ str, "Latest response by the neutral analyst" ] - judge_decision: Annotated[str, "Judge's decision"] count: Annotated[int, "Length of the current conversation"] diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index 775ddc3ae..733cfc00a 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -41,7 +41,6 @@ class Propagator: "bear_history": "", "history": "", "current_response": "", - "judge_decision": "", "count": 0, } ), @@ -55,7 +54,6 @@ class Propagator: "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", - "judge_decision": "", "count": 0, } ), diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index c3b7387ea..ec8f6b887 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -359,17 +359,13 @@ class TradingAgentsGraph: "current_response": final_state["investment_debate_state"][ "current_response" ], - "judge_decision": final_state["investment_debate_state"][ - "judge_decision" - ], }, - "trader_investment_decision": final_state["trader_investment_plan"], + "trader_investment_plan": final_state["trader_investment_plan"], "risk_debate_state": { "aggressive_history": final_state["risk_debate_state"]["aggressive_history"], "conservative_history": final_state["risk_debate_state"]["conservative_history"], "neutral_history": final_state["risk_debate_state"]["neutral_history"], "history": final_state["risk_debate_state"]["history"], - "judge_decision": final_state["risk_debate_state"]["judge_decision"], }, "investment_plan": final_state["investment_plan"], "final_trade_decision": final_state["final_trade_decision"], diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index f89d6a04d..62759b875 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -52,10 +52,10 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: research_dir.mkdir(exist_ok=True) (research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8") research_parts.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): + if final_state.get("investment_plan"): research_dir.mkdir(exist_ok=True) - (research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8") - research_parts.append(("Research Manager", debate["judge_decision"])) + (research_dir / "manager.md").write_text(final_state["investment_plan"], encoding="utf-8") + research_parts.append(("Research Manager", final_state["investment_plan"])) if research_parts: content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) sections.append(f"## II. Research Team Decision\n\n{content}") @@ -88,12 +88,12 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts) sections.append(f"## IV. Risk Management Team Decision\n\n{content}") - # 5. Portfolio Manager - if risk.get("judge_decision"): - portfolio_dir = save_path / "5_portfolio" - portfolio_dir.mkdir(exist_ok=True) - (portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8") - sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}") + # 5. Portfolio Manager + if final_state.get("final_trade_decision"): + portfolio_dir = save_path / "5_portfolio" + portfolio_dir.mkdir(exist_ok=True) + (portfolio_dir / "decision.md").write_text(final_state["final_trade_decision"], encoding="utf-8") + sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{final_state['final_trade_decision']}") # Write consolidated report header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" From 45391d09622576cbf6dcc914a7f379d155bd2341 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:06:14 +0000 Subject: [PATCH 17/63] feat(docker): keep results in a host folder when TRADINGAGENTS_DATA_DIR is set (#865) - both compose services mount ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}; unset keeps the named volume - README and .env.example show the setting --- .env.example | 3 +++ README.md | 2 ++ docker-compose.yml | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 61275aed6..0647b5115 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,9 @@ NVIDIA_API_KEY= #TRADINGAGENTS_MAX_RISK_ROUNDS=1 #TRADINGAGENTS_CHECKPOINT_ENABLED=false +# Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume). +#TRADINGAGENTS_DATA_DIR=./data + # Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index. #TRADINGAGENTS_RESULTS_DIR= #TRADINGAGENTS_CACHE_DIR= diff --git a/README.md b/README.md index 2fd419e7d..23bc73bc7 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ docker compose run --rm tradingagents After updating the repository, rebuild the image with `docker compose build`. +Results, reports, the memory log and the cache live in the `tradingagents_data` volume. To keep them in a folder on the host instead, create the folder and point `TRADINGAGENTS_DATA_DIR` at it, in `.env` or the shell: `mkdir -p data && TRADINGAGENTS_DATA_DIR=./data docker compose run --rm tradingagents`. + For local models with Ollama: ```bash docker compose --profile ollama run --rm tradingagents-ollama diff --git a/docker-compose.yml b/docker-compose.yml index 411c989c8..76cf0fe20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: env_file: - .env volumes: - - tradingagents_data:/home/appuser/.tradingagents + - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents tty: true stdin_open: true @@ -23,7 +23,7 @@ services: - TRADINGAGENTS_LLM_PROVIDER=ollama - OLLAMA_BASE_URL=http://ollama:11434/v1 volumes: - - tradingagents_data:/home/appuser/.tradingagents + - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents depends_on: - ollama tty: true From d91d1f4cb472cf3ae942d650fdd261f57356fc5f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:15:29 +0000 Subject: [PATCH 18/63] build: keep the package version in tradingagents.__version__ - pyproject reads the version from tradingagents/__init__.py, so a source checkout, an editable install and a wheel report the same one - the SEC EDGAR User-Agent names tradingagents.__version__ --- pyproject.toml | 5 ++++- tests/test_sec_edgar.py | 20 +++++--------------- tests/test_version.py | 18 ++++++++++++++++++ tradingagents/__init__.py | 4 ++++ tradingagents/dataflows/vendors/sec_edgar.py | 10 ++-------- 5 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 tests/test_version.py diff --git a/pyproject.toml b/pyproject.toml index b72cd3b39..a291e9ef9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents" -version = "0.5.2.dev0" +dynamic = ["version"] description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.10" @@ -42,6 +42,9 @@ bedrock = [ [project.scripts] tradingagents = "cli.main:app" +[tool.setuptools.dynamic] +version = {attr = "tradingagents.__version__"} + [tool.setuptools.packages.find] include = ["tradingagents*", "cli*"] diff --git a/tests/test_sec_edgar.py b/tests/test_sec_edgar.py index 40b2def9f..d1729ce51 100644 --- a/tests/test_sec_edgar.py +++ b/tests/test_sec_edgar.py @@ -221,22 +221,12 @@ def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both(): @pytest.mark.unit -def test_the_default_identification_tracks_the_installed_version(monkeypatch): - """A release should identify itself, not a version frozen in the source.""" +def test_the_default_identification_names_the_package_version(monkeypatch): + """SEC asks automated clients to identify themselves; a release names its own version.""" + import tradingagents + monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False) - monkeypatch.setattr(sec_edgar.metadata, "version", lambda name: "9.9.9") - assert sec_edgar._user_agent() == "TradingAgents/9.9.9 (contact@example.com)" - - -@pytest.mark.unit -def test_an_uninstalled_checkout_still_identifies_itself(monkeypatch): - monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False) - - def _missing(name): - raise sec_edgar.metadata.PackageNotFoundError(name) - - monkeypatch.setattr(sec_edgar.metadata, "version", _missing) - assert "@" in sec_edgar._user_agent() + assert sec_edgar._user_agent() == f"TradingAgents/{tradingagents.__version__} (contact@example.com)" @pytest.mark.unit diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..212960b87 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,18 @@ +"""The package version has one source, tradingagents.__version__, which the build reads.""" + +import re +from pathlib import Path + +import pytest + +import tradingagents + +PYPROJECT = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + + +@pytest.mark.unit +def test_the_build_reads_the_version_from_the_package(): + assert re.search(r'^dynamic = \["version"\]', PYPROJECT, re.M) + assert 'version = {attr = "tradingagents.__version__"}' in PYPROJECT + assert not re.search(r'^version = "', PYPROJECT, re.M) + assert re.fullmatch(r"\d+\.\d+\.\d+(\.dev\d+|rc\d+)?", tradingagents.__version__) diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 6151e5e91..9a1d262a9 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,3 +1,7 @@ +"""TradingAgents: multi-agent LLM financial trading framework.""" + +__version__ = "0.5.2.dev0" + from dotenv import find_dotenv, load_dotenv # Load .env at package import so DEFAULT_CONFIG's env-var overlay and every LLM diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index 9b7315ecc..76a9ce2ab 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -22,11 +22,11 @@ import logging import os import time from datetime import date, datetime -from importlib import metadata from pathlib import Path import requests +from tradingagents import __version__ from tradingagents.dataflows.config import get_config from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError @@ -93,15 +93,9 @@ def _user_agent() -> str: so SEC can reach you about your traffic rather than the project. """ configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip() - return configured or f"TradingAgents/{_version()} (contact@example.com)" + return configured or f"TradingAgents/{__version__} (contact@example.com)" -def _version() -> str: - """The installed package version, so a release identifies itself correctly.""" - try: - return metadata.version("tradingagents") - except metadata.PackageNotFoundError: - return "dev" def _fetch_json(url: str) -> dict: From a1b3b5bab8ab7fd4a6e07752e370cf5ea3f5afe6 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:07:54 +0000 Subject: [PATCH 19/63] feat(reports): record the analysis date and what produced a run (#752) - TradingAgentsGraph.run_settings(): version, provider, models, analysts, debate rounds, language and vendors; no endpoints, keys or paths - complete_report.md opens with the analysis date and those settings; the saved state log carries them as run_settings --- cli/run.py | 3 +- tests/test_cli_display.py | 6 ++- tests/test_reporting.py | 60 ++++++++++++++++++++++++++-- tradingagents/graph/trading_graph.py | 24 ++++++++++- tradingagents/reporting.py | 32 +++++++++++++-- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/cli/run.py b/cli/run.py index 60a2ffff2..a404085e9 100644 --- a/cli/run.py +++ b/cli/run.py @@ -381,7 +381,8 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ).strip() save_path = Path(save_path_str) try: - report_file = write_report_tree(final_state, selections["ticker"], save_path) + report_file = write_report_tree(final_state, selections["ticker"], save_path, + settings=graph.run_settings()) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 674e668bd..12a8f1084 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -61,7 +61,10 @@ def _bare_graph(tmp_path): from tradingagents.graph.trading_graph import TradingAgentsGraph graph = object.__new__(TradingAgentsGraph) - graph.config = {"results_dir": str(tmp_path)} + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) return graph @@ -87,6 +90,7 @@ def test_the_state_log_names_each_field_as_the_state_does(tmp_path): assert logged["investment_plan"] == "计划" assert "trader_investment_decision" not in logged assert "judge_decision" not in json.dumps(logged) + assert logged["run_settings"]["deep_think_llm"] == "d" @pytest.mark.unit diff --git a/tests/test_reporting.py b/tests/test_reporting.py index dd93e4edb..3ddd2b3fc 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -21,6 +21,11 @@ def _state(): } +SETTINGS = {"version": "0.5.2", "llm_provider": "openai", "deep_think_llm": "gpt-6-sol", + "quick_think_llm": "gpt-6-luna", "analysts": ["market", "news"], "max_debate_rounds": 1, + "max_risk_discuss_rounds": 2, "data_vendors": {"core_stock_apis": "yfinance"}} + + @pytest.mark.unit def test_write_report_tree_creates_files(tmp_path): out = write_report_tree(_state(), "AAPL", tmp_path) @@ -37,16 +42,65 @@ def test_write_report_tree_creates_files(tmp_path): @pytest.mark.unit def test_save_reports_explicit_path(tmp_path): - # Unbound: with an explicit save_path, the method doesn't touch self/config. - out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path) + graph = SimpleNamespace(run_settings=lambda: SETTINGS) + out = TradingAgentsGraph.save_reports(graph, _state(), "AAPL", save_path=tmp_path) assert (tmp_path / "complete_report.md").exists() assert out == tmp_path / "complete_report.md" @pytest.mark.unit def test_save_reports_defaults_under_results_dir(tmp_path): - mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}) + mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}, run_settings=lambda: SETTINGS) out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL") assert out.exists() assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_/... assert out.parent.name.startswith("AAPL_") + + + +@pytest.mark.unit +def test_the_report_names_the_analysis_date_and_what_produced_it(tmp_path): + state = dict(_state(), trade_date="2026-09-23") + + header = write_report_tree(state, "NVDA", tmp_path, settings=SETTINGS).read_text().split("## ")[0] + + assert "Analysis date: 2026-09-23" in header + assert "TradingAgents 0.5.2" in header + assert "openai, deep gpt-6-sol, quick gpt-6-luna" in header + assert "Analysts: market, news" in header + assert "research debate rounds 1, risk debate rounds 2" in header + assert "core_stock_apis yfinance" in header + + +@pytest.mark.unit +def test_run_settings_record_the_run_without_endpoints_or_paths(): + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts = ("market", "news") + graph.config = {"llm_provider": "openai", "deep_think_llm": "gpt-6-sol", "quick_think_llm": "gpt-6-luna", + "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "output_language": "English", + "data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {}, + "backend_url": "https://user:secret@relay.example/v1", "results_dir": "/home/me/results"} + + settings = graph.run_settings() + + assert settings["analysts"] == ["market", "news"] + assert settings["deep_think_llm"] == "gpt-6-sol" + assert "secret" not in str(settings) and "/home/me" not in str(settings) + + +@pytest.mark.unit +def test_a_partial_settings_dict_still_writes_the_report(tmp_path): + out = write_report_tree(_state(), "AAPL", tmp_path, settings={"llm_provider": "openai"}) + assert "openai" in out.read_text() + + +@pytest.mark.unit +def test_run_settings_name_the_version_of_the_running_code(): + import tradingagents + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts, graph.config = ("market",), {} + assert graph.run_settings()["version"] == tradingagents.__version__ diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index ec8f6b887..0c3e59b95 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Any +import tradingagents from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.rating import run_rating from tradingagents.dataflows.config import run_config, set_config @@ -241,6 +242,26 @@ class TradingAgentsGraph: self._run_signature(asset_type, portfolio), ) + def run_settings(self) -> dict: + """What produces this graph's runs, for the saved report and state log. + + An allowlist: endpoints (a backend_url can carry credentials), keys and + local paths are never recorded. + """ + cfg = self.config + return { + "version": tradingagents.__version__, + "llm_provider": cfg.get("llm_provider"), + "deep_think_llm": cfg.get("deep_think_llm"), + "quick_think_llm": cfg.get("quick_think_llm"), + "analysts": list(self.selected_analysts), + "max_debate_rounds": cfg.get("max_debate_rounds"), + "max_risk_discuss_rounds": cfg.get("max_risk_discuss_rounds"), + "output_language": cfg.get("output_language"), + "data_vendors": dict(cfg.get("data_vendors") or {}), + "tool_vendors": dict(cfg.get("tool_vendors") or {}), + } + def save_reports(self, final_state, ticker, save_path=None) -> Path: """Write the markdown report tree for a completed run, like the CLI does. @@ -254,7 +275,7 @@ class TradingAgentsGraph: / "reports" / f"{safe_ticker_component(ticker)}_{stamp}" ) - return write_report_tree(final_state, ticker, save_path) + return write_report_tree(final_state, ticker, save_path, settings=self.run_settings()) def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): """Build a run's initial state; propagate() and the CLI both start here. @@ -370,6 +391,7 @@ class TradingAgentsGraph: "investment_plan": final_state["investment_plan"], "final_trade_decision": final_state["final_trade_decision"], "final_rating": run_rating(final_state), + "run_settings": self.run_settings(), } # A ticker that would escape the results directory is rejected. diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index 62759b875..214e1df20 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -10,8 +10,31 @@ from datetime import datetime from pathlib import Path -def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: - """Save a completed run's reports to ``save_path``; return the complete-report path.""" +def _header(ticker: str, final_state: dict, settings: dict | None) -> str: + """The report's title and what produced it: analysis date, version, models, analysts, vendors.""" + lines = [f"# Trading Analysis Report: {ticker}", ""] + if final_state.get("trade_date"): + lines.append(f"- Analysis date: {final_state['trade_date']}") + lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + if settings: + s = settings.get + lines.append(f"- TradingAgents {s('version', '?')}: {s('llm_provider', '?')}, " + f"deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}") + lines.append(f"- Analysts: {', '.join(s('analysts') or [])}; " + f"research debate rounds {s('max_debate_rounds', '?')}, " + f"risk debate rounds {s('max_risk_discuss_rounds', '?')}") + vendors = {**(s("data_vendors") or {}), **(s("tool_vendors") or {})} + if vendors: + lines.append("- Data vendors: " + ", ".join(f"{k} {v}" for k, v in vendors.items())) + return "\n".join(lines) + "\n\n" + + +def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict | None = None) -> Path: + """Save a completed run's reports to ``save_path``; return the complete-report path. + + ``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run + to the report's header. + """ save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) sections = [] @@ -96,6 +119,7 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{final_state['final_trade_decision']}") # Write consolidated report - header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - (save_path / "complete_report.md").write_text(header + "\n\n".join(sections), encoding="utf-8") + (save_path / "complete_report.md").write_text( + _header(ticker, final_state, settings) + "\n\n".join(sections), encoding="utf-8" + ) return save_path / "complete_report.md" From 0c602846ba9bcb89a43ede9ac93f49bfdfdc7992 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:51:25 +0000 Subject: [PATCH 20/63] feat(cli): run an analysis without questions from flags (#1127, #1133) - --ticker, --date and --analysts answer their steps, checked as the prompts check them; --save/--no-save and --show/--no-show answer the questions after the run - with no terminal, the run stops before it starts and names every flag or TRADINGAGENTS_* variable still needed; a missing API key and an announcement no longer wait for input - README shows an unattended run --- README.md | 8 ++ cli/announcements.py | 3 +- cli/main.py | 20 +++- cli/prompts.py | 42 +++++++++ cli/run.py | 53 +++++++---- cli/selections.py | 102 ++++++++++++++------- tests/conftest.py | 9 ++ tests/test_cli_commands.py | 3 +- tests/test_cli_headless.py | 162 +++++++++++++++++++++++++++++++++ tests/test_cli_memory_log.py | 2 +- tests/test_rating_integrity.py | 2 +- 11 files changed, 347 insertions(+), 59 deletions(-) create mode 100644 tests/test_cli_headless.py diff --git a/README.md b/README.md index 23bc73bc7..c92b5dce6 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,14 @@ python -m cli.main # alternative: run directly from source ``` You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely. +To run without questions, for a scheduled job or a script, answer the per-run steps with flags and the rest with `TRADINGAGENTS_*` variables: +```bash +export TRADINGAGENTS_LLM_PROVIDER=openai TRADINGAGENTS_QUICK_THINK_LLM=gpt-6-luna TRADINGAGENTS_DEEP_THINK_LLM=gpt-6-sol +export TRADINGAGENTS_OUTPUT_LANGUAGE=English TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 TRADINGAGENTS_MAX_RISK_ROUNDS=1 +tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamentals --save --no-show +``` +Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set. + ### Markets and tickers TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market. diff --git a/cli/announcements.py b/cli/announcements.py index 3ff70159c..a3c8223f4 100644 --- a/cli/announcements.py +++ b/cli/announcements.py @@ -1,4 +1,5 @@ import getpass +import sys import requests from rich.console import Console @@ -46,7 +47,7 @@ def display_announcements(console: Console, data: dict) -> None: ) console.print(panel) - if require_attention: + if require_attention and sys.stdin.isatty(): getpass.getpass("Press Enter to continue...") else: console.print() diff --git a/cli/main.py b/cli/main.py index 7ee214d01..a906893d1 100644 --- a/cli/main.py +++ b/cli/main.py @@ -47,8 +47,23 @@ def analyze( help="JSON file with current holdings and cash, so the trader, risk and " "portfolio agents size against your actual position.", ), + ticker: str = typer.Option(None, "--ticker", help="Ticker to analyze, e.g. NVDA or 0700.HK; skips the prompt"), + date: str = typer.Option(None, "--date", help="Analysis date, YYYY-MM-DD; skips the prompt"), + analysts: str = typer.Option( + None, "--analysts", help="Comma-separated analysts, e.g. market,news; skips the prompt" + ), + save: bool | None = typer.Option( + None, "--save/--no-save", help="Save the report under results_dir without asking" + ), + show: bool | None = typer.Option( + None, "--show/--no-show", help="Show the full report at the end without asking" + ), ): - """Run an analysis. This is what a bare `tradingagents` does.""" + """Run an analysis. This is what a bare `tradingagents` does. + + Flags answer their questions; with provider, models, depth and language also + set through TRADINGAGENTS_* variables, the run asks nothing. + """ if ctx.invoked_subcommand is not None: return if clear_checkpoints: @@ -64,7 +79,8 @@ def analyze( raise typer.Exit(code=1) from None try: - run_analysis(checkpoint=checkpoint, portfolio=portfolio_context) + flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show} + run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags) except _NO_CONSOLE_ERRORS: # A terminal with no console buffer cannot host the interactive prompts. # Emit one actionable line on stderr instead of a prompt_toolkit diff --git a/cli/prompts.py b/cli/prompts.py index 1aae00f8e..4be760b9b 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -1,7 +1,10 @@ +import datetime import os +import sys from pathlib import Path import questionary +import typer from dotenv import find_dotenv, set_key from cli.display import console @@ -60,6 +63,41 @@ def get_ticker() -> str: return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY" +def parse_ticker(value: str) -> str: + """A ticker given on the command line, canonical; empty or malformed is refused.""" + if not value.strip() or not is_valid_ticker_input(value): + raise ValueError(f"not a ticker symbol: {value!r} (e.g. {TICKER_INPUT_EXAMPLES})") + return normalize_ticker_symbol(value) + + +def parse_analysis_date(value: str) -> str: + """An analysis date as YYYY-MM-DD, today or earlier.""" + try: + day = datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date() + except ValueError: + raise ValueError(f"not a date: {value!r}; use YYYY-MM-DD") from None + if day > datetime.date.today(): + raise ValueError(f"{value} is in the future") + return day.isoformat() + + +def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]: + """Comma-separated analyst names, in the canonical order, checked against the asset.""" + # "sentiment" is the name users see; the analyst's key is "social". + names = [{"sentiment": "social"}.get(n, n) for n in (n.strip().lower() for n in value.split(",")) if n] + if not names: + raise ValueError("name at least one analyst") + known = {a.value: a for a in AnalystType} + unknown = [n for n in names if n not in known] + if unknown: + raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}") + available = filter_analysts_for_asset_type(list(known.values()), asset_type) + unavailable = [n for n in names if known[n] not in available] + if unavailable: + raise ValueError(f"{', '.join(unavailable)} is not available for {asset_type.value}") + return [a for a in available if a.value in names] + + def normalize_ticker_symbol(ticker: str) -> str: """Resolve user input to its canonical Yahoo symbol (single source of truth). @@ -611,6 +649,10 @@ def ensure_api_key(provider: str) -> str | None: if existing: return existing + if not sys.stdin.isatty(): + console.print(f"[red]{env_var} is not set; there is no terminal to ask for it.[/red]") + raise typer.Exit(code=1) + console.print( f"\n[yellow]{env_var} is not set in your environment.[/yellow]" ) diff --git a/cli/run.py b/cli/run.py index a404085e9..dc6408cb2 100644 --- a/cli/run.py +++ b/cli/run.py @@ -2,6 +2,7 @@ import datetime import os +import sys import time from functools import wraps from pathlib import Path @@ -21,7 +22,7 @@ from cli.display import ( update_display, update_research_team_status, ) -from cli.selections import get_user_selections +from cli.selections import get_user_selections, unattended_gaps from cli.stats_handler import StatsCallbackHandler from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component @@ -93,9 +94,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: return config -def run_analysis(checkpoint: bool | None = None, portfolio=None): - # First get all user selections - selections = get_user_selections() +def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): + flags = flags or {} + # With no terminal nothing can answer a prompt: name every question still + # open before any model is called, rather than stopping at the first one. + if not (sys.stdin and sys.stdin.isatty()): + gaps = unattended_gaps(flags) + if gaps: + console.print("[red]No terminal to answer the setup questions. Set:[/red]") + for gap in gaps: + console.print(f" {gap}") + raise typer.Exit(code=1) + + selections = get_user_selections(flags) config = _build_run_config(selections, checkpoint) @@ -366,29 +377,35 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") - # Prompt to save report - save_choice = typer.prompt("Save report?", default="Y").strip().upper() - if save_choice in ("Y", "YES", ""): + _offer_reports(final_state, graph, config, selections["ticker"], + save=flags.get("save"), show=flags.get("show")) + + +def _offer_reports(final_state, graph, config, ticker, save=None, show=None): + """Save the report tree and show it; ``save``/``show`` answer the questions when given.""" + asked = save is None + if asked: + save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "") + if save: timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Under results_dir, not the working directory: in Docker the working # directory is inside the container and the report goes with it, while # results_dir is the mounted volume the rest of the run already writes to. - default_path = (Path(config["results_dir"]) / "reports" - / f"{safe_ticker_component(selections['ticker'])}_{timestamp}") - save_path_str = typer.prompt( - "Save path (press Enter for default)", - default=str(default_path) - ).strip() - save_path = Path(save_path_str) + save_path = (Path(config["results_dir"]) / "reports" + / f"{safe_ticker_component(ticker)}_{timestamp}") + if asked: # someone at the prompt may pick another folder + save_path = Path(typer.prompt( + "Save path (press Enter for default)", default=str(save_path) + ).strip()) try: - report_file = write_report_tree(final_state, selections["ticker"], save_path, + report_file = write_report_tree(final_state, ticker, save_path, settings=graph.run_settings()) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: console.print(f"[red]Error saving report: {e}[/red]") - # Prompt to display full report - display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() - if display_choice in ("Y", "YES", ""): + if show is None: + show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "") + if show: display_complete_report(final_state) diff --git a/cli/selections.py b/cli/selections.py index df7640481..e0e8f96ac 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -25,6 +25,9 @@ from cli.prompts import ( detect_asset_type, ensure_api_key, get_ticker, + parse_analysis_date, + parse_analysts, + parse_ticker, prompt_openai_compatible_url, resolve_backend_url, select_analysts, @@ -36,15 +39,40 @@ from cli.prompts import ( from tradingagents.default_config import DEFAULT_CONFIG -def get_user_selections(): +def get_user_selections(flags=None): """Ask for the run's settings, offering the previous run's answers.""" - selections = _prompt_selections(load_last_run()) + selections = _prompt_selections(load_last_run(), flags or {}) save_last_run(selections) return selections -def _prompt_selections(prefs): - """Walk the selection steps. ``prefs`` prefills, the environment skips.""" +def unattended_gaps(flags) -> list[str]: + """The flags and environment variables a run with no terminal still needs.""" + env = os.environ.get + gaps = [f"--{name}" for name in ("ticker", "date", "analysts") if flags.get(name) is None] + gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None] + if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"): + gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE") + if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")): + gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS") + if not env("TRADINGAGENTS_LLM_PROVIDER"): + gaps.append("TRADINGAGENTS_LLM_PROVIDER") + if not (env("TRADINGAGENTS_QUICK_THINK_LLM") or env("TRADINGAGENTS_DEEP_THINK_LLM")): + gaps.append("TRADINGAGENTS_QUICK_THINK_LLM or TRADINGAGENTS_DEEP_THINK_LLM") + return gaps + + +def _from_flag(parse, value, *args): + """A flag's value through the same check its prompt applies; a bad one ends the run.""" + try: + return parse(value, *args) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + + +def _prompt_selections(prefs, flags): + """Walk the selection steps. ``prefs`` prefills; flags and the environment skip.""" with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f: welcome_ascii = f.read() @@ -93,14 +121,18 @@ def _prompt_selections(prefs): return prompt_fn() # Step 1: Ticker symbol - console.print( - create_question_box( - "Step 1: Ticker Symbol", - "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", - "SPY", + if flags.get("ticker") is not None: + selected_ticker = _from_flag(parse_ticker, flags["ticker"]) + console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}") + else: + console.print( + create_question_box( + "Step 1: Ticker Symbol", + "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", + "SPY", + ) ) - ) - selected_ticker = get_ticker() + selected_ticker = get_ticker() asset_type = detect_asset_type(selected_ticker) # Only announce when it's not the default stock path, to avoid printing # "stock" on every run. @@ -110,15 +142,19 @@ def _prompt_selections(prefs): ) # Step 2: Analysis date - default_date = datetime.datetime.now().strftime("%Y-%m-%d") - console.print( - create_question_box( - "Step 2: Analysis Date", - "Enter the analysis date (YYYY-MM-DD)", - default_date, + if flags.get("date") is not None: + analysis_date = _from_flag(parse_analysis_date, flags["date"]) + console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}") + else: + default_date = datetime.datetime.now().strftime("%Y-%m-%d") + console.print( + create_question_box( + "Step 2: Analysis Date", + "Enter the analysis date (YYYY-MM-DD)", + default_date, + ) ) - ) - analysis_date = get_analysis_date() + analysis_date = get_analysis_date() # Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE) if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"): @@ -136,13 +172,16 @@ def _prompt_selections(prefs): output_language = ask_output_language(prefs.get("output_language")) # Step 4: Select analysts - console.print( - create_question_box( - "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" - ) - ) prefs = sanitize(prefs, asset_type.value) - selected_analysts = select_analysts(asset_type, prefs.get("analysts")) + if flags.get("analysts") is not None: + selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type) + else: + console.print( + create_question_box( + "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" + ) + ) + selected_analysts = select_analysts(asset_type, prefs.get("analysts")) console.print( f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" ) @@ -303,13 +342,6 @@ def get_analysis_date(): "", default=datetime.datetime.now().strftime("%Y-%m-%d") ) try: - # Validate date format and ensure it's not in the future - analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") - if analysis_date.date() > datetime.datetime.now().date(): - console.print("[red]Error: Analysis date cannot be in the future[/red]") - continue - return date_str - except ValueError: - console.print( - "[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]" - ) + return parse_analysis_date(date_str) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") diff --git a/tests/conftest.py b/tests/conftest.py index 52d1c185b..c134581fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,15 @@ def _no_network(request, monkeypatch): monkeypatch.setattr(socket.socket, "connect_ex", refuse) +@pytest.fixture(autouse=True) +def _at_a_terminal(monkeypatch): + """Tests of the interactive steps run as if at a terminal; pytest's stdin is + not one. A test of an unattended run sets isatty to False itself.""" + import sys + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + @pytest.fixture(autouse=True) def _own_cli_prefs(tmp_path, monkeypatch): """The CLI keeps the last run's selections in the user's home; tests keep theirs apart.""" diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6ad20dac0..c46fc66c4 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -25,7 +25,8 @@ calls: list = [] @pytest.mark.unit def test_no_arguments_still_runs_an_analysis(runner): assert runner.invoke(m.app, []).exit_code == 0 - assert calls == [("analysis", {"checkpoint": None, "portfolio": None})] + no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None} + assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})] @pytest.mark.unit diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py new file mode 100644 index 000000000..15f292cc6 --- /dev/null +++ b/tests/test_cli_headless.py @@ -0,0 +1,162 @@ +"""Unattended CLI runs: flags answer the per-run questions, and a run with no +terminal stops up front, naming what to set, instead of waiting on a prompt.""" + +import datetime + +import pytest +import typer + +from cli import prompts, run, selections +from cli.models import AnalystType, AssetType + +UNATTENDED_ENV = { + "TRADINGAGENTS_OUTPUT_LANGUAGE": "English", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "1", + "TRADINGAGENTS_LLM_PROVIDER": "openai", + "TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna", +} +FLAGS = {"ticker": "NVDA", "date": "2026-09-23", "analysts": "market,news", "save": True, "show": False} + + +@pytest.mark.unit +class TestFlagValues: + def test_a_ticker_is_normalised_and_an_empty_one_refused(self): + assert prompts.parse_ticker("0700.hk") == "0700.HK" + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker(" ") + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker("NV DA") + + def test_a_date_must_be_a_past_or_present_day(self): + assert prompts.parse_analysis_date("2026-09-23") == "2026-09-23" + tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).isoformat() + with pytest.raises(ValueError, match="future"): + prompts.parse_analysis_date(tomorrow) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + prompts.parse_analysis_date("23/09/2026") + + def test_analysts_are_named_and_checked_against_the_asset(self): + assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS] + with pytest.raises(ValueError, match="market, social, news, fundamentals"): + prompts.parse_analysts("market,macro", AssetType.STOCK) + with pytest.raises(ValueError, match="crypto"): + prompts.parse_analysts("fundamentals", AssetType.CRYPTO) + with pytest.raises(ValueError, match="at least one"): + prompts.parse_analysts(" , ", AssetType.STOCK) + + +@pytest.mark.unit +def test_flags_skip_exactly_their_own_steps(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + + def no_prompt(*a, **k): + raise AssertionError("prompted although a flag answered the step") + + for step in ("get_ticker", "get_analysis_date", "select_analysts"): + monkeypatch.setattr(selections, step, no_prompt) + monkeypatch.setattr(selections, "fetch_announcements", lambda: None) + monkeypatch.setattr(selections, "display_announcements", lambda *a: None) + + chosen = selections._prompt_selections({}, FLAGS) + + assert chosen["ticker"] == "NVDA" and chosen["analysis_date"] == "2026-09-23" + assert chosen["analysts"] == [AnalystType.MARKET, AnalystType.NEWS] + + +@pytest.mark.unit +def test_without_a_terminal_every_unanswered_question_is_named_before_the_run(monkeypatch, capsys): + for name in UNATTENDED_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(run.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + run.run_analysis(flags={"ticker": "NVDA"}) + + out = capsys.readouterr().out + for needed in ("--date", "--analysts", "--save", "--show", "TRADINGAGENTS_LLM_PROVIDER"): + assert needed in out + assert "--ticker" not in out + + +@pytest.mark.unit +def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + assert selections.unattended_gaps(FLAGS) == [] + + +@pytest.mark.unit +def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path): + def no_prompt(*a, **k): + raise AssertionError("prompted although --save/--show answered") + + monkeypatch.setattr(run.typer, "prompt", no_prompt) + shown = [] + monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state)) + graph = type("G", (), {"run_settings": lambda self: {}})() + state = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"} + + run._offer_reports(state, graph, {"results_dir": str(tmp_path)}, "NVDA", save=True, show=False) + + assert list(tmp_path.glob("reports/NVDA_*/complete_report.md")) + assert shown == [] + + + +@pytest.mark.unit +def test_an_announcement_does_not_wait_for_enter_without_a_terminal(monkeypatch): + from cli import announcements + from cli.display import console + + def no_wait(*a): + raise AssertionError("waited for Enter with no terminal") + + monkeypatch.setattr(announcements.getpass, "getpass", no_wait) + monkeypatch.setattr(announcements.sys.stdin, "isatty", lambda: False) + announcements.display_announcements(console, {"content": "Maintenance tonight", "require_attention": True}) + + +@pytest.mark.unit +def test_a_missing_key_without_a_terminal_names_the_variable(monkeypatch, capsys): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(prompts.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + prompts.ensure_api_key("openai") + assert "OPENAI_API_KEY" in capsys.readouterr().out + + +@pytest.mark.unit +def test_the_command_passes_its_flags_to_the_run(monkeypatch): + from typer.testing import CliRunner + + from cli import main as cli_main + + seen = {} + monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw)) + + result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--date", "2026-09-23", + "--analysts", "market,news", "--save", "--no-show"]) + + assert result.exit_code == 0, result.output + assert seen["flags"] == FLAGS + + +@pytest.mark.unit +def test_sentiment_names_the_sentiment_analyst(): + assert prompts.parse_analysts("sentiment,market", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.SOCIAL] + + +@pytest.mark.unit +def test_an_empty_flag_is_checked_like_its_step_not_reported_missing(monkeypatch): + assert "--ticker" not in selections.unattended_gaps(dict(FLAGS, ticker="")) + + +@pytest.mark.unit +def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys): + monkeypatch.setattr(run.sys, "stdin", None) + with pytest.raises(typer.Exit): + run.run_analysis(flags={}) + assert "--ticker" in capsys.readouterr().out diff --git a/tests/test_cli_memory_log.py b/tests/test_cli_memory_log.py index 9e266fbfb..fa55dfd63 100644 --- a/tests/test_cli_memory_log.py +++ b/tests/test_cli_memory_log.py @@ -147,7 +147,7 @@ def _run_cli(monkeypatch, tmp_path, fake): monkeypatch.setattr(cli_run, "create_layout", lambda: None) monkeypatch.setattr(cli_run, "update_display", lambda *a, **k: None) monkeypatch.setattr(cli_run, "Live", _NullLive) - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", }) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 8d6523e08..fabe76082 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -151,7 +151,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a))) monkeypatch.setattr(cli_run, "display_complete_report", lambda *a, **k: None) monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", }) From fc1ab1db07f1b4056bc3f62118b0bae96b3132c6 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 20:49:13 +0000 Subject: [PATCH 21/63] fix(dataflows): write cache files whole, through a temp file of their own - the OHLCV cache and the SEC EDGAR cache go through dataflows.files.replace_file: a reader sees the old file or the new one, and concurrent writers never share a temp file --- tests/test_cache_writes.py | 84 +++++++++++++++++++ tradingagents/dataflows/files.py | 31 +++++++ tradingagents/dataflows/vendors/sec_edgar.py | 5 +- .../dataflows/vendors/yahoo/ohlcv.py | 3 +- 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 tests/test_cache_writes.py create mode 100644 tradingagents/dataflows/files.py diff --git a/tests/test_cache_writes.py b/tests/test_cache_writes.py new file mode 100644 index 000000000..195809038 --- /dev/null +++ b/tests/test_cache_writes.py @@ -0,0 +1,84 @@ +"""Cache files are replaced whole: a reader never sees a half-written file, and +concurrent writers (tool calls run in parallel) never share a temp file.""" + +import os +from pathlib import Path + +import pandas as pd +import pytest + +from tradingagents.dataflows.vendors import sec_edgar +from tradingagents.dataflows.vendors.yahoo import ohlcv + + +def _recording_replace(monkeypatch): + moves = [] + real = os.replace + + def replace(src, dst): + moves.append((Path(src), Path(dst))) + real(src, dst) + + monkeypatch.setattr(os, "replace", replace) + return moves + + +@pytest.mark.unit +def test_sec_edgar_writers_never_share_a_temp_file(monkeypatch, tmp_path): + monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: {"url": url}) + moves = _recording_replace(monkeypatch) + + sec_edgar._cached_json("https://example/a", "facts.json") + (tmp_path / "sec_edgar" / "facts.json").unlink() + sec_edgar._cached_json("https://example/a", "facts.json") + + temps = [src for src, _ in moves] + assert len(set(temps)) == 2 + assert all(src.parent == dst.parent for src, dst in moves) + + +@pytest.mark.unit +def test_the_price_cache_is_replaced_whole(monkeypatch, tmp_path): + frame = pd.DataFrame({"Date": pd.bdate_range("2026-01-02", periods=3), "Open": 1.0, "High": 1.0, + "Low": 1.0, "Close": 1.0, "Volume": 1}) + monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: frame.set_index("Date")})()) + moves = _recording_replace(monkeypatch) + + ohlcv.load_ohlcv("AAPL", "2026-01-06") + + assert [dst.name for _, dst in moves] == ["AAPL-YFin-data.csv"] + assert moves[0][0].parent == tmp_path and moves[0][0] != moves[0][1] + assert not list(tmp_path.glob("*.tmp*")) + + +@pytest.mark.unit +def test_a_replaced_file_keeps_the_usual_permissions(tmp_path): + from tradingagents.dataflows.files import replace_file + + plain = tmp_path / "plain.txt" + plain.write_text("x") + replaced = tmp_path / "replaced.txt" + replace_file(replaced, lambda temp: Path(temp).write_text("x")) + + assert replaced.stat().st_mode & 0o777 == plain.stat().st_mode & 0o777 + + +@pytest.mark.unit +def test_a_file_held_open_elsewhere_keeps_its_old_content(monkeypatch, tmp_path): + """On Windows a file another reader has open cannot be replaced; the cache + write is skipped rather than failing the call that produced the data.""" + from tradingagents.dataflows import files + + target = tmp_path / "cache.csv" + target.write_text("old") + + def locked(src, dst): + raise PermissionError("in use") + + monkeypatch.setattr(files.os, "replace", locked) + files.replace_file(target, lambda temp: Path(temp).write_text("new")) + + assert target.read_text() == "old" + assert list(tmp_path.iterdir()) == [target] diff --git a/tradingagents/dataflows/files.py b/tradingagents/dataflows/files.py new file mode 100644 index 000000000..98e32afbd --- /dev/null +++ b/tradingagents/dataflows/files.py @@ -0,0 +1,31 @@ +"""Cache files written whole, safe under concurrent writers.""" + +import logging +import os +import uuid +from collections.abc import Callable +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def replace_file(path, write: Callable[[str], None]) -> None: + """Write ``path`` through a uniquely named temp file beside it, then move it into place. + + A reader sees the old file or the new one, never a partial write, and two + writers of the same path (tool calls run concurrently) never share a temp file. + ``write`` receives the temp path and creates the file, so it gets the usual + permissions. The file is a cache: where another reader holds it open (Windows), + the old one stays and the write is skipped. + """ + path = Path(path) + temp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp") + try: + write(str(temp)) + os.replace(temp, path) + except PermissionError as exc: + temp.unlink(missing_ok=True) + logger.warning("Kept the cached %s; it is in use (%s)", path.name, exc) + except BaseException: + temp.unlink(missing_ok=True) + raise diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index 76a9ce2ab..f9ccbadf2 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -29,6 +29,7 @@ import requests from tradingagents import __version__ from tradingagents.dataflows.config import get_config from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.files import replace_file logger = logging.getLogger(__name__) @@ -122,9 +123,7 @@ def _cached_json(url: str, name: str) -> dict: pass # a truncated file is a miss, not a failure data = _fetch_json(url) path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_suffix(".tmp") - temp.write_text(json.dumps(data), encoding="utf-8") - os.replace(temp, path) + replace_file(path, lambda temp: Path(temp).write_text(json.dumps(data), encoding="utf-8")) return data diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index 87029e777..ddcd58609 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -8,6 +8,7 @@ from yfinance.exceptions import YFRateLimitError from tradingagents.dataflows.config import get_config from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.files import replace_file from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component @@ -266,7 +267,7 @@ def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataF # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: raise_for_empty(symbol, canonical, "price rows") - downloaded.to_csv(data_file, index=False, encoding="utf-8") + replace_file(data_file, lambda temp: downloaded.to_csv(temp, index=False, encoding="utf-8")) data = downloaded data = _clean_dataframe(data) From 9968bd8dd1b8709939fe93b5c1d64f8c4ff17c03 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 20:55:35 +0000 Subject: [PATCH 22/63] feat(graph): run the analysts at the same time (#1255) - each analyst is a graph of its own (model and tools on a private message history) that returns only its report; all start together and the research debate waits for every report - the message-clearing nodes are gone; a checkpoint saved by the sequential layout starts fresh - TradingAgentsGraph.stream_run streams the analysts' messages for debug mode and the CLI, whose status and timing now track the analysts side by side --- README.md | 2 + cli/display.py | 42 ++++++------------ cli/run.py | 15 ++++--- tests/test_analyst_execution.py | 4 +- tests/test_checkpoint_resume.py | 4 ++ tests/test_cli_display.py | 31 +++++++------ tests/test_cli_memory_log.py | 6 +-- tests/test_graph_end_to_end.py | 48 ++++++++++++++++++++- tests/test_instrument_identity.py | 50 --------------------- tests/test_rating_integrity.py | 4 +- tradingagents/agents/__init__.py | 2 - tradingagents/agents/context.py | 32 -------------- tradingagents/graph/analyst_execution.py | 10 ----- tradingagents/graph/setup.py | 55 +++++++++++++----------- tradingagents/graph/trading_graph.py | 51 ++++++++++++++-------- 15 files changed, 163 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index c92b5dce6..d3cbb663b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Our framework decomposes complex trading tasks into specialized roles. - News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions. - Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements. +The selected analysts work at the same time, each on its own tools, and the research debate starts once all of their reports are in. +

diff --git a/cli/display.py b/cli/display.py index 1510e9581..0f9bd0943 100644 --- a/cli/display.py +++ b/cli/display.py @@ -463,22 +463,17 @@ ANALYST_REPORT_MAP = { def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): - """Update analyst statuses based on accumulated report state. + """Update analyst statuses from the reports filed so far. - Logic: - - Store new report content from the current chunk if present - - Check accumulated report_sections (not just current chunk) for status - - Analysts with reports = completed - - First analyst without report = in_progress - - Remaining analysts without reports = pending - - When all analysts done, set Bull Researcher to in_progress + The analysts run together: each is in progress until its own report lands. + When every selected analyst has filed, the research debate is in progress. """ selected = message_buffer.selected_analysts - found_active = False if wall_time_tracker is not None: sync_analyst_tracker_from_chunk(wall_time_tracker, chunk) + all_filed = True for analyst_key in ANALYST_ORDER: if analyst_key not in selected: continue @@ -490,20 +485,15 @@ def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): if chunk.get(report_key): message_buffer.update_report_section(report_key, chunk[report_key]) - # Determine status from accumulated sections, not just current chunk - has_report = bool(message_buffer.report_sections.get(report_key)) - - if has_report: + # Status comes from accumulated sections, not just the current chunk. + if message_buffer.report_sections.get(report_key): message_buffer.update_agent_status(agent_name, "completed") - elif not found_active: - message_buffer.update_agent_status(agent_name, "in_progress") - found_active = True else: - message_buffer.update_agent_status(agent_name, "pending") + message_buffer.update_agent_status(agent_name, "in_progress") + all_filed = False - # When all analysts complete, transition research team to in_progress if ( - not found_active + all_filed and selected and message_buffer.agent_status.get("Bull Researcher") == "pending" ): @@ -624,17 +614,9 @@ def sync_analyst_tracker_from_chunk( chunk: dict[str, str], now: float | None = None, ) -> None: + """The analysts start together; each stops its clock when its report lands.""" current_time = monotonic() if now is None else now - active_found = False - for spec in tracker.plan.specs: - has_report = bool(chunk.get(spec.report_key)) - - if has_report: - tracker.mark_started(spec.key, started_at=current_time) + tracker.mark_started(spec.key, started_at=current_time) + if chunk.get(spec.report_key): tracker.mark_completed(spec.key, completed_at=current_time) - continue - - if not active_found: - tracker.mark_started(spec.key, started_at=current_time) - active_found = True diff --git a/cli/run.py b/cli/run.py index dc6408cb2..4070d3e65 100644 --- a/cli/run.py +++ b/cli/run.py @@ -200,9 +200,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): ) update_display(layout, stats_handler=stats_handler, start_time=start_time) - first_analyst = analyst_execution_plan.specs[0].agent_node - message_buffer.update_agent_status(first_analyst, "in_progress") - analyst_wall_time_tracker.mark_started(selected_analyst_keys[0]) + # The analysts start together. + for spec in analyst_execution_plan.specs: + message_buffer.update_agent_status(spec.agent_node, "in_progress") + analyst_wall_time_tracker.mark_started(spec.key) update_display(layout, stats_handler=stats_handler, start_time=start_time) spinner_text = ( @@ -234,8 +235,8 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): # try/finally tears the checkpointer down even if the stream raises. trace = [] try: - for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args): - for message in chunk.get("messages", []): + for messages, chunk in graph.stream_run(graph.checkpoint_input(init_agent_state), **args): + for message in messages: msg_id = getattr(message, "id", None) if msg_id is not None: if msg_id in message_buffer._processed_message_ids: @@ -253,6 +254,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): else: message_buffer.add_tool_call(tool_call.name, tool_call.args) + if chunk is None: # a step inside an analyst's graph: messages only + update_display(layout, stats_handler=stats_handler, start_time=start_time) + continue + update_analyst_statuses( message_buffer, chunk, diff --git a/tests/test_analyst_execution.py b/tests/test_analyst_execution.py index f668b3b16..213189c4b 100644 --- a/tests/test_analyst_execution.py +++ b/tests/test_analyst_execution.py @@ -11,8 +11,8 @@ class AnalystExecutionPlanTests(unittest.TestCase): self.assertEqual([spec.key for spec in plan.specs], ["news", "market"]) self.assertEqual(plan.specs[0].agent_node, "News Analyst") - self.assertEqual(plan.specs[0].tool_node, "tools_news") - self.assertEqual(plan.specs[0].clear_node, "Msg Clear News") + self.assertEqual(plan.specs[0].report_key, "news_report") + self.assertFalse(hasattr(plan.specs[0], "clear_node")) def test_rejects_unknown_analyst_keys(self): with self.assertRaises(ValueError): diff --git a/tests/test_checkpoint_resume.py b/tests/test_checkpoint_resume.py index 6d134f6bb..c85f3d3c5 100644 --- a/tests/test_checkpoint_resume.py +++ b/tests/test_checkpoint_resume.py @@ -210,6 +210,10 @@ class TestCheckpointSignature(unittest.TestCase): # Stable for identical inputs. g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1} self.assertEqual(base, g._run_signature("stock")) + # A checkpoint saved by the sequential layout is not resumed on the + # parallel one: its pending node no longer exists, and the join would + # never fire. + self.assertIn("analysts=parallel", base) if __name__ == "__main__": diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 12a8f1084..702a31c8f 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -129,23 +129,28 @@ class AnalystWallTimeTrackerTests(unittest.TestCase): "Analyst wall time: News 4.00s | Market 2.25s", ) - def test_syncs_wall_time_from_sequential_chunks(self): + def test_analysts_run_together_and_finish_on_their_own_reports(self): plan = build_analyst_execution_plan(["market", "news"]) tracker = AnalystWallTimeTracker(plan) sync_analyst_tracker_from_chunk(tracker, {}, now=10.0) self.assertEqual(tracker.format_summary(), "Analyst wall time: pending") - sync_analyst_tracker_from_chunk( - tracker, - {"market_report": "done"}, - now=13.0, - ) - self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s") + sync_analyst_tracker_from_chunk(tracker, {"news_report": "done"}, now=13.0) + self.assertEqual(tracker.format_summary(), "Analyst wall time: News 3.00s") - sync_analyst_tracker_from_chunk( - tracker, - {"market_report": "done", "news_report": "done"}, - now=18.0, - ) - self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s | News 5.00s") + sync_analyst_tracker_from_chunk(tracker, {"news_report": "done", "market_report": "done"}, now=18.0) + self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 8.00s | News 3.00s") + + +@pytest.mark.unit +def test_every_selected_analyst_is_in_progress_until_its_report_lands(): + from cli.display import MessageBuffer, update_analyst_statuses + + buffer = MessageBuffer() + buffer.init_for_analysis(["market", "news", "fundamentals"]) + update_analyst_statuses(buffer, {"news_report": "done"}) + + assert buffer.agent_status["Market Analyst"] == "in_progress" + assert buffer.agent_status["Fundamentals Analyst"] == "in_progress" + assert buffer.agent_status["News Analyst"] == "completed" diff --git a/tests/test_cli_memory_log.py b/tests/test_cli_memory_log.py index fa55dfd63..4e3170660 100644 --- a/tests/test_cli_memory_log.py +++ b/tests/test_cli_memory_log.py @@ -95,9 +95,9 @@ class _FakeGraph: def end_checkpoint(self): pass - def stream(self, graph_input, **kwargs): - yield {"messages": [], "market_report": "M"} - yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"} + def stream_run(self, graph_input, **kwargs): + yield [], {"messages": [], "market_report": "M"} + yield [], {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"} class _NullLive: diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 7b791220c..e51df46ad 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -52,6 +52,7 @@ class ScriptedModel(BaseChatModel): structured: bool = False tools: tuple = () calls: list = Field(default_factory=list) # shared across bound copies + threads: set = Field(default_factory=set) # threads that served a tool-bound call fail_at: int | None = None # raise on this call, once @property @@ -73,6 +74,11 @@ class ScriptedModel(BaseChatModel): def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: self._count() + if self.tools: + import threading + import time + self.threads.add(threading.current_thread().name) + time.sleep(0.05) # long enough for concurrent analysts to overlap if self.tools and not isinstance(messages[-1], ToolMessage): calls = [{"name": t.name, "id": f"call_{i}", "args": {k: v for k, v in ARGS.items() @@ -113,12 +119,12 @@ def offline(monkeypatch, tmp_path): return called -def _graph(tmp_path, monkeypatch, model, **config): +def _graph(tmp_path, monkeypatch, model, debug=False, **config): cfg = copy.deepcopy(DEFAULT_CONFIG) cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"), memory_log_path=str(tmp_path / "log.md"), **config) monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model)) - return trading_graph.TradingAgentsGraph(config=cfg) + return trading_graph.TradingAgentsGraph(config=cfg, debug=debug) @pytest.mark.unit @@ -169,3 +175,41 @@ def test_a_graph_reused_across_runs_keeps_no_run_state(tmp_path, monkeypatch, of held = [v for v in vars(graph).values() if isinstance(v, dict) and TRADE_DATE in v] assert held == [] assert len(list(tmp_path.glob("results/NVDA/TradingAgentsStrategy_logs/*.json"))) == 2 + + +@pytest.mark.unit +def test_the_analysts_run_at_the_same_time(tmp_path, monkeypatch, offline): + model = ScriptedModel() + graph = _graph(tmp_path, monkeypatch, model) + + assert not [n for n in graph.graph.get_graph().nodes if n.startswith("Msg Clear")] + graph.propagate("NVDA", TRADE_DATE) + + assert len(model.threads) > 1 + + +@pytest.mark.unit +def test_a_debug_run_prints_the_analysts_work_and_reaches_the_same_decision(tmp_path, monkeypatch, offline, capsys): + """Debug mode streams the analysts' own graphs, so their tool calls still print.""" + graph = _graph(tmp_path, monkeypatch, ScriptedModel(), debug=True) + + state, signal = graph.propagate("NVDA", TRADE_DATE) + + assert signal == "Overweight" + assert state["market_report"].strip() and state["fundamentals_report"].strip() + printed = capsys.readouterr().out + assert "get_stock_data" in printed and "get_balance_sheet" in printed + + +@pytest.mark.unit +def test_each_report_streams_as_soon_as_its_analyst_files_it(tmp_path, monkeypatch, offline): + """The main state takes the analysts' reports only when the slowest one is + done; the CLI shows each report, and stops each clock, as it lands.""" + graph = _graph(tmp_path, monkeypatch, ScriptedModel()) + reports = ("market_report", "sentiment_report", "news_report", "fundamentals_report") + + first = next(state for _, state in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE), + **graph.propagator.get_graph_args()) + if state and any(state.get(k) for k in reports)) + + assert sum(bool(first.get(k)) for k in reports) == 1 diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py index 4c12a4769..11c79e67c 100644 --- a/tests/test_instrument_identity.py +++ b/tests/test_instrument_identity.py @@ -5,11 +5,9 @@ import unittest from unittest.mock import patch import pytest -from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage from tradingagents.agents.context import ( build_instrument_context, - create_msg_delete, get_instrument_context_from_state, resolve_instrument_identity, ) @@ -118,53 +116,5 @@ class GetInstrumentContextFromStateTests(unittest.TestCase): self.assertIn("crypto asset", context) -@pytest.mark.unit -class ContextAnchoredPlaceholderTests(unittest.TestCase): - """#888 — the message-clear placeholder must not be a bare 'Continue'.""" - - def _run(self, state_extra): - state = { - "messages": [ - HumanMessage(content="old", id="h1"), - AIMessage(content="reply", id="a1"), - ], - **state_extra, - } - return create_msg_delete()(state) - - def test_placeholder_is_not_bare_continue(self): - result = self._run( - {"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"} - ) - placeholder = result["messages"][-1] - self.assertIsInstance(placeholder, HumanMessage) - self.assertNotEqual(placeholder.content.strip(), "Continue") - - def test_placeholder_carries_resolved_identity(self): - result = self._run( - { - "company_of_interest": "EC", - "instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.", - "trade_date": "2026-05-28", - } - ) - content = result["messages"][-1].content - self.assertIn("Ecopetrol", content) - self.assertIn("2026-05-28", content) - - def test_old_messages_are_removed(self): - result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"}) - removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)] - humans = [m for m in result["messages"] if isinstance(m, HumanMessage)] - self.assertEqual(len(removals), 2) - self.assertEqual(len(humans), 1) - - def test_safe_defaults_when_state_minimal(self): - result = create_msg_delete()({"messages": [], "company_of_interest": "EC"}) - placeholder = result["messages"][-1] - self.assertNotEqual(placeholder.content.strip(), "Continue") - self.assertIn("EC", placeholder.content) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index fabe76082..61def3de2 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -136,8 +136,8 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path def end_checkpoint(self): pass - def stream(self, *a, **k): - yield {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW} + def stream_run(self, *a, **k): + yield [], {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW} fake = _Graph() fake.graph = fake diff --git a/tradingagents/agents/__init__.py b/tradingagents/agents/__init__.py index 5675c7d2e..169b70910 100644 --- a/tradingagents/agents/__init__.py +++ b/tradingagents/agents/__init__.py @@ -2,7 +2,6 @@ from .analysts.fundamentals_analyst import create_fundamentals_analyst from .analysts.market_analyst import create_market_analyst from .analysts.news_analyst import create_news_analyst from .analysts.sentiment_analyst import create_sentiment_analyst -from .context import create_msg_delete from .managers.portfolio_manager import create_portfolio_manager from .managers.research_manager import create_research_manager from .researchers.bear_researcher import create_bear_researcher @@ -15,7 +14,6 @@ from .trader.trader import create_trader __all__ = [ "AgentState", - "create_msg_delete", "InvestDebateState", "RiskDebateState", "create_bear_researcher", diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index 9842f2d5b..354531e27 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -6,8 +6,6 @@ import logging from collections.abc import Mapping from typing import Any -from langchain_core.messages import HumanMessage, RemoveMessage - from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.vendors.yahoo.fundamentals import get_company_profile @@ -206,33 +204,3 @@ def get_portfolio_context_from_state(state: Mapping[str, Any]) -> str: "holdings or cash, so do not assume a flat book; give direction and " "sizing guidance in terms the caller can apply to their own position." ) - - -def create_msg_delete(): - def delete_messages(state): - """Clear messages and add a context-anchored placeholder. - - The placeholder must not be a bare ``"Continue"``: some - OpenAI-compatible providers interpret that literally as the user task - and produce output about the word "continue" instead of analysing the - instrument (#888). Anchoring it to the resolved instrument context and - date keeps the next analyst on-task even if the provider treats the - placeholder as a standalone request. - """ - messages = state["messages"] - removal_operations = [RemoveMessage(id=m.id) for m in messages] - - instrument_context = get_instrument_context_from_state(state) - trade_date = state.get("trade_date", "the requested date") - placeholder = HumanMessage( - content=( - f"Proceed with your assigned analysis for this workflow. " - f"{instrument_context} The analysis date is {trade_date}." - ) - ) - return {"messages": removal_operations + [placeholder]} - - return delete_messages - - - diff --git a/tradingagents/graph/analyst_execution.py b/tradingagents/graph/analyst_execution.py index 741685c50..3a4d0f70b 100644 --- a/tradingagents/graph/analyst_execution.py +++ b/tradingagents/graph/analyst_execution.py @@ -8,15 +8,9 @@ from tradingagents.agents.analysts import fundamentals_analyst, market_analyst, class AnalystNodeSpec: key: str agent_node: str - clear_node: str report_key: str tools: tuple = () - @property - def tool_node(self) -> str | None: - """The node that runs this analyst's tool calls; None when it has no tools.""" - return f"tools_{self.key}" if self.tools else None - @dataclass(frozen=True) class AnalystExecutionPlan: @@ -27,7 +21,6 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { "market": AnalystNodeSpec( key="market", agent_node="Market Analyst", - clear_node="Msg Clear Market", report_key="market_report", tools=market_analyst.TOOLS, ), @@ -36,20 +29,17 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { # sources before calling the model, so it has no tools. key="social", agent_node="Sentiment Analyst", - clear_node="Msg Clear Sentiment", report_key="sentiment_report", ), "news": AnalystNodeSpec( key="news", agent_node="News Analyst", - clear_node="Msg Clear News", report_key="news_report", tools=news_analyst.TOOLS, ), "fundamentals": AnalystNodeSpec( key="fundamentals", agent_node="Fundamentals Analyst", - clear_node="Msg Clear Fundamentals", report_key="fundamentals_report", tools=fundamentals_analyst.TOOLS, ), diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index df4577fa0..d17215d96 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, TypedDict from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode @@ -10,7 +10,6 @@ from tradingagents.agents import ( create_conservative_debator, create_fundamentals_analyst, create_market_analyst, - create_msg_delete, create_neutral_debator, create_news_analyst, create_portfolio_manager, @@ -40,11 +39,28 @@ RISK_ANALYSIS_PATH_MAP = { } -def _tools_or_clear(spec): - """Route an analyst's turn: run its tool calls, or finish its report.""" - def route(state) -> str: - return spec.tool_node if state["messages"][-1].tool_calls else spec.clear_node - return route +def _tools_or_done(state) -> str: + """Route an analyst's turn: run its tool calls, or finish with its report.""" + return "tools" if state["messages"][-1].tool_calls else END + + +def _analyst_graph(spec, agent): + """One analyst as a graph of its own: the model and its tools, on a private message history. + + It returns only its report, so analysts running side by side never write the + same key, and its tool calls never reach the other analysts' messages. + """ + output = TypedDict(f"{spec.key.capitalize()}Report", {spec.report_key: str}) + graph = StateGraph(AgentState, output_schema=output) + graph.add_node("agent", agent) + graph.add_edge(START, "agent") + if spec.tools: + graph.add_node("tools", ToolNode(list(spec.tools))) + graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) + graph.add_edge("tools", "agent") + else: + graph.add_edge("agent", END) + return graph.compile() class GraphSetup: @@ -95,10 +111,7 @@ class GraphSetup: workflow = StateGraph(AgentState) for spec in plan.specs: - workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) - workflow.add_node(spec.clear_node, create_msg_delete()) - if spec.tools: - workflow.add_node(spec.tool_node, ToolNode(list(spec.tools))) + workflow.add_node(spec.agent_node, _analyst_graph(spec, analyst_factories[spec.key]())) workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bear Researcher", bear_researcher_node) @@ -109,20 +122,12 @@ class GraphSetup: workflow.add_node("Conservative Analyst", conservative_analyst) workflow.add_node("Portfolio Manager", portfolio_manager_node) - workflow.add_edge(START, plan.specs[0].agent_node) - - for i, spec in enumerate(plan.specs): - if spec.tools: - workflow.add_conditional_edges( - spec.agent_node, _tools_or_clear(spec), [spec.tool_node, spec.clear_node] - ) - workflow.add_edge(spec.tool_node, spec.agent_node) - else: - workflow.add_edge(spec.agent_node, spec.clear_node) - - # The last analyst hands over to the research debate. - following = plan.specs[i + 1].agent_node if i < len(plan.specs) - 1 else "Bull Researcher" - workflow.add_edge(spec.clear_node, following) + # The analysts work at the same time; the research debate starts once + # every one of them has filed its report. + analysts = [spec.agent_node for spec in plan.specs] + for node in analysts: + workflow.add_edge(START, node) + workflow.add_edge(analysts, "Bull Researcher") # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). for debate_node in ("Bull Researcher", "Bear Researcher"): diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 0c3e59b95..05953eb82 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -152,6 +152,9 @@ class TradingAgentsGraph: f"asset={asset_type}", # None, an empty book and a changed book are three different runs. f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}", + # The layout itself: a checkpoint saved when analysts ran one after + # another has pending nodes this graph no longer has. + "analysts=parallel", ]) def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): @@ -333,24 +336,16 @@ class TradingAgentsGraph: # None resumes an existing checkpoint; init_agent_state starts fresh (#1249). graph_input = self.checkpoint_input(init_agent_state) if self.debug: - trace = [] - last_printed = None - for chunk in self.graph.stream(graph_input, **args): - if chunk["messages"]: - msg = chunk["messages"][-1] - # Nodes after the trader don't append to messages, so the - # same trailing message repeats across chunks. Print it only - # when it changes (#1027); the trace/state merge is unchanged. - signature = (type(msg).__name__, getattr(msg, "content", None)) - if signature != last_printed: + # A state repeats the messages before it, so each prints once (#1027). + final_state, printed = {}, set() + for messages, state in self.stream_run(graph_input, **args): + for msg in messages: + key = getattr(msg, "id", None) or (type(msg).__name__, getattr(msg, "content", None)) + if key not in printed: + printed.add(key) msg.pretty_print() - last_printed = signature - trace.append(chunk) - # Streamed chunks are per-node deltas. Merge them so the returned - # state matches what graph.invoke() yields in the non-debug path. - final_state = {} - for chunk in trace: - final_state.update(chunk) + if state is not None: + final_state.update(state) else: final_state = self.graph.invoke(graph_input, **args) @@ -364,6 +359,28 @@ class TradingAgentsGraph: return final_state, run_rating(final_state) + def stream_run(self, graph_input, **args): + """Stream a run as ``(messages, state)`` pairs. + + ``messages`` are the agents' messages, the analysts' included. ``state`` + is the run's state after a top-level step; for a step inside an analyst's + graph it is that analyst's report once filed, else None. + + Each analyst works in a graph of its own, and the run's state takes the + analysts' reports only when the slowest has finished, so their messages + and reports come from their own finished steps ("tasks") as they happen. + """ + args = {**args, "stream_mode": ["values", "tasks"]} + for namespace, mode, chunk in self.graph.stream(graph_input, subgraphs=True, **args): + if namespace: + result = chunk.get("result") if mode == "tasks" else None + if isinstance(result, dict): + report = {k: v for k, v in result.items() if k != "messages" and v} + if result.get("messages") or report: + yield result.get("messages", []), report or None + elif mode == "values": + yield chunk.get("messages", []), chunk + def _log_state(self, trade_date, final_state): """Write a run's final state to JSON under the run's own ticker.""" entry = { From 9470b921501a16fb05a4adbfc27ab8a4cd31f67e Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 06:41:22 +0000 Subject: [PATCH 23/63] fix(cli): write the state log, and name the analysts as users know them - record_decision writes the run's state log, so CLI runs save full_states_log like propagate() does - with both round counts from the environment, the CLI no longer says a chosen research depth was overridden - an unknown --analysts name lists sentiment, not social --- cli/prompts.py | 3 ++- cli/run.py | 16 +++++++++------- cli/selections.py | 13 ++++++++----- tests/test_cli_headless.py | 23 ++++++++++++++++++++++- tests/test_cli_memory_log.py | 24 ++++++++++++++++++++++++ tradingagents/graph/trading_graph.py | 10 +++++----- 6 files changed, 70 insertions(+), 19 deletions(-) diff --git a/cli/prompts.py b/cli/prompts.py index 4be760b9b..549e63e49 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -90,7 +90,8 @@ def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]: known = {a.value: a for a in AnalystType} unknown = [n for n in names if n not in known] if unknown: - raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}") + choices = ", ".join("sentiment" if name == "social" else name for name in known) + raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {choices}") available = filter_analysts_for_asset_type(list(known.values()), asset_type) unavailable = [n for n in names if known[n] not in available] if unavailable: diff --git a/cli/run.py b/cli/run.py index 4070d3e65..bbcd1e0d4 100644 --- a/cli/run.py +++ b/cli/run.py @@ -22,7 +22,7 @@ from cli.display import ( update_display, update_research_team_status, ) -from cli.selections import get_user_selections, unattended_gaps +from cli.selections import depth_from_env, get_user_selections, unattended_gaps from cli.stats_handler import StatsCallbackHandler from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component @@ -67,17 +67,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: # Research depth sets both round counts, but an explicit env override # (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the # interactive selection — leave the env-applied value in place (#977). - for env_var, key in (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"), - ("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")): - if os.environ.get(env_var): - # The depth prompt still appeared (it is skipped only when both are + rounds = (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"), + ("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")) + depth_was_asked = not depth_from_env() + for env_var, key in rounds: + if not os.environ.get(env_var): + config[key] = selections["research_depth"] + elif depth_was_asked: + # The depth question appeared (it is skipped only when both are # set), so say which half of the answer the environment overrode. console.print( f"[green]✓ {key} from environment:[/green] {config[key]} " f"(set by {env_var}, so the research depth you chose does not apply to it)" ) - else: - config[key] = selections["research_depth"] config["quick_think_llm"] = selections["quick_think_llm"] config["deep_think_llm"] = selections["deep_think_llm"] config["backend_url"] = selections["backend_url"] diff --git a/cli/selections.py b/cli/selections.py index e0e8f96ac..a269158b2 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -46,6 +46,12 @@ def get_user_selections(flags=None): return selections +def depth_from_env() -> bool: + """Both round counts come from the environment, so the depth question is skipped.""" + return bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS") + and os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS")) + + def unattended_gaps(flags) -> list[str]: """The flags and environment variables a run with no terminal still needs.""" env = os.environ.get @@ -53,7 +59,7 @@ def unattended_gaps(flags) -> list[str]: gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None] if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"): gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE") - if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")): + if not depth_from_env(): gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS") if not env("TRADINGAGENTS_LLM_PROVIDER"): gaps.append("TRADINGAGENTS_LLM_PROVIDER") @@ -190,10 +196,7 @@ def _prompt_selections(prefs, flags): # Research depth maps to the debate + risk round counts; when both are # supplied through TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS we keep # the run non-interactive and honor the env values (#977). - depth_from_env = bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS")) and bool( - os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS") - ) - if depth_from_env: + if depth_from_env(): selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"] console.print( f"[green]✓ Research depth from environment:[/green] " diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index 15f292cc6..89972eab8 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -38,7 +38,7 @@ class TestFlagValues: def test_analysts_are_named_and_checked_against_the_asset(self): assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS] - with pytest.raises(ValueError, match="market, social, news, fundamentals"): + with pytest.raises(ValueError, match="market, sentiment, news, fundamentals"): prompts.parse_analysts("market,macro", AssetType.STOCK) with pytest.raises(ValueError, match="crypto"): prompts.parse_analysts("fundamentals", AssetType.CRYPTO) @@ -160,3 +160,24 @@ def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys): with pytest.raises(typer.Exit): run.run_analysis(flags={}) assert "--ticker" in capsys.readouterr().out + + +@pytest.mark.unit +def test_rounds_from_the_environment_do_not_claim_a_depth_was_chosen(monkeypatch, capsys): + """With both round counts set, the depth question never appeared.""" + monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1") + monkeypatch.setenv("TRADINGAGENTS_MAX_RISK_ROUNDS", "1") + selections = {"research_depth": 3, "quick_think_llm": "q", "deep_think_llm": "d", "backend_url": None, + "llm_provider": "openai", "google_thinking_level": None, "openai_reasoning_effort": None, + "anthropic_effort": None, "output_language": "English"} + + run._build_run_config(selections, None) + + assert "the research depth you chose" not in capsys.readouterr().out + + +@pytest.mark.unit +def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them(): + with pytest.raises(ValueError) as caught: + prompts.parse_analysts("macro", AssetType.STOCK) + assert "sentiment" in str(caught.value) and "social" not in str(caught.value) diff --git a/tests/test_cli_memory_log.py b/tests/test_cli_memory_log.py index 4e3170660..4171492ec 100644 --- a/tests/test_cli_memory_log.py +++ b/tests/test_cli_memory_log.py @@ -21,6 +21,7 @@ def _bare_graph(tmp_path): graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")} graph.memory_log = TradingMemoryLog(graph.config) + graph._log_state = lambda *a: None # these tests are about the memory log return graph @@ -187,3 +188,26 @@ def test_a_run_without_checkpointing_says_nothing_about_resuming(tmp_path, monke buffer = _run_cli(monkeypatch, tmp_path, _FakeGraph()) assert not any("resum" in text.lower() or "fresh" in text.lower() for _, _, text in buffer.messages) + + +@pytest.mark.unit +def test_recording_a_run_writes_its_state_log(tmp_path): + """The CLI records a run through record_decision, so the state log is written there.""" + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) + graph.memory_log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + state = {"company_of_interest": "NVDA", "trade_date": "2026-09-23", "market_report": "M", + "sentiment_report": "", "news_report": "", "fundamentals_report": "", + "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", + "current_response": ""}, + "trader_investment_plan": "T", "investment_plan": "P", + "risk_debate_state": {"aggressive_history": "", "conservative_history": "", + "neutral_history": "", "history": ""}, + "final_trade_decision": "**Rating**: Hold", "final_rating": "Hold"} + + graph.record_decision("NVDA", "2026-09-23", state) + + assert list(tmp_path.glob("NVDA/TradingAgentsStrategy_logs/full_states_log_2026-09-23.json")) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 05953eb82..3757ff5b2 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -312,10 +312,13 @@ class TradingAgentsGraph: settlement.settle_pending(company_name, self.memory_log, self.reflector, self.config) def record_decision(self, company_name, trade_date, final_state): - """Log a finished run's decision for reflection on the next same-ticker run.""" + """Record a finished run: its state log, and its decision in the memory log + for reflection on the next same-ticker run. propagate() and the CLI both end here.""" + self._log_state(trade_date, final_state) decision = final_state.get("final_trade_decision") if not decision: - logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) + logger.warning("No final decision for %s on %s; nothing added to the memory log", + company_name, trade_date) return self.memory_log.store_decision( ticker=company_name, trade_date=trade_date, final_trade_decision=decision, @@ -349,9 +352,6 @@ class TradingAgentsGraph: else: final_state = self.graph.invoke(graph_input, **args) - # Log state to disk. - self._log_state(trade_date, final_state) - self.record_decision(company_name, trade_date, final_state) # Clear checkpoint on successful completion to avoid stale state. From 052a98d6eb4b2b0796b2beca6c6cb9d7a725a1a7 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 06:43:20 +0000 Subject: [PATCH 24/63] fix(agents): serve the run's instrument, whatever symbol the model passes - the nine ticker-taking tools read company_of_interest from graph state, like trade_date; the model no longer supplies a symbol (it had passed indicator names such as RSI, a real ticker) - the News Analyst prompt and the tool descriptions no longer ask for a ticker --- tests/test_news_analyst_prompt.py | 14 +++--- tests/test_tool_date_enforcement.py | 36 ++++++++++++--- tradingagents/agents/analysts/news_analyst.py | 2 +- tradingagents/agents/context.py | 2 +- tradingagents/agents/tools.py | 45 ++++++++----------- 5 files changed, 56 insertions(+), 43 deletions(-) diff --git a/tests/test_news_analyst_prompt.py b/tests/test_news_analyst_prompt.py index 4af6b04e8..9b024121a 100644 --- a/tests/test_news_analyst_prompt.py +++ b/tests/test_news_analyst_prompt.py @@ -1,7 +1,8 @@ """Guard the news analyst prompt against tool-signature drift (#1116). -The prompt used to advertise ``get_news(query, ...)`` while the tool takes a -``ticker``, tricking the LLM into hallucinating free-text query calls. +The prompt used to advertise ``get_news(query, ...)``, tricking the LLM into +hallucinating free-text query calls. The instrument now comes from the run's +state, so the model passes only the window. """ import inspect @@ -12,14 +13,13 @@ from tradingagents.agents.tools import get_news @pytest.mark.unit -def test_get_news_takes_ticker_not_query(): - arg_names = set(get_news.args.keys()) - assert "ticker" in arg_names - assert "query" not in arg_names +def test_get_news_takes_the_window_not_a_query(): + arg_names = set(get_news.tool_call_schema.model_json_schema()["properties"]) + assert arg_names == {"start_date", "end_date"} @pytest.mark.unit def test_news_prompt_matches_get_news_signature(): src = inspect.getsource(na) - assert "get_news(ticker, start_date, end_date)" in src + assert "get_news(start_date, end_date)" in src assert "get_news(query" not in src diff --git a/tests/test_tool_date_enforcement.py b/tests/test_tool_date_enforcement.py index c313a2db7..cb968d688 100644 --- a/tests/test_tool_date_enforcement.py +++ b/tests/test_tool_date_enforcement.py @@ -68,8 +68,31 @@ def test_trade_date_is_hidden_from_the_model(tool): assert "trade_date" not in tool.tool_call_schema.model_json_schema()["properties"] +INSTRUMENT_TOOLS = [ + tools.get_stock_data, tools.get_indicators, tools.get_verified_market_snapshot, + tools.get_fundamentals, tools.get_balance_sheet, tools.get_cashflow, + tools.get_income_statement, tools.get_news, tools.get_insider_transactions, +] + + +@pytest.mark.unit +@pytest.mark.parametrize("tool", INSTRUMENT_TOOLS, ids=lambda t: t.name) +def test_the_instrument_is_hidden_from_the_model(tool): + """The model once passed an indicator name as the symbol ("rsi", "atr" are + real tickers), and another company's data reached the report.""" + properties = tool.tool_call_schema.model_json_schema()["properties"] + assert "symbol" not in properties and "ticker" not in properties + + +@pytest.mark.unit +def test_a_tool_serves_the_run_instrument_whatever_the_model_passes(): + args = _run(tools.get_indicators, {"symbol": "RSI", "indicator": "rsi", "curr_date": TRADE_DATE}, tools) + assert args[1] == "NVDA" + + class _State(MessagesState): trade_date: str + company_of_interest: str def _run(tool, args, module): @@ -82,28 +105,27 @@ def _run(tool, args, module): graph.compile().invoke({ "messages": [AIMessage("", tool_calls=[{"name": tool.name, "args": args, "id": "1"}])], "trade_date": TRADE_DATE, + "company_of_interest": "NVDA", }) return routed.call_args.args @pytest.mark.unit def test_statement_tool_with_omitted_date_uses_the_run_date(): - args = _run(tools.get_balance_sheet, {"ticker": "AAPL"}, tools) + args = _run(tools.get_balance_sheet, {}, tools) assert args[-1] == TRADE_DATE # #1331: an omitted date no longer means unfiltered @pytest.mark.unit def test_future_curr_date_from_the_model_is_clamped(): - args = _run(tools.get_fundamentals, - {"ticker": "AAPL", "curr_date": "2026-09-14"}, tools) - assert args == ("get_fundamentals", "AAPL", TRADE_DATE) + args = _run(tools.get_fundamentals, {"curr_date": "2026-09-14"}, tools) + assert args == ("get_fundamentals", "NVDA", TRADE_DATE) @pytest.mark.unit def test_future_window_from_the_model_is_clamped(): - args = _run(tools.get_stock_data, - {"symbol": "AAPL", "start_date": "2026-08-01", "end_date": "2026-09-14"}, tools) - assert args == ("get_stock_data", "AAPL", "2026-08-01", TRADE_DATE) + args = _run(tools.get_stock_data, {"start_date": "2026-08-01", "end_date": "2026-09-14"}, tools) + assert args == ("get_stock_data", "NVDA", "2026-08-01", TRADE_DATE) @pytest.mark.unit diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index fc9e0c9c9..e0307c8e1 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -25,7 +25,7 @@ def create_news_analyst(llm): instrument_context = get_instrument_context_from_state(state) 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(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, 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'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). 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(start_date, end_date) for news about the {asset_label} under analysis, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, 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'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). 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/context.py b/tradingagents/agents/context.py index 354531e27..e2a754369 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -116,7 +116,7 @@ def build_instrument_context( instrument_label = "asset" if is_crypto else "instrument" context = ( f"The {instrument_label} to analyze is `{ticker}`. " - "Use this exact ticker in every tool call, report, and recommendation, " + "The tools serve this instrument; refer to it by this exact ticker in every report and recommendation, " "preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)." ) diff --git a/tradingagents/agents/tools.py b/tradingagents/agents/tools.py index 712dd90f3..872df4481 100644 --- a/tradingagents/agents/tools.py +++ b/tradingagents/agents/tools.py @@ -17,20 +17,19 @@ from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market @tool def get_stock_data( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], start_date: Annotated[str, "Start date in yyyy-mm-dd format"], end_date: Annotated[str, "End date in yyyy-mm-dd format"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve stock price data (OHLCV) for a given ticker symbol. + Retrieve stock price data (OHLCV) for the instrument under analysis. Uses the configured core_stock_apis vendor. Args: - symbol (str): Ticker symbol of the company, e.g. AAPL, TSM start_date (str): Start date in yyyy-mm-dd format end_date (str): End date in yyyy-mm-dd format Returns: - str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified date range. + str: A formatted dataframe containing the price data for the instrument over the date range. """ start_date, end_date = as_of_window(start_date, end_date, trade_date) return route_to_vendor("get_stock_data", symbol, start_date, end_date) @@ -38,22 +37,21 @@ def get_stock_data( @tool def get_indicators( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], indicator: Annotated[str, "technical indicator to get the analysis and report of"], curr_date: Annotated[str, "The current trading date you are trading on, YYYY-mm-dd"], look_back_days: Annotated[int, "how many days to look back"] = 30, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve a single technical indicator for a given ticker symbol. + Retrieve a single technical indicator for the instrument under analysis. Uses the configured technical_indicators vendor. Args: - symbol (str): Ticker symbol of the company, e.g. AAPL, TSM indicator (str): A single technical indicator name, e.g. 'rsi', 'macd'. Call this tool once per indicator. curr_date (str): The current trading date you are trading on, YYYY-mm-dd look_back_days (int): How many days to look back, default is 30 Returns: - str: A formatted dataframe containing the technical indicators for the specified ticker symbol and indicator. + str: A formatted dataframe containing the technical indicators for the instrument and indicator. """ # LLMs sometimes pass multiple indicators as a comma-separated string; # split and process each individually. @@ -70,7 +68,7 @@ def get_indicators( @tool def get_verified_market_snapshot( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], curr_date: Annotated[str, "the current trading date, YYYY-mm-dd"], look_back_days: Annotated[ int, "number of recent trading rows to include for sanity-checking" @@ -93,15 +91,14 @@ def get_verified_market_snapshot( @tool def get_fundamentals( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve comprehensive fundamental data for a given ticker symbol. + Retrieve comprehensive fundamental data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: str: A formatted report containing comprehensive fundamental data @@ -111,16 +108,15 @@ def get_fundamentals( @tool def get_balance_sheet( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve balance sheet data for a given ticker symbol. + Retrieve balance sheet data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -131,16 +127,15 @@ def get_balance_sheet( @tool def get_cashflow( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve cash flow statement data for a given ticker symbol. + Retrieve cash flow statement data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -151,16 +146,15 @@ def get_cashflow( @tool def get_income_statement( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve income statement data for a given ticker symbol. + Retrieve income statement data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -171,16 +165,15 @@ def get_income_statement( @tool def get_news( - ticker: Annotated[str, "Ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], start_date: Annotated[str, "Start date in yyyy-mm-dd format"], end_date: Annotated[str, "End date in yyyy-mm-dd format"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve news data for a given ticker symbol. + Retrieve news data for the instrument under analysis. Uses the configured news_data vendor. Args: - ticker (str): Ticker symbol start_date (str): Start date in yyyy-mm-dd format end_date (str): End date in yyyy-mm-dd format Returns: @@ -216,14 +209,12 @@ def get_global_news( @tool def get_insider_transactions( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ Retrieve insider transaction information about a company. Uses the configured news_data vendor. - Args: - ticker (str): Ticker symbol of the company Returns: str: A report of insider transaction data """ From f4828b84ea29584721e138cb2aeff6fa778fff66 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 06:45:03 +0000 Subject: [PATCH 25/63] feat(backtest): show each cell as it starts, and the run id to continue with - run_backtest takes a progress(done, total, ticker, date) callback; tradingagents backtest prints [n/total] per cell and ends with the --run-id that continues or settles the sweep --- cli/main.py | 6 +++++- tests/test_backtest.py | 22 ++++++++++++++++++++++ tests/test_cli_commands.py | 16 ++++++++++++++++ tradingagents/backtest.py | 28 ++++++++++++++++------------ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/cli/main.py b/cli/main.py index a906893d1..264e363b5 100644 --- a/cli/main.py +++ b/cli/main.py @@ -125,7 +125,10 @@ def backtest( console.print("[red]No ticker to analyze; pass them comma-separated, e.g. NVDA,AAPL[/red]") raise typer.Exit(code=1) - kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id} + def show_progress(done, total, ticker, date): + console.print(f"[dim][{done}/{total}] {ticker} {date}[/dim]") + + kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id, "progress": show_progress} if analysts: kwargs["selected_analysts"] = [a.strip().lower() for a in analysts.split(",") if a.strip()] @@ -136,6 +139,7 @@ def backtest( raise typer.Exit(code=1) from None console.print(summarize(result).render()) console.print(f"\nRan {result.cells_run} cells, skipped {result.skipped}. Log: {result.log_path}") + console.print(f"Continue or settle this sweep: --run-id {result.run_id}") for ticker, date, reason in result.failures: console.print(f"[yellow]failed:[/yellow] {ticker} {date}: {reason}") for ticker, reason in result.settlement_failures: diff --git a/tests/test_backtest.py b/tests/test_backtest.py index f029dee56..834d324fd 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -274,3 +274,25 @@ def test_a_result_whose_cells_all_failed_summarizes_as_empty(tmp_path): result = BacktestResult(run_id="r", log_path=tmp_path / "never-written.md") assert summarize(result).resolved == 0 + + +@pytest.mark.unit +def test_progress_is_reported_before_each_cell(tmp_path): + seen = [] + + run_backtest(["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path), + progress=lambda done, total, ticker, date: seen.append((done, total, ticker, date))) + + assert seen == [(1, 4, "NVDA", "2026-01-05"), (2, 4, "NVDA", "2026-01-12"), + (3, 4, "AAPL", "2026-01-05"), (4, 4, "AAPL", "2026-01-12")] + + +@pytest.mark.unit +def test_a_resumed_sweep_reports_only_the_cells_it_runs(tmp_path): + first = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path)) + seen = [] + + run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], _config(tmp_path), run_id=first.run_id, + progress=lambda done, total, ticker, date: seen.append((done, total, date))) + + assert seen == [(1, 1, "2026-01-12")] diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index c46fc66c4..845b817fb 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -135,3 +135,19 @@ def test_backtest_reports_a_setup_failure_in_one_line(runner, monkeypatch): assert result.exit_code == 1 assert "API key" in result.output assert "Traceback" not in result.output + + +@pytest.mark.unit +def test_backtest_shows_each_cell_and_how_to_continue(runner, monkeypatch, tmp_path): + def sweep(tickers, dates, config, progress=None, **kw): + for i, date in enumerate(dates, 1): + progress(i, len(dates), tickers[0], date) + return _Result(tmp_path) + + monkeypatch.setattr(m, "run_backtest", sweep) + monkeypatch.setattr(m, "summarize", lambda log: _Summary()) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-08"]) + + assert "[1/2] NVDA 2026-06-01" in result.output and "[2/2] NVDA 2026-06-08" in result.output + assert f"--run-id {_Result(tmp_path).run_id}" in result.output diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index 49afb06d6..bf74528c5 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -17,6 +17,7 @@ cell rather than a position carried forward. from __future__ import annotations import logging +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path @@ -133,12 +134,14 @@ def run_backtest( portfolio=None, selected_analysts=("market", "social", "news", "fundamentals"), run_id: str | None = None, + progress: Callable[[int, int, str, str], None] | None = None, ) -> BacktestResult: """Analyze every ticker on every date, into a memory log of this run's own. The live log stays untouched: a sweep would otherwise flood the context that real runs read back. Cells already in this run's log are skipped, so an - interrupted sweep resumes by being run again. + interrupted sweep resumes by being run again. ``progress(done, total, + ticker, date)`` is called before each cell that runs. """ # run_id becomes a path segment, so it is validated like a ticker: an # absolute or dotted value would otherwise place the run outside results_dir. @@ -152,17 +155,18 @@ def run_backtest( result = BacktestResult(run_id=run_id, log_path=Path(run_config["memory_log_path"])) done = {(e["ticker"], e["date"]) for e in graph.memory_log.load_entries()} - for ticker in tickers: - for date in dates: - if (ticker, date) in done: - result.skipped += 1 - continue - try: - graph.propagate(ticker, date, asset_type, portfolio=portfolio) - result.cells_run += 1 - except Exception as exc: # one unreachable vendor must not end the sweep - logger.warning("Backtest cell %s %s failed: %s", ticker, date, exc) - result.failures.append((ticker, date, str(exc))) + cells = [(ticker, date) for ticker in tickers for date in dates] + todo = [cell for cell in cells if cell not in done] + result.skipped = len(cells) - len(todo) + for index, (ticker, date) in enumerate(todo, 1): + if progress: + progress(index, len(todo), ticker, date) + try: + graph.propagate(ticker, date, asset_type, portfolio=portfolio) + result.cells_run += 1 + except Exception as exc: # one unreachable vendor must not end the sweep + logger.warning("Backtest cell %s %s failed: %s", ticker, date, exc) + result.failures.append((ticker, date, str(exc))) # Settlement runs at the start of the next run for a ticker, so each ticker's # last cell would stay pending without this pass. From c540c007786f63b85faaf598f7b67ab08c490a68 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 06:45:10 +0000 Subject: [PATCH 26/63] docs: show save_reports in the Python example --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d3cbb663b..3f49da446 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,11 @@ from tradingagents.default_config import DEFAULT_CONFIG ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy()) # forward propagate -_, decision = ta.propagate("NVDA", "2026-09-01") +state, decision = ta.propagate("NVDA", "2026-09-01") print(decision) + +# the same report tree the CLI saves, under results_dir/reports +ta.save_reports(state, "NVDA") ``` You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc. From 389ff465072a23dce3835aae5b88e382afb802e6 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 07:00:14 +0000 Subject: [PATCH 27/63] fix(yahoo): report a Yahoo request that failed as unavailable, not as no data - yf_retry raises any failed request as unavailable: an unknown symbol comes back empty, so an exception is a failed request whether or not Yahoo's host answers a probe - fundamentals, statements, insider transactions, the company profile, news and closes no longer turn a failed request into "no data" --- tests/test_fundamentals_lookahead.py | 5 +- tests/test_yahoo_rate_limit.py | 68 ++++++-- .../dataflows/vendors/yahoo/fundamentals.py | 156 ++++++++---------- .../dataflows/vendors/yahoo/market.py | 5 +- tradingagents/dataflows/vendors/yahoo/news.py | 131 +++++++-------- .../dataflows/vendors/yahoo/ohlcv.py | 35 ++-- 6 files changed, 202 insertions(+), 198 deletions(-) diff --git a/tests/test_fundamentals_lookahead.py b/tests/test_fundamentals_lookahead.py index d91d4390c..f39f88124 100644 --- a/tests/test_fundamentals_lookahead.py +++ b/tests/test_fundamentals_lookahead.py @@ -125,10 +125,13 @@ class TestLiveRunUnchanged: @pytest.mark.unit class TestNoUsableFieldsStillRaises: - def test_stub_payload_raises_no_market_data(self): + def test_stub_payload_raises_no_market_data(self, monkeypatch): # yfinance returns {"trailingPegRatio": None} for unknown symbols; on a # live run that must stay a hard "no data", not a bare header. from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import ohlcv + + monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): _yf(_TODAY, info={"trailingPegRatio": None}) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index 97a6882ad..c441ef1b1 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -21,7 +21,7 @@ from tradingagents.agents.tools import ( ) from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.errors import VendorRateLimitError from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv DAY = "2026-09-18" @@ -112,19 +112,67 @@ def test_fundamentals_ask_a_new_ticker_after_a_rate_limit(yahoo): @pytest.mark.unit -def test_another_price_fetch_error_still_tells_an_outage_from_an_unknown_symbol(yahoo): - """``Ticker.history`` lets some errors through that ``yf.download`` turned - into an empty frame. Whether Yahoo answers still decides which one it is.""" +def test_a_price_request_that_raises_is_unavailable_whether_or_not_yahoo_answers(yahoo): + """An unknown symbol comes back from ``Ticker.history`` as an empty frame, so + a request that raised failed in transit, and says nothing about the symbol.""" def refused(self, **kwargs): raise ConnectionError("curl: (7) Failed to connect to query2.finance.yahoo.com") yahoo.setattr(yf.Ticker, "history", refused) + for reachable in (False, True): + yahoo.setattr(ohlcv, "vendor_reachable", lambda url, _r=reachable: _r) + with pytest.raises(VendorRateLimitError, match="request failed"): + ohlcv.load_ohlcv("AAPL", DAY) - yahoo.setattr(ohlcv, "vendor_reachable", lambda url: False) - with pytest.raises(VendorRateLimitError, match="unreachable"): - ohlcv.load_ohlcv("AAPL", DAY) - yahoo.setattr(ohlcv, "vendor_reachable", lambda url: True) - with pytest.raises(NoMarketDataError): - ohlcv.load_ohlcv("AAPL", DAY) +@pytest.mark.unit +def test_a_price_request_that_fails_is_unavailable_even_when_yahoo_answers(monkeypatch, tmp_path): + """A timeout says nothing about the symbol; a reachable host does not make it one.""" + def timed_out(**kwargs): + raise TimeoutError("read timed out") + + monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: timed_out(**k)})()) + monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + + with pytest.raises(VendorRateLimitError): + ohlcv.load_ohlcv("NVDA", "2026-09-23") + + +class _Unreachable: + """A Ticker whose every request fails in transit.""" + + def __init__(self, *args, **kwargs): + pass + + def __getattr__(self, name): + raise TimeoutError("read timed out") + + +def _yahoo_calls(): + from tradingagents.dataflows.date_window import get_current_date + from tradingagents.dataflows.vendors.yahoo import market, news + + today = get_current_date() + return [ + pytest.param(lambda: fundamentals.get_fundamentals("AAPL", today), id="fundamentals"), + pytest.param(lambda: fundamentals.get_balance_sheet("AAPL", "quarterly", today), id="statement"), + pytest.param(lambda: fundamentals.get_insider_transactions("AAPL", today), id="insider"), + pytest.param(lambda: fundamentals.get_company_profile("AAPL"), id="profile"), + pytest.param(lambda: news.get_news_yfinance("AAPL", "2026-09-10", DAY), id="news"), + pytest.param(lambda: news.get_global_news_yfinance(DAY, 7, 5), id="global_news"), + pytest.param(lambda: market.get_YFin_data_online("AAPL", "2026-09-10", DAY), id="stock_data"), + pytest.param(lambda: market.get_closes("AAPL", "2026-09-10", DAY), id="closes"), + ] + + +@pytest.mark.unit +@pytest.mark.parametrize("call", _yahoo_calls()) +def test_a_yahoo_request_that_fails_is_unavailable_not_absent(yahoo, call): + """An unknown symbol comes back empty, so a request that raised failed in transit.""" + yahoo.setattr(yf, "Ticker", _Unreachable) + yahoo.setattr(yf, "Search", _Unreachable) + + with pytest.raises(VendorRateLimitError): + call() diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 84d0e55c4..243c87704 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -4,7 +4,7 @@ import pandas as pd import yfinance as yf from tradingagents.dataflows.date_window import withhold_live_profile -from tradingagents.dataflows.errors import NoMarketDataError, VendorError, VendorRateLimitError +from tradingagents.dataflows.errors import VendorRateLimitError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import ( @@ -32,60 +32,51 @@ def get_fundamentals( if withheld: return withheld - try: - info = yf_retry(lambda: yf.Ticker(canonical).info) + info = yf_retry(lambda: yf.Ticker(canonical).info) + if not info: + raise_for_empty(ticker, canonical, "fundamentals") - if not info: - raise_for_empty(ticker, canonical, "fundamentals") + fields = [ + ("Name", info.get("longName")), + ("Sector", info.get("sector")), + ("Industry", info.get("industry")), + ("Market Cap", info.get("marketCap")), + ("PE Ratio (TTM)", info.get("trailingPE")), + ("Forward PE", info.get("forwardPE")), + ("PEG Ratio", info.get("pegRatio")), + ("Price to Book", info.get("priceToBook")), + ("EPS (TTM)", info.get("trailingEps")), + ("Forward EPS", info.get("forwardEps")), + ("Dividend Yield", info.get("dividendYield")), + ("Beta", info.get("beta")), + ("52 Week High", info.get("fiftyTwoWeekHigh")), + ("52 Week Low", info.get("fiftyTwoWeekLow")), + ("50 Day Average", info.get("fiftyDayAverage")), + ("200 Day Average", info.get("twoHundredDayAverage")), + ("Revenue (TTM)", info.get("totalRevenue")), + ("Gross Profit", info.get("grossProfits")), + ("EBITDA", info.get("ebitda")), + ("Net Income", info.get("netIncomeToCommon")), + ("Profit Margin", info.get("profitMargins")), + ("Operating Margin", info.get("operatingMargins")), + ("Return on Equity", info.get("returnOnEquity")), + ("Return on Assets", info.get("returnOnAssets")), + ("Debt to Equity", info.get("debtToEquity")), + ("Current Ratio", info.get("currentRatio")), + ("Book Value", info.get("bookValue")), + ("Free Cash Flow", info.get("freeCashflow")), + ] - fields = [ - ("Name", info.get("longName")), - ("Sector", info.get("sector")), - ("Industry", info.get("industry")), - ("Market Cap", info.get("marketCap")), - ("PE Ratio (TTM)", info.get("trailingPE")), - ("Forward PE", info.get("forwardPE")), - ("PEG Ratio", info.get("pegRatio")), - ("Price to Book", info.get("priceToBook")), - ("EPS (TTM)", info.get("trailingEps")), - ("Forward EPS", info.get("forwardEps")), - ("Dividend Yield", info.get("dividendYield")), - ("Beta", info.get("beta")), - ("52 Week High", info.get("fiftyTwoWeekHigh")), - ("52 Week Low", info.get("fiftyTwoWeekLow")), - ("50 Day Average", info.get("fiftyDayAverage")), - ("200 Day Average", info.get("twoHundredDayAverage")), - ("Revenue (TTM)", info.get("totalRevenue")), - ("Gross Profit", info.get("grossProfits")), - ("EBITDA", info.get("ebitda")), - ("Net Income", info.get("netIncomeToCommon")), - ("Profit Margin", info.get("profitMargins")), - ("Operating Margin", info.get("operatingMargins")), - ("Return on Equity", info.get("returnOnEquity")), - ("Return on Assets", info.get("returnOnAssets")), - ("Debt to Equity", info.get("debtToEquity")), - ("Current Ratio", info.get("currentRatio")), - ("Book Value", info.get("bookValue")), - ("Free Cash Flow", info.get("freeCashflow")), - ] + lines = [f"{label}: {v}" for label, v in fields if v is not None] - lines = [f"{label}: {v}" for label, v in fields if v is not None] + # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for + # unknown symbols, so `info` is truthy but every field is empty. Treat + # "no usable fields" as no data rather than emitting a bare header the + # agent might fabricate around. + if not lines: + raise_for_empty(ticker, canonical, "fundamental fields") - # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for - # unknown symbols, so `info` is truthy but every field is empty. Treat - # "no usable fields" as no data rather than emitting a bare header the - # agent might fabricate around. - if not lines: - raise NoMarketDataError(ticker, canonical, "no fundamental fields returned") - - header = f"# Company Fundamentals for {canonical}\n\n" - - return header + "\n".join(lines) - - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e + return f"# Company Fundamentals for {canonical}\n\n" + "\n".join(lines) # This vendor dates a statement by the period it covers, not by the day it was @@ -103,17 +94,11 @@ def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> """One financial statement as CSV, cut at ``as_of_date`` by period end.""" canonical = normalize_symbol(ticker) what = title.lower() - try: - ticker_obj = yf.Ticker(canonical) - attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr - data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), as_of_date) - if data.empty: - raise_for_empty(ticker, canonical, f"{what} data") - return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"{what} unavailable: {e}") from e + attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr + data = filter_financials_by_date(yf_retry(lambda: getattr(yf.Ticker(canonical), attr)), as_of_date) + if data.empty: + raise_for_empty(ticker, canonical, f"{what} data") + return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() def get_balance_sheet( @@ -160,42 +145,31 @@ def get_insider_transactions( ): """Get insider transactions data from yfinance.""" canonical = normalize_symbol(ticker) - try: - ticker_obj = yf.Ticker(canonical) - data = yf_retry(lambda: ticker_obj.insider_transactions) + data = yf_retry(lambda: yf.Ticker(canonical).insider_transactions) - # Empty is normal here (many valid symbols have no insider filings), - # so report it plainly rather than treating the symbol as invalid. - if data is None or data.empty: - if not vendor_reachable(YAHOO_HOST): - raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") - return f"No insider transactions reported for symbol '{canonical}'" + # Empty is normal here (many valid symbols have no insider filings), + # so report it plainly rather than treating the symbol as invalid. + if data is None or data.empty: + if not vendor_reachable(YAHOO_HOST): + raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") + return f"No insider transactions reported for symbol '{canonical}'" - if as_of_date: - traded = data["Start Date"] - kept = data[traded <= pd.Timestamp(as_of_date)] - if kept.empty: - return ( - f"" - ) - data = kept + if as_of_date: + traded = data["Start Date"] + kept = data[traded <= pd.Timestamp(as_of_date)] + if kept.empty: + return ( + f"" + ) + data = kept - return f"# Insider Transactions data for {canonical}\n" + _TRANSACTION_DATE_VINTAGE + data.to_csv() - - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e + return f"# Insider Transactions data for {canonical}\n" + _TRANSACTION_DATE_VINTAGE + data.to_csv() def get_company_profile(ticker: str) -> dict: """Yahoo's current profile for ``ticker``: name, sector, industry and the like.""" - canonical = normalize_symbol(ticker) - try: - return yf_retry(lambda: yf.Ticker(canonical).info) or {} - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e + return yf_retry(lambda: yf.Ticker(normalize_symbol(ticker)).info) or {} def filter_financials_by_date(data: pd.DataFrame, as_of_date: str) -> pd.DataFrame: diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index 6965e54d2..cc65ef2d5 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -273,10 +273,7 @@ def get_stockstats_indicator( def get_closes(symbol: str, start_date: str, end_date: str) -> pd.Series: """Daily closes from ``start_date`` up to, not including, ``end_date``.""" canonical = normalize_symbol(symbol) - try: - history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) - except Exception as e: - raise NoMarketDataError(symbol, canonical, f"prices unavailable: {e}") from e + history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) return history["Close"] if "Close" in history else pd.Series(dtype=float) diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 04fe18f8a..1a17ddf67 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -8,7 +8,6 @@ from dateutil.relativedelta import relativedelta from tradingagents.dataflows.config import get_config from tradingagents.dataflows.date_window import coverage_gap, in_window -from tradingagents.dataflows.errors import NoMarketDataError, VendorError from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry @@ -80,44 +79,38 @@ def get_news_yfinance( # returns no news. Keep the user's ticker in the report header. canonical = normalize_symbol(ticker) resolved = "" if canonical == ticker else f" (resolved to {canonical})" - try: - stock = yf.Ticker(canonical) - news = yf_retry(lambda: stock.get_news(count=article_limit)) or [] + news = yf_retry(lambda: yf.Ticker(canonical).get_news(count=article_limit)) or [] - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - end_dt = datetime.strptime(end_date, "%Y-%m-%d") + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") - news_str = "" - filtered_count = 0 + news_str = "" + filtered_count = 0 - for article in news: - data = _extract_article_data(article) + for article in news: + data = _extract_article_data(article) - # Keep only articles within the requested window (look-ahead safe). - if not in_window(data["pub_date"], start_dt, end_dt): - continue + # Keep only articles within the requested window (look-ahead safe). + if not in_window(data["pub_date"], start_dt, end_dt): + continue - news_str += f"### {data['title']} (source: {data['publisher']})\n" - if data["summary"]: - news_str += f"{data['summary']}\n" - if data["link"]: - news_str += f"Link: {data['link']}\n" - news_str += "\n" - filtered_count += 1 + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" + filtered_count += 1 - if filtered_count == 0: - gap = coverage_gap( - (_extract_article_data(a)["pub_date"] for a in news), - start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}", - ) - return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}" + if filtered_count == 0: + gap = coverage_gap( + (_extract_article_data(a)["pub_date"] for a in news), + start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}", + ) + return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}" - return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" + return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e def get_global_news_yfinance( @@ -152,49 +145,43 @@ def get_global_news_yfinance( in_window_news = [] seen_titles = set() - try: - for query in search_queries: - search = yf_retry(lambda q=query: yf.Search( - query=q, - news_count=limit, - enable_fuzzy_query=True, - )) + for query in search_queries: + found = yf_retry(lambda q=query: yf.Search( + query=q, + news_count=limit, + enable_fuzzy_query=True, + ).news) - for article in search.news or []: - # Window first: the limit counts what the run may read, so an - # out-of-window article must not spend the budget or cut the - # remaining searches short (#1356). Flat articles are filtered - # on the same rule, so none can leak future news (#1007). - data = _extract_article_data(article) - if not in_window(data["pub_date"], start_dt, curr_dt): - continue - if data["title"] and data["title"] not in seen_titles: - seen_titles.add(data["title"]) - in_window_news.append(data) + for article in found or []: + # Window first: the limit counts what the run may read, so an + # out-of-window article must not spend the budget or cut the + # remaining searches short (#1356). Flat articles are filtered + # on the same rule, so none can leak future news (#1007). + data = _extract_article_data(article) + if not in_window(data["pub_date"], start_dt, curr_dt): + continue + if data["title"] and data["title"] not in seen_titles: + seen_titles.add(data["title"]) + in_window_news.append(data) - if len(in_window_news) >= limit: - break + if len(in_window_news) >= limit: + break - news_str = "" - for data in in_window_news[:limit]: - news_str += f"### {data['title']} (source: {data['publisher']})\n" - if data["summary"]: - news_str += f"{data['summary']}\n" - if data["link"]: - news_str += f"Link: {data['link']}\n" - news_str += "\n" + news_str = "" + for data in in_window_news[:limit]: + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" - # Nothing fell inside the window -> say so rather than return an - # empty-bodied report (#993). - if not news_str: - # Results merge several fuzzy searches, so their timestamps prove no - # continuous coverage; judge the window against the present only. - gap = coverage_gap((), start_date, as_of_date, "Yahoo Finance global news", "market news") - return gap or f"No global news found between {start_date} and {as_of_date}" + # Nothing fell inside the window -> say so rather than return an + # empty-bodied report (#993). + if not news_str: + # Results merge several fuzzy searches, so their timestamps prove no + # continuous coverage; judge the window against the present only. + gap = coverage_gap((), start_date, as_of_date, "Yahoo Finance global news", "market news") + return gap or f"No global news found between {start_date} and {as_of_date}" - return f"## Global Market News, from {start_date} to {as_of_date}:\n\n{news_str}" - - except VendorError: - raise - except Exception as e: - raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e + return f"## Global Market News, from {start_date} to {as_of_date}:\n\n{news_str}" diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index ddcd58609..b9b0a710e 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -43,12 +43,13 @@ def yf_retry(func, max_retries=3, base_delay=2.0): """Execute a yfinance call with exponential backoff on rate limits. yfinance raises YFRateLimitError on HTTP 429 responses but does not - retry them internally. This wrapper adds retry logic specifically - for rate limits. Other exceptions propagate immediately. A rate limit - that outlasts the retries is raised as VendorRateLimitError, so the - router reports a throttled vendor rather than a symbol with no data. - ``func`` should build its own Ticker: a Ticker keeps a failed ``info`` - fetch as done, so asking the same one again reads an empty profile. + retry them internally, so this wrapper retries them. A rate limit that + outlasts the retries, or any other exception, is raised as + VendorRateLimitError: Yahoo answers an unknown symbol with an empty + result, so a request that raised failed in transit and says nothing about + the symbol. ``func`` should build its own Ticker and make the request + itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same + one again reads an empty profile. """ for attempt in range(max_retries + 1): try: @@ -62,6 +63,8 @@ def yf_retry(func, max_retries=3, base_delay=2.0): raise VendorRateLimitError( f"Yahoo Finance rate limited after {max_retries} retries: {exc}" ) from exc + except Exception as exc: + raise VendorRateLimitError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: @@ -249,20 +252,12 @@ def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataF if data is None: # yf.download catches every error, a rate limit included, and returns # an empty frame. Ticker.history raises the rate limit, so it is retried. - try: - downloaded = yf_retry(lambda: yf.Ticker(canonical).history( - start=start_str, - end=end_str, - auto_adjust=True, - actions=False, - )) - except VendorRateLimitError: - raise - except Exception as exc: - # Any other failure is an outage or an unknown symbol, which - # raise_for_empty tells apart by whether Yahoo answers at all. - logger.warning("Yahoo Finance price request for %s failed: %s", canonical, exc) - raise_for_empty(symbol, canonical, "price rows") + downloaded = yf_retry(lambda: yf.Ticker(canonical).history( + start=start_str, + end=end_str, + auto_adjust=True, + actions=False, + )) downloaded = _ensure_date_column(downloaded.reset_index()) # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: From a6318b31ccf221ca0241eb55516de8b8e3e65331 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Fri, 25 Sep 2026 07:00:38 +0000 Subject: [PATCH 28/63] fix(graph): stream a run under its graph's own config - stream_run, which the CLI uses, scopes the graph's config like propagate() does, so a process-wide config changed elsewhere does not reach its tools --- tests/test_graph_end_to_end.py | 19 ++++++++++++++++++ tradingagents/dataflows/config.py | 13 ++++++++++++- tradingagents/graph/trading_graph.py | 29 ++++++++++++++++++---------- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index e51df46ad..7259108a8 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -213,3 +213,22 @@ def test_each_report_streams_as_soon_as_its_analyst_files_it(tmp_path, monkeypat if state and any(state.get(k) for k in reports)) assert sum(bool(first.get(k)) for k in reports) == 1 + + +@pytest.mark.unit +def test_a_streamed_run_reads_its_own_graph_config(tmp_path, monkeypatch, offline): + """The CLI streams the run; a process-wide config set elsewhere must not reach + its tools, and the graph's config must not reach the caller between steps.""" + from tradingagents.dataflows.config import get_config, set_config + + graph = _graph(tmp_path, monkeypatch, ScriptedModel(), output_language="French") + set_config({"output_language": "German"}) + seen = [] + monkeypatch.setitem(router.VENDOR_METHODS["get_stock_data"], "yfinance", + lambda *a, **k: seen.append(get_config()["output_language"]) or "prices") + + between = [get_config()["output_language"] + for _ in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE), **graph.propagator.get_graph_args())] + + assert seen and set(seen) == {"French"} + assert set(between) == {"German"} diff --git a/tradingagents/dataflows/config.py b/tradingagents/dataflows/config.py index 19a640a43..f7ae14f36 100644 --- a/tradingagents/dataflows/config.py +++ b/tradingagents/dataflows/config.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from contextvars import ContextVar +from contextvars import Context, ContextVar, copy_context from copy import deepcopy import tradingagents.default_config as default_config @@ -51,6 +51,17 @@ def run_config(config: dict): _run_config.reset(token) +def run_config_context(config: dict) -> Context: + """A context serving ``config``, over the defaults, to every read run in it. + + For a run that yields between steps, where a ``run_config`` block would + stay open in the caller's context while it holds the results. + """ + context = copy_context() + context.run(_run_config.set, _merge(deepcopy(default_config.DEFAULT_CONFIG), config)) + return context + + def get_config() -> dict: """Get the configuration of the run in progress, else the process-wide one.""" scoped = _run_config.get() diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 3757ff5b2..c2872b5ff 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -9,7 +9,7 @@ from typing import Any import tradingagents from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.rating import run_rating -from tradingagents.dataflows.config import run_config, set_config +from tradingagents.dataflows.config import run_config, run_config_context, set_config from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG @@ -371,15 +371,24 @@ class TradingAgentsGraph: and reports come from their own finished steps ("tasks") as they happen. """ args = {**args, "stream_mode": ["values", "tasks"]} - for namespace, mode, chunk in self.graph.stream(graph_input, subgraphs=True, **args): - if namespace: - result = chunk.get("result") if mode == "tasks" else None - if isinstance(result, dict): - report = {k: v for k, v in result.items() if k != "messages" and v} - if result.get("messages") or report: - yield result.get("messages", []), report or None - elif mode == "values": - yield chunk.get("messages", []), chunk + # The graph's own config serves every tool call, as in propagate(), even + # when the process-wide config has changed since. Each step runs in the + # run's context, so the caller keeps its own between steps. + context = run_config_context(self.config) + stream = context.run(self.graph.stream, graph_input, subgraphs=True, **args) + try: + while (step := context.run(next, stream, None)) is not None: + namespace, mode, chunk = step + if namespace: + result = chunk.get("result") if mode == "tasks" else None + if isinstance(result, dict): + report = {k: v for k, v in result.items() if k != "messages" and v} + if result.get("messages") or report: + yield result.get("messages", []), report or None + elif mode == "values": + yield chunk.get("messages", []), chunk + finally: + context.run(stream.close) def _log_state(self, trade_date, final_state): """Write a run's final state to JSON under the run's own ticker.""" From 00924bb2034a8f496c1a50dc03cbfd032b8ee0b9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 19:57:31 +0000 Subject: [PATCH 29/63] refactor(dataflows): name the unavailable-vendor error for what it covers - VendorRateLimitError is VendorUnavailableError: a vendor that throttled or failed the request --- tests/test_no_data_handling.py | 4 ++-- tests/test_sec_edgar.py | 4 ++-- tests/test_undated_tools_as_of.py | 12 ++++++------ tests/test_vendor_errors.py | 6 +++--- tests/test_yahoo_rate_limit.py | 10 +++++----- tradingagents/agents/tools.py | 4 ++-- tradingagents/dataflows/errors.py | 9 ++++++--- tradingagents/dataflows/router.py | 8 ++++---- .../dataflows/vendors/alpha_vantage/common.py | 4 ++-- tradingagents/dataflows/vendors/sec_edgar.py | 6 +++--- .../dataflows/vendors/yahoo/fundamentals.py | 4 ++-- tradingagents/dataflows/vendors/yahoo/ohlcv.py | 10 +++++----- 12 files changed, 42 insertions(+), 39 deletions(-) diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index 355bc76bf..20e1adb7b 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -95,11 +95,11 @@ if __name__ == "__main__": @pytest.mark.unit def test_an_unreachable_yahoo_is_not_reported_as_a_symbol_without_insider_data(): - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import fundamentals ticker = type("T", (), {"insider_transactions": pd.DataFrame()})() with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \ mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \ - pytest.raises(VendorRateLimitError): + pytest.raises(VendorUnavailableError): fundamentals.get_insider_transactions("AAPL", as_of_date="2026-09-21") diff --git a/tests/test_sec_edgar.py b/tests/test_sec_edgar.py index d1729ce51..848fa5be0 100644 --- a/tests/test_sec_edgar.py +++ b/tests/test_sec_edgar.py @@ -144,13 +144,13 @@ def test_a_throttle_lets_the_next_vendor_try(monkeypatch): """SEC throttles by refusing the request; the router then tries yfinance.""" import requests - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.errors import VendorUnavailableError def _throttled(*a, **k): raise requests.HTTPError(response=mock.Mock(status_code=429)) monkeypatch.setattr(sec_edgar.requests, "get", _throttled) - with pytest.raises(VendorRateLimitError): + with pytest.raises(VendorUnavailableError): _REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json") diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index ea3450083..6af796ae6 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -196,14 +196,14 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): company has no balance sheet, when the truth is we could not ask.""" import pandas as pd - from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError + from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import ohlcv empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame()) monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: empty) monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) - with pytest.raises(VendorRateLimitError, match="unreachable"): + with pytest.raises(VendorUnavailableError, match="unreachable"): yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) @@ -216,10 +216,10 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): """A throttled or unreachable chain used to raise RuntimeError('No available vendor'), which ends the run, and never said the vendor was the problem.""" from tradingagents.dataflows import router - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.errors import VendorUnavailableError def _down(*a, **k): - raise VendorRateLimitError("Yahoo Finance is unreachable") + raise VendorUnavailableError("Yahoo Finance is unreachable") monkeypatch.setitem(router.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down) @@ -235,13 +235,13 @@ def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch) delisted symbol either.""" import pandas as pd - from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError + from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import ohlcv monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame())) monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) - with pytest.raises(VendorRateLimitError, match="unreachable"): + with pytest.raises(VendorUnavailableError, match="unreachable"): yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py index 51e2f15fd..0f65da1f3 100644 --- a/tests/test_vendor_errors.py +++ b/tests/test_vendor_errors.py @@ -16,7 +16,7 @@ from tradingagents.dataflows.errors import ( NoMarketDataError, VendorError, VendorNotConfiguredError, - VendorRateLimitError, + VendorUnavailableError, ) from tradingagents.dataflows.vendors.alpha_vantage.common import ( AlphaVantageNotConfiguredError, @@ -28,7 +28,7 @@ from tradingagents.dataflows.vendors.fred import FredNotConfiguredError @pytest.mark.unit class HierarchyTests(unittest.TestCase): def test_all_conditions_derive_from_vendor_error(self): - for cls in (NoMarketDataError, VendorRateLimitError, VendorNotConfiguredError): + for cls in (NoMarketDataError, VendorUnavailableError, VendorNotConfiguredError): self.assertTrue(issubclass(cls, VendorError)) def test_not_configured_is_still_a_value_error(self): @@ -36,7 +36,7 @@ class HierarchyTests(unittest.TestCase): self.assertTrue(issubclass(VendorNotConfiguredError, ValueError)) def test_vendor_named_errors_subclass_the_generic_bases(self): - self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorRateLimitError)) + self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorUnavailableError)) self.assertTrue(issubclass(AlphaVantageNotConfiguredError, VendorNotConfiguredError)) self.assertTrue(issubclass(FredNotConfiguredError, VendorNotConfiguredError)) # ... and therefore still ValueErrors diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index c441ef1b1..a24f1d179 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -21,7 +21,7 @@ from tradingagents.agents.tools import ( ) from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config -from tradingagents.dataflows.errors import VendorRateLimitError +from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv DAY = "2026-09-18" @@ -84,7 +84,7 @@ def test_the_indicator_path_retries_a_rate_limit(yahoo): def test_yfinance_raises_the_rate_limit_from_history(yahoo): yahoo.setattr(YfData, "_make_request", _rate_limited) - with pytest.raises(VendorRateLimitError, match="rate limited"): + with pytest.raises(VendorUnavailableError, match="rate limited"): ohlcv.load_ohlcv("AAPL", DAY) @@ -122,7 +122,7 @@ def test_a_price_request_that_raises_is_unavailable_whether_or_not_yahoo_answers yahoo.setattr(yf.Ticker, "history", refused) for reachable in (False, True): yahoo.setattr(ohlcv, "vendor_reachable", lambda url, _r=reachable: _r) - with pytest.raises(VendorRateLimitError, match="request failed"): + with pytest.raises(VendorUnavailableError, match="request failed"): ohlcv.load_ohlcv("AAPL", DAY) @@ -136,7 +136,7 @@ def test_a_price_request_that_fails_is_unavailable_even_when_yahoo_answers(monke monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: timed_out(**k)})()) monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) - with pytest.raises(VendorRateLimitError): + with pytest.raises(VendorUnavailableError): ohlcv.load_ohlcv("NVDA", "2026-09-23") @@ -174,5 +174,5 @@ def test_a_yahoo_request_that_fails_is_unavailable_not_absent(yahoo, call): yahoo.setattr(yf, "Ticker", _Unreachable) yahoo.setattr(yf, "Search", _Unreachable) - with pytest.raises(VendorRateLimitError): + with pytest.raises(VendorUnavailableError): call() diff --git a/tradingagents/agents/tools.py b/tradingagents/agents/tools.py index 872df4481..ef4949164 100644 --- a/tradingagents/agents/tools.py +++ b/tradingagents/agents/tools.py @@ -10,7 +10,7 @@ from langchain_core.tools import tool from langgraph.prebuilt import InjectedState from tradingagents.dataflows.date_window import as_of, as_of_window -from tradingagents.dataflows.errors import VendorRateLimitError +from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.router import route_to_vendor, vendor_unavailable from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot @@ -85,7 +85,7 @@ def get_verified_market_snapshot( # An exception out of a tool would end the run. try: return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) - except VendorRateLimitError as exc: + except VendorUnavailableError as exc: return vendor_unavailable("get_verified_market_snapshot", exc) diff --git a/tradingagents/dataflows/errors.py b/tradingagents/dataflows/errors.py index 2ce6a0c46..3ea1c2a82 100644 --- a/tradingagents/dataflows/errors.py +++ b/tradingagents/dataflows/errors.py @@ -7,7 +7,7 @@ these (or a thin vendor-named subclass) and needs no new ``except`` clause. VendorError ├── NoMarketDataError no usable rows (empty result OR stale data) - ├── VendorRateLimitError transient throttle -> skip to next vendor + ├── VendorUnavailableError throttled or failed request -> skip to next vendor └── VendorNotConfiguredError missing API key/config -> vendor unavailable The number of types is the number of distinct router reactions, not the number @@ -43,8 +43,11 @@ class NoMarketDataError(VendorError): super().__init__(msg) -class VendorRateLimitError(VendorError): - """A vendor throttled the request; the router skips to the next vendor.""" +class VendorUnavailableError(VendorError): + """A vendor could not answer: it throttled or failed the request. + + Says nothing about the instrument; the router skips to the next vendor. + """ class VendorNotConfiguredError(VendorError, ValueError): diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index 72f95e5cf..f8e2b3c2d 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -4,7 +4,7 @@ from tradingagents.dataflows.config import get_config from tradingagents.dataflows.errors import ( NoMarketDataError, VendorNotConfiguredError, - VendorRateLimitError, + VendorUnavailableError, ) from tradingagents.dataflows.vendors.alpha_vantage import ( get_balance_sheet as get_alpha_vantage_balance_sheet, @@ -173,7 +173,7 @@ def get_vendor(category: str, method: str = None) -> str: return config.get("data_vendors", {}).get(category, "default") -def vendor_unavailable(method: str, error: VendorRateLimitError) -> str: +def vendor_unavailable(method: str, error: VendorUnavailableError) -> str: """What a call returns when every vendor was throttled or unreachable.""" return ( f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " @@ -210,7 +210,7 @@ def route_to_vendor(method: str, *args, **kwargs): vendor_chain = all_available_vendors last_no_data: NoMarketDataError | None = None - last_unavailable: VendorRateLimitError | None = None + last_unavailable: VendorUnavailableError | None = None first_error: Exception | None = None for vendor in vendor_chain: vendor_impl = VENDOR_METHODS[method][vendor] @@ -218,7 +218,7 @@ def route_to_vendor(method: str, *args, **kwargs): try: return impl_func(*args, **kwargs) - except VendorRateLimitError as e: + except VendorUnavailableError as e: logger.warning("Vendor %r unavailable for %s: %s; trying next vendor.", vendor, method, e) # Kept so an all-unavailable chain can say the vendor was the # problem, rather than reporting nothing about the symbol. diff --git a/tradingagents/dataflows/vendors/alpha_vantage/common.py b/tradingagents/dataflows/vendors/alpha_vantage/common.py index 39ffb1d81..d8e47045f 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/common.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/common.py @@ -5,7 +5,7 @@ from io import StringIO import pandas as pd -from tradingagents.dataflows.errors import VendorNotConfiguredError, VendorRateLimitError +from tradingagents.dataflows.errors import VendorNotConfiguredError, VendorUnavailableError from tradingagents.dataflows.net import get_scrubbed API_BASE_URL = "https://www.alphavantage.co/query" @@ -62,7 +62,7 @@ def format_datetime_for_api(date_input, end_of_day: bool = False) -> str: raise ValueError(f"Date must be string or datetime object, got {type(date_input)}") -class AlphaVantageRateLimitError(VendorRateLimitError): +class AlphaVantageRateLimitError(VendorUnavailableError): """Raised when the Alpha Vantage API rate limit is exceeded.""" pass diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index f9ccbadf2..240da0d6b 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -28,7 +28,7 @@ import requests from tradingagents import __version__ from tradingagents.dataflows.config import get_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError from tradingagents.dataflows.files import replace_file logger = logging.getLogger(__name__) @@ -109,9 +109,9 @@ def _fetch_json(url: str) -> dict: status = getattr(getattr(exc, "response", None), "status_code", None) # Every failure here is "this vendor cannot serve it now", so the router # moves on instead of seeing a transport exception it has no rule for. - raise VendorRateLimitError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc + raise VendorUnavailableError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc except ValueError as exc: - raise VendorRateLimitError("SEC EDGAR returned an unreadable response") from exc + raise VendorUnavailableError("SEC EDGAR returned an unreadable response") from exc def _cached_json(url: str, name: str) -> dict: diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 243c87704..98e3dacc8 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -4,7 +4,7 @@ import pandas as pd import yfinance as yf from tradingagents.dataflows.date_window import withhold_live_profile -from tradingagents.dataflows.errors import VendorRateLimitError +from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import ( @@ -151,7 +151,7 @@ def get_insider_transactions( # so report it plainly rather than treating the symbol as invalid. if data is None or data.empty: if not vendor_reachable(YAHOO_HOST): - raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") + raise VendorUnavailableError("Yahoo Finance is unreachable; insider filings were not retrieved") return f"No insider transactions reported for symbol '{canonical}'" if as_of_date: diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index b9b0a710e..b5b886c0a 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -7,7 +7,7 @@ import yfinance as yf from yfinance.exceptions import YFRateLimitError from tradingagents.dataflows.config import get_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError from tradingagents.dataflows.files import replace_file from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component @@ -35,7 +35,7 @@ def raise_for_empty(symbol: str, canonical: str, what: str) -> None: without this a Yahoo outage reads as "this symbol has no {what}". """ if not vendor_reachable(YAHOO_HOST): - raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved") + raise VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved") raise NoMarketDataError(symbol, canonical, f"no {what}") @@ -45,7 +45,7 @@ def yf_retry(func, max_retries=3, base_delay=2.0): yfinance raises YFRateLimitError on HTTP 429 responses but does not retry them internally, so this wrapper retries them. A rate limit that outlasts the retries, or any other exception, is raised as - VendorRateLimitError: Yahoo answers an unknown symbol with an empty + VendorUnavailableError: Yahoo answers an unknown symbol with an empty result, so a request that raised failed in transit and says nothing about the symbol. ``func`` should build its own Ticker and make the request itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same @@ -60,11 +60,11 @@ def yf_retry(func, max_retries=3, base_delay=2.0): logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") time.sleep(delay) else: - raise VendorRateLimitError( + raise VendorUnavailableError( f"Yahoo Finance rate limited after {max_retries} retries: {exc}" ) from exc except Exception as exc: - raise VendorRateLimitError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc + raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: From ec2ecc79c372a2a5bb218a7166eb9e5b19c5309f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 19:58:37 +0000 Subject: [PATCH 30/63] fix(router): report a vendor chain with an unavailable vendor as unavailable, not as no data - a vendor that throttled or failed the request never said whether it has the symbol, so another vendor's "no data" no longer speaks for the chain --- tests/test_vendor_errors.py | 20 ++++++++++++++++++++ tests/test_vendor_routing.py | 7 ++++--- tradingagents/dataflows/router.py | 20 +++++++++++++------- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py index 0f65da1f3..f04eec8a1 100644 --- a/tests/test_vendor_errors.py +++ b/tests/test_vendor_errors.py @@ -94,6 +94,26 @@ class RouterHandlesBaseTypesTests(unittest.TestCase): ), self.assertRaises(AlphaVantageNotConfiguredError): router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + def test_an_unavailable_vendor_keeps_absence_from_being_claimed(self): + # The throttled vendor's coverage was never learned, so "no usable data + # from any configured vendor" would be a claim nobody checked. + def throttled(*a, **k): + raise VendorUnavailableError("Yahoo HTTP 429") + + def timed_out(*a, **k): + raise TimeoutError("read timed out") # a vendor that does not type its failures + + def not_covered(*a, **k): + raise NoMarketDataError("AAPL", "AAPL", "not covered") + + for chain in ({"yfinance": throttled, "alpha_vantage": not_covered}, + {"yfinance": not_covered, "alpha_vantage": throttled}, + {"alpha_vantage": timed_out, "yfinance": not_covered}): + set_config({"data_vendors": {"core_stock_apis": ",".join(chain)}}) + with mock.patch.dict(router.VENDOR_METHODS, {"get_stock_data": chain}, clear=False): + out = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + self.assertTrue(out.startswith("DATA_UNAVAILABLE"), out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vendor_routing.py b/tests/test_vendor_routing.py index 85db0ed3f..3d21759bf 100644 --- a/tests/test_vendor_routing.py +++ b/tests/test_vendor_routing.py @@ -72,13 +72,14 @@ class VendorRoutingTests(unittest.TestCase): self.assertEqual(result, "AV_DATA") def test_primary_error_is_logged_not_masked(self): - # #989: primary errors + fallback no-data -> NO_DATA, but the failure - # must be visible in logs (broken primary not hidden). + # #989: a broken primary is not hidden behind a fallback's verdict. It + # never said whether it has the symbol, so the answer is unavailable, + # and the failure is in the logs. set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}}) with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \ self.assertLogs("tradingagents.dataflows.router", level="WARNING") as cm: result = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") - self.assertIn("NO_DATA_AVAILABLE", result) + self.assertTrue(result.startswith("DATA_UNAVAILABLE"), result) joined = "\n".join(cm.output) self.assertIn("boom", joined) # the real error surfaced in logs self.assertIn("yfinance", joined) diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index f8e2b3c2d..b5f0d423f 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -173,7 +173,7 @@ def get_vendor(category: str, method: str = None) -> str: return config.get("data_vendors", {}).get(category, "default") -def vendor_unavailable(method: str, error: VendorUnavailableError) -> str: +def vendor_unavailable(method: str, error: Exception) -> str: """What a call returns when every vendor was throttled or unreachable.""" return ( f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " @@ -211,6 +211,7 @@ def route_to_vendor(method: str, *args, **kwargs): last_no_data: NoMarketDataError | None = None last_unavailable: VendorUnavailableError | None = None + failed: Exception | None = None # a vendor that raised something untyped first_error: Exception | None = None for vendor in vendor_chain: vendor_impl = VENDOR_METHODS[method][vendor] @@ -239,9 +240,19 @@ def route_to_vendor(method: str, *args, **kwargs): logger.warning("Vendor %r failed for %s: %s", vendor, method, e) if first_error is None: first_error = e + failed = e continue - # If any vendor reported "no data", the symbol is genuinely unavailable. + # A vendor that throttled or failed the request never said whether it has + # the symbol, so no other vendor's "no data" can speak for the whole chain: + # report the vendors as the problem, not the instrument. It must not end + # the run either. + if last_unavailable is not None: + return vendor_unavailable(method, last_unavailable) + if failed is not None and last_no_data is not None: + return vendor_unavailable(method, failed) + + # Every vendor that answered reported "no data": the symbol is genuinely unavailable. # Return one explicit, instructive sentinel rather than a vendor-specific # empty string, so the agent reports "unavailable" instead of inventing a # value. This takes precedence over incidental fallback errors. @@ -271,11 +282,6 @@ def route_to_vendor(method: str, *args, **kwargs): # first real error (e.g. the primary vendor's network failure). Optional # enrichment categories degrade to a sentinel instead, so flavour data can't # abort the run. - # Every vendor was throttled or unreachable: that is a fact about the - # vendors, not about the instrument, and it must not end the run. - if last_unavailable is not None: - return vendor_unavailable(method, last_unavailable) - if first_error is not None: if category in OPTIONAL_CATEGORIES: logger.warning("Optional %s unavailable for %s: %s", category, method, first_error) From 466d33ab3bbc313401e4eeaec2565b5d92b79977 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 19:59:50 +0000 Subject: [PATCH 31/63] fix(agents): answer a snapshot with no price rows as no data instead of ending the run - build_verified_market_snapshot raises NoMarketDataError for an unknown symbol or no row by the date - get_verified_market_snapshot returns the router's no-data answer, shared as router.no_data_available --- tests/test_yahoo_rate_limit.py | 23 +++++++++++++++ tests/test_yahoo_snapshot.py | 5 ++-- tradingagents/agents/tools.py | 6 ++-- tradingagents/dataflows/router.py | 29 ++++++++++--------- .../dataflows/vendors/yahoo/snapshot.py | 8 +++-- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index a24f1d179..ffbab5ec4 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -176,3 +176,26 @@ def test_a_yahoo_request_that_fails_is_unavailable_not_absent(yahoo, call): with pytest.raises(VendorUnavailableError): call() + + +@pytest.mark.unit +@pytest.mark.parametrize("frame", [ + pytest.param(pd.DataFrame(), id="unknown_symbol"), + pytest.param(pd.DataFrame({"Date": pd.to_datetime(["2026-09-25"]), "Open": [1.0], "High": [1.0], + "Low": [1.0], "Close": [1.0], "Volume": [1]}), id="no_row_by_the_date"), +]) +def test_a_snapshot_with_no_rows_is_reported_not_raised(yahoo, frame): + """An exception out of a tool ends the run; no rows is an answer about the symbol.""" + from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import snapshot + + def load(symbol, as_of_date, **kwargs): + if frame.empty: + raise NoMarketDataError(symbol, symbol, "no price rows") + return frame + + yahoo.setattr(snapshot, "load_ohlcv", load) + + out = get_verified_market_snapshot.func("ZZZZ", DAY, trade_date=DAY) + + assert out.startswith("NO_DATA_AVAILABLE"), out diff --git a/tests/test_yahoo_snapshot.py b/tests/test_yahoo_snapshot.py index 51c852c08..07dbc3d7e 100644 --- a/tests/test_yahoo_snapshot.py +++ b/tests/test_yahoo_snapshot.py @@ -6,6 +6,7 @@ import pandas as pd import pytest import tradingagents.dataflows.vendors.yahoo.snapshot as validator +from tradingagents.dataflows.errors import NoMarketDataError def _sample_ohlcv() -> pd.DataFrame: @@ -47,12 +48,12 @@ class TestVerifiedSnapshot: def test_raises_when_no_rows_on_or_before_date(self, monkeypatch): monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) - with pytest.raises(ValueError): + with pytest.raises(NoMarketDataError): validator.build_verified_market_snapshot("COF", "2020-01-01") def test_raises_on_empty_data(self, monkeypatch): monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: pd.DataFrame()) - with pytest.raises(ValueError): + with pytest.raises(NoMarketDataError): validator.build_verified_market_snapshot("COF", "2026-05-13") def test_look_back_window_capped_at_30(self, monkeypatch): diff --git a/tradingagents/agents/tools.py b/tradingagents/agents/tools.py index ef4949164..b8b323cba 100644 --- a/tradingagents/agents/tools.py +++ b/tradingagents/agents/tools.py @@ -10,8 +10,8 @@ from langchain_core.tools import tool from langgraph.prebuilt import InjectedState from tradingagents.dataflows.date_window import as_of, as_of_window -from tradingagents.dataflows.errors import VendorUnavailableError -from tradingagents.dataflows.router import route_to_vendor, vendor_unavailable +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.router import no_data_available, route_to_vendor, vendor_unavailable from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot @@ -87,6 +87,8 @@ def get_verified_market_snapshot( return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) except VendorUnavailableError as exc: return vendor_unavailable("get_verified_market_snapshot", exc) + except NoMarketDataError as exc: + return no_data_available(exc) @tool diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index b5f0d423f..b047e7699 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -182,6 +182,21 @@ def vendor_unavailable(method: str, error: Exception) -> str: ) +def no_data_available(error: NoMarketDataError) -> str: + """What a call returns when every vendor that answered had no usable data.""" + resolved = "" if error.canonical == error.symbol else f" (resolved to '{error.canonical}')" + # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... + # stale") so the agent sees the specific reason — invalid symbol, no + # coverage, or stale data — not just a generic "unavailable". + reason = f" ({error.detail})" if error.detail else "" + return ( + f"NO_DATA_AVAILABLE: No usable market data for '{error.symbol}'{resolved} from " + f"any configured vendor{reason}. The symbol may be invalid, delisted, " + f"not covered, or the vendor returned stale data. Do not estimate or " + f"fabricate values — report that data is unavailable for this symbol." + ) + + def route_to_vendor(method: str, *args, **kwargs): """Route method calls to appropriate vendor implementation with fallback support.""" category = get_category_for_method(method) @@ -264,19 +279,7 @@ def route_to_vendor(method: str, *args, **kwargs): "Returning NO_DATA for %s, but a vendor errored earlier: %s", method, first_error, ) - sym = last_no_data.symbol - canonical = last_no_data.canonical - resolved = "" if canonical == sym else f" (resolved to '{canonical}')" - # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... - # stale") so the agent sees the specific reason — invalid symbol, no - # coverage, or stale data — not just a generic "unavailable". - reason = f" ({last_no_data.detail})" if last_no_data.detail else "" - return ( - f"NO_DATA_AVAILABLE: No usable market data for '{sym}'{resolved} from " - f"any configured vendor{reason}. The symbol may be invalid, delisted, " - f"not covered, or the vendor returned stale data. Do not estimate or " - f"fabricate values — report that data is unavailable for this symbol." - ) + return no_data_available(last_no_data) # No vendor returned data and none reported clean "no data" — surface the # first real error (e.g. the primary vendor's network failure). Optional diff --git a/tradingagents/dataflows/vendors/yahoo/snapshot.py b/tradingagents/dataflows/vendors/yahoo/snapshot.py index 06c16bd3c..3bec5ebc5 100644 --- a/tradingagents/dataflows/vendors/yahoo/snapshot.py +++ b/tradingagents/dataflows/vendors/yahoo/snapshot.py @@ -15,6 +15,8 @@ from collections.abc import Iterable import pandas as pd from stockstats import wrap +from tradingagents.dataflows.errors import NoMarketDataError +from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import load_ohlcv # A fixed, common indicator set so the snapshot is the same shape every run. @@ -26,7 +28,7 @@ DEFAULT_SNAPSHOT_INDICATORS: tuple[str, ...] = ( def _verified_rows(symbol: str, as_of_date: str) -> pd.DataFrame: - """OHLCV on or before as_of_date, date-sorted. Raises if nothing usable. + """OHLCV on or before as_of_date, date-sorted. Raises NoMarketDataError if nothing usable. ``load_ohlcv`` already normalizes the Date column and filters out look-ahead rows, but we re-apply the cutoff defensively — this is a @@ -36,14 +38,14 @@ def _verified_rows(symbol: str, as_of_date: str) -> pd.DataFrame: # gap-filled cell would put the previous session's number under this date. data = load_ohlcv(symbol, as_of_date, fill_gaps=False) if data is None or data.empty: - raise ValueError(f"No OHLCV data available for {symbol}.") + raise NoMarketDataError(symbol, normalize_symbol(symbol), "no price rows") df = data.copy() df["Date"] = pd.to_datetime(df["Date"], errors="coerce") df = df.dropna(subset=["Date"]) df = df[df["Date"] <= pd.to_datetime(as_of_date)].sort_values("Date") if df.empty: - raise ValueError(f"No OHLCV rows on or before {as_of_date} for {symbol}.") + raise NoMarketDataError(symbol, normalize_symbol(symbol), f"no price rows on or before {as_of_date}") return df From b89909724a0cc4f8a4e55921ed65df5014c77163 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:01:14 +0000 Subject: [PATCH 32/63] fix(agents): ask again for an instrument identity whose lookup failed - only an answered lookup is cached for the process, so one failed request no longer leaves every later run for that ticker without an identity --- tests/test_graph_end_to_end.py | 2 +- tests/test_instrument_identity.py | 12 +++++++++++- tests/test_symbol_normalization_paths.py | 2 +- tradingagents/agents/context.py | 12 ++++++++---- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 7259108a8..b8092327a 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -115,7 +115,7 @@ def offline(monkeypatch, tmp_path): monkeypatch.setattr(sentiment_analyst, "fetch_stocktwits_messages", lambda *a, **k: "no posts") monkeypatch.setattr(sentiment_analyst, "fetch_reddit_posts", lambda *a, **k: "no posts") monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: type("T", (), {"info": {"longName": "NVIDIA"}})()) - context.resolve_instrument_identity.cache_clear() + context._identity.cache_clear() return called diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py index 11c79e67c..c4216f110 100644 --- a/tests/test_instrument_identity.py +++ b/tests/test_instrument_identity.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest from tradingagents.agents.context import ( + _identity, build_instrument_context, get_instrument_context_from_state, resolve_instrument_identity, @@ -16,7 +17,7 @@ from tradingagents.agents.context import ( @pytest.mark.unit class ResolveInstrumentIdentityTests(unittest.TestCase): def setUp(self): - resolve_instrument_identity.cache_clear() + _identity.cache_clear() def test_resolves_company_metadata_from_yfinance(self): with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: @@ -54,6 +55,15 @@ class ResolveInstrumentIdentityTests(unittest.TestCase): ): self.assertEqual(resolve_instrument_identity("TOTDY"), {}) + def test_a_failed_lookup_is_asked_again(self): + # A long process (a backtest) must not run every later cell without an + # identity because one request failed. + with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: + type(mock.return_value).info = property(lambda self: (_ for _ in ()).throw(TimeoutError())) + self.assertEqual(resolve_instrument_identity("TOTDY"), {}) + type(mock.return_value).info = {"longName": "TOTO LTD."} + self.assertEqual(resolve_instrument_identity("TOTDY")["company_name"], "TOTO LTD.") + def test_result_is_cached(self): with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: mock.return_value.info = {"longName": "TOTO LTD."} diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index f3ed1b21d..eb8a4dc35 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -25,7 +25,7 @@ def test_identity_lookup_normalizes_symbol(monkeypatch): return {"longName": "Gold Futures", "quoteType": "FUTURE"} monkeypatch.setattr(yahoo_market.yf, "Ticker", FakeTicker) - au.resolve_instrument_identity.cache_clear() + au._identity.cache_clear() identity = au.resolve_instrument_identity("XAUUSD") diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index e2a754369..fd6a77169 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -52,7 +52,6 @@ def _clean_identity_value(value: Any) -> str | None: return cleaned -@functools.lru_cache(maxsize=256) def resolve_instrument_identity(ticker: str) -> dict: """Resolve deterministic identity metadata (company name, sector, …) for a ticker. @@ -64,18 +63,23 @@ def resolve_instrument_identity(ticker: str) -> dict: Best-effort by design: if yfinance is unavailable, rate-limited, or doesn't recognise the ticker, we return ``{}`` and the caller falls back to - ticker-only context rather than failing before analysis starts. Cached so - the lookup happens at most once per ticker per process. + ticker-only context rather than failing before analysis starts. An answer + is cached for the process; a failed lookup is asked again next time. Identity resolves for the same instrument the price path fetches (``XAUUSD`` -> ``GC=F``, #983). """ try: - info = get_company_profile(ticker) + return _identity(ticker) except Exception as exc: # noqa: BLE001 — fail open, never block the run logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc) return {} + +@functools.lru_cache(maxsize=256) +def _identity(ticker: str) -> dict: + """The vendor's identity fields for ``ticker``; raises if the lookup fails.""" + info = get_company_profile(ticker) identity: dict[str, str] = {} company_name = _clean_identity_value(info.get("longName")) or _clean_identity_value( info.get("shortName") From 42e2d569e0e7aedb776eb0a6988c2703ba8c58f1 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:03:05 +0000 Subject: [PATCH 33/63] fix(memory): let one writer at a time change the memory log - storing a decision and settling outcomes hold a lock across the read, change and write, so runs sharing a log no longer drop each other's entries - files.locked takes the lock on a .lock file beside the log, across threads and processes --- tests/test_cache_writes.py | 44 ++++++++ tests/test_memory_log.py | 31 ++++++ tradingagents/dataflows/files.py | 52 ++++++++- tradingagents/memory/log.py | 184 ++++++++++++++++--------------- 4 files changed, 218 insertions(+), 93 deletions(-) diff --git a/tests/test_cache_writes.py b/tests/test_cache_writes.py index 195809038..a9bf7d94e 100644 --- a/tests/test_cache_writes.py +++ b/tests/test_cache_writes.py @@ -7,6 +7,7 @@ from pathlib import Path import pandas as pd import pytest +from tradingagents.dataflows import files from tradingagents.dataflows.vendors import sec_edgar from tradingagents.dataflows.vendors.yahoo import ohlcv @@ -82,3 +83,46 @@ def test_a_file_held_open_elsewhere_keeps_its_old_content(monkeypatch, tmp_path) assert target.read_text() == "old" assert list(tmp_path.iterdir()) == [target] + + +def _fake_msvcrt(results): + """msvcrt whose locking() raises the next error in ``results``, or succeeds on None.""" + import types + + calls = [] + + def locking(fd, mode, nbytes): + calls.append(mode) + if mode == 2 and results: # LK_LOCK; unlocking always succeeds + error = results.pop(0) + if error is not None: + raise error + + return types.SimpleNamespace(LK_LOCK=2, LK_UNLCK=0, locking=locking), calls + + +@pytest.mark.unit +def test_on_windows_the_lock_is_waited_for_while_another_writer_holds_it(tmp_path, monkeypatch): + import errno + import sys + + fake, calls = _fake_msvcrt([OSError(errno.EDEADLOCK, "held"), None]) + monkeypatch.setitem(sys.modules, "msvcrt", fake) + + with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle): + pass + + assert calls == [2, 2, 0] + + +@pytest.mark.unit +def test_on_windows_a_lock_that_cannot_be_taken_is_reported_not_retried_forever(tmp_path, monkeypatch): + import errno + import sys + + fake, _ = _fake_msvcrt([OSError(errno.EINVAL, "bad handle")] * 3) + monkeypatch.setitem(sys.modules, "msvcrt", fake) + + with pytest.raises(OSError, match="bad handle"), open(tmp_path / "log.md.lock", "a+b") as handle, \ + files._hold_windows(handle): + pass diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index bcd955eb7..73876d299 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -1029,3 +1029,34 @@ def test_a_longer_window_asks_for_enough_price_history(monkeypatch): raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY") assert days == 21 and resolved is not None, (raw, alpha, days, resolved) + + +@pytest.mark.unit +def test_concurrent_writers_keep_every_decision_and_outcome(tmp_path): + """Graphs sharing one log record and settle at the same time; none may lose another's write.""" + import threading + + path = tmp_path / "memory.md" + tickers = [f"T{i}" for i in range(8)] + dates = [f"2026-01-{day:02d}" for day in range(1, 13)] + errors = [] + + def work(ticker): + log = TradingMemoryLog({"memory_log_path": str(path)}) + try: + for date in dates: + log.store_decision(ticker, date, "**Rating**: Buy") + log.update_with_outcome(ticker, date, 0.01, 0.005, 5, "held up") + except Exception as exc: # noqa: BLE001 — collected for the assertion + errors.append(exc) + + threads = [threading.Thread(target=work, args=(t,)) for t in tickers] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries() + assert errors == [] + assert len(entries) == len(tickers) * len(dates) + assert all(not e["pending"] for e in entries) diff --git a/tradingagents/dataflows/files.py b/tradingagents/dataflows/files.py index 98e32afbd..0c7408919 100644 --- a/tradingagents/dataflows/files.py +++ b/tradingagents/dataflows/files.py @@ -1,9 +1,11 @@ -"""Cache files written whole, safe under concurrent writers.""" +"""Files written whole, and held by one writer at a time, under concurrent writers.""" +import errno import logging import os import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from pathlib import Path logger = logging.getLogger(__name__) @@ -29,3 +31,49 @@ def replace_file(path, write: Callable[[str], None]) -> None: except BaseException: temp.unlink(missing_ok=True) raise + + +@contextmanager +def locked(path) -> Iterator[None]: + """Hold ``path`` for one writer at a time, across threads and processes. + + For a file read, changed and written back: without the lock, two writers + each read the same text and the second write drops the first's change. The + lock is taken on a ``.lock`` file beside ``path``. + """ + with open(f"{path}.lock", "a+b") as handle: + hold = _hold_windows if os.name == "nt" else _hold_posix + with hold(handle): + yield + + +@contextmanager +def _hold_posix(handle) -> Iterator[None]: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _hold_windows(handle) -> Iterator[None]: + import msvcrt + + handle.seek(0) + while True: + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + break + except OSError as exc: + # LK_LOCK gives up after about ten seconds of another writer's hold; + # any other failure is not a wait. + if exc.errno != errno.EDEADLOCK: + raise + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) diff --git a/tradingagents/memory/log.py b/tradingagents/memory/log.py index e06a1afa7..dc1622ccf 100644 --- a/tradingagents/memory/log.py +++ b/tradingagents/memory/log.py @@ -4,6 +4,7 @@ import re from pathlib import Path from tradingagents.agents.rating import parse_rating +from tradingagents.dataflows.files import locked class TradingMemoryLog: @@ -41,20 +42,21 @@ class TradingMemoryLog: """ if not self._log_path: return - # Idempotency guard: fast raw-text scan instead of full parse. Any entry - # for this ticker and date blocks another, pending or settled: a re-run - # after the outcome landed would otherwise count the same decision twice - # in past context and in every aggregate over the log. - if self._log_path.exists(): - raw = self._log_path.read_text(encoding="utf-8") - for line in raw.splitlines(): - if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): - return - rating = rating or parse_rating(final_trade_decision) - tag = f"[{trade_date} | {ticker} | {rating} | pending]" - entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" - with open(self._log_path, "a", encoding="utf-8") as f: - f.write(entry) + with locked(self._log_path): + # Idempotency guard: fast raw-text scan instead of full parse. Any entry + # for this ticker and date blocks another, pending or settled: a re-run + # after the outcome landed would otherwise count the same decision twice + # in past context and in every aggregate over the log. + if self._log_path.exists(): + raw = self._log_path.read_text(encoding="utf-8") + for line in raw.splitlines(): + if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): + return + rating = rating or parse_rating(final_trade_decision) + tag = f"[{trade_date} | {ticker} | {rating} | pending]" + entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" + with open(self._log_path, "a", encoding="utf-8") as f: + f.write(entry) # --- Read --- @@ -135,51 +137,51 @@ class TradingMemoryLog: """ if not self._log_path or not self._log_path.exists(): return + with locked(self._log_path): + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) - text = self._log_path.read_text(encoding="utf-8") - blocks = text.split(self._SEPARATOR) + pending_prefix = f"[{trade_date} | {ticker} |" + raw_pct = f"{raw_return:+.1%}" + alpha_pct = f"{alpha_return:+.1%}" - pending_prefix = f"[{trade_date} | {ticker} |" - raw_pct = f"{raw_return:+.1%}" - alpha_pct = f"{alpha_return:+.1%}" + updated = False + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue - updated = False - new_blocks = [] - for block in blocks: - stripped = block.strip() - if not stripped: - new_blocks.append(block) - continue + lines = stripped.splitlines() + tag_line = lines[0].strip() - lines = stripped.splitlines() - tag_line = lines[0].strip() + if ( + not updated + and tag_line.startswith(pending_prefix) + and tag_line.endswith("| pending]") + ): + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}" + ) + updated = True + else: + new_blocks.append(block) - if ( - not updated - and tag_line.startswith(pending_prefix) - and tag_line.endswith("| pending]") - ): - fields = [f.strip() for f in tag_line[1:-1].split("|")] - rating = fields[2] - new_tag = self._resolved_tag( - trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date - ) - rest = "\n".join(lines[1:]) - new_blocks.append( - f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}" - ) - updated = True - else: - new_blocks.append(block) + if not updated: + return - if not updated: - return - - new_blocks = self._apply_rotation(new_blocks) - new_text = self._SEPARATOR.join(new_blocks) - tmp_path = self._log_path.with_suffix(".tmp") - tmp_path.write_text(new_text, encoding="utf-8") - tmp_path.replace(self._log_path) + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) def batch_update_with_outcomes(self, updates: list[dict]) -> None: """Apply multiple outcome updates in a single read + atomic write. @@ -189,50 +191,50 @@ class TradingMemoryLog: """ if not self._log_path or not self._log_path.exists() or not updates: return + with locked(self._log_path): + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) - text = self._log_path.read_text(encoding="utf-8") - blocks = text.split(self._SEPARATOR) + update_map = {(u["trade_date"], u["ticker"]): u for u in updates} - update_map = {(u["trade_date"], u["ticker"]): u for u in updates} + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue - new_blocks = [] - for block in blocks: - stripped = block.strip() - if not stripped: - new_blocks.append(block) - continue + lines = stripped.splitlines() + tag_line = lines[0].strip() - lines = stripped.splitlines() - tag_line = lines[0].strip() + matched = False + for (trade_date, ticker), upd in list(update_map.items()): + pending_prefix = f"[{trade_date} | {ticker} |" + if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"): + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + raw_pct = f"{upd['raw_return']:+.1%}" + alpha_pct = f"{upd['alpha_return']:+.1%}" + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, + upd["holding_days"], upd.get("resolution_date"), + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}" + ) + del update_map[(trade_date, ticker)] + matched = True + break - matched = False - for (trade_date, ticker), upd in list(update_map.items()): - pending_prefix = f"[{trade_date} | {ticker} |" - if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"): - fields = [f.strip() for f in tag_line[1:-1].split("|")] - rating = fields[2] - raw_pct = f"{upd['raw_return']:+.1%}" - alpha_pct = f"{upd['alpha_return']:+.1%}" - new_tag = self._resolved_tag( - trade_date, ticker, rating, raw_pct, alpha_pct, - upd["holding_days"], upd.get("resolution_date"), - ) - rest = "\n".join(lines[1:]) - new_blocks.append( - f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}" - ) - del update_map[(trade_date, ticker)] - matched = True - break + if not matched: + new_blocks.append(block) - if not matched: - new_blocks.append(block) - - new_blocks = self._apply_rotation(new_blocks) - new_text = self._SEPARATOR.join(new_blocks) - tmp_path = self._log_path.with_suffix(".tmp") - tmp_path.write_text(new_text, encoding="utf-8") - tmp_path.replace(self._log_path) + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) # --- Helpers --- From 13cf1e81388061c0c12a8ab151e161e353e685ca Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:05:11 +0000 Subject: [PATCH 34/63] fix(memory): score a decision against its benchmark over the same dates - the stock's own sessions set the holding window; the benchmark is valued at its last close by entry and by exit, so a coin held over a weekend is compared with SPY over the same span - bars meet on their calendar day across time zones, and a missing or non-positive close is dropped rather than scored - an outcome waits until the benchmark has traded through the exit --- tests/test_memory_log.py | 73 ++++++++++++++++++++++++++++++ tradingagents/memory/settlement.py | 44 ++++++++++++++---- 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 73876d299..b3516b3fb 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -1060,3 +1060,76 @@ def test_concurrent_writers_keep_every_decision_and_outcome(tmp_path): assert errors == [] assert len(entries) == len(tickers) * len(dates) assert all(not e["pending"] for e in entries) + + +def _closes(by_symbol): + """A get_closes stand-in serving each symbol's series between the dates asked.""" + def get_closes(symbol, start_date, end_date): + series = by_symbol[symbol] + return series[(series.index >= start_date) & (series.index < end_date)] + return get_closes + + +@pytest.mark.unit +class TestSettlementWindow: + """Stock and benchmark are measured over the same dates, whatever their calendars.""" + + def test_a_seven_day_market_is_scored_against_the_benchmark_over_the_same_dates(self, monkeypatch): + coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D")) + weekdays = pd.bdate_range("2025-12-29", "2026-01-16") + spy = pd.Series([400.0 + 2 * i for i in range(len(weekdays))], index=weekdays) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + raw, alpha, days, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") + + # The coin is held Sat 3 -> Thu 8; SPY over the same span is Fri 2 -> Thu 8. + assert resolved == "2026-01-08" + assert raw == pytest.approx(105.0 / 100.0 - 1) + assert alpha == pytest.approx(raw - (spy["2026-01-08"] / spy["2026-01-02"] - 1)) + + def test_bars_stamped_in_different_time_zones_meet_on_their_dates(self, monkeypatch): + # Yahoo stamps a coin's daily bar at midnight UTC and SPY's at midnight + # New York, five hours later: the same day, not the day after. + coin = pd.Series([100.0 + i for i in range(10)], + index=pd.date_range("2026-01-03", periods=10, freq="D", tz="UTC")) + weekdays = pd.bdate_range("2025-12-29", "2026-01-16", tz="America/New_York") + spy = pd.Series([400.0 + 2 * i for i in range(len(weekdays))], index=weekdays) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + raw, alpha, _, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") + + assert resolved == "2026-01-08" + spy_by_day = spy.tz_localize(None) + assert alpha == pytest.approx(raw - (spy_by_day["2026-01-08"] / spy_by_day["2026-01-02"] - 1)) + + def test_the_outcome_waits_for_the_benchmark_to_trade_through_the_window(self, monkeypatch): + coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D")) + spy = pd.Series([400.0, 401.0, 402.0], index=pd.to_datetime(["2026-01-02", "2026-01-05", "2026-01-06"])) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + assert settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") == (None, None, None, None) + + def test_a_missing_close_is_skipped_not_scored(self, monkeypatch): + idx = pd.bdate_range("2026-01-05", periods=8) + stock = pd.Series([100.0, float("nan"), 101.0, 102.0, 103.0, 104.0, 105.0, 106.0], index=idx) + spy = pd.Series([400.0 + i for i in range(8)], index=idx) + monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy})) + + raw, alpha, _, resolved = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") + + assert raw == pytest.approx(105.0 / 100.0 - 1) + assert resolved == "2026-01-13" + assert alpha == pytest.approx(raw - (spy["2026-01-13"] / spy["2026-01-05"] - 1)) + + +@pytest.mark.unit +def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch): + """A zero close would score as an infinite return and be written to the memory log.""" + idx = pd.bdate_range("2026-01-05", periods=8) + stock = pd.Series([0.0, 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0], index=idx) + spy = pd.Series([400.0 + i for i in range(8)], index=idx) + monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy})) + + raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") + + assert raw == pytest.approx(105.0 / 100.0 - 1) # the window opens on the first real close diff --git a/tradingagents/memory/settlement.py b/tradingagents/memory/settlement.py index 6f8354de8..de14c9b11 100644 --- a/tradingagents/memory/settlement.py +++ b/tradingagents/memory/settlement.py @@ -34,6 +34,20 @@ def resolve_benchmark(ticker: str, config: dict) -> str: return benchmark_map.get("", "SPY") +def _by_day(closes): + """Closes keyed by the calendar day of their bar, missing closes dropped. + + Yahoo stamps a daily bar at midnight in its market's time zone (UTC for a + coin, New York for SPY), so two series meet on their dates, not their + instants. A missing or non-positive close is no price at all, not a bar to + score on. + """ + closes = closes[closes > 0] + if getattr(closes.index, "tz", None) is not None: + closes = closes.tz_localize(None) + return closes + + def fetch_returns( ticker: str, trade_date: str, holding_days: int = 5, benchmark: str = "SPY", @@ -56,21 +70,31 @@ def fetch_returns( end_str = end.strftime("%Y-%m-%d") # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984). - stock = get_closes(ticker, trade_date, end_str) - bench = get_closes(benchmark, trade_date, end_str) + stock = _by_day(get_closes(ticker, trade_date, end_str)) + # From a week earlier, so the benchmark has a close on or before entry. + bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d") + bench = _by_day(get_closes(benchmark, bench_start, end_str)) - # Require the full holding window in both series. A rerun before it - # has traded leaves the entry pending to retry next run, rather than - # settling on a premature partial return (#1169). - if len(stock) <= holding_days or len(bench) <= holding_days: + # Require the full holding window to have traded. A rerun before it has + # leaves the entry pending to retry next run, rather than settling on a + # premature partial return (#1169). + if len(stock) <= holding_days: + return None, None, None, None + entry, exit_ = stock.index[0], stock.index[holding_days] + # The window is the stock's own sessions; the benchmark is valued at its + # last close by each end, so both returns span the same dates even when + # the calendars differ (a coin trades at weekends, an index does not). + # It must have traded through the exit, or its close there may yet move. + if bench.empty or bench.index[0] > entry or bench.index[-1] < exit_: return None, None, None, None raw = float((stock.iloc[holding_days] - stock.iloc[0]) / stock.iloc[0]) - bench_ret = float((bench.iloc[holding_days] - bench.iloc[0]) / bench.iloc[0]) + bench_entry, bench_exit = bench.asof(entry), bench.asof(exit_) + bench_ret = float((bench_exit - bench_entry) / bench_entry) alpha = raw - bench_ret - # The date of the last price bar used is when this outcome became - # known — the point-in-time cutoff for injecting the lesson (#1251). - resolution_date = stock.index[holding_days].strftime("%Y-%m-%d") + # Every close used is known by the exit: the point-in-time cutoff for + # injecting the lesson (#1251). + resolution_date = exit_.strftime("%Y-%m-%d") return raw, alpha, holding_days, resolution_date except Exception as e: logger.warning( From e973dd0e25c355adc81e97695c13859b3b4e5e78 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:05:51 +0000 Subject: [PATCH 35/63] fix(backtest): run and settle a ticker or date given twice once (#1415) - run_backtest drops repeated tickers and dates before building the grid --- tests/test_backtest.py | 8 ++++++++ tradingagents/backtest.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 834d324fd..6911f3574 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -296,3 +296,11 @@ def test_a_resumed_sweep_reports_only_the_cells_it_runs(tmp_path): progress=lambda done, total, ticker, date: seen.append((done, total, date))) assert seen == [(1, 1, "2026-01-12")] + + +@pytest.mark.unit +def test_a_ticker_or_date_given_twice_runs_and_settles_once(tmp_path): + run_backtest(["NVDA", "NVDA"], ["2026-01-05", "2026-01-05"], _config(tmp_path)) + graph = _FakeGraph.instances[-1] + assert graph.calls == [("NVDA", "2026-01-05")] + assert graph.settled == ["NVDA"] diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index bf74528c5..c4a33cb84 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -155,6 +155,8 @@ def run_backtest( result = BacktestResult(run_id=run_id, log_path=Path(run_config["memory_log_path"])) done = {(e["ticker"], e["date"]) for e in graph.memory_log.load_entries()} + # A ticker or date given twice is one cell, run and settled once. + tickers, dates = list(dict.fromkeys(tickers)), list(dict.fromkeys(dates)) cells = [(ticker, date) for ticker in tickers for date in dates] todo = [cell for cell in cells if cell not in done] result.skipped = len(cells) - len(todo) From d201105a4d7e9568a79884dd2ae6c395a4f8cb71 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:07:08 +0000 Subject: [PATCH 36/63] fix(cli): take the backtest's analysts as an analysis does - backtest --analysts accepts sentiment and is checked against --asset-type before the sweep - without --analysts, a crypto sweep runs the analysts crypto allows, not fundamentals --- cli/main.py | 14 ++++++++++---- tests/test_cli_commands.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/cli/main.py b/cli/main.py index 264e363b5..7fbb53b15 100644 --- a/cli/main.py +++ b/cli/main.py @@ -3,6 +3,8 @@ import sys import typer from cli.display import console +from cli.models import AnalystType, AssetType +from cli.prompts import filter_analysts_for_asset_type, parse_analysts from cli.run import run_analysis from tradingagents.backtest import iter_grid, run_backtest, summarize from tradingagents.default_config import DEFAULT_CONFIG @@ -101,7 +103,7 @@ def backtest( end: str = typer.Option(..., "--end", help="Last analysis date, YYYY-MM-DD"), every: int = typer.Option(7, "--every", help="Days between analysis dates"), analysts: str = typer.Option( - None, "--analysts", help="Comma-separated analysts to run; omit for all four" + None, "--analysts", help="Comma-separated analysts to run: market, sentiment, news, fundamentals; omit for all the asset type allows" ), asset_type: str = typer.Option("stock", "--asset-type", help="stock or crypto"), portfolio: str = typer.Option( @@ -116,6 +118,11 @@ def backtest( try: dates = iter_grid(start, end, every) book = load_portfolio(portfolio) if portfolio else None + kind = AssetType(asset_type.strip().lower()) + # The analysts are named and checked as for an analysis; without a + # choice, every analyst the asset type allows runs. + chosen = (parse_analysts(analysts, kind) if analysts + else filter_analysts_for_asset_type(list(AnalystType), kind)) except ValueError as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(code=1) from None @@ -128,9 +135,8 @@ def backtest( def show_progress(done, total, ticker, date): console.print(f"[dim][{done}/{total}] {ticker} {date}[/dim]") - kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id, "progress": show_progress} - if analysts: - kwargs["selected_analysts"] = [a.strip().lower() for a in analysts.split(",") if a.strip()] + kwargs = {"asset_type": kind.value, "portfolio": book, "run_id": run_id, "progress": show_progress, + "selected_analysts": [a.value for a in chosen]} try: result = run_backtest(names, dates, DEFAULT_CONFIG, **kwargs) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 845b817fb..a0bd36a9a 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -151,3 +151,32 @@ def test_backtest_shows_each_cell_and_how_to_continue(runner, monkeypatch, tmp_p assert "[1/2] NVDA 2026-06-01" in result.output and "[2/2] NVDA 2026-06-08" in result.output assert f"--run-id {_Result(tmp_path).run_id}" in result.output + + +@pytest.mark.unit +@pytest.mark.parametrize("args, analysts", [ + pytest.param(["--analysts", "sentiment,market"], ["market", "social"], id="named_as_in_analyze"), + pytest.param(["--asset-type", "crypto"], ["market", "social", "news"], id="crypto_default"), +]) +def test_backtest_takes_its_analysts_as_an_analysis_does(runner, monkeypatch, tmp_path, args, analysts): + swept = [] + monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append(kw) or _Result(tmp_path)) + monkeypatch.setattr(m, "summarize", lambda log: _Summary()) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args]) + + assert result.exit_code == 0, result.output + assert swept[0]["selected_analysts"] == analysts + + +@pytest.mark.unit +@pytest.mark.parametrize("args", [ + pytest.param(["--analysts", "fundamentals", "--asset-type", "crypto"], id="unavailable_for_crypto"), + pytest.param(["--asset-type", "bond"], id="unknown_asset_type"), +]) +def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args): + monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: pytest.fail("the sweep started")) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args]) + + assert result.exit_code == 1 From 0d22cad2af1d8df1de9132e91d0ed8d510c821f3 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:09:42 +0000 Subject: [PATCH 37/63] fix(yahoo): print the fundamentals given in percent with their unit (#1414) - Dividend Yield and Debt to Equity carry a % sign, and Debt to Equity its ratio, beside the margins and returns Yahoo gives as fractions --- tests/test_fundamentals_units.py | 39 +++++++++++++++++++ .../dataflows/vendors/yahoo/fundamentals.py | 9 ++++- 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/test_fundamentals_units.py diff --git a/tests/test_fundamentals_units.py b/tests/test_fundamentals_units.py new file mode 100644 index 000000000..f960ad6c6 --- /dev/null +++ b/tests/test_fundamentals_units.py @@ -0,0 +1,39 @@ +"""Yahoo's percent-valued fields say they are percentages (#1414). + +``Ticker.info`` gives ``dividendYield`` and ``debtToEquity`` in percent (0.41 is +0.41%; 78.4 is 78.4%, a ratio of 0.78) but margins and returns as fractions +(0.27 is 27%). Printed side by side without a unit, one scale reads as the other. +""" + +from unittest import mock + +import pytest + +from tradingagents.dataflows import date_window +from tradingagents.dataflows.vendors.yahoo import fundamentals + +TODAY = "2026-09-26" + + +def _fundamentals(info): + with mock.patch.object(date_window, "get_current_date", return_value=TODAY), \ + mock.patch.object(fundamentals, "yf_retry", lambda fn: info): + return fundamentals.get_fundamentals("AAPL", TODAY) + + +@pytest.mark.unit +def test_percent_fields_carry_their_unit_and_fractions_are_unchanged(): + out = _fundamentals({"longName": "Apple Inc.", "dividendYield": 0.32, "debtToEquity": 78.445, + "profitMargins": 0.27619, "returnOnEquity": 1.4875101}) + + assert "Dividend Yield: 0.32%" in out + assert "Debt to Equity: 78.445% (0.78x)" in out + assert "Profit Margin: 0.27619" in out + assert "Return on Equity: 1.4875101" in out + + +@pytest.mark.unit +def test_an_absent_percent_field_is_left_out(): + out = _fundamentals({"longName": "JPMorgan Chase & Co.", "profitMargins": 0.35}) + + assert "Dividend Yield" not in out and "Debt to Equity" not in out diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 98e3dacc8..15a6b7b97 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -36,6 +36,10 @@ def get_fundamentals( if not info: raise_for_empty(ticker, canonical, "fundamentals") + # Yahoo gives these two in percent (dividendYield 0.41 is 0.41%; debtToEquity + # 78.4 is 78.4%, a ratio of 0.78) but the margins and returns as fractions, + # so each carries its unit. + dividend_yield, debt_to_equity = info.get("dividendYield"), info.get("debtToEquity") fields = [ ("Name", info.get("longName")), ("Sector", info.get("sector")), @@ -47,7 +51,7 @@ def get_fundamentals( ("Price to Book", info.get("priceToBook")), ("EPS (TTM)", info.get("trailingEps")), ("Forward EPS", info.get("forwardEps")), - ("Dividend Yield", info.get("dividendYield")), + ("Dividend Yield", None if dividend_yield is None else f"{dividend_yield}%"), ("Beta", info.get("beta")), ("52 Week High", info.get("fiftyTwoWeekHigh")), ("52 Week Low", info.get("fiftyTwoWeekLow")), @@ -61,7 +65,8 @@ def get_fundamentals( ("Operating Margin", info.get("operatingMargins")), ("Return on Equity", info.get("returnOnEquity")), ("Return on Assets", info.get("returnOnAssets")), - ("Debt to Equity", info.get("debtToEquity")), + ("Debt to Equity", None if debt_to_equity is None + else f"{debt_to_equity}% ({debt_to_equity / 100:.2f}x)"), ("Current Ratio", info.get("currentRatio")), ("Book Value", info.get("bookValue")), ("Free Cash Flow", info.get("freeCashflow")), From ca2ebbbdcb204a8cc27699ebd0a7744964f76b64 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:12:13 +0000 Subject: [PATCH 38/63] fix(graph): ask an analyst for its report once its tool rounds are spent (#1420) - after max_tool_rounds rounds of tool calls (default 20, TRADINGAGENTS_MAX_TOOL_ROUNDS) the analyst takes one last turn with no tools, its tool calls and results given as text, and that turn ends its graph - the three tool-using analysts take their turns through analysts.turn.take_turn - a limit the recursion limit cannot hold is refused when the graph is built --- .env.example | 1 + tests/test_graph_end_to_end.py | 57 ++++++++++++++++++- .../agents/analysts/fundamentals_analyst.py | 10 +--- .../agents/analysts/market_analyst.py | 10 +--- tradingagents/agents/analysts/news_analyst.py | 9 +-- tradingagents/agents/analysts/turn.py | 42 ++++++++++++++ tradingagents/default_config.py | 3 + tradingagents/graph/setup.py | 46 ++++++++++++--- tradingagents/graph/trading_graph.py | 11 +++- 9 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 tradingagents/agents/analysts/turn.py diff --git a/.env.example b/.env.example index 0647b5115..cea96cb0f 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,7 @@ NVIDIA_API_KEY= #TRADINGAGENTS_OUTPUT_LANGUAGE=English #TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 #TRADINGAGENTS_MAX_RISK_ROUNDS=1 +#TRADINGAGENTS_MAX_TOOL_ROUNDS=20 #TRADINGAGENTS_CHECKPOINT_ENABLED=false # Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume). diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index b8092327a..3c6ef016f 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -12,7 +12,7 @@ import copy import pandas as pd import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.runnables import RunnableLambda from pydantic import Field @@ -232,3 +232,58 @@ def test_a_streamed_run_reads_its_own_graph_config(tmp_path, monkeypatch, offlin assert seen and set(seen) == {"French"} assert set(between) == {"German"} + + +class LoopingModel(ScriptedModel): + """Calls a tool on every turn it is offered one (#1420).""" + + tool_turns: list = Field(default_factory=list) # shared across bound copies + last_turns: list = Field(default_factory=list) # histories of the turns offered no tools + + def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: + if not self.tools: + self.last_turns.append(messages) + return super()._generate(messages, stop, run_manager, **kwargs) + self.tool_turns.append(1) + tool = self.tools[0] + call = {"name": tool.name, "id": f"call_{len(self.tool_turns)}", + "args": {k: v for k, v in ARGS.items() + if k in tool.tool_call_schema.model_json_schema()["properties"]}} + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="", tool_calls=[call]))]) + + +@pytest.mark.unit +def test_an_analyst_that_keeps_calling_tools_writes_its_report_at_the_limit(tmp_path, monkeypatch, offline): + """A model that does not stop calling tools must not end the run (#1420).""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=3) + + final_state, rating = graph.propagate("NVDA", TRADE_DATE) + + assert len(model.tool_turns) == 3 * 3 # three tool-using analysts, three rounds each + for key in ("market_report", "news_report", "fundamentals_report"): + assert final_state[key] == TEXT + assert rating == "Overweight" + + +@pytest.mark.unit +def test_the_last_turn_is_offered_no_tools_and_reads_its_tool_results_as_text(tmp_path, monkeypatch, offline): + """Offered tools, a model may call one whatever it is told; a history holding + tool calls may be refused by a provider when no tools are bound.""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=2) + + graph.propagate("NVDA", TRADE_DATE) + + wrap_ups = [h for h in model.last_turns if isinstance(h[-1], HumanMessage) and "tool round" in h[-1].content] + assert len(wrap_ups) == 3 + for history in wrap_ups: + assert not any(isinstance(m, ToolMessage) or getattr(m, "tool_calls", None) for m in history) + assert any("returned]" in str(m.content) for m in history) + assert "tool rounds are spent" in history[0].content # the system prompt lists no tools + + +@pytest.mark.unit +def test_a_tool_limit_the_recursion_limit_cannot_hold_is_refused(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="max_tool_rounds"): + _graph(tmp_path, monkeypatch, ScriptedModel(), max_tool_rounds=60, max_recur_limit=100) diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index f368a25de..961d58203 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_balance_sheet, @@ -53,14 +54,7 @@ def create_fundamentals_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index b8abf576d..3a95df7e8 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import get_indicators, get_stock_data, get_verified_market_snapshot @@ -73,14 +74,7 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index e0307c8e1..df80d114e 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_global_news, @@ -52,13 +53,7 @@ def create_news_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/turn.py b/tradingagents/agents/analysts/turn.py new file mode 100644 index 000000000..3b568b22d --- /dev/null +++ b/tradingagents/agents/analysts/turn.py @@ -0,0 +1,42 @@ +"""One turn of a tool-using analyst, and its last turn once its tool rounds are spent.""" + +import json + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +WRAP_UP = ( + "You have used every tool round this report allows. Write your final report " + "now from the tool results above, and say which data you could not retrieve." +) + + +def take_turn(prompt, llm, tools, messages): + """The analyst's reply and its report, which stays empty while it calls tools. + + Told to wrap up, it answers without tools: a model offered tools may call one + whatever it is told. Its earlier calls and their results reach it as text, + since a provider may refuse a history holding tool calls when none are bound. + """ + if messages and isinstance(messages[-1], HumanMessage) and messages[-1].content == WRAP_UP: + # The prompt lists the tools it offers; a model told of tools it cannot + # call may still try, and some providers then answer with nothing. + prompt = prompt.partial(tool_names="none; your tool rounds are spent") + result = (prompt | llm).invoke(_as_text(messages)) + return result, result.content + result = (prompt | llm.bind_tools(tools)).invoke(messages) + return result, "" if result.tool_calls else result.content + + +def _as_text(messages): + """The history with its tool calls and tool results written out as plain messages.""" + written = [] + for message in messages: + if isinstance(message, AIMessage) and message.tool_calls: + said = message.content if isinstance(message.content, str) else "" + calls = "; ".join(f"{c['name']}({json.dumps(c['args'], default=str)})" for c in message.tool_calls) + written.append(AIMessage(content=f"{said}\n[Called {calls}]".strip())) + elif isinstance(message, ToolMessage): + written.append(HumanMessage(content=f"[{message.name or 'Tool'} returned]\n{message.content}")) + else: + written.append(message) + return written diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 8c59c76e3..d33a206f4 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -15,6 +15,7 @@ _ENV_OVERRIDES = { "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", "TRADINGAGENTS_MAX_RISK_ROUNDS": "max_risk_discuss_rounds", + "TRADINGAGENTS_MAX_TOOL_ROUNDS": "max_tool_rounds", "TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled", "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", "TRADINGAGENTS_TEMPERATURE": "temperature", @@ -115,6 +116,8 @@ DEFAULT_CONFIG = _apply_env_overrides({ "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "max_recur_limit": 100, + # Rounds of tool calls an analyst may make before it is asked for its report. + "max_tool_rounds": 20, # News / data fetching parameters # Increase for longer lookback strategies or to broaden macro coverage; # decrease to reduce token usage in agent prompts. diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index d17215d96..46027e346 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -1,5 +1,8 @@ +import logging +from collections import Counter from typing import Any, TypedDict +from langchain_core.messages import HumanMessage from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode @@ -17,11 +20,14 @@ from tradingagents.agents import ( create_sentiment_analyst, create_trader, ) +from tradingagents.agents.analysts.turn import WRAP_UP from tradingagents.agents.state import AgentState from .analyst_execution import build_analyst_execution_plan from .conditional_logic import ConditionalLogic +logger = logging.getLogger(__name__) + # Every target a shared conditional router can return. Each edge driven by the # router maps all of them, so a fall-through return (e.g. under prompt/i18n/ # refactor drift in the speaker labels) can never hit a missing path_map entry @@ -44,22 +50,43 @@ def _tools_or_done(state) -> str: return "tools" if state["messages"][-1].tool_calls else END -def _analyst_graph(spec, agent): +def _analyst_graph(spec, agent, max_tool_rounds: int): """One analyst as a graph of its own: the model and its tools, on a private message history. It returns only its report, so analysts running side by side never write the - same key, and its tool calls never reach the other analysts' messages. + same key, and its tool calls never reach the other analysts' messages. After + ``max_tool_rounds`` rounds of tool calls it is told to write its report, and + that turn ends it whatever it answers, so a model that keeps calling tools + cannot run the graph into its recursion limit (#1420). """ output = TypedDict(f"{spec.key.capitalize()}Report", {spec.report_key: str}) graph = StateGraph(AgentState, output_schema=output) graph.add_node("agent", agent) graph.add_edge(START, "agent") - if spec.tools: - graph.add_node("tools", ToolNode(list(spec.tools))) - graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) - graph.add_edge("tools", "agent") - else: + if not spec.tools: graph.add_edge("agent", END) + return graph.compile() + + def calls(messages): + return [call["name"] for m in messages for call in (getattr(m, "tool_calls", None) or [])] + + def rounds(messages) -> int: + return sum(1 for m in messages if getattr(m, "tool_calls", None)) + + def more_or_wrap_up(state) -> str: + return "wrap_up" if rounds(state["messages"]) >= max_tool_rounds else "agent" + + def wrap_up(state): + repeated = ", ".join(f"{name} x{n}" for name, n in Counter(calls(state["messages"])).most_common()) + logger.warning("%s used its %d tool rounds (%s); asking for its report", + spec.agent_node, max_tool_rounds, repeated) + return agent({**state, "messages": [*state["messages"], HumanMessage(WRAP_UP)]}) + + graph.add_node("tools", ToolNode(list(spec.tools))) + graph.add_node("wrap_up", wrap_up) + graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) + graph.add_conditional_edges("tools", more_or_wrap_up, ["agent", "wrap_up"]) + graph.add_edge("wrap_up", END) return graph.compile() @@ -71,11 +98,13 @@ class GraphSetup: quick_thinking_llm: Any, deep_thinking_llm: Any, conditional_logic: ConditionalLogic, + max_tool_rounds: int, ): """Initialize with required components.""" self.quick_thinking_llm = quick_thinking_llm self.deep_thinking_llm = deep_thinking_llm self.conditional_logic = conditional_logic + self.max_tool_rounds = max_tool_rounds def setup_graph( self, selected_analysts=("market", "social", "news", "fundamentals") @@ -111,7 +140,8 @@ class GraphSetup: workflow = StateGraph(AgentState) for spec in plan.specs: - workflow.add_node(spec.agent_node, _analyst_graph(spec, analyst_factories[spec.key]())) + workflow.add_node(spec.agent_node, + _analyst_graph(spec, analyst_factories[spec.key](), self.max_tool_rounds)) workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bear Researcher", bear_researcher_node) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index c2872b5ff..b59e8a1be 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -94,14 +94,23 @@ class TradingAgentsGraph: max_debate_rounds=self.config["max_debate_rounds"], max_risk_discuss_rounds=self.config["max_risk_discuss_rounds"], ) + # An analyst takes two graph steps per tool round, plus its first turn + # and its wrap-up; a limit past the recursion limit would end the run + # there instead. + max_tool_rounds, max_recur_limit = self.config["max_tool_rounds"], self.config["max_recur_limit"] + if 2 * max_tool_rounds + 2 >= max_recur_limit: + raise ValueError( + f"max_tool_rounds={max_tool_rounds} needs max_recur_limit above {2 * max_tool_rounds + 2}" + ) self.graph_setup = GraphSetup( self.quick_thinking_llm, self.deep_thinking_llm, self.conditional_logic, + max_tool_rounds, ) self.propagator = Propagator( - max_recur_limit=self.config.get("max_recur_limit", 100), + max_recur_limit=max_recur_limit, ) self.reflector = Reflector(self.quick_thinking_llm) From 540ea8ca7fac23a235d472a6e6019008472507c9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:15:33 +0000 Subject: [PATCH 39/63] fix(agents): keep the labelled rating lines in English in every output language (#1435) - the language instruction exempts the lines the program reads, the "**Rating**:" line and the transaction proposal, so a decision written in another language keeps a rating that can be read --- tests/test_i18n_coverage.py | 9 +++++++++ tradingagents/agents/context.py | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py index e101dd1df..4a989302c 100644 --- a/tests/test_i18n_coverage.py +++ b/tests/test_i18n_coverage.py @@ -46,6 +46,15 @@ class TestLanguageInstruction: assert "中文" in out assert "entire response" in out + def test_the_labelled_lines_stay_as_the_format_gives_them(self): + # The rating is read from its "**Rating**:" line; a translated label + # leaves the reader a translated negation ("不建议卖出 (Sell)") to find + # instead, and a Buy reads as Sell (#1435). + from tradingagents.dataflows.config import set_config + set_config({"output_language": "中文"}) + out = get_language_instruction() + assert "**Rating**:" in out and "FINAL TRANSACTION PROPOSAL" in out + @pytest.mark.unit @pytest.mark.parametrize("rel", REPORT_AGENTS) diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index fd6a77169..2ff9122a5 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -25,7 +25,14 @@ def get_language_instruction() -> str: lang = get_config().get("output_language", "English") if lang.strip().lower() == "english": return "" - return f" Write your entire response in {lang}." + # The labelled lines are read by the program, so they keep their English + # label and value: a translated rating line leaves the reader prose to + # search, where a negated rating ("not a Sell") reads as the call (#1435). + return ( + f" Write your entire response in {lang}, except the labelled lines the format" + f" asks for (the \"**Rating**:\" line, \"FINAL TRANSACTION PROPOSAL:\"):" + f" keep their label and value in English, exactly as specified." + ) def opponent_argument_or_opening(text: str, opponent: str) -> str: From b99b843415ca606d270d3066ecbc8d11677fcffb Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:28:02 +0000 Subject: [PATCH 40/63] fix(agents): give a past run the company's current name only, as an identifier - the vendor profile describes the company today, so a run dated earlier is given its name marked as the current one and no sector, industry or exchange --- tests/test_undated_tools_as_of.py | 17 +++++++++-------- tradingagents/agents/context.py | 28 +++++++++++++++------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index 6af796ae6..3252f928a 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -93,19 +93,20 @@ def test_trade_date_is_injected_not_model_visible(tool): # --- the instrument's identity ------------------------------------------------- @pytest.mark.unit -def test_a_historical_run_is_told_the_identity_is_current(monkeypatch): - """The company name, sector and industry come from today's vendor profile. - They are usually right for a past date, but a company that renamed or was - reclassified since would read wrong, and every agent is told to anchor to - this identity, so the run has to know which date it describes.""" +def test_a_historical_run_gets_the_current_name_only_as_an_identifier(monkeypatch): + """The profile is today's. The name still tells the company apart from others + (#814), so a past run keeps it, marked as today's; a sector, industry or + exchange that may not have held on the run's date is not given.""" from tradingagents.agents.context import build_instrument_context identity = {"company_name": "Example Corp", "sector": "Technology", "industry": "Software", "exchange": "NMS"} historical = build_instrument_context("EXMP", "stock", identity, trade_date="2024-03-14") - assert "Example Corp" in historical - assert "2024-03-14" in historical and "today" in historical.lower() + assert "Example Corp" in historical and "current name" in historical + assert "2024-03-14" in historical + for later in ("Technology", "Software", "NMS"): + assert later not in historical @pytest.mark.unit @@ -116,7 +117,7 @@ def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch): today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"}, trade_date=get_current_date()) assert "Example Corp" in today - assert "resolved today" not in today.lower() + assert "current name" not in today @pytest.mark.unit diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index 2ff9122a5..87ec7a433 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -119,9 +119,10 @@ def build_instrument_context( than pattern-matching the price chart to a wrong one (#814). That profile carries no historical vintage: it describes the company today. - For a run dated earlier, the context says so, since a company that has since - renamed or been reclassified would otherwise anchor the whole graph to an - identity it did not have on the analysis date. + A run dated earlier gets the current name alone, as a way to tell the + company apart from others rather than as what it was called then; a sector, + industry or exchange it holds today is not given, since it may not have held + on the analysis date. """ is_crypto = asset_type == "crypto" instrument_label = "asset" if is_crypto else "instrument" @@ -131,11 +132,19 @@ def build_instrument_context( "preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)." ) + identity = identity or {} + name = identity.get("company_name") or identity.get("name") + label = "Name" if is_crypto else "Company" details = [] - if identity: - name = identity.get("company_name") or identity.get("name") + if trade_date and str(trade_date) < get_current_date(): if name: - details.append(f"{'Name' if is_crypto else 'Company'}: {name}") + details.append( + f"{label}: {name} (its current name, given only to identify it; " + f"on {trade_date} it may have been named differently)" + ) + else: + if name: + details.append(f"{label}: {name}") sector, industry = identity.get("sector"), identity.get("industry") if sector and industry: details.append(f"Business classification: {sector} / {industry}") @@ -152,13 +161,6 @@ def build_instrument_context( "Do not substitute a different company or ticker unless a tool " "result explicitly disproves this resolved identity." ) - today = get_current_date() - if trade_date and str(trade_date) < today: - context += ( - f" This identity is how the vendor describes the instrument today, " - f"not necessarily on {trade_date}: a name or classification changed " - f"since then would read as the current one." - ) if is_crypto: context += ( From 9a568d19b1965ef897a0c3b1667c9babba5288c9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:29:07 +0000 Subject: [PATCH 41/63] fix(graph): start a checkpointed run afresh when the settings that shape it change - the checkpoint signature folds in the run config (provider, models, endpoint, language, vendors, limits), leaving out only file locations, the checkpoint switch and the retry count, so a resume no longer carries reports that other settings produced --- tests/test_checkpoint_resume.py | 28 ++++++++++++++++++++++++++++ tradingagents/graph/trading_graph.py | 18 ++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/test_checkpoint_resume.py b/tests/test_checkpoint_resume.py index c85f3d3c5..ae21500f0 100644 --- a/tests/test_checkpoint_resume.py +++ b/tests/test_checkpoint_resume.py @@ -190,6 +190,34 @@ class TestCheckpointSignature(unittest.TestCase): # sig1's checkpoint remains untouched. self.assertIsNotNone(checkpoint_step(self.tmpdir, self.ticker, self.date, sig1)) + def test_a_resume_under_other_settings_starts_fresh(self): + """The report names one provider, model set, language and vendor chain; + a resume must not carry reports another of them produced.""" + from tradingagents.graph.trading_graph import TradingAgentsGraph + + g = object.__new__(TradingAgentsGraph) + g.selected_analysts = ("market", "news") + base_config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "llm_provider": "openai", + "quick_think_llm": "q", "deep_think_llm": "d", "output_language": "English", + "data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {}} + g.config = dict(base_config) + base = g._run_signature("stock") + for key, value in (("llm_provider", "google"), ("quick_think_llm", "q2"), ("deep_think_llm", "d2"), + ("output_language", "Deutsch"), + ("data_vendors", {"core_stock_apis": "alpha_vantage"}), + ("tool_vendors", {"get_news": "alpha_vantage"}), + ("backend_url", "http://other-endpoint/v1"), ("max_tool_rounds", 30), + ("temperature", 0.2), ("openai_reasoning_effort", "high")): + g.config = {**base_config, key: value} + self.assertNotEqual(base, g._run_signature("stock"), key) + # Where a run keeps its files, and how it retries, do not change what it writes. + for key, value in (("results_dir", "/elsewhere"), ("data_cache_dir", "/cache"), + ("memory_log_path", "/log.md"), ("checkpoint_enabled", True), ("llm_max_retries", 9)): + g.config = {**base_config, key: value} + self.assertEqual(base, g._run_signature("stock"), key) + g.config = dict(base_config) + self.assertEqual(base, g._run_signature("stock")) + def test_run_signature_captures_graph_shape(self): from tradingagents.graph.trading_graph import TradingAgentsGraph diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index b59e8a1be..820398c18 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import os @@ -40,6 +41,13 @@ def _validate_trade_date(trade_date) -> str: return value +# Config keys that do not change what a run writes: where it keeps its files, +# whether it checkpoints, and how often it retries a provider. +_NOT_IN_SIGNATURE = frozenset({ + "results_dir", "data_cache_dir", "memory_log_path", "checkpoint_enabled", "llm_max_retries", +}) + + class TradingAgentsGraph: """Main class that orchestrates the trading agents framework.""" @@ -148,12 +156,17 @@ class TradingAgentsGraph: return td if td < datetime.now().strftime("%Y-%m-%d") else None def _run_signature(self, asset_type: str, portfolio=None) -> str: - """Graph-shape inputs that must invalidate a checkpoint if changed. + """Run inputs that must invalidate a checkpoint if changed. Keyed into the checkpoint thread ID so a resume under a different analyst selection, debate/risk depth, or asset mode starts fresh instead of - silently continuing the previous graph (#1089). + silently continuing the previous graph (#1089). The rest of the config + counts too (provider, models, endpoint, language, vendors, limits): a + resume must not carry reports that other settings produced. Only where + the run keeps its files and how it retries are left out. """ + settings = {k: v for k, v in self.config.items() if k not in _NOT_IN_SIGNATURE} + digest = hashlib.sha256(json.dumps(settings, sort_keys=True, default=str).encode()).hexdigest()[:12] return "|".join([ "analysts=" + ",".join(self.selected_analysts), f"debate={self.config['max_debate_rounds']}", @@ -164,6 +177,7 @@ class TradingAgentsGraph: # The layout itself: a checkpoint saved when analysts ran one after # another has pending nodes this graph no longer has. "analysts=parallel", + f"settings={digest}", ]) def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): From fa87947c33950c84458950a0171368b3710bf64a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:38:35 +0000 Subject: [PATCH 42/63] test: stop reloading modules, so the suite passes in any order - default_config.build_default_config gives the defaults with the environment as it is now; the overlay tests call it instead of reloading the module, whose reloaded DEFAULT_CONFIG leaked their settings into later tests - the Ollama and API-key tests import the modules they check, which read the environment when called; two tests that only read os.environ are dropped --- tests/test_api_key_env.py | 8 +- tests/test_env_overrides.py | 92 ++++----- tests/test_llm_max_retries.py | 17 +- tests/test_llm_max_tokens.py | 17 +- tests/test_ollama_base_url.py | 67 +------ tests/test_openai_compatible_provider.py | 8 +- tests/test_temperature_config.py | 12 +- tradingagents/default_config.py | 238 ++++++++++++----------- 8 files changed, 201 insertions(+), 258 deletions(-) diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py index bdec18441..e973032ce 100644 --- a/tests/test_api_key_env.py +++ b/tests/test_api_key_env.py @@ -67,12 +67,10 @@ def test_case_insensitive_lookup(): @pytest.fixture -def prompts(monkeypatch): - """Import cli.prompts with a fresh environment so module-level state is consistent.""" - import importlib - +def prompts(): + """cli.prompts, which reads the environment when it is called.""" import cli.prompts as prompts_module - return importlib.reload(prompts_module) + return prompts_module def test_ensure_api_key_returns_existing(monkeypatch, prompts): diff --git a/tests/test_env_overrides.py b/tests/test_env_overrides.py index 8dde7b492..3680f7ad6 100644 --- a/tests/test_env_overrides.py +++ b/tests/test_env_overrides.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib import os import pytest @@ -10,27 +9,27 @@ import pytest import tradingagents.default_config as default_config_module -def _reload_with_env(monkeypatch, **overrides): - """Set/clear env vars then reload default_config to re-evaluate DEFAULT_CONFIG.""" +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() def test_no_env_uses_built_in_defaults(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" - assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-6-sol" - assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-6-luna" - assert dc.DEFAULT_CONFIG["backend_url"] is None - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 - assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False + config = _config_with_env(monkeypatch) + assert config["llm_provider"] == "openai" + assert config["deep_think_llm"] == "gpt-6-sol" + assert config["quick_think_llm"] == "gpt-6-luna" + assert config["backend_url"] is None + assert config["max_debate_rounds"] == 1 + assert config["checkpoint_enabled"] is False def test_string_overrides(monkeypatch): - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_LLM_PROVIDER="google", TRADINGAGENTS_DEEP_THINK_LLM="gemini-3-pro-preview", @@ -38,23 +37,23 @@ def test_string_overrides(monkeypatch): TRADINGAGENTS_LLM_BACKEND_URL="https://example.invalid/v1", TRADINGAGENTS_OUTPUT_LANGUAGE="Chinese", ) - assert dc.DEFAULT_CONFIG["llm_provider"] == "google" - assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gemini-3-pro-preview" - assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gemini-3-flash-preview" - assert dc.DEFAULT_CONFIG["backend_url"] == "https://example.invalid/v1" - assert dc.DEFAULT_CONFIG["output_language"] == "Chinese" + assert config["llm_provider"] == "google" + assert config["deep_think_llm"] == "gemini-3-pro-preview" + assert config["quick_think_llm"] == "gemini-3-flash-preview" + assert config["backend_url"] == "https://example.invalid/v1" + assert config["output_language"] == "Chinese" def test_int_coercion(monkeypatch): - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_MAX_DEBATE_ROUNDS="3", TRADINGAGENTS_MAX_RISK_ROUNDS="2", ) - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 3 - assert isinstance(dc.DEFAULT_CONFIG["max_debate_rounds"], int) - assert dc.DEFAULT_CONFIG["max_risk_discuss_rounds"] == 2 - assert isinstance(dc.DEFAULT_CONFIG["max_risk_discuss_rounds"], int) + assert config["max_debate_rounds"] == 3 + assert isinstance(config["max_debate_rounds"], int) + assert config["max_risk_discuss_rounds"] == 2 + assert isinstance(config["max_risk_discuss_rounds"], int) @pytest.mark.parametrize( @@ -65,65 +64,62 @@ def test_int_coercion(monkeypatch): ], ) def test_bool_coercion(monkeypatch, raw, expected): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw) - assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is expected + config = _config_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw) + assert config["checkpoint_enabled"] is expected def test_reasoning_thinking_overrides(monkeypatch): """The provider reasoning/thinking knobs are env-configurable (non-interactive runs).""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_OPENAI_REASONING_EFFORT="high", TRADINGAGENTS_GOOGLE_THINKING_LEVEL="minimal", TRADINGAGENTS_ANTHROPIC_EFFORT="low", ) - assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] == "high" - assert dc.DEFAULT_CONFIG["google_thinking_level"] == "minimal" - assert dc.DEFAULT_CONFIG["anthropic_effort"] == "low" + assert config["openai_reasoning_effort"] == "high" + assert config["google_thinking_level"] == "minimal" + assert config["anthropic_effort"] == "low" def test_reasoning_effort_defaults_to_none(monkeypatch): """Unset reasoning/thinking knobs stay None so each provider uses its own default.""" - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] is None - assert dc.DEFAULT_CONFIG["google_thinking_level"] is None - assert dc.DEFAULT_CONFIG["anthropic_effort"] is None + config = _config_with_env(monkeypatch) + assert config["openai_reasoning_effort"] is None + assert config["google_thinking_level"] is None + assert config["anthropic_effort"] is None def test_empty_env_value_is_passthrough(monkeypatch): """Empty TRADINGAGENTS_* values must not clobber the built-in default.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_LLM_PROVIDER="", TRADINGAGENTS_MAX_DEBATE_ROUNDS="", ) - assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 + assert config["llm_provider"] == "openai" + assert config["max_debate_rounds"] == 1 def test_empty_path_value_keeps_the_default_path(monkeypatch): """.env.example lists the path variables blank; uncommenting one made the path empty, and the graph failed creating its directories.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_RESULTS_DIR="", TRADINGAGENTS_CACHE_DIR="", TRADINGAGENTS_MEMORY_LOG_PATH="", ) - home = dc._TRADINGAGENTS_HOME - assert dc.DEFAULT_CONFIG["results_dir"] == os.path.join(home, "logs") - assert dc.DEFAULT_CONFIG["data_cache_dir"] == os.path.join(home, "cache") - assert dc.DEFAULT_CONFIG["memory_log_path"] == os.path.join(home, "memory", "trading_memory.md") + home = default_config_module._TRADINGAGENTS_HOME + assert config["results_dir"] == os.path.join(home, "logs") + assert config["data_cache_dir"] == os.path.join(home, "cache") + assert config["memory_log_path"] == os.path.join(home, "memory", "trading_memory.md") def test_invalid_int_raises(monkeypatch): """Garbage int values should surface a ValueError at import, not silently misconfigure.""" monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "not-a-number") with pytest.raises(ValueError, match="TRADINGAGENTS_MAX_DEBATE_ROUNDS"): - importlib.reload(default_config_module) - # Restore module state for subsequent tests in this process - monkeypatch.delenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", raising=False) - importlib.reload(default_config_module) + default_config_module.build_default_config() @pytest.mark.parametrize("bad", ["treu", "flase", "maybe", "2", "enabled"]) @@ -131,15 +127,13 @@ def test_invalid_bool_raises(monkeypatch, bad): """A misspelled boolean must fail loudly (like ints) instead of silently False.""" monkeypatch.setenv("TRADINGAGENTS_CHECKPOINT_ENABLED", bad) with pytest.raises(ValueError, match="TRADINGAGENTS_CHECKPOINT_ENABLED"): - importlib.reload(default_config_module) - monkeypatch.delenv("TRADINGAGENTS_CHECKPOINT_ENABLED", raising=False) - importlib.reload(default_config_module) + default_config_module.build_default_config() def test_unknown_env_var_is_ignored(monkeypatch): """Env vars outside _ENV_OVERRIDES must not bleed into DEFAULT_CONFIG.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_NONEXISTENT_KEY="oops", ) - assert "nonexistent_key" not in dc.DEFAULT_CONFIG + assert "nonexistent_key" not in config diff --git a/tests/test_llm_max_retries.py b/tests/test_llm_max_retries.py index bac0c8bd1..1baad0905 100644 --- a/tests/test_llm_max_retries.py +++ b/tests/test_llm_max_retries.py @@ -6,8 +6,6 @@ opt-in llm_max_retries knob forwarded to every provider chat client. """ from __future__ import annotations -import importlib - import pytest import tradingagents.default_config as default_config_module @@ -74,23 +72,24 @@ def test_invalid_config_value_fails_loudly(): # --- env overlay ----------------------------------------------------------- -def _reload_with_env(monkeypatch, **overrides): +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() @pytest.mark.unit def test_default_is_none(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["llm_max_retries"] is None + config = _config_with_env(monkeypatch) + assert config["llm_max_retries"] is None @pytest.mark.unit def test_env_override_sets_config(monkeypatch): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8") + config = _config_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8") # None-default key: env value arrives as a string and is coerced downstream. - assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8" - assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8 + assert config["llm_max_retries"] == "8" + assert _coerce_max_retries(config["llm_max_retries"]) == 8 diff --git a/tests/test_llm_max_tokens.py b/tests/test_llm_max_tokens.py index 2715e179e..d4007f164 100644 --- a/tests/test_llm_max_tokens.py +++ b/tests/test_llm_max_tokens.py @@ -8,8 +8,6 @@ right key per provider. """ from __future__ import annotations -import importlib - import pytest import tradingagents.default_config as default_config_module @@ -97,22 +95,23 @@ def test_openai_and_google_clients_accept_the_kwarg(): # --- env overlay ----------------------------------------------------------- -def _reload_with_env(monkeypatch, **overrides): +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() @pytest.mark.unit def test_default_is_none(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["max_tokens"] is None + config = _config_with_env(monkeypatch) + assert config["max_tokens"] is None @pytest.mark.unit def test_env_override_sets_config(monkeypatch): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192") - assert dc.DEFAULT_CONFIG["max_tokens"] == "8192" - assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192 + config = _config_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192") + assert config["max_tokens"] == "8192" + assert _coerce_max_tokens(config["max_tokens"]) == 8192 diff --git a/tests/test_ollama_base_url.py b/tests/test_ollama_base_url.py index 6cac1f55e..f3511590a 100644 --- a/tests/test_ollama_base_url.py +++ b/tests/test_ollama_base_url.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib import re import pytest @@ -18,30 +17,12 @@ def _console_out(capsys) -> str: return _ANSI.sub("", capsys.readouterr().out) -@pytest.fixture(scope="module", autouse=True) -def _resync_reloaded_modules(): - """Restore module state after this file's importlib.reload() calls. - - Several tests below reload ``cli.prompts`` to re-evaluate OLLAMA_BASE_URL. - That leaves the modules importing from it (cli.selections, then cli.run and - cli.main) bound to the pre-reload functions, which breaks identity checks in - unrelated tests that run afterward. Re-sync them in import order on teardown. - """ - yield - import cli.main - import cli.prompts - import cli.run - import cli.selections - for module in (cli.prompts, cli.selections, cli.run, cli.main): - importlib.reload(module) - - # ---- openai_client side: registry-driven base_url resolution -------------- -def _reload_client(): +def _client_module(): import tradingagents.llm_clients.openai_client as mod - return importlib.reload(mod) + return mod def _base_url(mod, provider, **kwargs): @@ -50,20 +31,20 @@ def _base_url(mod, provider, **kwargs): def test_resolver_returns_default_when_env_unset(monkeypatch): monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "ollama") == "http://localhost:11434/v1" def test_resolver_returns_env_when_set(monkeypatch): monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-ollama:11434/v1") - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "ollama") == "http://remote-ollama:11434/v1" def test_resolver_evaluation_is_call_time(monkeypatch): """Setting the env AFTER module import must still take effect.""" monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - mod = _reload_client() + mod = _client_module() monkeypatch.setenv("OLLAMA_BASE_URL", "http://late-set:11434/v1") assert _base_url(mod, "ollama") == "http://late-set:11434/v1" @@ -71,7 +52,7 @@ def test_resolver_evaluation_is_call_time(monkeypatch): def test_resolver_does_not_affect_other_providers(monkeypatch): """OLLAMA_BASE_URL should NOT leak into xai/deepseek/etc.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://elsewhere/v1") - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "xai") == "https://api.x.ai/v1" assert _base_url(mod, "deepseek") == "https://api.deepseek.com" @@ -79,7 +60,7 @@ def test_resolver_does_not_affect_other_providers(monkeypatch): def test_client_get_llm_picks_up_env(monkeypatch): """End-to-end: OllamaClient.get_llm() respects OLLAMA_BASE_URL.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://my-ollama:11434/v1") - mod = _reload_client() + mod = _client_module() client = mod.OpenAIClient(model="llama3.1", provider="ollama") llm = client.get_llm() assert "my-ollama" in str(llm.openai_api_base) @@ -88,7 +69,7 @@ def test_client_get_llm_picks_up_env(monkeypatch): def test_explicit_base_url_overrides_env(monkeypatch): """An explicit base_url passed to the client wins over the env var.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-set:11434/v1") - mod = _reload_client() + mod = _client_module() client = mod.OpenAIClient( model="llama3.1", provider="ollama", @@ -99,40 +80,12 @@ def test_explicit_base_url_overrides_env(monkeypatch): assert "env-set" not in str(llm.openai_api_base) -# ---- cli.prompts side: select_llm_provider dropdown ------------------------- - - -def test_cli_dropdown_uses_env(monkeypatch): - """The Ollama entry in the CLI dropdown must reflect OLLAMA_BASE_URL.""" - monkeypatch.setenv("OLLAMA_BASE_URL", "http://cli-remote:11434/v1") - from cli import prompts - importlib.reload(prompts) - # Reach inside the function via the same env-read it does at call time - ollama_url = ( - __import__("os").environ.get("OLLAMA_BASE_URL") - or "http://localhost:11434/v1" - ) - assert ollama_url == "http://cli-remote:11434/v1" - - -def test_cli_dropdown_default_when_unset(monkeypatch): - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - from cli import prompts - importlib.reload(prompts) - ollama_url = ( - __import__("os").environ.get("OLLAMA_BASE_URL") - or "http://localhost:11434/v1" - ) - assert ollama_url == "http://localhost:11434/v1" - - # ---- confirm_ollama_endpoint UX ------------------------------------------- def test_confirm_endpoint_shows_default(monkeypatch, capsys): monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://localhost:11434/v1") out = _console_out(capsys) assert "http://localhost:11434/v1" in out @@ -143,7 +96,6 @@ def test_confirm_endpoint_shows_default(monkeypatch, capsys): def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys): monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host:11434/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://remote-host:11434/v1") out = _console_out(capsys) assert "http://remote-host:11434/v1" in out @@ -154,7 +106,6 @@ def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys): """If user sets OLLAMA_BASE_URL=0.0.0.128, advise on the expected shape.""" monkeypatch.setenv("OLLAMA_BASE_URL", "0.0.0.128") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("0.0.0.128") out = _console_out(capsys) assert "missing a scheme" in out @@ -165,7 +116,6 @@ def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys): """A remote host with no :11434 gets a soft hint about port mismatch.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://remote-host/v1") out = _console_out(capsys) assert "port 11434" in out @@ -175,7 +125,6 @@ def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys): """Local host without port shouldn't trigger the remote-port hint.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://localhost/v1") out = _console_out(capsys) assert "Note" not in out # localhost is fine without explicit port diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index f23c4e64f..f43989709 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -9,18 +9,16 @@ import pytest from tradingagents.llm_clients.api_key_env import get_api_key_env from tradingagents.llm_clients.factory import create_llm_client +from tradingagents.llm_clients.openai_client import LocalCompatibleChatOpenAI, OpenAIClient from tradingagents.llm_clients.validators import validate_model -# Note: assert by class NAME, not isinstance — other tests reload the -# openai_client module, which would otherwise create a second class identity. - @pytest.mark.unit def test_factory_routes_to_openai_client(): client = create_llm_client( provider="openai_compatible", model="my-model", base_url="http://localhost:8000/v1" ) - assert type(client).__name__ == "OpenAIClient" + assert isinstance(client, OpenAIClient) @pytest.mark.unit @@ -36,7 +34,7 @@ def test_keyless_local_uses_placeholder_and_chat_completions(monkeypatch): llm = create_llm_client( provider="openai_compatible", model="qwen2.5", base_url="http://localhost:8000/v1" ).get_llm() - assert type(llm).__name__ == "LocalCompatibleChatOpenAI" + assert isinstance(llm, LocalCompatibleChatOpenAI) assert str(llm.openai_api_base) == "http://localhost:8000/v1" # keyless local servers: a placeholder key is sent key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key diff --git a/tests/test_temperature_config.py b/tests/test_temperature_config.py index 502182fd1..714eb5244 100644 --- a/tests/test_temperature_config.py +++ b/tests/test_temperature_config.py @@ -4,7 +4,6 @@ Temperature is a cross-provider knob: when set it must reach the underlying chat client; when unset the provider keeps its own default. """ -import importlib import pytest @@ -45,18 +44,15 @@ class TestTemperatureEnvOverlay: def test_env_sets_temperature(self, monkeypatch): import tradingagents.default_config as dc monkeypatch.setenv("TRADINGAGENTS_TEMPERATURE", "0.2") - importlib.reload(dc) + config = dc.build_default_config() # Stored on config (string from env is fine; consumed via float()). - assert dc.DEFAULT_CONFIG["temperature"] in ("0.2", 0.2) - assert float(dc.DEFAULT_CONFIG["temperature"]) == 0.2 - monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False) - importlib.reload(dc) + assert config["temperature"] in ("0.2", 0.2) + assert float(config["temperature"]) == 0.2 def test_default_temperature_is_none(self, monkeypatch): import tradingagents.default_config as dc monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False) - importlib.reload(dc) - assert dc.DEFAULT_CONFIG["temperature"] is None + assert dc.build_default_config()["temperature"] is None @pytest.mark.unit diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index d33a206f4..a79c819b2 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -70,117 +70,127 @@ def _apply_env_overrides(config: dict) -> dict: return config -DEFAULT_CONFIG = _apply_env_overrides({ - "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR") or os.path.join(_TRADINGAGENTS_HOME, "logs"), - "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR") or os.path.join(_TRADINGAGENTS_HOME, "cache"), - "memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH") or os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md"), - # Optional cap on the number of resolved memory log entries. When set, - # the oldest resolved entries are pruned once this limit is exceeded. - # Pending entries are never pruned. None disables rotation entirely. - "memory_log_max_entries": None, - # LLM settings - "llm_provider": "openai", - "deep_think_llm": "gpt-6-sol", - "quick_think_llm": "gpt-6-luna", - # When None, each provider's client falls back to its own default endpoint - # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). - # The CLI overrides this per provider when the user picks one. Keeping a - # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously - # being forwarded to Gemini, producing malformed request URLs). - "backend_url": None, - # Provider-specific thinking configuration - "google_thinking_level": None, # "high", "minimal", etc. - "openai_reasoning_effort": None, # "medium", "high", "low" - "anthropic_effort": None, # "high", "medium", "low" - # Sampling temperature, forwarded to every provider when set. None leaves - # each provider at its own default. Lower values reduce run-to-run - # variation on models that honor it; reasoning models largely ignore it - # and no setting makes LLM output bit-identical across runs (see README). - "temperature": None, - # SDK retry budget forwarded to every provider chat client. None leaves each - # provider/SDK at its own default (usually 2). Raise it to ride out bursty - # 429 throttling on rate-limited deployments instead of aborting a run (#1091). - "llm_max_retries": None, - # Cap on output tokens forwarded to every provider chat client. None leaves - # each provider at its own default. Set it to bound a model that emits - # unbounded reasoning/output and hangs or trips a gateway idle timeout - # (e.g. some deepseek-v4-flash deployments, #1204). - "max_tokens": None, - # Checkpoint/resume: when True, LangGraph saves state after each node - # so a crashed run can resume from the last successful step. - "checkpoint_enabled": False, - # Output language for analyst reports and final decision - # Internal agent debate stays in English for reasoning quality - "output_language": "English", - # Debate and discussion settings - "max_debate_rounds": 1, - "max_risk_discuss_rounds": 1, - "max_recur_limit": 100, - # Rounds of tool calls an analyst may make before it is asked for its report. - "max_tool_rounds": 20, - # News / data fetching parameters - # Increase for longer lookback strategies or to broaden macro coverage; - # decrease to reduce token usage in agent prompts. - "news_article_limit": 20, # max articles per ticker (ticker-news) - "global_news_article_limit": 10, # max articles for global/macro news - "global_news_lookback_days": 7, # macro news lookback window - # Search queries used by get_global_news for macro headlines. Extend or - # replace to broaden geographic / sector coverage. - "global_news_queries": [ - "Federal Reserve interest rates inflation", - "S&P 500 earnings GDP economic outlook", - "geopolitical risk trade war sanctions", - "ECB Bank of England BOJ central bank policy", - "oil commodities supply chain energy", - ], - # Data vendor configuration - # Category-level configuration (default for all tools in category). - # The configured value is the exact vendor chain — requests are NOT silently - # routed to vendors you didn't choose. For ordered fallback, list several, - # e.g. "yfinance,alpha_vantage". "default" uses all available vendors. - "data_vendors": { - "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance - "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) - "prediction_markets": "polymarket", # Options: polymarket (keyless) - }, - # Tool-level configuration (takes precedence over category-level) - "tool_vendors": { - # Example: "get_stock_data": "alpha_vantage", # Override category default - }, - # Benchmark for alpha calculation in the reflection layer. - # ``benchmark_ticker`` (when set) overrides the suffix map for all - # tickers; leave it None to use ``benchmark_map`` for auto-detection - # based on the ticker's exchange suffix. SPY remains the US default - # so the reflection label keeps reading "Alpha vs SPY" for US tickers - # while non-US tickers get their regional index automatically. - # Trading days after the analysis date over which a decision's outcome is - # measured, for reflection and for the backtest figures. - "holding_period_days": 5, - "benchmark_ticker": None, - "benchmark_map": { - ".NS": "^NSEI", # NSE India (Nifty 50) - ".BO": "^BSESN", # BSE India (Sensex) - ".T": "^N225", # Tokyo (Nikkei 225) - ".TW": "^TWII", # Taiwan (TAIEX) - ".TWO": "^TWII", # Taipei OTC (TPEx has no Yahoo index; TAIEX) - ".KS": "^KS11", # Korea (KOSPI) - ".KQ": "^KQ11", # Korea (KOSDAQ) - ".HK": "^HSI", # Hong Kong (Hang Seng) - ".SI": "^STI", # Singapore (Straits Times) - ".L": "^FTSE", # London (FTSE 100) - ".DE": "^GDAXI", # Germany (DAX) - ".PA": "^FCHI", # Paris (CAC 40) - ".AS": "^AEX", # Amsterdam (AEX) - ".SW": "^SSMI", # Switzerland (SMI) - ".MI": "FTSEMIB.MI", # Milan (FTSE MIB) - ".TO": "^GSPTSE", # Toronto (TSX Composite) - ".AX": "^AXJO", # Australia (ASX 200) - ".SS": "000001.SS", # Shanghai (SSE Composite) - ".SZ": "399001.SZ", # Shenzhen (SZSE Component) - ".SA": "^BVSP", # B3 Brazil (Ibovespa) - "": "SPY", # default for US-listed tickers (no suffix) - }, -}) +def build_default_config() -> dict: + """The built-in defaults with the TRADINGAGENTS_* environment folded in. + + Read when the package is imported, as DEFAULT_CONFIG; call it again to see + the environment as it is now. + """ + return _apply_env_overrides({ + "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR") or os.path.join(_TRADINGAGENTS_HOME, "logs"), + "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR") or os.path.join(_TRADINGAGENTS_HOME, "cache"), + "memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH") or os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md"), + # Optional cap on the number of resolved memory log entries. When set, + # the oldest resolved entries are pruned once this limit is exceeded. + # Pending entries are never pruned. None disables rotation entirely. + "memory_log_max_entries": None, + # LLM settings + "llm_provider": "openai", + "deep_think_llm": "gpt-6-sol", + "quick_think_llm": "gpt-6-luna", + # When None, each provider's client falls back to its own default endpoint + # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). + # The CLI overrides this per provider when the user picks one. Keeping a + # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously + # being forwarded to Gemini, producing malformed request URLs). + "backend_url": None, + # Provider-specific thinking configuration + "google_thinking_level": None, # "high", "minimal", etc. + "openai_reasoning_effort": None, # "medium", "high", "low" + "anthropic_effort": None, # "high", "medium", "low" + # Sampling temperature, forwarded to every provider when set. None leaves + # each provider at its own default. Lower values reduce run-to-run + # variation on models that honor it; reasoning models largely ignore it + # and no setting makes LLM output bit-identical across runs (see README). + "temperature": None, + # SDK retry budget forwarded to every provider chat client. None leaves each + # provider/SDK at its own default (usually 2). Raise it to ride out bursty + # 429 throttling on rate-limited deployments instead of aborting a run (#1091). + "llm_max_retries": None, + # Cap on output tokens forwarded to every provider chat client. None leaves + # each provider at its own default. Set it to bound a model that emits + # unbounded reasoning/output and hangs or trips a gateway idle timeout + # (e.g. some deepseek-v4-flash deployments, #1204). + "max_tokens": None, + # Checkpoint/resume: when True, LangGraph saves state after each node + # so a crashed run can resume from the last successful step. + "checkpoint_enabled": False, + # Output language for analyst reports and final decision + # Internal agent debate stays in English for reasoning quality + "output_language": "English", + # Debate and discussion settings + "max_debate_rounds": 1, + "max_risk_discuss_rounds": 1, + "max_recur_limit": 100, + # Rounds of tool calls an analyst may make before it is asked for its report. + "max_tool_rounds": 20, + # News / data fetching parameters + # Increase for longer lookback strategies or to broaden macro coverage; + # decrease to reduce token usage in agent prompts. + "news_article_limit": 20, # max articles per ticker (ticker-news) + "global_news_article_limit": 10, # max articles for global/macro news + "global_news_lookback_days": 7, # macro news lookback window + # Search queries used by get_global_news for macro headlines. Extend or + # replace to broaden geographic / sector coverage. + "global_news_queries": [ + "Federal Reserve interest rates inflation", + "S&P 500 earnings GDP economic outlook", + "geopolitical risk trade war sanctions", + "ECB Bank of England BOJ central bank policy", + "oil commodities supply chain energy", + ], + # Data vendor configuration + # Category-level configuration (default for all tools in category). + # The configured value is the exact vendor chain — requests are NOT silently + # routed to vendors you didn't choose. For ordered fallback, list several, + # e.g. "yfinance,alpha_vantage". "default" uses all available vendors. + "data_vendors": { + "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance + "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) + "prediction_markets": "polymarket", # Options: polymarket (keyless) + }, + # Tool-level configuration (takes precedence over category-level) + "tool_vendors": { + # Example: "get_stock_data": "alpha_vantage", # Override category default + }, + # Benchmark for alpha calculation in the reflection layer. + # ``benchmark_ticker`` (when set) overrides the suffix map for all + # tickers; leave it None to use ``benchmark_map`` for auto-detection + # based on the ticker's exchange suffix. SPY remains the US default + # so the reflection label keeps reading "Alpha vs SPY" for US tickers + # while non-US tickers get their regional index automatically. + # Trading days after the analysis date over which a decision's outcome is + # measured, for reflection and for the backtest figures. + "holding_period_days": 5, + "benchmark_ticker": None, + "benchmark_map": { + ".NS": "^NSEI", # NSE India (Nifty 50) + ".BO": "^BSESN", # BSE India (Sensex) + ".T": "^N225", # Tokyo (Nikkei 225) + ".TW": "^TWII", # Taiwan (TAIEX) + ".TWO": "^TWII", # Taipei OTC (TPEx has no Yahoo index; TAIEX) + ".KS": "^KS11", # Korea (KOSPI) + ".KQ": "^KQ11", # Korea (KOSDAQ) + ".HK": "^HSI", # Hong Kong (Hang Seng) + ".SI": "^STI", # Singapore (Straits Times) + ".L": "^FTSE", # London (FTSE 100) + ".DE": "^GDAXI", # Germany (DAX) + ".PA": "^FCHI", # Paris (CAC 40) + ".AS": "^AEX", # Amsterdam (AEX) + ".SW": "^SSMI", # Switzerland (SMI) + ".MI": "FTSEMIB.MI", # Milan (FTSE MIB) + ".TO": "^GSPTSE", # Toronto (TSX Composite) + ".AX": "^AXJO", # Australia (ASX 200) + ".SS": "000001.SS", # Shanghai (SSE Composite) + ".SZ": "399001.SZ", # Shenzhen (SZSE Component) + ".SA": "^BVSP", # B3 Brazil (Ibovespa) + "": "SPY", # default for US-listed tickers (no suffix) + }, + + }) + + +DEFAULT_CONFIG = build_default_config() From 4bb9272c8f4a96e74f489d86e0ef22e915953d4a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:40:33 +0000 Subject: [PATCH 43/63] test: keep the suite off the network and out of the home directory, yfinance included - the network guard also refuses DNS lookups and curl_cffi requests, the HTTP client yfinance uses, which never touches Python's sockets - results, the data cache, the memory log and yfinance's time-zone cache live in a directory of the suite's own --- tests/conftest.py | 34 ++++++++++++++++++++++++++++++++-- tests/test_suite_isolation.py | 27 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c134581fe..ffd70a3c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,9 +27,33 @@ def _blank_settings_overlay(): _blank_settings_overlay() +def _own_file_locations(): + """Keep the suite's results, cache and memory log in a directory of its own. + + Set before the package is imported, whose defaults put them in the user's + home: a test reading the user's cache would see what their runs fetched. + """ + import tempfile + + home = tempfile.mkdtemp(prefix="tradingagents-tests-") + os.environ["TRADINGAGENTS_RESULTS_DIR"] = os.path.join(home, "logs") + os.environ["TRADINGAGENTS_CACHE_DIR"] = os.path.join(home, "cache") + os.environ["TRADINGAGENTS_MEMORY_LOG_PATH"] = os.path.join(home, "memory", "trading_memory.md") + + +_own_file_locations() + + def pytest_configure(config): for marker in ("unit", "integration", "smoke"): config.addinivalue_line("markers", f"{marker}: {marker}-level tests") + # yfinance caches each symbol's time zone on disk, in the home directory + # by default; the suite keeps it in a directory of its own. + import tempfile + + import yfinance + + yfinance.set_tz_cache_location(tempfile.mkdtemp(prefix="tradingagents-tests-yf-")) @pytest.fixture(autouse=True) @@ -38,11 +62,17 @@ def _no_network(request, monkeypatch): if request.node.get_closest_marker("integration"): return - def refuse(self, address): - raise OSError(f"test tried to reach the network: {address}") + def refuse(*args, **kwargs): + raise OSError(f"test tried to reach the network: {args[1:] or kwargs}") monkeypatch.setattr(socket.socket, "connect", refuse) monkeypatch.setattr(socket.socket, "connect_ex", refuse) + monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: refuse(None, *a)) + # yfinance's HTTP client is libcurl, which never touches Python's sockets. + from curl_cffi import requests as curl_requests + + monkeypatch.setattr(curl_requests.Session, "request", refuse) + monkeypatch.setattr(curl_requests.AsyncSession, "request", refuse) @pytest.fixture(autouse=True) diff --git a/tests/test_suite_isolation.py b/tests/test_suite_isolation.py index 7337e232e..a14590d43 100644 --- a/tests/test_suite_isolation.py +++ b/tests/test_suite_isolation.py @@ -9,7 +9,10 @@ import pytest @pytest.mark.parametrize("connect", [ lambda: socket.create_connection(("192.0.2.1", 80), timeout=1), lambda: socket.socket().connect_ex(("192.0.2.1", 80)), -], ids=["connect", "connect_ex"]) + lambda: socket.getaddrinfo("example.com", 443), + # yfinance's HTTP client is libcurl, which never touches Python's sockets. + lambda: __import__("curl_cffi.requests").requests.Session().get("http://192.0.2.1", timeout=1), +], ids=["connect", "connect_ex", "dns", "curl_cffi"]) def test_a_test_cannot_reach_the_network(connect): """A test that silently depends on a live vendor passes or fails with the machine it runs on; conftest refuses the connection instead.""" @@ -27,3 +30,25 @@ def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path): assert prefs._PREFS_PATH.is_relative_to(tmp_path) assert prefs._PREFS_PATH.exists() + + +@pytest.mark.unit +def test_yfinance_keeps_its_cache_out_of_the_users_home(): + """yfinance caches each symbol's time zone on disk, in the home directory by default.""" + from pathlib import Path + + from yfinance.cache import _TzDBManager + + assert not Path(_TzDBManager.get_location()).is_relative_to(Path.home()) + + +@pytest.mark.unit +@pytest.mark.parametrize("key", ["results_dir", "data_cache_dir", "memory_log_path"]) +def test_the_suite_keeps_its_files_out_of_the_users_home(key): + """A test that reads the user's cache sees what their runs fetched, and passes + or fails with it; one that writes there changes their data.""" + from pathlib import Path + + from tradingagents.default_config import DEFAULT_CONFIG + + assert not Path(DEFAULT_CONFIG[key]).is_relative_to(Path.home() / ".tradingagents") From e44cbbc8eb9395cd5d7cb2526a38a84f7d4983db Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:41:13 +0000 Subject: [PATCH 44/63] test: run the live integration tests only when asked for - pytest deselects integration tests unless -m integration is given, so a key in .env no longer makes a plain run call a paid provider --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a291e9ef9..c999551f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,10 +53,10 @@ cli = ["static/*"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-ra --strict-markers" +addopts = "-ra --strict-markers -m \"not integration\"" markers = [ "unit: fast isolated unit tests", - "integration: tests requiring external services", + "integration: tests requiring external services; run only when asked for, with -m integration", "smoke: quick sanity-check tests", ] filterwarnings = [ From 0d2cfa32e044f74ab2667a40d479a8ca8bda5427 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:42:01 +0000 Subject: [PATCH 45/63] docs(changelog): complete the 0.5.1 import moves (#1437) - get_scrubbed and vendor_reachable moved to dataflows.net, and symbol_utils.is_yahoo_safe was removed --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b5278ad..a22b5251d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,12 @@ fixes to run isolation, SEC EDGAR statements and historical runs. Some modules moved, and the old import paths are gone. Update imports as follows: -- `tradingagents.dataflows.interface` is `tradingagents.dataflows.router`, and `dataflows.symbol_utils` is `dataflows.symbols`. `dataflows.utils` is gone: `get_current_date` is in `dataflows.date_window`, `safe_ticker_component` in `dataflows.symbols`. +- `tradingagents.dataflows.interface` is `tradingagents.dataflows.router`, and `dataflows.symbol_utils` is `dataflows.symbols`. `dataflows.utils` is gone: `get_current_date` is in `dataflows.date_window`, `safe_ticker_component` in `dataflows.symbols`, and `get_scrubbed` and `vendor_reachable` in `dataflows.net`. - Vendor modules live under `tradingagents.dataflows.vendors`: `yahoo` (`ohlcv`, `market`, `fundamentals`, `news`, `snapshot`, from the former `stockstats_utils`, `y_finance`, `yfinance_news` and `market_data_validator`), `alpha_vantage` (a package, from the `alpha_vantage_*` modules), and `sec_edgar`, `fred`, `polymarket`, `reddit`, `stocktwits`. - `tradingagents.agents.utils` is gone: the agent tools are in `agents.tools`, and `agent_utils`, `agent_states`, `rating` and `structured` are `agents.context`, `agents.state`, `agents.rating` and `agents.structured`. - The decision log is `tradingagents.decision_log` (was `agents.utils.memory`), and `cli.utils` is `cli.prompts`. - `backtest.summarize` takes a `run_backtest` result or the path of a decision log, in place of a `TradingMemoryLog`. -- Removed: `SignalProcessor` (the rating is parsed by `process_signal`), the `create_social_media_analyst` alias (use `create_sentiment_analyst`), the unused `project_dir` config key, and the graph attributes `curr_state`, `ticker` and `log_states_dict`, which held the previous run's state. +- Removed: `SignalProcessor` (the rating is parsed by `process_signal`), the `create_social_media_analyst` alias (use `create_sentiment_analyst`), `symbol_utils.is_yahoo_safe`, the unused `project_dir` config key, and the graph attributes `curr_state`, `ticker` and `log_states_dict`, which held the previous run's state. ### Added From 06bf82569de30ec63f1879981e326644955c1d4e Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:42:01 +0000 Subject: [PATCH 46/63] docs: drop comments that describe code no longer there - agents.context no longer holds a message reset between analysts - main.py no longer ends on a reflection step it does not take --- main.py | 2 -- tests/test_alpha_vantage_hardening.py | 2 +- tradingagents/agents/context.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index d3a13d678..105e24f0e 100644 --- a/main.py +++ b/main.py @@ -14,5 +14,3 @@ ta = TradingAgentsGraph(debug=True, config=config) # forward propagate _, decision = ta.propagate("NVDA", "2026-09-01") print(decision) - -# Memorize mistakes and reflect diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index d82989875..f60f0b095 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -102,7 +102,7 @@ def test_fundamentals_non_json_body_unchanged(monkeypatch): # --------------------------------------------------------------------------- -# Date trim (see the rationale on the unguarded trim in alpha_vantage_common) +# Date trim (see the rationale on the unguarded trim in alpha_vantage.common) # --------------------------------------------------------------------------- _DAILY_CSV = ( diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index 87ec7a433..c3eecafca 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -1,5 +1,4 @@ -"""Prompt context shared by the agents: instrument identity, output language, -portfolio, and the message reset between analysts.""" +"""Prompt context shared by the agents: instrument identity, output language and portfolio.""" import functools import logging From 660966d168b9cb69f3e5d52b0b1723b67d212ee2 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:50:52 +0000 Subject: [PATCH 47/63] refactor(dataflows): decide in one place whether a run is dated in the past - date_window.is_historical replaces the four copies of the rule in the profile guard, Polymarket, the instrument context and the memory cutoff, which read the clock on its own --- tests/test_undated_tools_as_of.py | 3 ++- tradingagents/agents/context.py | 4 ++-- tradingagents/dataflows/date_window.py | 10 ++++++---- tradingagents/dataflows/vendors/polymarket.py | 4 ++-- tradingagents/graph/trading_graph.py | 5 ++--- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index 3252f928a..a75a7276c 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -14,6 +14,7 @@ import pandas as pd import pytest from tradingagents.agents import tools +from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.vendors import polymarket from tradingagents.dataflows.vendors.alpha_vantage import news as alpha_vantage_news from tradingagents.dataflows.vendors.yahoo import ( @@ -77,7 +78,7 @@ def test_polymarket_withholds_live_odds_from_a_historical_run(): @pytest.mark.unit def test_polymarket_serves_a_current_run(): with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req: - polymarket.get_prediction_markets("Fed rate cut", as_of_date=polymarket.get_current_date()) + polymarket.get_prediction_markets("Fed rate cut", as_of_date=get_current_date()) req.assert_called_once() diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index c3eecafca..88a01b9d2 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -5,7 +5,7 @@ import logging from collections.abc import Mapping from typing import Any -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.date_window import is_historical from tradingagents.dataflows.vendors.yahoo.fundamentals import get_company_profile logger = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def build_instrument_context( name = identity.get("company_name") or identity.get("name") label = "Name" if is_crypto else "Company" details = [] - if trade_date and str(trade_date) < get_current_date(): + if is_historical(trade_date): if name: details.append( f"{label}: {name} (its current name, given only to identify it; " diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index b74202d14..e799914a0 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -36,6 +36,11 @@ def get_current_date() -> str: return date.today().strftime("%Y-%m-%d") +def is_historical(run_date) -> bool: + """Whether a run is dated before today, so live-only data would come from after it.""" + return bool(run_date) and str(run_date) < get_current_date() + + def coverage_gap( dates, start_date: str, end_date: str, source: str, subject: str ) -> str | None: @@ -106,10 +111,7 @@ def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: Every fundamentals vendor withholds on this rule, so switching between them cannot reintroduce the leak. """ - if not as_of_date: - return None - today = get_current_date() - if as_of_date >= today: + if not is_historical(as_of_date): return None return ( f"# Company Fundamentals for {label}\n" diff --git a/tradingagents/dataflows/vendors/polymarket.py b/tradingagents/dataflows/vendors/polymarket.py index 0e5303dd1..2dae8f367 100644 --- a/tradingagents/dataflows/vendors/polymarket.py +++ b/tradingagents/dataflows/vendors/polymarket.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone import requests -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.date_window import is_historical logger = logging.getLogger(__name__) @@ -83,7 +83,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str each with its implied probability, traded volume, resolution date, and recent (1-week) move. """ - if as_of_date and as_of_date < get_current_date(): + if is_historical(as_of_date): return ( f"Prediction-market odds are withheld for {as_of_date}. Polymarket serves " f"only live odds on open markets, with no historical vintage, so serving " diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 820398c18..d2e29bdb6 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -11,7 +11,7 @@ import tradingagents from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.rating import run_rating from tradingagents.dataflows.config import run_config, run_config_context, set_config -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.date_window import get_current_date, is_historical from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import build_llm_kwargs, create_llm_client @@ -152,8 +152,7 @@ class TradingAgentsGraph: None, disabling the filter so live behavior and pre-migration entries (which have no stored resolution date) are unaffected. """ - td = str(trade_date) - return td if td < datetime.now().strftime("%Y-%m-%d") else None + return str(trade_date) if is_historical(trade_date) else None def _run_signature(self, asset_type: str, portfolio=None) -> str: """Run inputs that must invalidate a checkpoint if changed. From f65eab7b8072478f00cc667ba9e4a1462ae51754 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:52:20 +0000 Subject: [PATCH 48/63] build(deps): require the current yfinance, LangChain and LangGraph releases - yfinance 1.7.0, langchain-core 1.6.5, langchain-openai 1.6.6, langchain-anthropic 1.7.4, langchain-google-genai 4.4.0, langgraph 1.2.12, langgraph-checkpoint-sqlite 3.1.1 - langchain-aws 1.7.9 for the bedrock extra, and ruff 0.16 for development --- pyproject.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c999551f8..4cb3f1216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,12 @@ description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.10" dependencies = [ - "langchain-core>=0.3.81", - "langchain-anthropic>=0.3.15", - "langchain-google-genai>=4.0.0", - "langchain-openai>=0.3.23", - "langgraph>=0.4.8", - "langgraph-checkpoint-sqlite>=2.0.0", + "langchain-core>=1.6.5", + "langchain-anthropic>=1.7.4", + "langchain-google-genai>=4.4.0", + "langchain-openai>=1.6.6", + "langgraph>=1.2.12", + "langgraph-checkpoint-sqlite>=3.1.1", "pandas>=2.3.0", "python-dotenv>=1.0.0", "pytz>=2025.2", @@ -24,19 +24,19 @@ dependencies = [ "typer>=0.21.0", "stockstats>=0.6.5", "typing-extensions>=4.14.0", - "yfinance>=1.4.1", + "yfinance>=1.7.0", ] [project.optional-dependencies] dev = [ - "ruff>=0.15", + "ruff>=0.16", "pytest>=8.0", "pytest-subtests>=0.13", ] # Amazon Bedrock support (AWS SigV4 auth + boto3). Optional so the core install # stays lean: pip install "tradingagents[bedrock]". bedrock = [ - "langchain-aws>=1.5.0", + "langchain-aws>=1.7.9", ] [project.scripts] From 23bed8dace0daf473e4d2f0a81699a1458186341 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:52:46 +0000 Subject: [PATCH 49/63] ci: test Python 3.14 and run the current checkout and setup-python actions - the test matrix adds Python 3.14 - actions/checkout and actions/setup-python v7; the lint job installs ruff 0.16 --- .github/workflows/ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94fea9cc..e869ed0ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] # One run away from UTC, so a test that only passes in UTC fails here. include: - python-version: "3.12" @@ -24,8 +24,8 @@ jobs: env: TZ: ${{ matrix.tz }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install (with dev extras) @@ -39,8 +39,8 @@ jobs: name: clean-install smoke runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Fresh install (no dev extras) and import @@ -55,12 +55,12 @@ jobs: name: ruff (strict, full repo) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install ruff - run: pip install "ruff>=0.15" + run: pip install "ruff>=0.16" - name: Lint the repository # The repo is fully clean under the strict select, so we lint everything # (generated results/ is excluded via pyproject extend-exclude). From 76a93480ddcd586da49c7332057d53e6d31dc7b5 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:54:26 +0000 Subject: [PATCH 50/63] build: run the Docker image on Python 3.13, and set up with it in the README --- Dockerfile | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 024c7c72d..19469e5c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim AS builder +FROM python:3.13-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 @@ -10,7 +10,7 @@ WORKDIR /build COPY . . RUN pip install --no-cache-dir . -FROM python:3.12-slim +FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md index 3f49da446..560d1bb42 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,13 @@ cd TradingAgents Create a virtual environment in any of your favorite environment managers: ```bash -conda create -n tradingagents python=3.12 +conda create -n tradingagents python=3.13 conda activate tradingagents ``` Or with [uv](https://docs.astral.sh/uv/): ```bash -uv venv --python 3.12 +uv venv --python 3.13 source .venv/bin/activate ``` From 0f0568933415cc668d4d6b252a6083926748b4ed Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 20:55:55 +0000 Subject: [PATCH 51/63] docs: show a current Claude model ID in the Bedrock example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 560d1bb42..378804c5d 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ export TYPESAFE_API_KEY=... # Jev social-post screening (optional) For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. -For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-4-8-v1:0`. +For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-5-5`. For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull `, and pick "Custom model ID" in the CLI for any model not listed by default. From 1bf67e35b648baac27bd255fb6e165f8af52a8de Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:01:59 +0000 Subject: [PATCH 52/63] fix(yahoo): report the failed requests yfinance would answer as empty - yfinance raises its failures instead of answering them with an empty result, so a statement request that timed out is unavailable, not "no data" - Yahoo answering that it has no such symbol (HTTP 404) stays an answer: no data --- tests/test_yahoo_rate_limit.py | 41 +++++++++++++++++++ .../dataflows/vendors/yahoo/fundamentals.py | 3 +- .../dataflows/vendors/yahoo/market.py | 7 ++-- .../dataflows/vendors/yahoo/ohlcv.py | 20 +++++++-- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index ffbab5ec4..3669a4fa2 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -199,3 +199,44 @@ def test_a_snapshot_with_no_rows_is_reported_not_raised(yahoo, frame): out = get_verified_market_snapshot.func("ZZZZ", DAY, trade_date=DAY) assert out.startswith("NO_DATA_AVAILABLE"), out + + +def _yahoo_request_raises(yahoo, exc): + """Every request yfinance makes raises ``exc``, below its own error handling.""" + from yfinance.data import YfData + + def request(self, *args, **kwargs): + raise exc + + yahoo.setattr(YfData, "_make_request", request) + + +@pytest.mark.unit +@pytest.mark.parametrize("call", _yahoo_calls()) +def test_a_request_that_yfinance_would_swallow_is_unavailable(yahoo, call): + """yfinance answers some failed requests with an empty result; an empty + statement from a Yahoo whose host answers a probe read as "no data".""" + _yahoo_request_raises(yahoo, TimeoutError("read timed out")) + + with pytest.raises(VendorUnavailableError): + call() + + +@pytest.mark.unit +@pytest.mark.parametrize("call", [c for c in _yahoo_calls() if c.id in ("fundamentals", "stock_data", "insider")]) +def test_yahoo_answering_not_found_is_no_data(yahoo, call): + """Yahoo answers an unknown symbol with HTTP 404: an answer about the symbol, not an outage.""" + from curl_cffi.requests import Response + from curl_cffi.requests.exceptions import HTTPError + + from tradingagents.dataflows.errors import NoMarketDataError + + not_found = Response() + not_found.status_code = 404 + _yahoo_request_raises(yahoo, HTTPError("HTTP Error 404: ", response=not_found)) + + try: + out = call() + except NoMarketDataError: + return + assert out.startswith("No insider transactions"), out # an empty filing list is not an error diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 15a6b7b97..3bbcdb890 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -100,7 +100,8 @@ def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> canonical = normalize_symbol(ticker) what = title.lower() attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr - data = filter_financials_by_date(yf_retry(lambda: getattr(yf.Ticker(canonical), attr)), as_of_date) + data = yf_retry(lambda: getattr(yf.Ticker(canonical), attr)) + data = pd.DataFrame() if data is None else filter_financials_by_date(data, as_of_date) if data.empty: raise_for_empty(ticker, canonical, f"{what} data") return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index cc65ef2d5..7ce6e0735 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -30,18 +30,17 @@ def get_YFin_data_online( # Resolve broker/forex symbols to Yahoo's convention (XAUUSD+ -> GC=F). canonical = normalize_symbol(symbol) - ticker = yf.Ticker(canonical) # yfinance treats ``end`` as EXCLUSIVE, so it would drop the requested # end_date row (and the current day when end_date is today). Request one day # past end_date so the requested range is actually inclusive (#986/#987). end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d") - data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive)) + data = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_inclusive)) # Empty result means the symbol is unknown/delisted. Raise a typed error # instead of returning prose: the routing layer turns it into a single # unambiguous "no data" signal so the agent never fabricates a price. - if data.empty: + if data is None or data.empty: raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}") # Remove timezone info from index for cleaner output @@ -274,7 +273,7 @@ def get_closes(symbol: str, start_date: str, end_date: str) -> pd.Series: """Daily closes from ``start_date`` up to, not including, ``end_date``.""" canonical = normalize_symbol(symbol) history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) - return history["Close"] if "Close" in history else pd.Series(dtype=float) + return history["Close"] if history is not None and "Close" in history else pd.Series(dtype=float) def get_stock_stats( diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index b5b886c0a..f7c1cd8d1 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -39,15 +39,25 @@ def raise_for_empty(symbol: str, canonical: str, what: str) -> None: raise NoMarketDataError(symbol, canonical, f"no {what}") +# yfinance answers some failed requests with an empty result, which would read as +# "no data" for a symbol nobody checked; raised, the failure is reported as one. +yf.config.debug.hide_exceptions = False + + +def _not_found(exc: Exception) -> bool: + """Whether Yahoo answered that it has no such symbol (HTTP 404).""" + return getattr(getattr(exc, "response", None), "status_code", None) == 404 + + def yf_retry(func, max_retries=3, base_delay=2.0): """Execute a yfinance call with exponential backoff on rate limits. yfinance raises YFRateLimitError on HTTP 429 responses but does not retry them internally, so this wrapper retries them. A rate limit that outlasts the retries, or any other exception, is raised as - VendorUnavailableError: Yahoo answers an unknown symbol with an empty - result, so a request that raised failed in transit and says nothing about - the symbol. ``func`` should build its own Ticker and make the request + VendorUnavailableError: it failed in transit and says nothing about the + symbol. Yahoo answering that it has no such symbol (HTTP 404) returns None, + an empty answer. ``func`` should build its own Ticker and make the request itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same one again reads an empty profile. """ @@ -64,6 +74,8 @@ def yf_retry(func, max_retries=3, base_delay=2.0): f"Yahoo Finance rate limited after {max_retries} retries: {exc}" ) from exc except Exception as exc: + if _not_found(exc): + return None raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc @@ -258,6 +270,8 @@ def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataF auto_adjust=True, actions=False, )) + if downloaded is None: + raise_for_empty(symbol, canonical, "price rows") downloaded = _ensure_date_column(downloaded.reset_index()) # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: From 1d92269d35554b8bd91203dced568641a4e4fc41 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:07:16 +0000 Subject: [PATCH 53/63] fix(rating): read a decision's rating only from its label - a decision without a rating label is REVIEW; a lone rating word in its prose may be one it argues against ("not a Sell"), so it is no longer read as the call --- tests/test_rating_integrity.py | 11 +++++++++-- tradingagents/agents/rating.py | 29 +++++++++-------------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 61def3de2..9fad9d5fe 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -41,8 +41,15 @@ def test_prose_naming_several_ratings_without_a_label_needs_review(): @pytest.mark.unit -def test_prose_naming_one_rating_is_taken_as_the_call(): - assert extract_rating("On balance we stay Underweight until margins recover.") == "Underweight" +@pytest.mark.parametrize("prose", [ + "We would not Sell here; the dip is a chance to add.", + "**评级**:买入\n\n不建议卖出 (Sell)。", # a label in another language, and a negated Sell + "On balance we stay Underweight until margins recover.", +]) +def test_a_rating_word_without_a_label_is_not_read_as_the_call(prose): + """A rating word in prose may be one the text argues against (#1435).""" + assert extract_rating(prose) is None + assert parse_rating(prose) == RATING_REVIEW @pytest.mark.unit diff --git a/tradingagents/agents/rating.py b/tradingagents/agents/rating.py index 16d7689dd..c82d5cb28 100644 --- a/tradingagents/agents/rating.py +++ b/tradingagents/agents/rating.py @@ -49,20 +49,13 @@ _RATING_LINE_RE = re.compile( # A line presenting the scale rather than a decision ("Rating Scale: Buy, ..."). _RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE) -# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match). -_RATING_WORD_RE = re.compile( - r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE -) - - def extract_rating(text: str) -> str | None: - """Extract a 5-tier rating from prose, or ``None`` if none is present. + """Extract a 5-tier rating from its label, or ``None`` if there is none. - Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like - ``Rating:Overweight`` is matched the same as ASCII): - 1. An explicit "Rating: X" label (tolerant of markdown bold): the first one - opening its own line, else the last one anywhere. - 2. A single 5-tier rating word, when the text names only one. + Reads an explicit "Rating: X" label (tolerant of markdown bold) in the + NFKC-normalized text, so fullwidth punctuation like ``Rating:Overweight`` + matches as ASCII does: the first one opening its own line, else the last + one anywhere. """ if not text: return None @@ -83,14 +76,10 @@ def extract_rating(text: str) -> str | None: m = _RATING_LABEL_RE.search(line) if m and m.group(1).lower() in _RATING_SET: anywhere = m.group(1).capitalize() - if on_own_line or anywhere: - return on_own_line or anywhere - - # No label. A single rating word in the text is the call; several are an - # argument, and picking one of them reports a direction nobody decided -- - # prose that rejects a Buy before concluding Underweight read as Buy. - named = {m.group(1).capitalize() for m in _RATING_WORD_RE.finditer(norm)} - return named.pop() if len(named) == 1 else None + # Without a label there is no call to read: a rating word in the prose may be + # one the text argues against ("not a Sell"), and reading it reports a + # direction nobody decided. + return on_own_line or anywhere def parse_rating(text: str, default: str = RATING_REVIEW) -> str: From d7ac9461dafaaeb58c0dd0e9182afcc6951c8ecb Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:10:27 +0000 Subject: [PATCH 54/63] fix(dataflows): serve a past run financial statements only with a known filing date - Yahoo and Alpha Vantage statements, dated by the period they cover, are withheld from a run dated in the past, with a notice saying why - statements come from SEC EDGAR by default (fundamental_data "sec_edgar,yfinance"), so a past run reads US filers as filed --- README.md | 8 +-- tests/test_alpha_vantage_hardening.py | 18 ------ tests/test_dataflows_config.py | 4 +- tests/test_statement_knowledge_time.py | 60 +++++++++++++++++++ tests/test_undated_tools_as_of.py | 13 ++-- tradingagents/dataflows/date_window.py | 24 +++++++- .../vendors/alpha_vantage/fundamentals.py | 50 +++++----------- .../dataflows/vendors/yahoo/fundamentals.py | 37 +++--------- tradingagents/default_config.py | 4 +- 9 files changed, 119 insertions(+), 99 deletions(-) create mode 100644 tests/test_statement_knowledge_time.py diff --git a/README.md b/README.md index 378804c5d..8510ce373 100644 --- a/README.md +++ b/README.md @@ -280,13 +280,9 @@ See `tradingagents/default_config.py` for all configuration options. ### Fundamentals as filed -US company statements can come from SEC EDGAR, which records the date every figure was filed. A run dated in the past then reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B. +US company statements come from SEC EDGAR, which records the date every figure was filed. A run dated in the past reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B. EDGAR needs no account or API key. -EDGAR needs no account or API key. Add the vendor to the chain: - -```python -config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance" -``` +Other companies' statements come from Yahoo Finance, which dates a statement by the period it covers rather than by when it was published. A run dated today reads them; a run dated in the past is told they are withheld, since Yahoo cannot say which figures were public by then. SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project: diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index f60f0b095..02c73e933 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -77,30 +77,12 @@ _FUNDAMENTALS_JSON = json.dumps({ }) -@pytest.mark.unit -def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch): - # #1115: the payload arrives as a JSON *string*; the old dict-only guard let - # future-dated fiscal periods leak into historical runs. - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) - out = avf.get_balance_sheet("AAPL", as_of_date="2024-01-01") - assert isinstance(out, str) # callers still receive a str - parsed = json.loads(out) - assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"] - assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"] - - @pytest.mark.unit def test_fundamentals_no_curr_date_passes_through(monkeypatch): monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON -@pytest.mark.unit -def test_fundamentals_non_json_body_unchanged(monkeypatch): - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json") - assert avf.get_cashflow("AAPL", as_of_date="2024-01-01") == "not-json" - - # --------------------------------------------------------------------------- # Date trim (see the rationale on the unguarded trim in alpha_vantage.common) # --------------------------------------------------------------------------- diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py index dd0393c27..03b691e92 100644 --- a/tests/test_dataflows_config.py +++ b/tests/test_dataflows_config.py @@ -49,7 +49,7 @@ class DataflowsConfigIsolationTests(unittest.TestCase): fresh = get_config() self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage") self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance") - self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance") + self.assertEqual(fresh["data_vendors"]["fundamental_data"], "sec_edgar,yfinance") self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance") def test_nested_dict_updates_merge_one_level_deep(self): @@ -95,7 +95,7 @@ def test_a_run_reads_its_own_graphs_vendors_not_the_last_graph_built(): set_config(first) # graph A is built second = _graph(copy.deepcopy(default_config.DEFAULT_CONFIG)) - assert _vendors_seen_by_a_run(second) == ["yfinance"] + assert _vendors_seen_by_a_run(second) == ["sec_edgar,yfinance"] @pytest.mark.unit diff --git a/tests/test_statement_knowledge_time.py b/tests/test_statement_knowledge_time.py new file mode 100644 index 000000000..6c18ed91b --- /dev/null +++ b/tests/test_statement_knowledge_time.py @@ -0,0 +1,60 @@ +"""A historical run is served statements only with a known filing time. + +SEC EDGAR dates every filing, so it serves a past date as filed. Yahoo and Alpha +Vantage date a statement by the period it covers, which a company publishes weeks +later, so a past run is told they are withheld rather than served figures that may +not yet have been public. +""" + +import copy +from unittest import mock + +import pytest + +from tradingagents import default_config +from tradingagents.dataflows import router +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.vendors.alpha_vantage import fundamentals as alpha_vantage +from tradingagents.dataflows.vendors.yahoo import fundamentals as yahoo + +PAST = "2024-03-14" + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [yahoo.get_balance_sheet, yahoo.get_cashflow, yahoo.get_income_statement]) +def test_yahoo_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(yahoo, "yf_retry", side_effect=AssertionError("requested")): + out = statement("SAP.DE", "quarterly", PAST) + assert "withheld" in out and PAST in out and "filing date" in out + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [alpha_vantage.get_balance_sheet, alpha_vantage.get_cashflow, + alpha_vantage.get_income_statement]) +def test_alpha_vantage_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(alpha_vantage, "_make_api_request", side_effect=AssertionError("requested")): + out = statement("IBM", "quarterly", PAST) + assert "withheld" in out and PAST in out + + +@pytest.mark.unit +def test_a_current_run_is_still_served_yahoo_statements(): + import pandas as pd + + from tradingagents.dataflows.date_window import get_current_date + + frame = pd.DataFrame({pd.Timestamp("2026-06-30"): [1.0]}, index=["Total Assets"]) + with mock.patch.object(yahoo, "yf_retry", return_value=frame): + out = yahoo.get_balance_sheet("SAP.DE", "quarterly", get_current_date()) + assert "Total Assets" in out and "withheld" not in out + + +@pytest.mark.unit +def test_statements_come_from_sec_edgar_first_by_default(): + set_config(copy.deepcopy(default_config.DEFAULT_CONFIG)) + served = [] + chain = {name: (lambda *a, _n=name, **k: served.append(_n) or f"{_n} statements") + for name in router.VENDOR_METHODS["get_balance_sheet"]} + with mock.patch.dict(router.VENDOR_METHODS, {"get_balance_sheet": chain}): + router.route_to_vendor("get_balance_sheet", "AAPL", "quarterly", PAST) + assert served == ["sec_edgar"] diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index a75a7276c..bd4aa01c3 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -160,9 +160,10 @@ def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): # A past date withholds the live profile before any request, so the # fundamentals case is exercised on the date it does fetch. ("get_fundamentals", ("AAPL", None)), - ("get_balance_sheet", ("AAPL", "annual", "2026-09-01")), - ("get_cashflow", ("AAPL", "annual", "2026-09-01")), - ("get_income_statement", ("AAPL", "annual", "2026-09-01")), + # Statements are requested only for a run dated today; a past one withholds them. + ("get_balance_sheet", ("AAPL", "annual", get_current_date())), + ("get_cashflow", ("AAPL", "annual", get_current_date())), + ("get_income_statement", ("AAPL", "annual", get_current_date())), ("get_insider_transactions", ("AAPL", "2026-09-01")), ]) def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args): @@ -206,11 +207,11 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) with pytest.raises(VendorUnavailableError, match="unreachable"): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) @pytest.mark.unit @@ -218,11 +219,13 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): """A throttled or unreachable chain used to raise RuntimeError('No available vendor'), which ends the run, and never said the vendor was the problem.""" from tradingagents.dataflows import router + from tradingagents.dataflows.config import set_config from tradingagents.dataflows.errors import VendorUnavailableError def _down(*a, **k): raise VendorUnavailableError("Yahoo Finance is unreachable") + set_config({"data_vendors": {"fundamental_data": "yfinance"}}) monkeypatch.setitem(router.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down) out = router.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01") diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index e799914a0..1b8558f21 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -122,6 +122,26 @@ def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: f"today's quote, and even the name, sector and industry reflect today " f"rather than {as_of_date} (companies rename and get reclassified). " f"Serving them would put post-decision information into a {as_of_date} " - f"analysis. Point-in-time fundamentals for {as_of_date} are available " - f"from the balance sheet, income statement, and cash flow tools." + f"analysis. Statements filed by {as_of_date} are served as filed where " + f"the filing date is known (SEC EDGAR, for US filers)." + ) + + +def withhold_undated_statements(as_of_date: str | None, label: str, title: str) -> str | None: + """Notice to serve instead of a statement with no filing date, or None to serve it. + + A vendor that dates a statement by the period it covers (Yahoo, Alpha + Vantage) cannot say when its figures became public: a company files weeks + after the period ends, so a run dated in that gap would read figures that + were not yet known. A past run is told so; SEC EDGAR, which dates every + filing, serves US filers as filed. + """ + if not is_historical(as_of_date): + return None + return ( + f"# {title} for {label}\n" + f"# Point-in-time as of: {as_of_date}\n\n" + f"{title} data is withheld for this date. This vendor dates a statement by " + f"the period it covers and reports no filing date, so it cannot show which " + f"figures were public on {as_of_date}. SEC EDGAR serves US filers as filed." ) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py index 17a304ed5..8cc90b5ae 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py @@ -1,41 +1,15 @@ -import json - -from tradingagents.dataflows.date_window import withhold_live_profile +from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request -def _filter_reports_by_date(result, as_of_date: str): - """Drop annual/quarterly reports dated after as_of_date to prevent look-ahead. - - ``_make_api_request`` returns the fundamentals payload as a JSON string, so - parse, filter, and re-serialize. A non-JSON body or an unset ``as_of_date`` is - returned unchanged. - """ - if not as_of_date or not isinstance(result, str): - return result - try: - payload = json.loads(result) - except json.JSONDecodeError: - return result - if not isinstance(payload, dict): - return result - for key in ("annualReports", "quarterlyReports"): - if isinstance(payload.get(key), list): - payload[key] = [ - r for r in payload[key] - if r.get("fiscalDateEnding", "") <= as_of_date - ] - return json.dumps(payload) - - def get_fundamentals(ticker: str, as_of_date: str = None) -> str: """ Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. OVERVIEW serves only present-day values and carries no historical vintage, so a past ``as_of_date`` withholds it rather than leaking post-decision figures - into a backtest (#1300); the statement endpoints below stay point-in-time via - ``_filter_reports_by_date``. + into a backtest (#1300); so are the statement endpoints below, which carry + no filing date. Args: ticker (str): Ticker symbol of the company @@ -57,18 +31,24 @@ def get_fundamentals(ticker: str, as_of_date: str = None) -> str: def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Balance Sheet") + if withheld: + return withheld + return _make_api_request("BALANCE_SHEET", {"symbol": ticker}) def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("CASH_FLOW", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Cash Flow") + if withheld: + return withheld + return _make_api_request("CASH_FLOW", {"symbol": ticker}) def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Income Statement") + if withheld: + return withheld + return _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 3bbcdb890..8fa5a0a5c 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -3,7 +3,7 @@ from typing import Annotated import pandas as pd import yfinance as yf -from tradingagents.dataflows.date_window import withhold_live_profile +from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol @@ -84,27 +84,18 @@ def get_fundamentals( return f"# Company Fundamentals for {canonical}\n\n" + "\n".join(lines) -# This vendor dates a statement by the period it covers, not by the day it was -# filed, and carries no filing date to do better. A company files weeks after its -# period ends, so a run dated in that gap can be served figures that were not yet -# public. Say so rather than implying the stricter guarantee (SEC EDGAR, which -# does carry filing dates, serves US filers as filed). -_PERIOD_END_VINTAGE = ( - "# Periods are cut at the fiscal period end; this vendor does not report " - "filing dates, so the most recent period may not have been published yet.\n\n" -) - - def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> str: - """One financial statement as CSV, cut at ``as_of_date`` by period end.""" + """One financial statement as CSV, for a run dated today.""" canonical = normalize_symbol(ticker) + withheld = withhold_undated_statements(as_of_date, canonical, title) + if withheld: + return withheld what = title.lower() attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr data = yf_retry(lambda: getattr(yf.Ticker(canonical), attr)) - data = pd.DataFrame() if data is None else filter_financials_by_date(data, as_of_date) - if data.empty: + if data is None or data.empty: raise_for_empty(ticker, canonical, f"{what} data") - return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() + return f"# {title} data for {canonical} ({freq})\n" + data.to_csv() def get_balance_sheet( @@ -176,17 +167,3 @@ def get_insider_transactions( def get_company_profile(ticker: str) -> dict: """Yahoo's current profile for ``ticker``: name, sector, industry and the like.""" return yf_retry(lambda: yf.Ticker(normalize_symbol(ticker)).info) or {} - - -def filter_financials_by_date(data: pd.DataFrame, as_of_date: str) -> pd.DataFrame: - """Drop financial statement columns (fiscal period timestamps) after as_of_date. - - yfinance financial statements use fiscal period end dates as columns. - Columns after as_of_date represent future data and are removed to - prevent look-ahead bias. - """ - if not as_of_date or data.empty: - return data - cutoff = pd.Timestamp(as_of_date) - mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff - return data.loc[:, mask] diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index a79c819b2..49ecf7875 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -147,7 +147,9 @@ def build_default_config() -> dict: "data_vendors": { "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance + # Statements come from SEC EDGAR as filed (US filers), then Yahoo; the + # overview and insider tools, which SEC EDGAR does not serve, from Yahoo. + "fundamental_data": "sec_edgar,yfinance", # Options: sec_edgar, alpha_vantage, yfinance "news_data": "yfinance", # Options: alpha_vantage, yfinance "macro_data": "fred", # Options: fred (needs FRED_API_KEY) "prediction_markets": "polymarket", # Options: polymarket (keyless) From ad3913356989227b0efcdb90b9e65f7c6cace653 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:12:56 +0000 Subject: [PATCH 55/63] refactor(yahoo): keep what every Yahoo request shares in yahoo.common - yf_retry, raise_for_empty and YAHOO_HOST move from yahoo.ohlcv, which the news, fundamentals and market modules imported them from --- tests/test_fundamentals_lookahead.py | 4 +- tests/test_no_data_handling.py | 4 +- tests/test_undated_tools_as_of.py | 12 ++-- tests/test_yahoo_rate_limit.py | 10 +-- .../dataflows/vendors/yahoo/common.py | 65 +++++++++++++++++++ .../dataflows/vendors/yahoo/fundamentals.py | 2 +- .../dataflows/vendors/yahoo/market.py | 8 +-- tradingagents/dataflows/vendors/yahoo/news.py | 2 +- .../dataflows/vendors/yahoo/ohlcv.py | 59 +---------------- 9 files changed, 86 insertions(+), 80 deletions(-) create mode 100644 tradingagents/dataflows/vendors/yahoo/common.py diff --git a/tests/test_fundamentals_lookahead.py b/tests/test_fundamentals_lookahead.py index f39f88124..6852dc3d1 100644 --- a/tests/test_fundamentals_lookahead.py +++ b/tests/test_fundamentals_lookahead.py @@ -129,9 +129,9 @@ class TestNoUsableFieldsStillRaises: # yfinance returns {"trailingPegRatio": None} for unknown symbols; on a # live run that must stay a hard "no data", not a bare header. from tradingagents.dataflows.errors import NoMarketDataError - from tradingagents.dataflows.vendors.yahoo import ohlcv + from tradingagents.dataflows.vendors.yahoo import common - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): _yf(_TODAY, info={"trailingPegRatio": None}) diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index 20e1adb7b..5caab2ce6 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -17,7 +17,7 @@ import pytest from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config from tradingagents.dataflows.errors import NoMarketDataError -from tradingagents.dataflows.vendors.yahoo import ohlcv +from tradingagents.dataflows.vendors.yahoo import common, ohlcv @pytest.mark.unit @@ -35,7 +35,7 @@ class TestLoadOhlcvNoPoison(unittest.TestCase): def test_empty_download_raises_and_does_not_cache(self): empty = mock.Mock(history=mock.Mock(return_value=pd.DataFrame())) # Yahoo answers, so an empty download means the symbol has no data. - reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True) + reachable = mock.patch.object(common, "vendor_reachable", return_value=True) reachable.start() self.addCleanup(reachable.stop) with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \ diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index bd4aa01c3..521507509 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -200,16 +200,16 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): import pandas as pd from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError - from tradingagents.dataflows.vendors.yahoo import ohlcv + from tradingagents.dataflows.vendors.yahoo import common empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame()) monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: empty) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) + monkeypatch.setattr(common, "vendor_reachable", lambda url: False) with pytest.raises(VendorUnavailableError, match="unreachable"): yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) @@ -241,15 +241,15 @@ def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch) import pandas as pd from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError - from tradingagents.dataflows.vendors.yahoo import ohlcv + from tradingagents.dataflows.vendors.yahoo import common monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame())) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) + monkeypatch.setattr(common, "vendor_reachable", lambda url: False) with pytest.raises(VendorUnavailableError, match="unreachable"): yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index 3669a4fa2..81a585fc8 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -22,7 +22,7 @@ from tradingagents.agents.tools import ( from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config from tradingagents.dataflows.errors import VendorUnavailableError -from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv +from tradingagents.dataflows.vendors.yahoo import common, fundamentals, ohlcv DAY = "2026-09-18" @@ -34,8 +34,8 @@ def _rate_limited(*args, **kwargs): @pytest.fixture def yahoo(monkeypatch, tmp_path): set_config({"data_cache_dir": str(tmp_path)}) - monkeypatch.setattr(ohlcv.time, "sleep", lambda seconds: None) - for module in (ohlcv, fundamentals): + monkeypatch.setattr(common.time, "sleep", lambda seconds: None) + for module in (common, fundamentals): monkeypatch.setattr(module, "vendor_reachable", lambda url: True) return monkeypatch @@ -121,7 +121,7 @@ def test_a_price_request_that_raises_is_unavailable_whether_or_not_yahoo_answers yahoo.setattr(yf.Ticker, "history", refused) for reachable in (False, True): - yahoo.setattr(ohlcv, "vendor_reachable", lambda url, _r=reachable: _r) + yahoo.setattr(common, "vendor_reachable", lambda url, _r=reachable: _r) with pytest.raises(VendorUnavailableError, match="request failed"): ohlcv.load_ohlcv("AAPL", DAY) @@ -134,7 +134,7 @@ def test_a_price_request_that_fails_is_unavailable_even_when_yahoo_answers(monke monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: timed_out(**k)})()) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(VendorUnavailableError): ohlcv.load_ohlcv("NVDA", "2026-09-23") diff --git a/tradingagents/dataflows/vendors/yahoo/common.py b/tradingagents/dataflows/vendors/yahoo/common.py new file mode 100644 index 000000000..40b0a3bf0 --- /dev/null +++ b/tradingagents/dataflows/vendors/yahoo/common.py @@ -0,0 +1,65 @@ +"""What every Yahoo request shares: its retry, and how a failure or an empty answer is reported.""" + +import logging +import time + +import yfinance as yf +from yfinance.exceptions import YFRateLimitError + +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.net import vendor_reachable + +logger = logging.getLogger(__name__) + +YAHOO_HOST = "https://query2.finance.yahoo.com" + + +def raise_for_empty(symbol: str, canonical: str, what: str) -> None: + """Report an empty Yahoo answer as an absence, or as an outage if it is one. + + An empty answer from a Yahoo that cannot be reached is not an answer about + the symbol, so the host is probed before "this symbol has no {what}" is said. + """ + if not vendor_reachable(YAHOO_HOST): + raise VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved") + raise NoMarketDataError(symbol, canonical, f"no {what}") + + +# yfinance answers some failed requests with an empty result, which would read as +# "no data" for a symbol nobody checked; raised, the failure is reported as one. +yf.config.debug.hide_exceptions = False + + +def _not_found(exc: Exception) -> bool: + """Whether Yahoo answered that it has no such symbol (HTTP 404).""" + return getattr(getattr(exc, "response", None), "status_code", None) == 404 + + +def yf_retry(func, max_retries=3, base_delay=2.0): + """Execute a yfinance call with exponential backoff on rate limits. + + yfinance raises YFRateLimitError on HTTP 429 responses but does not + retry them internally, so this wrapper retries them. A rate limit that + outlasts the retries, or any other exception, is raised as + VendorUnavailableError: it failed in transit and says nothing about the + symbol. Yahoo answering that it has no such symbol (HTTP 404) returns None, + an empty answer. ``func`` should build its own Ticker and make the request + itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same + one again reads an empty profile. + """ + for attempt in range(max_retries + 1): + try: + return func() + except YFRateLimitError as exc: + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") + time.sleep(delay) + else: + raise VendorUnavailableError( + f"Yahoo Finance rate limited after {max_retries} retries: {exc}" + ) from exc + except Exception as exc: + if _not_found(exc): + return None + raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 8fa5a0a5c..22055805c 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -7,7 +7,7 @@ from tradingagents.dataflows.date_window import withhold_live_profile, withhold_ from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import ( +from tradingagents.dataflows.vendors.yahoo.common import ( YAHOO_HOST, raise_for_empty, yf_retry, diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index 7ce6e0735..b099caabf 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -9,12 +9,8 @@ from stockstats import wrap from tradingagents.dataflows.errors import NoMarketDataError, VendorError from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import ( - _assert_ohlcv_not_stale, - load_ohlcv, - raise_for_empty, - yf_retry, -) +from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry +from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv logger = logging.getLogger(__name__) diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 1a17ddf67..49e5c08cf 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -9,7 +9,7 @@ from dateutil.relativedelta import relativedelta from tradingagents.dataflows.config import get_config from tradingagents.dataflows.date_window import coverage_gap, in_window from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry +from tradingagents.dataflows.vendors.yahoo.common import yf_retry def _extract_article_data(article: dict) -> dict: diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index f7c1cd8d1..beb612f22 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -1,21 +1,17 @@ import logging import os -import time import pandas as pd import yfinance as yf -from yfinance.exceptions import YFRateLimitError from tradingagents.dataflows.config import get_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.errors import NoMarketDataError from tradingagents.dataflows.files import replace_file -from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component +from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry logger = logging.getLogger(__name__) -YAHOO_HOST = "https://query2.finance.yahoo.com" - # A vendor's latest OHLCV row this many calendar days before the requested date # is treated as stale. Generous enough to span long holiday weekends, tight # enough to catch the year-old frames yfinance occasionally returns (#1021). @@ -28,57 +24,6 @@ MAX_OHLCV_STALE_DAYS = 10 OHLCV_CACHE_TTL_SECONDS = 900 -def raise_for_empty(symbol: str, canonical: str, what: str) -> None: - """Report an empty Yahoo result as an absence, or as an outage if it is one. - - yfinance returns an empty frame for a failed request rather than raising, so - without this a Yahoo outage reads as "this symbol has no {what}". - """ - if not vendor_reachable(YAHOO_HOST): - raise VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved") - raise NoMarketDataError(symbol, canonical, f"no {what}") - - -# yfinance answers some failed requests with an empty result, which would read as -# "no data" for a symbol nobody checked; raised, the failure is reported as one. -yf.config.debug.hide_exceptions = False - - -def _not_found(exc: Exception) -> bool: - """Whether Yahoo answered that it has no such symbol (HTTP 404).""" - return getattr(getattr(exc, "response", None), "status_code", None) == 404 - - -def yf_retry(func, max_retries=3, base_delay=2.0): - """Execute a yfinance call with exponential backoff on rate limits. - - yfinance raises YFRateLimitError on HTTP 429 responses but does not - retry them internally, so this wrapper retries them. A rate limit that - outlasts the retries, or any other exception, is raised as - VendorUnavailableError: it failed in transit and says nothing about the - symbol. Yahoo answering that it has no such symbol (HTTP 404) returns None, - an empty answer. ``func`` should build its own Ticker and make the request - itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same - one again reads an empty profile. - """ - for attempt in range(max_retries + 1): - try: - return func() - except YFRateLimitError as exc: - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") - time.sleep(delay) - else: - raise VendorUnavailableError( - f"Yahoo Finance rate limited after {max_retries} retries: {exc}" - ) from exc - except Exception as exc: - if _not_found(exc): - return None - raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc - - def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: """Normalize the date column to ``Date``. From e1a19fd78e21c77cba521433d007b9f03c8a44b8 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:14:13 +0000 Subject: [PATCH 56/63] refactor(cli): save a run's reports through the graph, as a script does - TradingAgentsGraph.default_report_path names the results_dir/reports/TICKER_stamp folder; save_reports and the CLI both use it --- cli/run.py | 13 ++++--------- tests/test_cli_headless.py | 8 ++++++-- tests/test_reporting.py | 6 ++++-- tradingagents/graph/trading_graph.py | 12 ++++++------ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/cli/run.py b/cli/run.py index bbcd1e0d4..b8a1792f0 100644 --- a/cli/run.py +++ b/cli/run.py @@ -1,6 +1,5 @@ """Running one analysis from the CLI: build the graph, stream it into the live view, save the report.""" -import datetime import os import sys import time @@ -31,7 +30,6 @@ from tradingagents.graph.analyst_execution import ( build_analyst_execution_plan, ) from tradingagents.graph.trading_graph import TradingAgentsGraph -from tradingagents.reporting import write_report_tree def _run_directory(config: dict, ticker: str, trade_date: str) -> Path: @@ -384,29 +382,26 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): ) console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") - _offer_reports(final_state, graph, config, selections["ticker"], + _offer_reports(final_state, graph, selections["ticker"], save=flags.get("save"), show=flags.get("show")) -def _offer_reports(final_state, graph, config, ticker, save=None, show=None): +def _offer_reports(final_state, graph, ticker, save=None, show=None): """Save the report tree and show it; ``save``/``show`` answer the questions when given.""" asked = save is None if asked: save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "") if save: - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Under results_dir, not the working directory: in Docker the working # directory is inside the container and the report goes with it, while # results_dir is the mounted volume the rest of the run already writes to. - save_path = (Path(config["results_dir"]) / "reports" - / f"{safe_ticker_component(ticker)}_{timestamp}") + save_path = graph.default_report_path(ticker) if asked: # someone at the prompt may pick another folder save_path = Path(typer.prompt( "Save path (press Enter for default)", default=str(save_path) ).strip()) try: - report_file = write_report_tree(final_state, ticker, save_path, - settings=graph.run_settings()) + report_file = graph.save_reports(final_state, ticker, save_path) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index 89972eab8..3ba31b20f 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -95,10 +95,14 @@ def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path) monkeypatch.setattr(run.typer, "prompt", no_prompt) shown = [] monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state)) - graph = type("G", (), {"run_settings": lambda self: {}})() + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.run_settings = lambda: {} state = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"} - run._offer_reports(state, graph, {"results_dir": str(tmp_path)}, "NVDA", save=True, show=False) + run._offer_reports(state, graph, "NVDA", save=True, show=False) assert list(tmp_path.glob("reports/NVDA_*/complete_report.md")) assert shown == [] diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 3ddd2b3fc..1f634a89c 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -50,8 +50,10 @@ def test_save_reports_explicit_path(tmp_path): @pytest.mark.unit def test_save_reports_defaults_under_results_dir(tmp_path): - mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}, run_settings=lambda: SETTINGS) - out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL") + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.run_settings = lambda: SETTINGS + out = graph.save_reports(_state(), "AAPL") assert out.exists() assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_/... assert out.parent.name.startswith("AAPL_") diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index d2e29bdb6..180ab65b5 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -294,14 +294,14 @@ class TradingAgentsGraph: an explicit ``save_path`` or let it default under ``results_dir``. """ if save_path is None: - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - save_path = ( - Path(self.config["results_dir"]) - / "reports" - / f"{safe_ticker_component(ticker)}_{stamp}" - ) + save_path = self.default_report_path(ticker) return write_report_tree(final_state, ticker, save_path, settings=self.run_settings()) + def default_report_path(self, ticker) -> Path: + """Where a run's reports go unless told otherwise: under results_dir, stamped now.""" + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return Path(self.config["results_dir"]) / "reports" / f"{safe_ticker_component(ticker)}_{stamp}" + def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): """Build a run's initial state; propagate() and the CLI both start here. From b21f8ce1c938e1438a00b0ce061b4a3043735d64 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:15:38 +0000 Subject: [PATCH 57/63] refactor(cli): write the saved selections through the shared file writer - save_last_run uses files.replace_file, with a temp file of its own, in place of a fixed .tmp name --- cli/prefs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/prefs.py b/cli/prefs.py index 5cd3fbaf3..af2e8eb5c 100644 --- a/cli/prefs.py +++ b/cli/prefs.py @@ -22,6 +22,7 @@ from pathlib import Path from cli.models import AnalystType, AssetType from cli.prompts import _llm_provider_table, filter_analysts_for_asset_type +from tradingagents.dataflows.files import replace_file from tradingagents.llm_clients.model_catalog import get_model_options _PREFS_PATH = Path(os.path.expanduser("~")) / ".tradingagents" / "cli_prefs.json" @@ -52,9 +53,8 @@ def save_last_run(selections: dict) -> None: kept = {k: v for k, v in kept.items() if v is not None} try: _PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) - temp = _PREFS_PATH.with_suffix(".tmp") - temp.write_text(json.dumps(kept, indent=2), encoding="utf-8") - os.replace(temp, _PREFS_PATH) # a concurrent run reads one file or the other + # A concurrent run reads one file or the other. + replace_file(_PREFS_PATH, lambda temp: Path(temp).write_text(json.dumps(kept, indent=2), encoding="utf-8")) except OSError: return From bd9693f8ba7f149df6e3c0eb81f96d283b4469a8 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:15:56 +0000 Subject: [PATCH 58/63] docs: write the provider key notes without em-dashes --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8510ce373..05d16f0db 100644 --- a/README.md +++ b/README.md @@ -163,12 +163,12 @@ export GOOGLE_API_KEY=... # Google (Gemini) export ANTHROPIC_API_KEY=... # Anthropic (Claude) export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek -export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com) -export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com) +export DASHSCOPE_API_KEY=... # Qwen (international, dashscope-intl.aliyuncs.com) +export DASHSCOPE_CN_API_KEY=... # Qwen (China, dashscope.aliyuncs.com) export ZHIPU_API_KEY=... # GLM via Z.AI (international) export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn) -export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io) -export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com) +export MINIMAX_API_KEY=... # MiniMax (global, api.minimax.io) +export MINIMAX_CN_API_KEY=... # MiniMax (China, api.minimaxi.com) export OPENROUTER_API_KEY=... # OpenRouter export MISTRAL_API_KEY=... # Mistral export MOONSHOT_API_KEY=... # Kimi (Moonshot) From 248647049501a758ec7ed6d59fbe437e40a9480d Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:25:09 +0000 Subject: [PATCH 59/63] fix(yahoo): read Yahoo's answer that a window has no prices as no data - a chart with no prices, or Yahoo saying the data does not exist, is an empty answer; yfinance's same error for an error status or a service failure, and a missing time zone, stay unavailable --- tests/test_yahoo_rate_limit.py | 42 +++++++++++++++++++ .../dataflows/vendors/yahoo/common.py | 21 +++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index 81a585fc8..ef61c3779 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -240,3 +240,45 @@ def test_yahoo_answering_not_found_is_no_data(yahoo, call): except NoMarketDataError: return assert out.startswith("No insider transactions"), out # an empty filing list is not an error + + +@pytest.mark.unit +@pytest.mark.parametrize("debug, reason, absent", [ + pytest.param(" (1d 2026-09-26 -> 2026-09-27)", None, True, id="a_chart_with_no_prices"), + pytest.param(" (1d 1970-01-01 -> 1970-02-01)", "Data doesn't exist for startDate = 18000, endDate = 2696400", + True, id="yahoo_says_the_data_does_not_exist"), + pytest.param(" (1d 2026-09-21 -> 2026-09-25)(Yahoo status_code = 503)", None, False, id="an_http_error"), + pytest.param(" (1d 2026-09-21 -> 2026-09-25)", "Service temporarily unavailable", False, + id="yahoo_reports_a_service_failure"), +]) +def test_only_yahoo_saying_a_window_has_no_prices_is_no_data(yahoo, debug, reason, absent): + """yfinance raises YFPricesMissingError both when Yahoo answered with no prices + and when its answer was an error; only the first is a fact about the symbol.""" + from yfinance.exceptions import YFPricesMissingError + + from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import market + + def no_prices(self, **kwargs): + raise YFPricesMissingError("AAPL", debug, yahoo_reason=reason) + + yahoo.setattr(yf.Ticker, "history", no_prices) + + with pytest.raises(NoMarketDataError if absent else VendorUnavailableError): + market.get_YFin_data_online("AAPL", "2026-09-21", "2026-09-25") + + +@pytest.mark.unit +def test_a_missing_time_zone_is_not_taken_for_an_answer(yahoo): + """yfinance reports a failed time-zone lookup the same way as a symbol without one.""" + from yfinance.exceptions import YFTzMissingError + + from tradingagents.dataflows.vendors.yahoo import market + + def no_tz(self, **kwargs): + raise YFTzMissingError("AAPL") + + yahoo.setattr(yf.Ticker, "history", no_tz) + + with pytest.raises(VendorUnavailableError): + market.get_YFin_data_online("AAPL", "2026-09-21", "2026-09-25") diff --git a/tradingagents/dataflows/vendors/yahoo/common.py b/tradingagents/dataflows/vendors/yahoo/common.py index 40b0a3bf0..7ab0afec2 100644 --- a/tradingagents/dataflows/vendors/yahoo/common.py +++ b/tradingagents/dataflows/vendors/yahoo/common.py @@ -4,7 +4,7 @@ import logging import time import yfinance as yf -from yfinance.exceptions import YFRateLimitError +from yfinance.exceptions import YFPricesMissingError, YFRateLimitError from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable @@ -30,8 +30,19 @@ def raise_for_empty(symbol: str, canonical: str, what: str) -> None: yf.config.debug.hide_exceptions = False -def _not_found(exc: Exception) -> bool: - """Whether Yahoo answered that it has no such symbol (HTTP 404).""" +def _answered_empty(exc: Exception) -> bool: + """Whether Yahoo answered that it has nothing: no such symbol (HTTP 404), or a + price window with no prices in it. + + yfinance raises YFPricesMissingError for that answer and also for an answer + that was an error (an error status, or Yahoo describing a failure), so only + a chart with no prices, or Yahoo saying the data does not exist, counts. + A missing time zone is not an answer: yfinance reports a failed lookup the same way. + """ + if isinstance(exc, YFPricesMissingError): + reason = exc.yahoo_reason + return "status_code" not in (exc.debug_info or "") and ( + reason is None or reason.startswith("Data doesn't exist")) return getattr(getattr(exc, "response", None), "status_code", None) == 404 @@ -42,7 +53,7 @@ def yf_retry(func, max_retries=3, base_delay=2.0): retry them internally, so this wrapper retries them. A rate limit that outlasts the retries, or any other exception, is raised as VendorUnavailableError: it failed in transit and says nothing about the - symbol. Yahoo answering that it has no such symbol (HTTP 404) returns None, + symbol. Yahoo answering that it has nothing for the symbol returns None, an empty answer. ``func`` should build its own Ticker and make the request itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same one again reads an empty profile. @@ -60,6 +71,6 @@ def yf_retry(func, max_retries=3, base_delay=2.0): f"Yahoo Finance rate limited after {max_retries} retries: {exc}" ) from exc except Exception as exc: - if _not_found(exc): + if _answered_empty(exc): return None raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc From 1cf16bd0e4d5b7ae1d0802d6cc9d206770f46fa5 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:27:39 +0000 Subject: [PATCH 60/63] fix(sec_edgar): serve a quarterly figure filed only year to date, naming its span - each row takes the quarter where filed, else its 6- or 9-month figure to date, in a column labelled with the span; a filer that tags 10-Q cash flows only year to date no longer stops at each first quarter --- tests/test_sec_edgar.py | 46 ++++++++++-- tradingagents/dataflows/vendors/sec_edgar.py | 75 +++++++++++++------- 2 files changed, 90 insertions(+), 31 deletions(-) diff --git a/tests/test_sec_edgar.py b/tests/test_sec_edgar.py index 848fa5be0..be6e007f9 100644 --- a/tests/test_sec_edgar.py +++ b/tests/test_sec_edgar.py @@ -205,8 +205,8 @@ def test_older_periods_fall_back_to_the_tag_the_filer_used_then(): "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [ _fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30")]}}} values, unit = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax", - "Revenues"), "2026-01-01", (300, 400)) - assert values == {"2015-09-26": 233_715_000_000, "2024-09-28": 391_035_000_000} + "Revenues"), "2026-01-01", ((300, 400),)) + assert values == {("2015-09-26", 0): 233_715_000_000, ("2024-09-28", 0): 391_035_000_000} assert unit == "USD" @@ -216,8 +216,8 @@ def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both(): "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [ _fact("2024-09-28", 999, "2024-11-01", start="2023-09-30")]}}} values, _ = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax", - "Revenues"), "2026-01-01", (300, 400)) - assert values == {"2024-09-28": 999} + "Revenues"), "2026-01-01", ((300, 400),)) + assert values == {("2024-09-28", 0): 999} @pytest.mark.unit @@ -283,3 +283,41 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp assert eps("2019-01-01") == "16.97" assert eps("2020-01-01") == "2.12" + + +@pytest.mark.unit +def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch): + """Many filers tag a 10-Q's cash flows only year to date (3, 6, then 9 months): + a quarterly table of discrete quarters alone would stop at each first quarter.""" + ytd = [ + _fact("2025-12-27", 30_000_000_000, "2026-01-30", form="10-Q", fp="Q1", start="2025-09-28"), + _fact("2026-03-28", 53_000_000_000, "2026-05-01", form="10-Q", fp="Q2", start="2025-09-28"), + _fact("2026-06-27", 81_000_000_000, "2026-08-01", form="10-Q", fp="Q3", start="2025-09-28"), + ] + facts = {"facts": {"us-gaap": {"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": ytd}}}}} + monkeypatch.setattr(sec_edgar, "_fetch_json", + lambda url: TICKER_MAP if "company_tickers" in url else facts) + + out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28") + + assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"] + assert [r for r in out.splitlines() if r.startswith("Operating Cash Flow")] == ["Operating Cash Flow,30000,53000,81000"] + + +@pytest.mark.unit +def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_quarter(monkeypatch): + facts = {"facts": {"us-gaap": { + "NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": [ + _fact("2026-06-30", 120_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-01-01")]}}, + "PaymentsToAcquirePropertyPlantAndEquipment": {"units": {"USD": [ + _fact("2026-06-30", 10_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-04-01")]}}, + }}} + monkeypatch.setattr(sec_edgar, "_fetch_json", + lambda url: TICKER_MAP if "company_tickers" in url else facts) + + out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28") + + assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"] + rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]} + assert rows["Operating Cash Flow"] == ["", "120"] + assert rows["Capital Expenditure"] == ["10", ""] diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index 240da0d6b..653cb0a1b 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -77,6 +77,11 @@ _STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = { # end date, so a match on the end date alone can report half a year as a quarter. _SPANS = {"quarterly": (60, 115), "annual": (300, 400)} +# A 10-Q's cash flows are often filed only year to date. A quarterly table takes +# the quarter where filed, else the span to date, named in the column; it never +# subtracts one filing from another, which would give a figure no filing states. +_YEAR_TO_DATE = ((150, 200, 6), (240, 290, 9)) + # A fiscal year is a period an annual report covers. A 10-Q balance has no span # to reject, and some filers' 10-Qs report twelve-month totals that pass the span # check, so either would read as a fiscal year. The value is still the latest @@ -137,43 +142,48 @@ def cik_for(ticker: str) -> str | None: return None -def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, span: tuple[int, int], +def _span_index(fact: dict, spans: tuple[tuple[int, int], ...]) -> int | None: + """Which of ``spans`` a duration fact covers (0 for an instant fact), or None.""" + if "start" not in fact: + return 0 + days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days + return next((i for i, (low, high) in enumerate(spans) if low <= days <= high), None) + + +def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, spans: tuple[tuple[int, int], ...], forms: tuple[str, ...] = ()) -> tuple[dict, str]: - """({period end: value}, unit) for the first tag the filer reports, as known then. + """({(period end, span index): value}, unit) for the tags the filer reports, as known then. A period reported more than once takes its latest filing on or before the date, so an amendment counts from the day it was filed and not before. The unit comes from the filing: most lines are USD, earnings per share are - USD/shares, and scaling those alike would print a real figure as zero. + USD/shares, and scaling those alike would print a real figure as zero. A + duration fact (revenue, cash flow) must cover one of ``spans``; an instant + fact (a balance) has no span and serves any. """ - low, high = span - values: dict[str, float] = {} + values: dict[tuple[str, int], float] = {} chosen_unit = "USD" # Tags are tried in order and a period keeps the first one that reports it: # filers renamed lines over the years, so one tag covers only part of the # history. Values are never added across tags, which would double count. for tag in tags: for unit, unit_values in ((facts.get(tag) or {}).get("units", {})).items(): - latest: dict[str, dict] = {} - covered: set[str] = set() # period ends a filing of ``forms`` reports + latest: dict[tuple[str, int], dict] = {} + covered: set[tuple[str, int]] = set() # periods a filing of ``forms`` reports for fact in unit_values: - if fact["filed"] > as_of_date or fact["end"] in values: + index = _span_index(fact, spans) + key = (fact["end"], index) + if fact["filed"] > as_of_date or index is None or key in values: continue - # A duration fact (revenue, cash flow) must cover the span asked - # for. An instant fact (a balance) has no span and serves both. - if "start" in fact: - days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days - if not low <= days <= high: - continue if not forms or fact.get("form", "").startswith(forms): - covered.add(fact["end"]) - seen = latest.get(fact["end"]) + covered.add(key) + seen = latest.get(key) if seen is None or fact["filed"] >= seen["filed"]: - latest[fact["end"]] = fact - latest = {end: fact for end, fact in latest.items() if end in covered} + latest[key] = fact + latest = {key: fact for key, fact in latest.items() if key in covered} if latest: chosen_unit = unit - values.update({end: fact["val"] for end, fact in latest.items()}) + values.update({key: fact["val"] for key, fact in latest.items()}) return dict(sorted(values.items())), chosen_unit @@ -189,10 +199,21 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) - raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts") quarterly = freq.lower() == "quarterly" - span = _SPANS["quarterly" if quarterly else "annual"] + if quarterly: + spans = (_SPANS["quarterly"], *((low, high) for low, high, _ in _YEAR_TO_DATE)) + names = ["", *(f" ({months} months)" for _, _, months in _YEAR_TO_DATE)] + else: + spans, names = (_SPANS["annual"],), [""] forms = () if quarterly else _ANNUAL_FORMS - lines = {label: _as_of(us_gaap, tags, as_of_date, span, forms) for label, tags in _STATEMENTS[kind]} - periods = sorted({end for values, _ in lines.values() for end in values}) + lines = {label: _as_of(us_gaap, tags, as_of_date, spans, forms) for label, tags in _STATEMENTS[kind]} + # Each row takes the shortest span it reports for a period, and a column + # holds one span of one period, so a row filed only to date keeps its figure + # beside a row filed by quarter. + chosen = {label: {} for label in lines} + for label, (values, _) in lines.items(): + for end, index in values: + chosen[label][end] = min(index, chosen[label].get(end, index)) + periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()}) if not periods: raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}") @@ -200,7 +221,7 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) - f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n" f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n" ) - rows = [",".join([""] + periods)] + rows = [",".join([""] + [end + names[index] for end, index in periods])] for label, (values, unit) in lines.items(): # Every row spans the same columns, or a reader lines the table up wrong. if not values: @@ -208,10 +229,10 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) - continue name = label if unit == "USD" else f"{label} ({unit})" # Plain numbers: a thousands separator would split the CSV field. - cells = [ - (f"{values[p] / 1e6:.0f}" if unit == "USD" else f"{values[p]:.2f}") - if p in values else "" for p in periods - ] + cells = [] + for end, index in periods: + value = values.get((end, index)) if chosen[label].get(end) == index else None + cells.append("" if value is None else f"{value / 1e6:.0f}" if unit == "USD" else f"{value:.2f}") rows.append(",".join([name] + cells)) return header + "\n".join(rows) + "\n" From da6b35f22fd7237b6e76dd3b5f3121c4972f2332 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:33:58 +0000 Subject: [PATCH 61/63] fix(dataflows): withhold insider trades with no filing date from a past run - Yahoo and Alpha Vantage date a trade by when it happened, not by its Form 4 filing, so a run dated in the past is told they are withheld instead of served trades that may not yet have been public --- README.md | 2 + tests/test_no_data_handling.py | 3 +- tests/test_statement_knowledge_time.py | 18 +++++++ tests/test_undated_tools_as_of.py | 50 +------------------ tests/test_yahoo_rate_limit.py | 4 +- tradingagents/dataflows/date_window.py | 19 +++++++ .../dataflows/vendors/alpha_vantage/news.py | 18 +++---- .../dataflows/vendors/yahoo/fundamentals.py | 37 ++++---------- 8 files changed, 62 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 05d16f0db..686ad0c03 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,8 @@ US company statements come from SEC EDGAR, which records the date every figure w Other companies' statements come from Yahoo Finance, which dates a statement by the period it covers rather than by when it was published. A run dated today reads them; a run dated in the past is told they are withheld, since Yahoo cannot say which figures were public by then. +Insider trades are dated by when they happened, not when they were filed, so a run dated in the past is told they are withheld as well. + SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project: ```bash diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index 5caab2ce6..744e024bc 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -16,6 +16,7 @@ import pytest from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.errors import NoMarketDataError from tradingagents.dataflows.vendors.yahoo import common, ohlcv @@ -102,4 +103,4 @@ def test_an_unreachable_yahoo_is_not_reported_as_a_symbol_without_insider_data() with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \ mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \ pytest.raises(VendorUnavailableError): - fundamentals.get_insider_transactions("AAPL", as_of_date="2026-09-21") + fundamentals.get_insider_transactions("AAPL", as_of_date=get_current_date()) diff --git a/tests/test_statement_knowledge_time.py b/tests/test_statement_knowledge_time.py index 6c18ed91b..2a75b2e58 100644 --- a/tests/test_statement_knowledge_time.py +++ b/tests/test_statement_knowledge_time.py @@ -58,3 +58,21 @@ def test_statements_come_from_sec_edgar_first_by_default(): with mock.patch.dict(router.VENDOR_METHODS, {"get_balance_sheet": chain}): router.route_to_vendor("get_balance_sheet", "AAPL", "quarterly", PAST) assert served == ["sec_edgar"] + + +@pytest.mark.unit +def test_yahoo_withholds_insider_trades_from_a_past_run_without_asking(): + """A trade is dated when it happened; it became public with its Form 4, up to + two business days later, and Yahoo reports no filing date.""" + with mock.patch.object(yahoo, "yf_retry", side_effect=AssertionError("requested")): + out = yahoo.get_insider_transactions("AAPL", PAST) + assert "withheld" in out and PAST in out + + +@pytest.mark.unit +def test_alpha_vantage_withholds_insider_trades_from_a_past_run_without_asking(): + from tradingagents.dataflows.vendors.alpha_vantage import news as alpha_vantage_news + + with mock.patch.object(alpha_vantage_news, "_make_api_request", side_effect=AssertionError("requested")): + out = alpha_vantage_news.get_insider_transactions("IBM", PAST) + assert "withheld" in out and PAST in out diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index 521507509..b4b5ed920 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -7,7 +7,6 @@ only live odds, so a historical run withholds them. from __future__ import annotations -import json from unittest import mock import pandas as pd @@ -16,7 +15,6 @@ import pytest from tradingagents.agents import tools from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.vendors import polymarket -from tradingagents.dataflows.vendors.alpha_vantage import news as alpha_vantage_news from tradingagents.dataflows.vendors.yahoo import ( fundamentals as yahoo_fundamentals, market as yahoo_market, @@ -37,37 +35,12 @@ def _yf_insider(frame, curr_date): return yahoo_fundamentals.get_insider_transactions("AAPL", curr_date) -@pytest.mark.unit -def test_yfinance_insider_filings_after_the_date_are_dropped(): - out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02", "2025-05-30", "2025-01-10"), "2025-06-01") - assert "2026-09-08" not in out and "2025-06-02" not in out - assert "2025-05-30" in out and "2025-01-10" in out - - -@pytest.mark.unit -def test_yfinance_insider_date_before_coverage_is_unavailable_not_absent(): - out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02"), "2024-01-01") - assert "unavailable" in out and "No insider transactions reported" not in out - assert "2025-06-02" not in out # a transaction after the run date - - @pytest.mark.unit def test_yfinance_insider_without_a_date_is_unfiltered(): out = _yf_insider(_insider_frame("2026-09-08", "2025-01-10"), None) assert "2026-09-08" in out and "2025-01-10" in out -@pytest.mark.unit -def test_alpha_vantage_insider_filings_after_the_date_are_dropped(): - body = json.dumps({"data": [ - {"transaction_date": "2026-09-08", "executive": "A"}, - {"transaction_date": "2025-05-30", "executive": "B"}, - ]}) - with mock.patch.object(alpha_vantage_news, "_make_api_request", return_value=body): - out = json.loads(alpha_vantage_news.get_insider_transactions("AAPL", "2025-06-01")) - assert [t["executive"] for t in out["data"]] == ["B"] - - @pytest.mark.unit def test_polymarket_withholds_live_odds_from_a_historical_run(): with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")): @@ -121,27 +94,6 @@ def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch): assert "current name" not in today -@pytest.mark.unit -def test_insider_rows_are_dated_by_the_trade_not_the_filing(): - """yfinance reports the transaction date and carries no filing date. A trade - becomes public when the Form 4 is filed, up to two business days later, so a - run must not be told these rows were public on their transaction date.""" - import pandas as pd - - frame = pd.DataFrame({ - "Shares": [100, 200], - "Text": ["Sale at price 10.00 per share.", "Sale at price 11.00 per share."], - "Start Date": pd.to_datetime(["2026-05-01", "2026-05-20"]), - }) - ticker = mock.Mock(insider_transactions=frame) - with mock.patch.object(yahoo_market.yf, "Ticker", return_value=ticker): - out = yahoo_fundamentals.get_insider_transactions("AAPL", "2026-05-10") - - assert "2026-05-01" in out and "2026-05-20" not in out # still bounded by the date - assert "transaction date" in out.lower() # and says what the date means - assert "filed" in out.lower() # and that filing comes later - - @pytest.mark.unit def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): """The per-day fallback returned an empty string for a failed read, so the @@ -164,7 +116,7 @@ def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): ("get_balance_sheet", ("AAPL", "annual", get_current_date())), ("get_cashflow", ("AAPL", "annual", get_current_date())), ("get_income_statement", ("AAPL", "annual", get_current_date())), - ("get_insider_transactions", ("AAPL", "2026-09-01")), + ("get_insider_transactions", ("AAPL", get_current_date())), ]) def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args): """Returning the failure as text makes the router count it as an answer, so diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py index ef61c3779..29d3b14be 100644 --- a/tests/test_yahoo_rate_limit.py +++ b/tests/test_yahoo_rate_limit.py @@ -21,6 +21,7 @@ from tradingagents.agents.tools import ( ) from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import common, fundamentals, ohlcv @@ -55,7 +56,8 @@ def test_a_rate_limit_that_outlasts_the_retries_is_reported_as_one(yahoo, tool, yahoo.setattr(yf.Ticker, "insider_transactions", property(_rate_limited)) yahoo.setattr(yf, "Search", _rate_limited) - out = tool.func(*args, trade_date=DAY) + # Dated today: a past run is not served insider trades at all. + out = tool.func(*args, trade_date=get_current_date()) assert out.startswith("DATA_UNAVAILABLE"), out assert "delisted" not in out diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 1b8558f21..7852da1ac 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -127,6 +127,25 @@ def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: ) +def withhold_undisclosed_trades(as_of_date: str | None, label: str) -> str | None: + """Notice to serve instead of insider trades with no filing date, or None to serve them. + + A vendor that dates an insider trade by when it happened (Yahoo, Alpha + Vantage) cannot say when it became public: its Form 4 is filed up to two + business days later. A past run is told so rather than served trades that + may not yet have been disclosed. + """ + if not is_historical(as_of_date): + return None + return ( + f"# Insider Transactions for {label}\n" + f"# Point-in-time as of: {as_of_date}\n\n" + f"Insider transactions are withheld for this date. This vendor dates a trade " + f"by when it happened and reports no filing date, so it cannot show which " + f"trades were public on {as_of_date}." + ) + + def withhold_undated_statements(as_of_date: str | None, label: str, title: str) -> str | None: """Notice to serve instead of a statement with no filing date, or None to serve it. diff --git a/tradingagents/dataflows/vendors/alpha_vantage/news.py b/tradingagents/dataflows/vendors/alpha_vantage/news.py index 164768f47..3b5b10864 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/news.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/news.py @@ -1,6 +1,6 @@ -import json from tradingagents.dataflows.config import get_config +from tradingagents.dataflows.date_window import withhold_undisclosed_trades from tradingagents.dataflows.vendors.alpha_vantage.common import ( _make_api_request, format_datetime_for_api, @@ -77,19 +77,13 @@ def get_insider_transactions(symbol: str, as_of_date: str | None = None) -> dict Args: symbol: Ticker symbol. Example: "IBM". - as_of_date: When given, only transactions on or before it (yyyy-mm-dd). + as_of_date: The analysis date (yyyy-mm-dd); a past one withholds them. Returns: Dictionary containing insider transaction data or JSON string. """ - params = { - "symbol": symbol, - } - - response = _make_api_request("INSIDER_TRANSACTIONS", params) - if not as_of_date: - return response - payload = json.loads(response) - payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= as_of_date] - return json.dumps(payload) + withheld = withhold_undisclosed_trades(as_of_date, symbol) + if withheld: + return withheld + return _make_api_request("INSIDER_TRANSACTIONS", {"symbol": symbol}) diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 22055805c..37000728b 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -1,9 +1,12 @@ from typing import Annotated -import pandas as pd import yfinance as yf -from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements +from tradingagents.dataflows.date_window import ( + withhold_live_profile, + withhold_undated_statements, + withhold_undisclosed_trades, +) from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol @@ -125,23 +128,15 @@ def get_income_statement( return _statement(ticker, freq, as_of_date, "Income Statement", "quarterly_income_stmt", "income_stmt") -# Rows are dated by the transaction, which is when the insider traded, not when -# the market learned of it: a Form 4 is filed up to two business days later and -# this vendor reports no filing date, so the most recent rows may not have been -# public on the analysis date. -_TRANSACTION_DATE_VINTAGE = ( - "# Rows are dated by transaction date. A trade becomes public when its Form 4 " - "is filed, up to two business days later, so the newest rows may not have been " - "known on this date.\n\n" -) - - def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"], - as_of_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, + as_of_date: Annotated[str | None, "analysis date, yyyy-mm-dd"] = None, ): - """Get insider transactions data from yfinance.""" + """Get insider transactions data from yfinance, for a run dated today.""" canonical = normalize_symbol(ticker) + withheld = withhold_undisclosed_trades(as_of_date, canonical) + if withheld: + return withheld data = yf_retry(lambda: yf.Ticker(canonical).insider_transactions) # Empty is normal here (many valid symbols have no insider filings), @@ -151,17 +146,7 @@ def get_insider_transactions( raise VendorUnavailableError("Yahoo Finance is unreachable; insider filings were not retrieved") return f"No insider transactions reported for symbol '{canonical}'" - if as_of_date: - traded = data["Start Date"] - kept = data[traded <= pd.Timestamp(as_of_date)] - if kept.empty: - return ( - f"" - ) - data = kept - - return f"# Insider Transactions data for {canonical}\n" + _TRANSACTION_DATE_VINTAGE + data.to_csv() + return f"# Insider Transactions data for {canonical}\n" + data.to_csv() def get_company_profile(ticker: str) -> dict: From 7d38885e81230f73a4ae9a8761a883b2f6d87390 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Tue, 29 Sep 2026 05:25:14 +0000 Subject: [PATCH 62/63] build: require Python 3.11 and pandas 3 - requires-python 3.11 and pandas 3.0.6; the CI matrix runs Python 3.11 to 3.14 and ruff targets 3.11 - datetime.UTC in place of timezone.utc, and the rating, action, band, analyst and asset enums are StrEnum, which formats a member as its value --- .github/workflows/ci.yml | 2 +- README.md | 2 +- cli/models.py | 6 ++--- pyproject.toml | 6 ++--- tests/test_news_lookahead.py | 24 +++++++++---------- tests/test_social_lookahead.py | 18 +++++++------- tradingagents/agents/schemas.py | 8 +++---- tradingagents/dataflows/date_window.py | 8 +++---- tradingagents/dataflows/vendors/polymarket.py | 4 ++-- tradingagents/dataflows/vendors/reddit.py | 6 ++--- tradingagents/dataflows/vendors/yahoo/news.py | 4 ++-- 11 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e869ed0ed..48cd4b255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] # One run away from UTC, so a test that only passes in UTC fails here. include: - python-version: "3.12" diff --git a/README.md b/README.md index 686ad0c03..98c411d44 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ git clone https://github.com/TauricResearch/TradingAgents.git cd TradingAgents ``` -Create a virtual environment in any of your favorite environment managers: +TradingAgents needs Python 3.11 or later. Create a virtual environment in any of your favorite environment managers: ```bash conda create -n tradingagents python=3.13 conda activate tradingagents diff --git a/cli/models.py b/cli/models.py index 1eef2b7e3..244f5d40d 100644 --- a/cli/models.py +++ b/cli/models.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class AnalystType(str, Enum): +class AnalystType(StrEnum): MARKET = "market" # Wire value stays "social" for saved-config and string-keyed-caller # back-compat; the user-facing label is "Sentiment Analyst". @@ -10,6 +10,6 @@ class AnalystType(str, Enum): FUNDAMENTALS = "fundamentals" -class AssetType(str, Enum): +class AssetType(StrEnum): STOCK = "stock" CRYPTO = "crypto" diff --git a/pyproject.toml b/pyproject.toml index 4cb3f1216..47c4269b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "tradingagents" dynamic = ["version"] description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "langchain-core>=1.6.5", "langchain-anthropic>=1.7.4", @@ -15,7 +15,7 @@ dependencies = [ "langchain-openai>=1.6.6", "langgraph>=1.2.12", "langgraph-checkpoint-sqlite>=3.1.1", - "pandas>=2.3.0", + "pandas>=3.0.6", "python-dotenv>=1.0.0", "pytz>=2025.2", "questionary>=2.1.0", @@ -65,7 +65,7 @@ filterwarnings = [ [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" extend-exclude = ["results"] [tool.ruff.lint] diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py index 5c5c1c420..9d96dfbab 100644 --- a/tests/test_news_lookahead.py +++ b/tests/test_news_lookahead.py @@ -6,7 +6,7 @@ news injected future articles), #993 (empty-after-filter returned a blank body), and #1126 (inclusive upper bound leaked the midnight-after article; host-local timestamp parsing made filtering machine-dependent). """ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -16,7 +16,7 @@ from tradingagents.dataflows.date_window import in_window def _epoch(date_str): """Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent).""" - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -28,7 +28,7 @@ def test_flat_article_publish_time_is_parsed(): ) assert data["pub_date"] is not None assert data["pub_date"].tzinfo is not None - assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc) + assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=UTC) @pytest.mark.unit @@ -45,7 +45,7 @@ def test_window_excludes_future_and_undated_in_backtest(): @pytest.mark.unit def test_window_keeps_undated_in_live_window(): # Live window (reaches today): undated articles can't be "future", so keep them. - now = datetime.now(timezone.utc) + now = datetime.now(UTC) assert in_window(None, now, now) is True @@ -55,8 +55,8 @@ def test_upper_bound_is_exclusive(): # the old inclusive bound; the whole of end_date itself must still be kept. start = datetime(2025, 5, 1) end = datetime(2025, 5, 9) - midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc) - last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc) + midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=UTC) + last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=UTC) assert in_window(midnight_after, start, end) is False assert in_window(last_moment, start, end) is True @@ -146,11 +146,11 @@ def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch): @pytest.mark.parametrize("dates, expect_gap", [ ([], True), # empty feed: covers at most now ([None], True), # undated only: same - ([datetime(2026, 5, 20, tzinfo=timezone.utc)], True), # all after the window - ([datetime(2026, 5, 4, tzinfo=timezone.utc)], True), # starts mid-window: partial - ([datetime(2026, 5, 1, 18, tzinfo=timezone.utc)], False), # reaches the first day - ([datetime(2026, 5, 20, tzinfo=timezone.utc), - datetime(2026, 4, 1, tzinfo=timezone.utc)], False), # coverage reaches back + ([datetime(2026, 5, 20, tzinfo=UTC)], True), # all after the window + ([datetime(2026, 5, 4, tzinfo=UTC)], True), # starts mid-window: partial + ([datetime(2026, 5, 1, 18, tzinfo=UTC)], False), # reaches the first day + ([datetime(2026, 5, 20, tzinfo=UTC), + datetime(2026, 4, 1, tzinfo=UTC)], False), # coverage reaches back ]) def test_coverage_gap_boundaries(dates, expect_gap): from tradingagents.dataflows.date_window import coverage_gap @@ -208,7 +208,7 @@ def test_coverage_gap_future_window_is_unavailable(): from datetime import timedelta from tradingagents.dataflows.date_window import coverage_gap - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items") assert out is not None and "past today" in out diff --git a/tests/test_social_lookahead.py b/tests/test_social_lookahead.py index ca58e1a37..11e62c42d 100644 --- a/tests/test_social_lookahead.py +++ b/tests/test_social_lookahead.py @@ -8,7 +8,7 @@ as-of date. All three sources share dataflows.date_window.in_window. from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -38,10 +38,10 @@ class _JsonResp: def test_in_window_bounds_and_exclusive_upper(): start = datetime(2026, 5, 1) end = datetime(2026, 5, 9) - assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True - assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True + assert in_window(datetime(2026, 5, 5, tzinfo=UTC), start, end) is True + assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=UTC), start, end) is True # exactly midnight after end -> excluded (no leak) - assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False + assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=UTC), start, end) is False # offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True @@ -50,7 +50,7 @@ def test_in_window_bounds_and_exclusive_upper(): def test_in_window_undated_excluded_in_backtest_kept_live(): old = datetime(2026, 5, 9) assert in_window(None, datetime(2026, 5, 1), old) is False # historical - now = datetime.now(timezone.utc) + now = datetime.now(UTC) assert in_window(None, now, now) is True # live @@ -96,7 +96,7 @@ def test_stocktwits_no_window_is_unfiltered(monkeypatch): # --- Reddit ----------------------------------------------------------------- def _epoch(date_str): - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -184,7 +184,7 @@ def test_reddit_window_straddling_the_lookback_is_unavailable(monkeypatch): # Ten days ago through five days ago: the week-long search never reaches the # first three days, so an empty result cannot stand for the whole window. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -198,7 +198,7 @@ def test_reddit_standard_week_window_empty_is_a_real_absence(monkeypatch): # The graph's window is [trade_date - 7, trade_date]; the week-long search # covers it, so an empty result is genuine silence. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -212,7 +212,7 @@ def test_reddit_full_page_does_not_vouch_for_older_days(monkeypatch): # 100 posts from today say nothing about five days ago: the page may have # cut older matches off, so the window stays unavailable. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() ts = _epoch(str(today)) page = [{"title": f"T{i}", "created_utc": ts, "subreddit": "stocks"} for i in range(reddit._FEED_PAGE)] monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: page) diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py index fb89aa42b..be561f2c4 100644 --- a/tradingagents/agents/schemas.py +++ b/tradingagents/agents/schemas.py @@ -18,7 +18,7 @@ so that: from __future__ import annotations -from enum import Enum +from enum import StrEnum from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -63,7 +63,7 @@ def _coerce_optional_float(value): # --------------------------------------------------------------------------- -class PortfolioRating(str, Enum): +class PortfolioRating(StrEnum): """5-tier rating used by the Research Manager and Portfolio Manager.""" BUY = "Buy" @@ -73,7 +73,7 @@ class PortfolioRating(str, Enum): SELL = "Sell" -class TraderAction(str, Enum): +class TraderAction(StrEnum): """3-tier transaction direction used by the Trader. The Trader's job is to translate the Research Manager's investment plan @@ -293,7 +293,7 @@ def render_pm_decision(decision: PortfolioDecision) -> str: # --------------------------------------------------------------------------- -class SentimentBand(str, Enum): +class SentimentBand(StrEnum): """Discrete sentiment direction produced by the Sentiment Analyst. Six tiers keep the signal granular enough to be actionable while remaining diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 7852da1ac..6c30051f7 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -12,12 +12,12 @@ from __future__ import annotations -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta def to_utc(dt: datetime) -> datetime: """Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.""" - return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool: @@ -28,7 +28,7 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> end = to_utc(end_dt) if pub_dt is not None: return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1) - return end >= datetime.now(timezone.utc) - timedelta(days=1) + return end >= datetime.now(UTC) - timedelta(days=1) def get_current_date() -> str: @@ -57,7 +57,7 @@ def coverage_gap( returned newest-first and unbroken in time; a merged or relevance-ranked result passes no dates, leaving only the present as the bound. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) oldest = min((to_utc(d) for d in dates if d is not None), default=now) if datetime.strptime(end_date, "%Y-%m-%d").date() > now.date(): reason = "the window extends past today" diff --git a/tradingagents/dataflows/vendors/polymarket.py b/tradingagents/dataflows/vendors/polymarket.py index 2dae8f367..69e27e6ff 100644 --- a/tradingagents/dataflows/vendors/polymarket.py +++ b/tradingagents/dataflows/vendors/polymarket.py @@ -11,7 +11,7 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance). """ import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime import requests @@ -101,7 +101,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str f"Proceed without prediction-market signal for '{topic}'." ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) candidates = [ m for event in data.get("events", []) diff --git a/tradingagents/dataflows/vendors/reddit.py b/tradingagents/dataflows/vendors/reddit.py index c490ebabe..d39a052fd 100644 --- a/tradingagents/dataflows/vendors/reddit.py +++ b/tradingagents/dataflows/vendors/reddit.py @@ -24,7 +24,7 @@ import re import time import xml.etree.ElementTree as ET from collections.abc import Iterable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -51,7 +51,7 @@ def _within_window(posts, start_date, end_date): def _posted_at(post) -> datetime | None: """A post's ``created_utc`` epoch as a UTC datetime, or None when missing.""" ts = post.get("created_utc") - return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None + return datetime.fromtimestamp(ts, tz=UTC) if ts else None def _coverage_dates(posts) -> list: @@ -61,7 +61,7 @@ def _coverage_dates(posts) -> list: themselves do.""" dates = [_posted_at(p) for p in posts] if len(posts) < _FEED_PAGE: - dates.append(datetime.now(timezone.utc) - _SEARCH_LOOKBACK) + dates.append(datetime.now(UTC) - _SEARCH_LOOKBACK) return dates diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 49e5c08cf..1671daa8b 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -1,7 +1,7 @@ """yfinance-based news data fetching functions.""" import contextlib -from datetime import datetime, timezone +from datetime import UTC, datetime import yfinance as yf from dateutil.relativedelta import relativedelta @@ -47,7 +47,7 @@ def _extract_article_data(article: dict) -> dict: # Epoch seconds are UTC; parse them as UTC-aware so filtering does # not shift with the host timezone (#1126). with contextlib.suppress(ValueError, OSError, TypeError): - pub_date = datetime.fromtimestamp(ts, tz=timezone.utc) + pub_date = datetime.fromtimestamp(ts, tz=UTC) return { "title": article.get("title", "No title"), "summary": article.get("summary", ""), From 23536f545a2a7c807361b2d5dc2d0d224ac02b7d Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Tue, 29 Sep 2026 05:55:36 +0000 Subject: [PATCH 63/63] release: 0.5.2 --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++- README.md | 3 ++- tradingagents/__init__.py | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a22b5251d..35bbfb2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,60 @@ All notable changes to TradingAgents are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Changes that need action when upgrading are listed first in their release. +Changes that need action when upgrading are listed under "Upgrading from" in their release. + +## [0.5.2] — 2026-09-29 + +Parallel analysts, unattended CLI runs, reports that record what produced them, +and past runs served only what was known on their date. + +### Added + +- **Parallel analysts.** The selected analysts run at the same time, so the analyst phase takes about as long as the slowest one; the CLI shows each analyst's progress and time as it finishes, and the debate starts when all reports are in. (#1255, #433) +- **Unattended CLI runs.** Flags answer the per-run questions, and `TRADINGAGENTS_*` variables the rest, so a scheduled job or script asks nothing: `tradingagents --ticker NVDA --date 2026-09-26 --analysts market,news --save --no-show`. Without a terminal, a missing answer stops the run before it starts and names what to set. (#1127, #1133) +- **Reports record what produced them.** Each report opens with the analysis date, version, provider, models, analysts, rounds and data vendors; the state log keeps them as `run_settings`. (#752, #1197) +- **Docker data folder.** `TRADINGAGENTS_DATA_DIR` keeps results, reports and the memory log in a host folder. (#865) +- **A cap on an analyst's tool calls.** After `max_tool_rounds` rounds (default 20, `TRADINGAGENTS_MAX_TOOL_ROUNDS`) the analyst writes its report from what it has. (#1420) +- `tradingagents backtest` shows each cell as it starts and the `--run-id` to continue; `--analysts` accepts `sentiment`. +- Benchmarks for Taiwan, Korea, Singapore and the main European exchanges. (#1392) +- Python 3.14 support; the Docker image runs Python 3.13. + +### Models + +- `deepseek-flash` is treated as a thinking model, so structured output works. (#1388) + +### Upgrading from 0.5.1 + +- Python 3.11 or later is required; pandas 3 is installed. +- Minimum versions: yfinance 1.7.0, langchain-core 1.6.5, langchain-openai 1.6.6, langchain-anthropic 1.7.4, langchain-google-genai 4.4.0, langgraph 1.2.12, langgraph-checkpoint-sqlite 3.1.1, langchain-aws 1.7.9 (`bedrock` extra). +- Statements default to SEC EDGAR, then Yahoo (`fundamental_data = "sec_edgar,yfinance"`). +- A past-dated run gets no Yahoo or Alpha Vantage statements or insider trades, which carry no filing date, and only the company's current name, marked as such. +- The memory log is the `tradingagents.memory` package (was `tradingagents.decision_log`); settlement and reflection moved there from `tradingagents.graph`, and `Reflector` is no longer exported from `tradingagents.graph`. +- `process_signal` is removed; the rating is `propagate()`'s second value or `final_state["final_rating"]`. +- A rating is read only from its `Rating:` label; a free-text decision without one is `REVIEW`. +- Data-layer functions take `as_of_date=` (was `curr_date=`); `build_instrument_context` and `resolve_instrument_context` take `trade_date=`. +- `VendorRateLimitError` is `VendorUnavailableError`. +- Data tools take the instrument from the run's state; tool calls no longer carry `symbol` or `ticker`. +- State log: `trader_investment_plan` (was `trader_investment_decision`), `judge_decision` removed, `final_rating` and `run_settings` added. +- Checkpoints from 0.5.1, or saved under different settings, are not resumed. + +### Fixed + +- Data tools serve the run's instrument, even when the model passes another symbol. +- A failed vendor request is reported as unavailable, not as a missing or delisted symbol; one vendor's "no data" no longer speaks for a chain in which another failed. (#1386) +- The rating is the Portfolio Manager's own, not one its thesis quotes (#1383); its line keeps an English label in other output languages (#1435). +- Backtest cells given twice run once (#1415), and settlement compares a decision with its benchmark over the same dates. +- A snapshot for a symbol with no prices no longer ends the run. +- Concurrent runs no longer drop memory log entries. +- SEC EDGAR quarterly figures filed only year to date are served, labelled with their span. +- Yahoo dividend yield and debt-to-equity print as percentages. (#1414) +- The FRED change line names the dates it spans. (#1397) +- `stream_run` serves the graph's own config to its tools. +- The test suite stays off the network and out of the user's files, and passes in any order; integration tests run with `-m integration`. (#1395) + +### Contributors + +[@1Wizzy](https://github.com/1Wizzy), [@BichengWang](https://github.com/BichengWang), [@Chaoqi31](https://github.com/Chaoqi31), [@davidalmeida90](https://github.com/davidalmeida90), [@djconnexion77](https://github.com/djconnexion77), [@fuzing](https://github.com/fuzing), [@hailampy123](https://github.com/hailampy123), [@hesam-shams](https://github.com/hesam-shams), [@HEYALT](https://github.com/HEYALT), [@Jackzigen](https://github.com/Jackzigen), [@kagura-agent](https://github.com/kagura-agent), [@loulanyue](https://github.com/loulanyue), [@mannubaveja007](https://github.com/mannubaveja007), [@olivergpt](https://github.com/olivergpt), [@rita112025-cpu](https://github.com/rita112025-cpu), [@shuyan-code](https://github.com/shuyan-code), [@SingTheCode](https://github.com/SingTheCode), [@sjq597](https://github.com/sjq597), [@xiaodu55](https://github.com/xiaodu55), [@Youholdme](https://github.com/Youholdme). ## [0.5.1] — 2026-09-24 diff --git a/README.md b/README.md index 98c411d44..ff99bb47a 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,16 @@ ## News +- [2026-09] **TradingAgents v0.5.2** released with analysts that work at the same time, unattended CLI runs from flags, reports that record what produced them, and past runs served only what was known on their date. - [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements. - [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider. -- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. Full release notes are in [CHANGELOG.md](CHANGELOG.md).
Earlier news +- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. - [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. - [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate. - [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening. diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 9a1d262a9..01faa960c 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,6 +1,6 @@ """TradingAgents: multi-agent LLM financial trading framework.""" -__version__ = "0.5.2.dev0" +__version__ = "0.5.2" from dotenv import find_dotenv, load_dotenv