diff --git a/tests/test_tool_date_enforcement.py b/tests/test_tool_date_enforcement.py index 719bfa286..584eb01a9 100644 --- a/tests/test_tool_date_enforcement.py +++ b/tests/test_tool_date_enforcement.py @@ -28,6 +28,7 @@ TRADE_DATE = "2026-08-14" (None, TRADE_DATE), # omitted ("Sept 1", TRADE_DATE), # unparseable ("", TRADE_DATE), + ("2026-8-1", "2026-08-01"), # unpadded: served in its canonical form ]) def test_as_of_takes_the_earlier_date(requested, expected): assert as_of(requested, TRADE_DATE) == expected @@ -36,6 +37,7 @@ def test_as_of_takes_the_earlier_date(requested, 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" + assert as_of("2026-9-14", "") == "2026-09-14" @pytest.mark.unit @@ -43,6 +45,7 @@ def test_as_of_without_a_trade_date_passes_the_request_through(): ("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 + ("2026-8-1", "2026-8-10", ("2026-08-01", "2026-08-10")), # unpadded: canonical ]) def test_as_of_window(start, end, expected): assert as_of_window(start, end, TRADE_DATE) == expected @@ -128,6 +131,16 @@ def test_future_window_from_the_model_is_clamped(): assert args == ("get_stock_data", "NVDA", "2026-08-01", TRADE_DATE) +@pytest.mark.unit +def test_an_unpadded_date_reaches_the_vendor_in_canonical_form(): + # Vendors compare dates as YYYY-MM-DD text: "2026-07-23" > "2026-7-3" is False, + # so an unpadded date would let a filing made after it through. + args = _run(tools.get_income_statement, {"curr_date": "2026-7-3"}, tools) + assert args[-1] == "2026-07-03" + args = _run(tools.get_news, {"start_date": "2026-7-1", "end_date": "2026-7-3"}, tools) + assert args[-2:] == ("2026-07-01", "2026-07-03") + + @pytest.mark.unit def test_direct_call_without_state_is_unchanged(): with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed: @@ -161,6 +174,8 @@ def test_propagate_rejects_a_non_canonical_date(bad): with pytest.raises(ValueError, match="YYYY-MM-DD"): object.__new__(TradingAgentsGraph).propagate("AAPL", bad) + with pytest.raises(ValueError, match="YYYY-MM-DD"): # the CLI starts a run here + object.__new__(TradingAgentsGraph).create_run_state("AAPL", bad) @pytest.mark.unit diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 9eff63010..9d9083ff4 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -1,7 +1,8 @@ """Point-in-time rules shared by every dated path: data is served as of the run's date. - ``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. + trade date, so no tool reaches a vendor with a later one, and always hand it + over as ``YYYY-MM-DD``: vendors compare dates as text. - ``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 @@ -79,13 +80,14 @@ 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 + would walk past every point-in-time guard behind the tool. A date is served in + its canonical form: "2026-7-3" compares as later than "2026-07-23". 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 + if not trade_date: + return requested if parsed is None else f"{parsed:%Y-%m-%d}" + return f"{parsed:%Y-%m-%d}" 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]: @@ -99,10 +101,11 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, if start is None: raise ValueError(f"start_date {start_date!r} is not a date; give it as YYYY-MM-DD") end = as_of(end_date, trade_date) - if end == end_date or start <= _parse(end): - return start_date, end + end_dt = _parse(end) + if end_dt is None or end_dt == old_end or start <= end_dt: + return f"{start:%Y-%m-%d}", 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 + return f"{end_dt - span:%Y-%m-%d}", end def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index a265ff88f..54c5d3f4f 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -297,6 +297,7 @@ class TradingAgentsGraph: memory log's lessons are not here: the graph's Memory Log step settles and loads them alongside the analysts (see ``_memory_step``). """ + trade_date = _validate_trade_date(trade_date) return self.propagator.create_initial_state( company_name, trade_date,