mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-27 06:56:39 +03:00
- each analyst is a graph of its own (model and tools on a private message history) that returns only its report; all start together and the research debate waits for every report - the message-clearing nodes are gone; a checkpoint saved by the sequential layout starts fresh - TradingAgentsGraph.stream_run streams the analysts' messages for debug mode and the CLI, whose status and timing now track the analysts side by side
216 lines
9.4 KiB
Python
216 lines
9.4 KiB
Python
"""The whole graph, end to end, with scripted models and no network.
|
|
|
|
Every tool-using analyst calls each of its tools once through the vendor router,
|
|
the debates and managers run, and the decision is parsed and logged. This pins
|
|
the wiring: a restructure that drops a node, a tool or an edge fails here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from langchain_core.language_models.chat_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, ToolMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from langchain_core.runnables import RunnableLambda
|
|
from pydantic import Field
|
|
|
|
from tradingagents.agents import context, schemas
|
|
from tradingagents.agents.analysts import sentiment_analyst
|
|
from tradingagents.dataflows import router
|
|
from tradingagents.dataflows.vendors.yahoo import market as yahoo_market, snapshot
|
|
from tradingagents.default_config import DEFAULT_CONFIG
|
|
from tradingagents.graph import trading_graph
|
|
|
|
TRADE_DATE = "2026-01-09"
|
|
|
|
# Enough for every free-text reader: the PM's labelled rating and the trader's
|
|
# closing proposal line.
|
|
TEXT = "Report.\n\n**Rating**: Overweight\n\nFINAL TRANSACTION PROPOSAL: **BUY**"
|
|
|
|
STRUCTURED = {
|
|
schemas.ResearchPlan: schemas.ResearchPlan(
|
|
recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"),
|
|
schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"),
|
|
# The thesis quotes another party's rating; the decision is still the PM's own.
|
|
schemas.PortfolioDecision: schemas.PortfolioDecision(
|
|
rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s",
|
|
investment_thesis="Street consensus rating: Buy (28 of 35 analysts)."),
|
|
schemas.SentimentReport: schemas.SentimentReport(
|
|
overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"),
|
|
}
|
|
|
|
ARGS = {"symbol": "NVDA", "ticker": "NVDA", "curr_date": TRADE_DATE, "start_date": "2026-01-02",
|
|
"end_date": TRADE_DATE, "indicator": "rsi", "topic": "Fed rate cut", "freq": "quarterly"}
|
|
|
|
|
|
class ScriptedModel(BaseChatModel):
|
|
"""Calls every bound tool once, then answers with TEXT."""
|
|
|
|
structured: bool = False
|
|
tools: tuple = ()
|
|
calls: list = Field(default_factory=list) # shared across bound copies
|
|
threads: set = Field(default_factory=set) # threads that served a tool-bound call
|
|
fail_at: int | None = None # raise on this call, once
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "scripted"
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self.model_copy(update={"tools": tuple(tools)})
|
|
|
|
def with_structured_output(self, schema, **kwargs):
|
|
if not self.structured:
|
|
raise NotImplementedError
|
|
return RunnableLambda(lambda _: self._count() or STRUCTURED[schema])
|
|
|
|
def _count(self) -> None:
|
|
self.calls.append(1)
|
|
if len(self.calls) == self.fail_at:
|
|
raise RuntimeError("provider unavailable")
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
|
|
self._count()
|
|
if self.tools:
|
|
import threading
|
|
import time
|
|
self.threads.add(threading.current_thread().name)
|
|
time.sleep(0.05) # long enough for concurrent analysts to overlap
|
|
if self.tools and not isinstance(messages[-1], ToolMessage):
|
|
calls = [{"name": t.name, "id": f"call_{i}",
|
|
"args": {k: v for k, v in ARGS.items()
|
|
if k in t.tool_call_schema.model_json_schema()["properties"]}}
|
|
for i, t in enumerate(self.tools)]
|
|
message = AIMessage(content="", tool_calls=calls)
|
|
else:
|
|
message = AIMessage(content=TEXT)
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
|
|
class _Client:
|
|
def __init__(self, model):
|
|
self.model = model
|
|
|
|
def get_llm(self):
|
|
return self.model
|
|
|
|
|
|
@pytest.fixture
|
|
def offline(monkeypatch, tmp_path):
|
|
"""Every vendor answers offline; returns the set of router methods called."""
|
|
called: set[str] = set()
|
|
for method, vendors in router.VENDOR_METHODS.items():
|
|
for vendor in vendors:
|
|
monkeypatch.setitem(vendors, vendor,
|
|
lambda *a, _m=method, **k: called.add(_m) or f"{_m} data")
|
|
prices = pd.DataFrame({
|
|
"Date": pd.bdate_range(end=TRADE_DATE, periods=60),
|
|
"Open": 100.0, "High": 101.0, "Low": 99.0, "Close": 100.5, "Volume": 1_000_000,
|
|
})
|
|
monkeypatch.setattr(snapshot, "load_ohlcv",
|
|
lambda *a, **k: called.add("ohlcv") or prices.copy())
|
|
monkeypatch.setattr(sentiment_analyst, "fetch_stocktwits_messages", lambda *a, **k: "no posts")
|
|
monkeypatch.setattr(sentiment_analyst, "fetch_reddit_posts", lambda *a, **k: "no posts")
|
|
monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: type("T", (), {"info": {"longName": "NVIDIA"}})())
|
|
context.resolve_instrument_identity.cache_clear()
|
|
return called
|
|
|
|
|
|
def _graph(tmp_path, monkeypatch, model, debug=False, **config):
|
|
cfg = copy.deepcopy(DEFAULT_CONFIG)
|
|
cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"),
|
|
memory_log_path=str(tmp_path / "log.md"), **config)
|
|
monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model))
|
|
return trading_graph.TradingAgentsGraph(config=cfg, debug=debug)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.parametrize("structured", [False, True], ids=["free-text", "structured"])
|
|
def test_a_full_run_reaches_a_logged_decision(tmp_path, monkeypatch, offline, structured):
|
|
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=structured))
|
|
|
|
state, signal = graph.propagate("NVDA", TRADE_DATE)
|
|
|
|
assert signal == state["final_rating"] == "Overweight"
|
|
for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report",
|
|
"investment_plan", "trader_investment_plan", "final_trade_decision"):
|
|
assert state[key].strip(), key
|
|
tool_methods = {"get_stock_data", "get_indicators", "get_news", "get_global_news",
|
|
"get_macro_indicators", "get_prediction_markets", "get_fundamentals",
|
|
"get_balance_sheet", "get_cashflow", "get_income_statement",
|
|
"get_insider_transactions", "ohlcv"}
|
|
assert offline == tool_methods
|
|
assert [e["rating"] for e in graph.memory_log.load_entries()] == ["Overweight"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_an_interrupted_run_resumes_from_its_checkpoint(tmp_path, monkeypatch, offline):
|
|
model = ScriptedModel(fail_at=12) # past the analysts, before the decision
|
|
graph = _graph(tmp_path, monkeypatch, model, checkpoint_enabled=True)
|
|
with pytest.raises(RuntimeError, match="provider unavailable"):
|
|
graph.propagate("NVDA", TRADE_DATE)
|
|
calls_before = len(model.calls)
|
|
|
|
_, signal = graph.propagate("NVDA", TRADE_DATE)
|
|
|
|
assert signal == "Overweight"
|
|
resumed_calls = len(model.calls) - calls_before
|
|
full_run = ScriptedModel()
|
|
_graph(tmp_path / "fresh", monkeypatch, full_run).propagate("NVDA", TRADE_DATE)
|
|
# The resumed run makes only the calls the interrupted one had not completed.
|
|
assert resumed_calls == len(full_run.calls) - (model.fail_at - 1)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_a_graph_reused_across_runs_keeps_no_run_state(tmp_path, monkeypatch, offline):
|
|
"""A backtest reuses one graph over its whole grid; holding every run's full
|
|
state would grow without bound."""
|
|
graph = _graph(tmp_path, monkeypatch, ScriptedModel())
|
|
for trade_date in ("2026-01-08", TRADE_DATE):
|
|
graph.propagate("NVDA", trade_date)
|
|
|
|
held = [v for v in vars(graph).values() if isinstance(v, dict) and TRADE_DATE in v]
|
|
assert held == []
|
|
assert len(list(tmp_path.glob("results/NVDA/TradingAgentsStrategy_logs/*.json"))) == 2
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_analysts_run_at_the_same_time(tmp_path, monkeypatch, offline):
|
|
model = ScriptedModel()
|
|
graph = _graph(tmp_path, monkeypatch, model)
|
|
|
|
assert not [n for n in graph.graph.get_graph().nodes if n.startswith("Msg Clear")]
|
|
graph.propagate("NVDA", TRADE_DATE)
|
|
|
|
assert len(model.threads) > 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_a_debug_run_prints_the_analysts_work_and_reaches_the_same_decision(tmp_path, monkeypatch, offline, capsys):
|
|
"""Debug mode streams the analysts' own graphs, so their tool calls still print."""
|
|
graph = _graph(tmp_path, monkeypatch, ScriptedModel(), debug=True)
|
|
|
|
state, signal = graph.propagate("NVDA", TRADE_DATE)
|
|
|
|
assert signal == "Overweight"
|
|
assert state["market_report"].strip() and state["fundamentals_report"].strip()
|
|
printed = capsys.readouterr().out
|
|
assert "get_stock_data" in printed and "get_balance_sheet" in printed
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_each_report_streams_as_soon_as_its_analyst_files_it(tmp_path, monkeypatch, offline):
|
|
"""The main state takes the analysts' reports only when the slowest one is
|
|
done; the CLI shows each report, and stops each clock, as it lands."""
|
|
graph = _graph(tmp_path, monkeypatch, ScriptedModel())
|
|
reports = ("market_report", "sentiment_report", "news_report", "fundamentals_report")
|
|
|
|
first = next(state for _, state in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE),
|
|
**graph.propagator.get_graph_args())
|
|
if state and any(state.get(k) for k in reports))
|
|
|
|
assert sum(bool(first.get(k)) for k in reports) == 1
|