From 28d32653d3bd3daf8e1bf053504e1c7dc48a5c62 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Tue, 27 Oct 2026 00:45:19 +0000 Subject: [PATCH] fix(backtest): give each sweep started without a run_id its own run (#1549) - the stamped run directory is claimed atomically; a second sweep in the same second takes the next suffix instead of resuming the first --- tests/test_backtest.py | 17 +++++++++++++++++ tradingagents/backtest.py | 28 +++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index ded0fae06..6a06d88d8 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -155,6 +155,23 @@ def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path): assert _FakeGraph.instances[-1].analysts == ["market", "news"] +@pytest.mark.unit +def test_sweeps_started_in_the_same_second_get_their_own_runs(tmp_path, monkeypatch): + import tradingagents.backtest as backtest + + class _Frozen(backtest.datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 1, 20, 9, 30, 0) + + monkeypatch.setattr(backtest, "datetime", _Frozen) + first = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path)) + second = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path)) + + assert first.run_id == "20260120_093000" and second.run_id == "20260120_093000_2" + assert first.cells_run == second.cells_run == 1 + + @pytest.mark.unit def test_a_run_id_cannot_escape_the_results_directory(tmp_path): """run_id becomes a path segment, so it is validated like a ticker is.""" diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index 3d83a31f8..612c565b3 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -16,6 +16,7 @@ cell rather than a position carried forward. from __future__ import annotations +import itertools import logging from collections.abc import Callable from dataclasses import dataclass, field @@ -126,6 +127,19 @@ class BacktestSummary: return "\n".join(lines) +def _new_run(runs: Path) -> str: + """A new run's id, stamped now, and its directory, claimed so no other sweep shares it.""" + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + runs.mkdir(parents=True, exist_ok=True) + for n in itertools.count(1): + run_id = stamp if n == 1 else f"{stamp}_{n}" + try: + (runs / run_id).mkdir() + return run_id + except FileExistsError: + continue + + def run_backtest( tickers: list[str], dates: list[str], @@ -143,11 +157,15 @@ def run_backtest( 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. - run_id = safe_ticker_component(run_id or datetime.now().strftime("%Y%m%d_%H%M%S")) - run_dir = Path(config["results_dir"]) / "backtest" / run_id - run_dir.mkdir(parents=True, exist_ok=True) + runs = Path(config["results_dir"]) / "backtest" + if run_id: + # 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. + run_id = safe_ticker_component(run_id) + (runs / run_id).mkdir(parents=True, exist_ok=True) + else: + run_id = _new_run(runs) + run_dir = runs / run_id run_config = {**config, "results_dir": str(run_dir), "memory_log_path": str(run_dir / "trading_memory.md")}