diff --git a/tests/test_tool_date_enforcement.py b/tests/test_tool_date_enforcement.py new file mode 100644 index 000000000..d3e8e7fb7 --- /dev/null +++ b/tests/test_tool_date_enforcement.py @@ -0,0 +1,140 @@ +"""Dated tools take the analysis date from graph state, not from the model. + +Every point-in-time guard behind a tool trusts the date it is given. A model that +omits the date, or passes today's instead of the analysis date, would otherwise +walk past them. The run's trade_date is injected from state and hidden from the +model-visible schema. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest +from langchain_core.messages import AIMessage +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode + +from tradingagents.agents.utils import ( + core_stock_tools, + fundamental_data_tools, + macro_data_tools, + market_data_validation_tools, + news_data_tools, + technical_indicators_tools, +) +from tradingagents.dataflows.date_window import as_of, as_of_window + +TRADE_DATE = "2026-08-14" + + +@pytest.mark.unit +@pytest.mark.parametrize("requested, expected", [ + ("2026-09-14", TRADE_DATE), # later than the run: clamped + ("2026-08-01", "2026-08-01"), # earlier: narrows, allowed + (None, TRADE_DATE), # omitted + ("Sept 1", TRADE_DATE), # unparseable + ("", TRADE_DATE), +]) +def test_as_of_takes_the_earlier_date(requested, expected): + assert as_of(requested, TRADE_DATE) == expected + + +@pytest.mark.unit +def test_as_of_without_a_trade_date_passes_the_request_through(): + assert as_of("2026-09-14", "") == "2026-09-14" + + +@pytest.mark.unit +@pytest.mark.parametrize("start, end, expected", [ + ("2026-08-01", "2026-09-14", ("2026-08-01", TRADE_DATE)), # end clamped + ("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged + ("2026-09-01", "2026-09-08", ("2026-08-07", TRADE_DATE)), # wholly later: span kept, moved back +]) +def test_as_of_window(start, end, expected): + assert as_of_window(start, end, TRADE_DATE) == expected + + +DATED_TOOLS = [ + core_stock_tools.get_stock_data, + fundamental_data_tools.get_fundamentals, + fundamental_data_tools.get_balance_sheet, + fundamental_data_tools.get_cashflow, + fundamental_data_tools.get_income_statement, + news_data_tools.get_news, + news_data_tools.get_global_news, + technical_indicators_tools.get_indicators, + macro_data_tools.get_macro_indicators, + market_data_validation_tools.get_verified_market_snapshot, +] + + +@pytest.mark.unit +@pytest.mark.parametrize("tool", DATED_TOOLS, ids=lambda t: t.name) +def test_trade_date_is_hidden_from_the_model(tool): + assert "trade_date" not in tool.tool_call_schema.model_json_schema()["properties"] + + +class _State(MessagesState): + trade_date: str + + +def _run(tool, args, module): + """Call the tool through a ToolNode in a graph carrying the run's trade_date.""" + graph = StateGraph(_State) + graph.add_node("tools", ToolNode([tool])) + graph.add_edge(START, "tools") + graph.add_edge("tools", END) + with mock.patch.object(module, "route_to_vendor", return_value="ok") as routed: + graph.compile().invoke({ + "messages": [AIMessage("", tool_calls=[{"name": tool.name, "args": args, "id": "1"}])], + "trade_date": TRADE_DATE, + }) + return routed.call_args.args + + +@pytest.mark.unit +def test_statement_tool_with_omitted_date_uses_the_run_date(): + args = _run(fundamental_data_tools.get_balance_sheet, {"ticker": "AAPL"}, fundamental_data_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(fundamental_data_tools.get_fundamentals, + {"ticker": "AAPL", "curr_date": "2026-09-14"}, fundamental_data_tools) + assert args == ("get_fundamentals", "AAPL", TRADE_DATE) + + +@pytest.mark.unit +def test_future_window_from_the_model_is_clamped(): + args = _run(core_stock_tools.get_stock_data, + {"symbol": "AAPL", "start_date": "2026-08-01", "end_date": "2026-09-14"}, core_stock_tools) + assert args == ("get_stock_data", "AAPL", "2026-08-01", TRADE_DATE) + + +@pytest.mark.unit +def test_direct_call_without_state_is_unchanged(): + with mock.patch.object(news_data_tools, "route_to_vendor", return_value="ok") as routed: + news_data_tools.get_news.func("AAPL", "2026-09-01", "2026-09-08") + assert routed.call_args.args == ("get_news", "AAPL", "2026-09-01", "2026-09-08") + + +# --- the run date itself (#1319) ------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.parametrize("bad", ["2026-9-10", "2026-09-10 00:00", "Sept 10", None]) +def test_propagate_rejects_a_non_canonical_date(bad): + from tradingagents.graph.trading_graph import TradingAgentsGraph + + with pytest.raises(ValueError, match="YYYY-MM-DD"): + object.__new__(TradingAgentsGraph).propagate("AAPL", bad) + + +@pytest.mark.unit +def test_propagate_rejects_a_future_date(monkeypatch): + import tradingagents.graph.trading_graph as tg + + monkeypatch.setattr(tg, "get_current_date", lambda: "2026-09-10") + with pytest.raises(ValueError, match="future"): + object.__new__(tg.TradingAgentsGraph).propagate("AAPL", "2026-09-11") diff --git a/tradingagents/agents/utils/core_stock_tools.py b/tradingagents/agents/utils/core_stock_tools.py index bd5cabfad..eb97d982c 100644 --- a/tradingagents/agents/utils/core_stock_tools.py +++ b/tradingagents/agents/utils/core_stock_tools.py @@ -1,7 +1,9 @@ from typing import Annotated from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState +from tradingagents.dataflows.date_window import as_of_window from tradingagents.dataflows.interface import route_to_vendor @@ -10,6 +12,7 @@ def get_stock_data( symbol: Annotated[str, "ticker symbol of the company"], 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. @@ -21,4 +24,5 @@ def get_stock_data( Returns: str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified 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) diff --git a/tradingagents/agents/utils/fundamental_data_tools.py b/tradingagents/agents/utils/fundamental_data_tools.py index aefa0de7c..ffe76decc 100644 --- a/tradingagents/agents/utils/fundamental_data_tools.py +++ b/tradingagents/agents/utils/fundamental_data_tools.py @@ -1,7 +1,9 @@ from typing import Annotated from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState +from tradingagents.dataflows.date_window import as_of from tradingagents.dataflows.interface import route_to_vendor @@ -9,6 +11,7 @@ from tradingagents.dataflows.interface import route_to_vendor def get_fundamentals( ticker: Annotated[str, "ticker symbol"], 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. @@ -19,7 +22,7 @@ def get_fundamentals( Returns: str: A formatted report containing comprehensive fundamental data """ - return route_to_vendor("get_fundamentals", ticker, curr_date) + return route_to_vendor("get_fundamentals", ticker, as_of(curr_date, trade_date)) @tool @@ -27,6 +30,7 @@ def get_balance_sheet( ticker: Annotated[str, "ticker symbol"], 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. @@ -38,7 +42,7 @@ def get_balance_sheet( Returns: str: A formatted report containing balance sheet data """ - return route_to_vendor("get_balance_sheet", ticker, freq, curr_date) + return route_to_vendor("get_balance_sheet", ticker, freq, as_of(curr_date, trade_date)) @tool @@ -46,6 +50,7 @@ def get_cashflow( ticker: Annotated[str, "ticker symbol"], 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. @@ -57,7 +62,7 @@ def get_cashflow( Returns: str: A formatted report containing cash flow statement data """ - return route_to_vendor("get_cashflow", ticker, freq, curr_date) + return route_to_vendor("get_cashflow", ticker, freq, as_of(curr_date, trade_date)) @tool @@ -65,6 +70,7 @@ def get_income_statement( ticker: Annotated[str, "ticker symbol"], 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. @@ -76,4 +82,4 @@ def get_income_statement( Returns: str: A formatted report containing income statement data """ - return route_to_vendor("get_income_statement", ticker, freq, curr_date) + return route_to_vendor("get_income_statement", ticker, freq, as_of(curr_date, trade_date)) diff --git a/tradingagents/agents/utils/macro_data_tools.py b/tradingagents/agents/utils/macro_data_tools.py index 3d567549b..f44809e6c 100644 --- a/tradingagents/agents/utils/macro_data_tools.py +++ b/tradingagents/agents/utils/macro_data_tools.py @@ -1,7 +1,9 @@ from typing import Annotated from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState +from tradingagents.dataflows.date_window import as_of from tradingagents.dataflows.interface import route_to_vendor @@ -17,6 +19,7 @@ def get_macro_indicators( look_back_days: Annotated[ int | None, "Trailing window length in days; omit for a 1-year window" ] = None, + trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ Retrieve a macroeconomic indicator time series from FRED (Federal Reserve @@ -33,4 +36,4 @@ def get_macro_indicators( Returns: str: A formatted markdown report of the macro series """ - return route_to_vendor("get_macro_indicators", indicator, curr_date, look_back_days) + return route_to_vendor("get_macro_indicators", indicator, as_of(curr_date, trade_date), look_back_days) diff --git a/tradingagents/agents/utils/market_data_validation_tools.py b/tradingagents/agents/utils/market_data_validation_tools.py index 356a4ac49..853bda007 100644 --- a/tradingagents/agents/utils/market_data_validation_tools.py +++ b/tradingagents/agents/utils/market_data_validation_tools.py @@ -1,7 +1,9 @@ from typing import Annotated from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState +from tradingagents.dataflows.date_window import as_of from tradingagents.dataflows.market_data_validator import build_verified_market_snapshot @@ -12,6 +14,7 @@ def get_verified_market_snapshot( look_back_days: Annotated[ int, "number of recent trading rows to include for sanity-checking" ] = 30, + trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """Deterministic verification snapshot for exact market-data claims. @@ -20,4 +23,4 @@ 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, curr_date, look_back_days) + return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) diff --git a/tradingagents/agents/utils/news_data_tools.py b/tradingagents/agents/utils/news_data_tools.py index 6c6f5352a..5be1c310d 100644 --- a/tradingagents/agents/utils/news_data_tools.py +++ b/tradingagents/agents/utils/news_data_tools.py @@ -1,7 +1,9 @@ from typing import Annotated 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.interface import route_to_vendor @@ -10,6 +12,7 @@ def get_news( ticker: Annotated[str, "Ticker symbol"], 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. @@ -21,6 +24,7 @@ def get_news( Returns: str: A formatted string containing news data """ + start_date, end_date = as_of_window(start_date, end_date, trade_date) return route_to_vendor("get_news", ticker, start_date, end_date) @tool @@ -28,6 +32,7 @@ def get_global_news( curr_date: Annotated[str, "Current date in yyyy-mm-dd format"], look_back_days: Annotated[int | None, "Days to look back; omit to use the configured default"] = None, limit: Annotated[int | None, "Max articles to return; omit to use the configured default"] = None, + trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ Retrieve global news data. @@ -43,7 +48,7 @@ def get_global_news( Returns: str: A formatted string containing global news data """ - return route_to_vendor("get_global_news", curr_date, look_back_days, limit) + return route_to_vendor("get_global_news", as_of(curr_date, trade_date), look_back_days, limit) @tool def get_insider_transactions( diff --git a/tradingagents/agents/utils/technical_indicators_tools.py b/tradingagents/agents/utils/technical_indicators_tools.py index c1c5ad99f..c6baecf37 100644 --- a/tradingagents/agents/utils/technical_indicators_tools.py +++ b/tradingagents/agents/utils/technical_indicators_tools.py @@ -1,7 +1,9 @@ from typing import Annotated from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState +from tradingagents.dataflows.date_window import as_of from tradingagents.dataflows.interface import route_to_vendor @@ -11,6 +13,7 @@ def get_indicators( 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. @@ -25,6 +28,7 @@ def get_indicators( """ # LLMs sometimes pass multiple indicators as a comma-separated string; # split and process each individually. + curr_date = as_of(curr_date, trade_date) indicators = [i.strip().lower() for i in indicator.split(",") if i.strip()] results = [] for ind in indicators: diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 075aa2f95..57ba38e1b 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -59,6 +59,39 @@ def coverage_gap( return f"<{source} unavailable for {start_date}..{end_date}: {reason}, so this is not an absence of {subject}>" +def _parse(date: str | None) -> datetime | None: + try: + return datetime.strptime(date, "%Y-%m-%d") + except (TypeError, ValueError): + return None + + +def as_of(requested: str | None, trade_date: str) -> str | None: + """The date a tool serves: the model's date, but never later than the run's. + + A model can omit the date or pass today's instead of the analysis date, which + would walk past every point-in-time guard behind the tool. An empty + ``trade_date`` (a direct call outside a graph run) passes the request through. + """ + if not trade_date: + return requested + parsed = _parse(requested) + return requested if parsed is not None and parsed <= _parse(trade_date) else trade_date + + +def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, str]: + """``[start, end]`` with its end clamped to the run date. + + A window wholly after the run date keeps its length and moves back to end there. + """ + end = as_of(end_date, trade_date) + start, old_end = _parse(start_date), _parse(end_date) + if end == end_date or start is None or start <= _parse(end): + return start_date, end + span = (old_end - start) if old_end is not None and old_end >= start else timedelta(0) + return f"{_parse(end) - span:%Y-%m-%d}", end + + def withhold_live_profile(curr_date: str | None, label: str) -> str | None: """Notice to serve instead of a live-only company profile, or None to serve it. diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index f20d7cce0..4d732644b 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -30,7 +30,7 @@ from tradingagents.agents.utils.agent_utils import ( ) from tradingagents.agents.utils.memory import TradingMemoryLog from tradingagents.dataflows.config import set_config -from tradingagents.dataflows.utils import safe_ticker_component +from tradingagents.dataflows.utils import get_current_date, safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import create_llm_client from tradingagents.reporting import write_report_tree @@ -45,6 +45,20 @@ from .signal_processing import SignalProcessor logger = logging.getLogger(__name__) +def _validate_trade_date(trade_date) -> str: + """The run date as a canonical ``YYYY-MM-DD`` string no later than today.""" + value = str(trade_date) + try: + canonical = datetime.strptime(value, "%Y-%m-%d").strftime("%Y-%m-%d") == value + except ValueError: + canonical = False + if not canonical: + raise ValueError(f"trade_date must be a date in YYYY-MM-DD format, got {trade_date!r}") + if value > get_current_date(): + raise ValueError(f"trade_date cannot be in the future: {value}") + return value + + def _coerce_max_retries(value): """Validate an ``llm_max_retries`` value to a non-negative int. @@ -417,6 +431,7 @@ class TradingAgentsGraph: ``tradingagents.agents.utils.rating.is_review`` before mapping it to the PortfolioRating enum. """ + trade_date = _validate_trade_date(trade_date) self.ticker = company_name with self.checkpoint_scope(company_name, trade_date, asset_type) as thread_id_value: