refactor(graph): settle past decisions in graph/settlement.py

- resolve_benchmark, fetch_returns and settle_pending are module functions; the graph's settle_pending runs them under its config
- create_run_state settles through settle_pending, so the CLI path also settles under the graph's config
This commit is contained in:
Yijia-Xiao
2026-09-24 05:00:36 +00:00
parent c9695673bf
commit 969861e8be
7 changed files with 201 additions and 213 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ def test_create_run_state_settles_pending_and_carries_context(tmp_path, monkeypa
graph = _bare_graph(tmp_path) graph = _bare_graph(tmp_path)
graph.propagator = Propagator() graph.propagator = Propagator()
settled = [] settled = []
monkeypatch.setattr(graph, "_resolve_pending_entries", settled.append, raising=False) monkeypatch.setattr(graph, "settle_pending", settled.append, raising=False)
monkeypatch.setattr(graph, "resolve_instrument_context", lambda t, a="stock", d=None: f"id:{t}", raising=False) monkeypatch.setattr(graph, "resolve_instrument_context", lambda t, a="stock", d=None: f"id:{t}", raising=False)
monkeypatch.setattr(graph, "_memory_as_of", lambda d: d, raising=False) monkeypatch.setattr(graph, "_memory_as_of", lambda d: d, raising=False)
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call") graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call")
+6 -3
View File
@@ -140,15 +140,18 @@ def test_concurrent_runs_each_read_their_own_config():
@pytest.mark.unit @pytest.mark.unit
def test_settling_reads_the_graphs_own_config(): def test_settling_reads_the_graphs_own_config(monkeypatch):
from tradingagents.dataflows.router import get_vendor from tradingagents.dataflows.router import get_vendor
config = copy.deepcopy(default_config.DEFAULT_CONFIG) config = copy.deepcopy(default_config.DEFAULT_CONFIG)
config["tool_vendors"] = {"get_stock_data": "alpha_vantage"} config["tool_vendors"] = {"get_stock_data": "alpha_vantage"}
graph = _graph(config) graph = _graph(config)
graph.memory_log = graph.reflector = None # the settlement below is a stand-in
seen = [] seen = []
graph._resolve_pending_entries = lambda ticker: seen.append( from tradingagents.graph import settlement
get_vendor("core_stock_apis", "get_stock_data"))
monkeypatch.setattr(settlement, "settle_pending",
lambda *a: seen.append(get_vendor("core_stock_apis", "get_stock_data")))
graph.settle_pending("AAPL") graph.settle_pending("AAPL")
+54 -80
View File
@@ -8,6 +8,7 @@ import pytest
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
from tradingagents.decision_log import TradingMemoryLog from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph import settlement
from tradingagents.graph.propagation import Propagator from tradingagents.graph.propagation import Propagator
from tradingagents.graph.reflection import Reflector from tradingagents.graph.reflection import Reflector
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
@@ -407,7 +408,7 @@ class TestTradingMemoryLogCore:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Deferred reflection: update_with_outcome, Reflector, _fetch_returns # Deferred reflection: update_with_outcome, Reflector, fetch_returns
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDeferredReflection: class TestDeferredReflection:
@@ -510,19 +511,18 @@ class TestDeferredReflection:
assert "-5.0%" in human_content assert "-5.0%" in human_content
assert "Exit position immediately." in human_content assert "Exit position immediately." in human_content
# TradingAgentsGraph._fetch_returns # settlement.fetch_returns
def test_fetch_returns_valid_ticker(self): def test_fetch_returns_valid_ticker(self):
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0] spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0]
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym): def _make_ticker(sym):
m = MagicMock() m = MagicMock()
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m return m
mock_ticker_cls.side_effect = _make_ticker mock_ticker_cls.side_effect = _make_ticker
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
assert raw is not None and alpha is not None and days is not None assert raw is not None and alpha is not None and days is not None
assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int) assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
assert days == 5 assert days == 5
@@ -531,22 +531,20 @@ class TestDeferredReflection:
def test_fetch_returns_too_recent(self): def test_fetch_returns_too_recent(self):
"""Only 1 data point available → returns all-None, no crash.""" """Only 1 data point available → returns all-None, no crash."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock() m = MagicMock()
m.history.return_value = _price_df([100.0]) m.history.return_value = _price_df([100.0])
mock_ticker_cls.return_value = m mock_ticker_cls.return_value = m
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19") raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-04-19")
assert (raw, alpha, days, resolved) == (None, None, None, None) assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_delisted(self): def test_fetch_returns_delisted(self):
"""Empty DataFrame → returns all-None, no crash.""" """Empty DataFrame → returns all-None, no crash."""
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock() m = MagicMock()
m.history.return_value = pd.DataFrame({"Close": []}) m.history.return_value = pd.DataFrame({"Close": []})
mock_ticker_cls.return_value = m mock_ticker_cls.return_value = m
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10") raw, alpha, days, resolved = settlement.fetch_returns("XXXXXFAKE", "2026-01-10")
assert (raw, alpha, days, resolved) == (None, None, None, None) assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_spy_shorter_than_stock(self): def test_fetch_returns_spy_shorter_than_stock(self):
@@ -554,14 +552,13 @@ class TestDeferredReflection:
not raise IndexError.""" not raise IndexError."""
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym): def _make_ticker(sym):
m = MagicMock() m = MagicMock()
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m return m
mock_ticker_cls.side_effect = _make_ticker mock_ticker_cls.side_effect = _make_ticker
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
assert raw is not None and alpha is not None assert raw is not None and alpha is not None
assert days == 5 # full holding window used for both series assert days == 5 # full holding window used for both series
assert resolved == "2026-01-10" assert resolved == "2026-01-10"
@@ -572,32 +569,29 @@ class TestDeferredReflection:
on a premature partial return.""" on a premature partial return."""
stock_prices = [100.0, 102.0, 104.0] # only 3 rows; holding window is 5 stock_prices = [100.0, 102.0, 104.0] # only 3 rows; holding window is 5
spy_prices = [400.0, 402.0, 404.0] spy_prices = [400.0, 402.0, 404.0]
mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym): def _make_ticker(sym):
m = MagicMock() m = MagicMock()
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m return m
mock_ticker_cls.side_effect = _make_ticker mock_ticker_cls.side_effect = _make_ticker
result = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") result = settlement.fetch_returns("NVDA", "2026-01-05")
assert result == (None, None, None, None) assert result == (None, None, None, None)
# TradingAgentsGraph._resolve_benchmark — picks index for alpha calc # settlement.resolve_benchmark picks the index for the alpha calculation
def test_resolve_benchmark_explicit_override(self): def test_resolve_benchmark_explicit_override(self):
"""config['benchmark_ticker'] wins for every ticker.""" """config['benchmark_ticker'] wins for every ticker."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {
mock_graph.config = {
"benchmark_ticker": "QQQ", "benchmark_ticker": "QQQ",
"benchmark_map": {"": "SPY", ".T": "^N225"}, "benchmark_map": {"": "SPY", ".T": "^N225"},
} }
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "QQQ" assert settlement.resolve_benchmark("7203.T", config) == "QQQ"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "QQQ" assert settlement.resolve_benchmark("NVDA", config) == "QQQ"
def test_resolve_benchmark_suffix_map(self): def test_resolve_benchmark_suffix_map(self):
"""Known suffixes route to their regional index.""" """Known suffixes route to their regional index."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {
mock_graph.config = {
"benchmark_ticker": None, "benchmark_ticker": None,
"benchmark_map": { "benchmark_map": {
".T": "^N225", ".HK": "^HSI", ".NS": "^NSEI", ".T": "^N225", ".HK": "^HSI", ".NS": "^NSEI",
@@ -605,66 +599,60 @@ class TestDeferredReflection:
".BO": "^BSESN", "": "SPY", ".BO": "^BSESN", "": "SPY",
}, },
} }
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "^N225" assert settlement.resolve_benchmark("7203.T", config) == "^N225"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "0700.HK") == "^HSI" assert settlement.resolve_benchmark("0700.HK", config) == "^HSI"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI" assert settlement.resolve_benchmark("RELIANCE.NS", config) == "^NSEI"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE" assert settlement.resolve_benchmark("AZN.L", config) == "^FTSE"
def test_explicit_benchmark_is_resolved_like_any_other_symbol(self): def test_explicit_benchmark_is_resolved_like_any_other_symbol(self):
"""A configured benchmark takes the same alias mapping as the ticker, or """A configured benchmark takes the same alias mapping as the ticker, or
the return lookup finds nothing and the decision never settles.""" the return lookup finds nothing and the decision never settles."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {"benchmark_ticker": "SPX500", "benchmark_map": {"": "SPY"}}
mock_graph.config = {"benchmark_ticker": "SPX500", "benchmark_map": {"": "SPY"}} assert settlement.resolve_benchmark("NVDA", config) == "^GSPC"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "^GSPC"
def test_resolve_benchmark_china_a_shares(self): def test_resolve_benchmark_china_a_shares(self):
"""A-share tickers route to their exchange composite (uses the real """A-share tickers route to their exchange composite (uses the real
default benchmark_map, since A-share support relies on it).""" default benchmark_map, since A-share support relies on it)."""
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {"benchmark_ticker": None,
mock_graph.config = {"benchmark_ticker": None,
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]} "benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS" assert settlement.resolve_benchmark("600519.SS", config) == "000001.SS"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ" assert settlement.resolve_benchmark("000001.SZ", config) == "399001.SZ"
# .SH is the exchange's own suffix; Yahoo spells Shanghai .SS (#1260) # .SH is the exchange's own suffix; Yahoo spells Shanghai .SS (#1260)
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SH") == "000001.SS" assert settlement.resolve_benchmark("600519.SH", config) == "000001.SS"
def test_resolve_benchmark_brazil(self): def test_resolve_benchmark_brazil(self):
"""B3 tickers were measured against SPY.""" """B3 tickers were measured against SPY."""
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {"benchmark_ticker": None,
mock_graph.config = {"benchmark_ticker": None,
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]} "benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "PETR4.SA") == "^BVSP" assert settlement.resolve_benchmark("PETR4.SA", config) == "^BVSP"
def test_resolve_benchmark_us_ticker_defaults_to_spy(self): def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
"""US tickers (no dotted suffix) take the empty-suffix entry.""" """US tickers (no dotted suffix) take the empty-suffix entry."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {
mock_graph.config = {
"benchmark_ticker": None, "benchmark_ticker": None,
"benchmark_map": {"": "SPY", ".T": "^N225"}, "benchmark_map": {"": "SPY", ".T": "^N225"},
} }
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "SPY" assert settlement.resolve_benchmark("NVDA", config) == "SPY"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AAPL") == "SPY" assert settlement.resolve_benchmark("AAPL", config) == "SPY"
def test_resolve_benchmark_unknown_suffix_falls_back(self): def test_resolve_benchmark_unknown_suffix_falls_back(self):
"""Unrecognised suffix (BRK.B, FAKE.XX) falls back to SPY.""" """Unrecognised suffix (BRK.B, FAKE.XX) falls back to SPY."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {
mock_graph.config = {
"benchmark_ticker": None, "benchmark_ticker": None,
"benchmark_map": {"": "SPY", ".T": "^N225"}, "benchmark_map": {"": "SPY", ".T": "^N225"},
} }
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "FAKE.XX") == "SPY" assert settlement.resolve_benchmark("FAKE.XX", config) == "SPY"
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "BRK.B") == "SPY" assert settlement.resolve_benchmark("BRK.B", config) == "SPY"
def test_resolve_benchmark_case_insensitive(self): def test_resolve_benchmark_case_insensitive(self):
"""Suffix matching is case-insensitive so 7203.t resolves like 7203.T.""" """Suffix matching is case-insensitive so 7203.t resolves like 7203.T."""
mock_graph = MagicMock(spec=TradingAgentsGraph) config = {
mock_graph.config = {
"benchmark_ticker": None, "benchmark_ticker": None,
"benchmark_map": {".T": "^N225", "": "SPY"}, "benchmark_map": {".T": "^N225", "": "SPY"},
} }
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.t") == "^N225" assert settlement.resolve_benchmark("7203.t", config) == "^N225"
def test_reflector_includes_benchmark_in_label(self): def test_reflector_includes_benchmark_in_label(self):
"""benchmark_name appears in the prompt label, not 'SPY' hardcoded.""" """benchmark_name appears in the prompt label, not 'SPY' hardcoded."""
@@ -696,32 +684,25 @@ class TestDeferredReflection:
human_content = next(content for role, content in messages if role == "human") human_content = next(content for role, content in messages if role == "human")
assert "Alpha vs SPY:" in human_content assert "Alpha vs SPY:" in human_content
# TradingAgentsGraph._resolve_pending_entries # settlement.settle_pending
def test_resolve_skips_other_tickers(self, tmp_path): def test_resolve_skips_other_tickers(self, tmp_path):
"""Pending AAPL entry is not resolved when the run is for NVDA.""" """Pending AAPL entry is not resolved when the run is for NVDA."""
log = make_log(tmp_path) log = make_log(tmp_path)
log.store_decision("AAPL", "2026-01-10", DECISION_BUY) log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
mock_graph = MagicMock(spec=TradingAgentsGraph) with patch.object(settlement, "fetch_returns", return_value=(0.05, 0.02, 5, "2026-01-12")) as fetch:
mock_graph.config = {} settlement.settle_pending("NVDA", log, MagicMock(), {})
mock_graph.memory_log = log fetch.assert_not_called()
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
mock_graph._fetch_returns.assert_not_called()
assert len(log.get_pending_entries()) == 1 assert len(log.get_pending_entries()) == 1
def test_resolve_marks_entry_completed(self, tmp_path): def test_resolve_marks_entry_completed(self, tmp_path):
"""After resolve, get_pending_entries() is empty and the entry has a REFLECTION.""" """After resolve, get_pending_entries() is empty and the entry has a REFLECTION."""
log = make_log(tmp_path) log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY) log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
mock_reflector = MagicMock() reflector = MagicMock()
mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed." reflector.reflect_on_final_decision.return_value = "Momentum confirmed."
mock_graph = MagicMock(spec=TradingAgentsGraph) with patch.object(settlement, "fetch_returns", return_value=(0.05, 0.02, 5, "2026-01-12")):
mock_graph.config = {} settlement.settle_pending("NVDA", log, reflector, {})
mock_graph.memory_log = log
mock_graph.reflector = mock_reflector
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
assert log.get_pending_entries() == [] assert log.get_pending_entries() == []
entries = log.load_entries() entries = log.load_entries()
assert len(entries) == 1 assert len(entries) == 1
@@ -731,19 +712,15 @@ class TestDeferredReflection:
assert "+2.0%" in entries[0]["alpha"] assert "+2.0%" in entries[0]["alpha"]
def test_resolve_leaves_premature_entry_pending(self, tmp_path): def test_resolve_leaves_premature_entry_pending(self, tmp_path):
"""#1169: when the outcome can't be settled yet (_fetch_returns None), """#1169: when the outcome can't be settled yet (fetch_returns None),
the entry stays pending and the reflector is never called.""" the entry stays pending and the reflector is never called."""
log = make_log(tmp_path) log = make_log(tmp_path)
log.store_decision("NVDA", "2026-01-05", DECISION_BUY) log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
mock_reflector = MagicMock() reflector = MagicMock()
mock_graph = MagicMock(spec=TradingAgentsGraph) with patch.object(settlement, "fetch_returns", return_value=(None, None, None, None)):
mock_graph.config = {} settlement.settle_pending("NVDA", log, reflector, {})
mock_graph.memory_log = log
mock_graph.reflector = mock_reflector
mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None))
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
assert len(log.get_pending_entries()) == 1 # still pending assert len(log.get_pending_entries()) == 1 # still pending
mock_reflector.reflect_on_final_decision.assert_not_called() reflector.reflect_on_final_decision.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -965,9 +942,9 @@ def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp
graph.memory_log = TradingMemoryLog(graph.config) graph.memory_log = TradingMemoryLog(graph.config)
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\n\nx") graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\n\nx")
graph.memory_log.store_decision("NVDA", "2026-01-12", "Rating: Sell\n\ny") graph.memory_log.store_decision("NVDA", "2026-01-12", "Rating: Sell\n\ny")
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False) monkeypatch.setattr(settlement, "resolve_benchmark", lambda t, c: "SPY")
monkeypatch.setattr(graph, "_fetch_returns", monkeypatch.setattr(settlement, "fetch_returns",
lambda t, d, holding_days=5, benchmark=None: (0.01, 0.005, holding_days, "2026-01-19"), raising=False) lambda t, d, holding_days=5, benchmark=None: (0.01, 0.005, holding_days, "2026-01-19"))
class _Reflector: class _Reflector:
calls = 0 calls = 0
@@ -980,7 +957,7 @@ def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp
graph.reflector = _Reflector() graph.reflector = _Reflector()
graph._resolve_pending_entries("NVDA") # must not raise graph.settle_pending("NVDA") # must not raise
entries = graph.memory_log.load_entries() entries = graph.memory_log.load_entries()
assert [e["pending"] for e in entries] == [True, False] # the failed one waits for next time assert [e["pending"] for e in entries] == [True, False] # the failed one waits for next time
@@ -997,17 +974,17 @@ def test_the_holding_window_is_configurable(tmp_path, monkeypatch):
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21} graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21}
graph.memory_log = TradingMemoryLog(graph.config) graph.memory_log = TradingMemoryLog(graph.config)
graph.memory_log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") graph.memory_log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False) monkeypatch.setattr(settlement, "resolve_benchmark", lambda t, c: "SPY")
asked = {} asked = {}
def _returns(ticker, date, holding_days=5, benchmark=None): def _returns(ticker, date, holding_days=5, benchmark=None):
asked["holding_days"] = holding_days asked["holding_days"] = holding_days
return 0.05, 0.02, holding_days, "2026-02-02" return 0.05, 0.02, holding_days, "2026-02-02"
monkeypatch.setattr(graph, "_fetch_returns", _returns, raising=False) monkeypatch.setattr(settlement, "fetch_returns", _returns)
graph.reflector = type("R", (), {"reflect_on_final_decision": lambda self, **kw: "lesson"})() graph.reflector = type("R", (), {"reflect_on_final_decision": lambda self, **kw: "lesson"})()
graph._resolve_pending_entries("NVDA") graph.settle_pending("NVDA")
assert asked["holding_days"] == 21 assert asked["holding_days"] == 21
assert graph.memory_log.load_entries()[0]["holding"] == "21d" assert graph.memory_log.load_entries()[0]["holding"] == "21d"
@@ -1027,9 +1004,6 @@ def test_the_reflection_states_the_window_it_judges():
def test_a_longer_window_asks_for_enough_price_history(monkeypatch): def test_a_longer_window_asks_for_enough_price_history(monkeypatch):
"""Trading days are not calendar days: a 21-day window needs about a month """Trading days are not calendar days: a 21-day window needs about a month
of bars, and asking for 28 days left every outcome unsettled.""" of bars, and asking for 28 days left every outcome unsettled."""
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
asked = {} asked = {}
class _Ticker: class _Ticker:
@@ -1044,6 +1018,6 @@ def test_a_longer_window_asks_for_enough_price_history(monkeypatch):
monkeypatch.setattr("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker", _Ticker) monkeypatch.setattr("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker", _Ticker)
raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY") 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) assert days == 21 and resolved is not None, (raw, alpha, days, resolved)
+1 -1
View File
@@ -86,7 +86,7 @@ def _bare_graph(tmp_path):
graph.memory_log = TradingMemoryLog(graph.config) graph.memory_log = TradingMemoryLog(graph.config)
graph.propagator = Propagator() graph.propagator = Propagator()
graph.selected_analysts = ["market"] graph.selected_analysts = ["market"]
graph._resolve_pending_entries = lambda t: None graph.settle_pending = lambda t: None
graph.resolve_instrument_context = lambda t, a="stock", d=None: "" graph.resolve_instrument_context = lambda t, a="stock", d=None: ""
graph._memory_as_of = lambda d: None graph._memory_as_of = lambda d: None
return graph return graph
+3 -4
View File
@@ -10,7 +10,7 @@ import pandas as pd
import tradingagents.agents.context as au import tradingagents.agents.context as au
import tradingagents.dataflows.vendors.yahoo.market as yahoo_market import tradingagents.dataflows.vendors.yahoo.market as yahoo_market
import tradingagents.dataflows.vendors.yahoo.news as ynews import tradingagents.dataflows.vendors.yahoo.news as ynews
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph import settlement
def test_identity_lookup_normalizes_symbol(monkeypatch): def test_identity_lookup_normalizes_symbol(monkeypatch):
@@ -47,9 +47,8 @@ def test_fetch_returns_normalizes_symbol(monkeypatch):
monkeypatch.setattr(yahoo_market.yf, "Ticker", FakeTicker) monkeypatch.setattr(yahoo_market.yf, "Ticker", FakeTicker)
# _fetch_returns does not use ``self``; call unbound to avoid building the graph. raw, alpha, days, resolved = settlement.fetch_returns(
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns( "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY"
None, "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY"
) )
assert queried[0] == "GC=F" # stock symbol normalized (#984) assert queried[0] == "GC=F" # stock symbol normalized (#984)
+132
View File
@@ -0,0 +1,132 @@
"""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."""
import logging
from datetime import datetime, timedelta
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.market import get_closes
logger = logging.getLogger(__name__)
def resolve_benchmark(ticker: str, config: dict) -> str:
"""Pick the benchmark ticker for alpha calculation against ``ticker``.
``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.
"""
explicit = config.get("benchmark_ticker")
if explicit:
# Same alias mapping as the analyzed ticker; an unmapped alias finds
# no prices, and the decision would stay pending for good.
return normalize_symbol(explicit)
benchmark_map = config.get("benchmark_map", {})
ticker_upper = normalize_symbol(ticker)
for suffix, benchmark in benchmark_map.items():
if suffix and ticker_upper.endswith(suffix.upper()):
return benchmark
return benchmark_map.get("", "SPY")
def fetch_returns(
ticker: str, trade_date: str, holding_days: int = 5,
benchmark: str = "SPY",
) -> tuple[float | None, float | None, int | None, str | None]:
"""Fetch raw and alpha return for ticker over holding_days from trade_date.
``benchmark`` is the index used as the alpha baseline (resolved by the
caller via ``resolve_benchmark``). Returns ``(raw_return, alpha_return,
holding_days, resolution_date)`` — where ``resolution_date`` is the date
of the last price bar used, i.e. when the outcome became known (#1251) —
or ``(None, None, None, None)`` when the outcome cannot be settled yet:
the full holding window has not traded (#1169), or the symbol is delisted
or unreachable.
"""
try:
start = datetime.strptime(trade_date, "%Y-%m-%d")
# holding_days counts trading days, so ask for the calendar span they
# occupy (about 7 for every 5) plus a week for holidays.
end = start + timedelta(days=round(holding_days * 7 / 5) + 7)
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)
# 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:
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])
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")
return raw, alpha, holding_days, resolution_date
except Exception as e:
logger.warning(
"Could not resolve outcome for %s on %s vs %s (will retry next run): %s",
ticker, trade_date, benchmark, e,
)
return None, None, None, None
def settle_pending(ticker: str, memory_log, reflector, config: dict) -> None:
"""Settle ``ticker``'s pending decisions whose holding window has traded.
Fetches returns for each same-ticker pending entry, generates reflections,
then writes all updates in a single atomic batch write to avoid redundant I/O.
Skips entries whose price data is not yet available (too recent or delisted).
Trade-off: only same-ticker entries are resolved per run. Entries for
other tickers accumulate until that ticker is run again.
"""
pending = [e for e in memory_log.get_pending_entries() if e["ticker"] == ticker]
if not pending:
return
benchmark = resolve_benchmark(ticker, config)
updates = []
for entry in pending:
raw, alpha, days, resolution_date = fetch_returns(
ticker, entry["date"], config.get("holding_period_days", 5),
benchmark=benchmark,
)
if raw is None:
continue # price not available yet — try again next run
try:
reflection = reflector.reflect_on_final_decision(
final_decision=entry.get("decision", ""),
raw_return=raw,
alpha_return=alpha,
benchmark_name=benchmark,
holding_days=days,
)
except Exception as exc:
# Reflection calls a provider, and this runs on the way into a
# new run: a transient failure leaves the entry pending for the
# next one rather than stopping the analysis that was asked for.
logger.warning("Reflection failed for %s on %s: %s", ticker, entry["date"], exc)
continue
updates.append({
"ticker": ticker,
"trade_date": entry["date"],
"raw_return": raw,
"alpha_return": alpha,
"holding_days": days,
"reflection": reflection,
"resolution_date": resolution_date,
})
if updates:
memory_log.batch_update_with_outcomes(updates)
+4 -124
View File
@@ -4,7 +4,7 @@ import json
import logging import logging
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -13,12 +13,12 @@ from tradingagents.agents.rating import parse_rating
from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.config import run_config, set_config
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.dataflows.vendors.yahoo.market import get_closes
from tradingagents.decision_log import TradingMemoryLog from tradingagents.decision_log import TradingMemoryLog
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients import build_llm_kwargs, create_llm_client from tradingagents.llm_clients import build_llm_kwargs, create_llm_client
from tradingagents.reporting import write_report_tree from tradingagents.reporting import write_report_tree
from . import settlement
from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id
from .conditional_logic import ConditionalLogic from .conditional_logic import ConditionalLogic
from .propagation import Propagator from .propagation import Propagator
@@ -121,126 +121,6 @@ class TradingAgentsGraph:
self._checkpointer_ctx = None self._checkpointer_ctx = None
self._resuming = False self._resuming = False
def _resolve_benchmark(self, ticker: str) -> str:
"""Pick the benchmark ticker for alpha calculation against ``ticker``.
``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.
"""
from tradingagents.dataflows.symbols import normalize_symbol
explicit = self.config.get("benchmark_ticker")
if explicit:
# Same alias mapping as the analyzed ticker; an unmapped alias finds
# no prices, and the decision would stay pending for good.
return normalize_symbol(explicit)
benchmark_map = self.config.get("benchmark_map", {})
ticker_upper = normalize_symbol(ticker)
for suffix, benchmark in benchmark_map.items():
if suffix and ticker_upper.endswith(suffix.upper()):
return benchmark
return benchmark_map.get("", "SPY")
def _fetch_returns(
self, ticker: str, trade_date: str, holding_days: int = 5,
benchmark: str = "SPY",
) -> tuple[float | None, float | None, int | None, str | None]:
"""Fetch raw and alpha return for ticker over holding_days from trade_date.
``benchmark`` is the index used as the alpha baseline (resolved by the
caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return,
holding_days, resolution_date)`` — where ``resolution_date`` is the date
of the last price bar used, i.e. when the outcome became known (#1251) —
or ``(None, None, None, None)`` when the outcome cannot be settled yet:
the full holding window has not traded (#1169), or the symbol is delisted
or unreachable.
"""
try:
start = datetime.strptime(trade_date, "%Y-%m-%d")
# holding_days counts trading days, so ask for the calendar span they
# occupy (about 7 for every 5) plus a week for holidays.
end = start + timedelta(days=round(holding_days * 7 / 5) + 7)
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)
# 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:
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])
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")
return raw, alpha, holding_days, resolution_date
except Exception as e:
logger.warning(
"Could not resolve outcome for %s on %s vs %s (will retry next run): %s",
ticker, trade_date, benchmark, e,
)
return None, None, None, None
def _resolve_pending_entries(self, ticker: str) -> None:
"""Resolve pending log entries for ticker at the start of a new run.
Fetches returns for each same-ticker pending entry, generates reflections,
then writes all updates in a single atomic batch write to avoid redundant I/O.
Skips entries whose price data is not yet available (too recent or delisted).
Trade-off: only same-ticker entries are resolved per run. Entries for
other tickers accumulate until that ticker is run again.
"""
pending = [e for e in self.memory_log.get_pending_entries() if e["ticker"] == ticker]
if not pending:
return
benchmark = self._resolve_benchmark(ticker)
updates = []
for entry in pending:
raw, alpha, days, resolution_date = self._fetch_returns(
ticker, entry["date"], self.config.get("holding_period_days", 5),
benchmark=benchmark,
)
if raw is None:
continue # price not available yet — try again next run
try:
reflection = self.reflector.reflect_on_final_decision(
final_decision=entry.get("decision", ""),
raw_return=raw,
alpha_return=alpha,
benchmark_name=benchmark,
holding_days=days,
)
except Exception as exc:
# Reflection calls a provider, and this runs on the way into a
# new run: a transient failure leaves the entry pending for the
# next one rather than stopping the analysis that was asked for.
logger.warning("Reflection failed for %s on %s: %s", ticker, entry["date"], exc)
continue
updates.append({
"ticker": ticker,
"trade_date": entry["date"],
"raw_return": raw,
"alpha_return": alpha,
"holding_days": days,
"reflection": reflection,
"resolution_date": resolution_date,
})
if updates:
self.memory_log.batch_update_with_outcomes(updates)
def resolve_instrument_context(self, ticker: str, asset_type: str = "stock", def resolve_instrument_context(self, ticker: str, asset_type: str = "stock",
curr_date: str | None = None) -> str: curr_date: str | None = None) -> str:
"""Resolve ticker identity once and return the full instrument context. """Resolve ticker identity once and return the full instrument context.
@@ -392,7 +272,7 @@ class TradingAgentsGraph:
resolved instrument identity for every agent (#814). An entry point that 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 decision log.
""" """
self._resolve_pending_entries(company_name) self.settle_pending(company_name)
return self.propagator.create_initial_state( return self.propagator.create_initial_state(
company_name, company_name,
trade_date, trade_date,
@@ -413,7 +293,7 @@ class TradingAgentsGraph:
this to settle it now. this to settle it now.
""" """
with run_config(self.config): with run_config(self.config):
self._resolve_pending_entries(company_name) settlement.settle_pending(company_name, self.memory_log, self.reflector, self.config)
def record_decision(self, company_name, trade_date, final_state): def record_decision(self, company_name, trade_date, final_state):
"""Log a finished run's decision for reflection on the next same-ticker run.""" """Log a finished run's decision for reflection on the next same-ticker run."""