feat(graph): settle past decisions alongside the analysts (#1428)

- a Memory Log step runs beside the analysts, settles every ticker's due decisions and loads the lessons known by the trade date for the Portfolio Manager; the run no longer settles before it starts
- a settlement failure is logged and noted in the report's header instead of ending the run
- the checkpoint signature records the new layout
This commit is contained in:
Yijia-Xiao
2026-10-03 21:27:43 +00:00
parent 5d8de36f6d
commit f65fcabb60
9 changed files with 158 additions and 43 deletions
-21
View File
@@ -25,27 +25,6 @@ def _bare_graph(tmp_path):
return graph
@pytest.mark.unit
def test_create_run_state_settles_pending_and_carries_context(tmp_path, monkeypatch):
from tradingagents.graph.propagation import Propagator
graph = _bare_graph(tmp_path)
graph.propagator = Propagator()
settled = []
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, "_memory_as_of", lambda d: d, raising=False)
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call")
graph.memory_log.update_with_outcome("NVDA", "2026-01-05", 0.01, 0.005, 5, "great trade", "2026-01-12")
state = graph.create_run_state("NVDA", "2026-02-01")
assert settled == ["NVDA"]
assert "great trade" in state["past_context"]
assert state["instrument_context"] == "id:NVDA"
assert state["company_of_interest"] == "NVDA"
@pytest.mark.unit
def test_record_decision_appends_a_pending_entry(tmp_path):
graph = _bare_graph(tmp_path)
+100
View File
@@ -8,6 +8,7 @@ the wiring: a restructure that drops a node, a tool or an edge fails here.
from __future__ import annotations
import copy
import time
import pandas as pd
import pytest
@@ -287,3 +288,102 @@ def test_the_last_turn_is_offered_no_tools_and_reads_its_tool_results_as_text(tm
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)
class TimedModel(ScriptedModel):
"""ScriptedModel that records each call's graph step, prompt and timing."""
seen: list = Field(default_factory=list) # shared across bound copies
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
step = (run_manager.metadata or {}).get("langgraph_node") if run_manager else None
started = time.monotonic()
if step in {"Market Analyst", "agent"} or step == "Memory Log":
time.sleep(0.2) # long enough to see what overlaps
result = super()._generate(messages, stop, run_manager, **kwargs)
prompt = "\n".join(str(m.content) for m in messages)
self.seen.append((step, prompt, started, time.monotonic()))
return result
@pytest.mark.unit
def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, offline):
from tradingagents.memory import settlement
model = TimedModel()
graph = _graph(tmp_path, monkeypatch, model)
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
graph.propagate("NVDA", TRADE_DATE)
reflections = [c for c in model.seen if c[0] == "Memory Log"]
assert len(reflections) == 1 # settled once, inside the run
analyst_turns = [c for c in model.seen if c[0] == "agent"]
assert reflections[0][2] < max(c[3] for c in analyst_turns), "settled before the analysts, not alongside"
settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2026-01-02")
assert not settled["pending"]
lessons = graph.memory_log.get_past_context("NVDA", as_of=TRADE_DATE)
assert lessons
pm_prompt = next(c[1] for c in model.seen if c[0] == "Portfolio Manager")
assert lessons in pm_prompt
@pytest.mark.unit
def test_a_settlement_failure_is_noted_and_the_run_goes_on(tmp_path, monkeypatch, offline):
"""The analysts have spent their tokens by the time the join waits on settlement."""
from tradingagents.reporting import write_report_tree
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True))
def locked():
raise OSError("memory log is locked")
monkeypatch.setattr(graph, "settle_all_pending", locked)
final_state, rating = graph.propagate("NVDA", TRADE_DATE)
assert rating == "Overweight"
assert "could not be settled" in final_state["memory_note"]
report = write_report_tree(final_state, "NVDA", tmp_path / "report").read_text()
assert "Memory log: Past decisions could not be settled" in report
@pytest.mark.unit
def test_the_run_settles_other_tickers_due_decisions_too(tmp_path, monkeypatch, offline):
"""A ticker a scheduler stopped analysing still gets its decisions settled (#1445)."""
from tradingagents.memory import settlement
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True))
graph.memory_log.store_decision("MSFT", "2026-01-02", "Rating: Buy\n\nBuy MSFT.")
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
graph.propagate("NVDA", TRADE_DATE)
msft = next(e for e in graph.memory_log.load_entries() if e["ticker"] == "MSFT")
assert not msft["pending"]
@pytest.mark.unit
def test_the_memory_step_reads_lessons_as_of_the_trade_date_and_settles_once(tmp_path, monkeypatch, offline):
"""A past-dated run reads only lessons known by its date (#1251); a resumed run
that re-runs the step settles nothing twice."""
from tradingagents.memory import settlement
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True))
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
asked = []
real = graph.memory_log.get_past_context
monkeypatch.setattr(graph.memory_log, "get_past_context",
lambda ticker, as_of=None, **k: asked.append(as_of) or real(ticker, as_of=as_of, **k))
state = {"company_of_interest": "NVDA", "trade_date": "2026-01-20"}
graph._memory_step(state)
log_after_first = (tmp_path / "log.md").read_text()
graph._memory_step(state)
assert asked == ["2026-01-20", "2026-01-20"]
assert (tmp_path / "log.md").read_text() == log_after_first