mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-04 00:22:43 +02:00
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:
@@ -320,11 +320,11 @@ TradingAgents persists two kinds of state across runs.
|
||||
|
||||
### Memory log
|
||||
|
||||
The memory log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't.
|
||||
The memory log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. While the analysts of a later run work, TradingAgents settles every logged decision whose holding period has passed: it fetches the realised return (raw, and alpha against the instrument's regional benchmark) and generates a one-paragraph reflection. The Portfolio Manager then reads the most recent decisions for the same ticker plus recent lessons from other tickers, so each analysis carries forward what worked and what didn't. If settling fails, the run goes on and its report says so.
|
||||
|
||||
Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
|
||||
|
||||
A scheduled job whose tickers change can settle the decisions of tickers it no longer analyses with `ta.settle_all_pending()`, which returns the decisions it settled and any it could not.
|
||||
To settle decisions without running an analysis, for a scheduled job, call `ta.settle_all_pending()`; it returns the decisions it settled and any it could not.
|
||||
|
||||
### Checkpoint resume
|
||||
|
||||
|
||||
+3
-2
@@ -211,8 +211,9 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None):
|
||||
)
|
||||
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
# The same initial state propagate() builds: settled memory log, past
|
||||
# context and resolved instrument identity.
|
||||
# The same initial state propagate() builds, with the resolved
|
||||
# instrument identity; the graph's Memory Log step settles past
|
||||
# decisions and loads their lessons alongside the analysts.
|
||||
init_agent_state = graph.create_run_state(
|
||||
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,5 +68,6 @@ class AgentState(MessagesState):
|
||||
]
|
||||
final_trade_decision: Annotated[str, "Final decision from the Portfolio Manager"]
|
||||
final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"]
|
||||
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
|
||||
past_context: Annotated[str, "Memory log context for the Portfolio Manager (same-ticker decisions + cross-ticker lessons), written by the Memory Log step"]
|
||||
memory_note: Annotated[str, "What the Memory Log step could not settle or read this run, for the report; empty when all went well"]
|
||||
portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"]
|
||||
|
||||
@@ -34,6 +34,7 @@ class Propagator:
|
||||
"instrument_context": instrument_context,
|
||||
"trade_date": str(trade_date),
|
||||
"past_context": past_context,
|
||||
"memory_note": "",
|
||||
"portfolio_context": portfolio_context,
|
||||
"investment_debate_state": InvestDebateState(
|
||||
{
|
||||
|
||||
@@ -107,7 +107,7 @@ class GraphSetup:
|
||||
self.max_tool_rounds = max_tool_rounds
|
||||
|
||||
def setup_graph(
|
||||
self, selected_analysts=("market", "social", "news", "fundamentals")
|
||||
self, selected_analysts=("market", "social", "news", "fundamentals"), memory_node=None
|
||||
):
|
||||
"""Set up and compile the agent workflow graph.
|
||||
|
||||
@@ -117,6 +117,8 @@ class GraphSetup:
|
||||
- "social": Sentiment analyst
|
||||
- "news": News analyst
|
||||
- "fundamentals": Fundamentals analyst
|
||||
memory_node: Node that settles past decisions and returns the run's
|
||||
``past_context``. It runs alongside the analysts.
|
||||
"""
|
||||
plan = build_analyst_execution_plan(selected_analysts)
|
||||
|
||||
@@ -153,11 +155,15 @@ class GraphSetup:
|
||||
workflow.add_node("Portfolio Manager", portfolio_manager_node)
|
||||
|
||||
# The analysts work at the same time; the research debate starts once
|
||||
# every one of them has filed its report.
|
||||
analysts = [spec.agent_node for spec in plan.specs]
|
||||
for node in analysts:
|
||||
# every one of them has filed its report. The memory log settles past
|
||||
# decisions alongside them: only the Portfolio Manager reads its lessons.
|
||||
first_steps = [spec.agent_node for spec in plan.specs]
|
||||
if memory_node is not None:
|
||||
workflow.add_node("Memory Log", memory_node)
|
||||
first_steps.append("Memory Log")
|
||||
for node in first_steps:
|
||||
workflow.add_edge(START, node)
|
||||
workflow.add_edge(analysts, "Bull Researcher")
|
||||
workflow.add_edge(first_steps, "Bull Researcher")
|
||||
|
||||
# Both research-debate edges share the complete DEBATE_PATH_MAP (#1088).
|
||||
for debate_node in ("Bull Researcher", "Bear Researcher"):
|
||||
|
||||
@@ -126,7 +126,7 @@ class TradingAgentsGraph:
|
||||
self.selected_analysts = tuple(selected_analysts)
|
||||
|
||||
# Set up the graph: keep the workflow for recompilation with a checkpointer.
|
||||
self.workflow = self.graph_setup.setup_graph(selected_analysts)
|
||||
self.workflow = self.graph_setup.setup_graph(selected_analysts, memory_node=self._memory_step)
|
||||
self.graph = self.workflow.compile()
|
||||
self._checkpointer_ctx = None
|
||||
self._resuming = False
|
||||
@@ -174,8 +174,10 @@ class TradingAgentsGraph:
|
||||
# None, an empty book and a changed book are three different runs.
|
||||
f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}",
|
||||
# The layout itself: a checkpoint saved when analysts ran one after
|
||||
# another has pending nodes this graph no longer has.
|
||||
# another has pending nodes this graph no longer has, and one saved
|
||||
# before the Memory Log step would resume without the lessons.
|
||||
"analysts=parallel",
|
||||
"memory=parallel",
|
||||
f"settings={digest}",
|
||||
])
|
||||
|
||||
@@ -305,28 +307,51 @@ class TradingAgentsGraph:
|
||||
def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
|
||||
"""Build a run's initial state; propagate() and the CLI both start here.
|
||||
|
||||
Settles this ticker's pending decisions first, then injects the lessons
|
||||
known by the trade date for the Portfolio Manager (#1251) and the
|
||||
resolved instrument identity for every agent (#814). An entry point that
|
||||
assembled the state itself would skip the memory log.
|
||||
Injects the resolved instrument identity for every agent (#814). The
|
||||
memory log's lessons are not here: the graph's Memory Log step settles
|
||||
and loads them alongside the analysts (see ``_memory_step``).
|
||||
"""
|
||||
self.settle_pending(company_name)
|
||||
return self.propagator.create_initial_state(
|
||||
company_name,
|
||||
trade_date,
|
||||
asset_type=asset_type,
|
||||
past_context=self.memory_log.get_past_context(
|
||||
company_name, as_of=self._memory_as_of(trade_date)
|
||||
),
|
||||
instrument_context=self.resolve_instrument_context(company_name, asset_type, trade_date),
|
||||
portfolio_context=portfolio.render(company_name) if portfolio is not None else "",
|
||||
)
|
||||
|
||||
def _memory_step(self, state):
|
||||
"""The graph's Memory Log step, alongside the analysts (#1428): settle every
|
||||
ticker's due decisions (#1445), then return the lessons known by the
|
||||
trade date for the Portfolio Manager (#1251).
|
||||
|
||||
Settling fetches prices and asks the model for a reflection per decision,
|
||||
so it runs beside the analysts instead of before them. A failure here
|
||||
does not end the run: the analysts' work is kept, the lessons already in
|
||||
the log are used, and the report says what could not be settled.
|
||||
"""
|
||||
note = ""
|
||||
try:
|
||||
done = self.settle_all_pending()
|
||||
if done.failed:
|
||||
note = (f"{len(done.failed)} past decision(s) could not be settled this run "
|
||||
"and stay pending.")
|
||||
except Exception as exc:
|
||||
logger.warning("Settling past decisions failed: %s", exc)
|
||||
note = f"Past decisions could not be settled this run ({type(exc).__name__}); they stay pending."
|
||||
try:
|
||||
past_context = self.memory_log.get_past_context(
|
||||
state["company_of_interest"], as_of=self._memory_as_of(state["trade_date"]))
|
||||
except Exception as exc:
|
||||
logger.warning("Reading the memory log failed: %s", exc)
|
||||
past_context = ""
|
||||
note = note or f"The memory log could not be read this run ({type(exc).__name__})."
|
||||
return {"past_context": past_context, "memory_note": note}
|
||||
|
||||
def settle_pending(self, company_name) -> settlement.Settlement:
|
||||
"""Settle this ticker's decisions whose holding window has now traded.
|
||||
|
||||
A run settles its ticker's earlier decisions as it starts, so the most
|
||||
recent one stays pending until a later run. A caller that is done
|
||||
A run settles every due decision alongside its analysts, so its own
|
||||
decision stays pending until a later run. A caller that is done
|
||||
analyzing a ticker (a backtest sweep, a scheduled job) calls this to
|
||||
settle it now. Returns what was settled and what failed.
|
||||
"""
|
||||
|
||||
@@ -26,6 +26,8 @@ def _header(ticker: str, final_state: dict, settings: dict | None) -> str:
|
||||
vendors = {**(s("data_vendors") or {}), **(s("tool_vendors") or {})}
|
||||
if vendors:
|
||||
lines.append("- Data vendors: " + ", ".join(f"{k} {v}" for k, v in vendors.items()))
|
||||
if final_state.get("memory_note"):
|
||||
lines.append(f"- Memory log: {final_state['memory_note']}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user