diff --git a/cli/run.py b/cli/run.py index 60a2ffff2..a404085e9 100644 --- a/cli/run.py +++ b/cli/run.py @@ -381,7 +381,8 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ).strip() save_path = Path(save_path_str) try: - report_file = write_report_tree(final_state, selections["ticker"], save_path) + report_file = write_report_tree(final_state, selections["ticker"], save_path, + settings=graph.run_settings()) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 674e668bd..12a8f1084 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -61,7 +61,10 @@ def _bare_graph(tmp_path): from tradingagents.graph.trading_graph import TradingAgentsGraph graph = object.__new__(TradingAgentsGraph) - graph.config = {"results_dir": str(tmp_path)} + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) return graph @@ -87,6 +90,7 @@ def test_the_state_log_names_each_field_as_the_state_does(tmp_path): assert logged["investment_plan"] == "计划" assert "trader_investment_decision" not in logged assert "judge_decision" not in json.dumps(logged) + assert logged["run_settings"]["deep_think_llm"] == "d" @pytest.mark.unit diff --git a/tests/test_reporting.py b/tests/test_reporting.py index dd93e4edb..3ddd2b3fc 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -21,6 +21,11 @@ def _state(): } +SETTINGS = {"version": "0.5.2", "llm_provider": "openai", "deep_think_llm": "gpt-6-sol", + "quick_think_llm": "gpt-6-luna", "analysts": ["market", "news"], "max_debate_rounds": 1, + "max_risk_discuss_rounds": 2, "data_vendors": {"core_stock_apis": "yfinance"}} + + @pytest.mark.unit def test_write_report_tree_creates_files(tmp_path): out = write_report_tree(_state(), "AAPL", tmp_path) @@ -37,16 +42,65 @@ def test_write_report_tree_creates_files(tmp_path): @pytest.mark.unit def test_save_reports_explicit_path(tmp_path): - # Unbound: with an explicit save_path, the method doesn't touch self/config. - out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path) + graph = SimpleNamespace(run_settings=lambda: SETTINGS) + out = TradingAgentsGraph.save_reports(graph, _state(), "AAPL", save_path=tmp_path) assert (tmp_path / "complete_report.md").exists() assert out == tmp_path / "complete_report.md" @pytest.mark.unit def test_save_reports_defaults_under_results_dir(tmp_path): - mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}) + mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}, run_settings=lambda: SETTINGS) out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL") assert out.exists() assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_/... assert out.parent.name.startswith("AAPL_") + + + +@pytest.mark.unit +def test_the_report_names_the_analysis_date_and_what_produced_it(tmp_path): + state = dict(_state(), trade_date="2026-09-23") + + header = write_report_tree(state, "NVDA", tmp_path, settings=SETTINGS).read_text().split("## ")[0] + + assert "Analysis date: 2026-09-23" in header + assert "TradingAgents 0.5.2" in header + assert "openai, deep gpt-6-sol, quick gpt-6-luna" in header + assert "Analysts: market, news" in header + assert "research debate rounds 1, risk debate rounds 2" in header + assert "core_stock_apis yfinance" in header + + +@pytest.mark.unit +def test_run_settings_record_the_run_without_endpoints_or_paths(): + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts = ("market", "news") + graph.config = {"llm_provider": "openai", "deep_think_llm": "gpt-6-sol", "quick_think_llm": "gpt-6-luna", + "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "output_language": "English", + "data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {}, + "backend_url": "https://user:secret@relay.example/v1", "results_dir": "/home/me/results"} + + settings = graph.run_settings() + + assert settings["analysts"] == ["market", "news"] + assert settings["deep_think_llm"] == "gpt-6-sol" + assert "secret" not in str(settings) and "/home/me" not in str(settings) + + +@pytest.mark.unit +def test_a_partial_settings_dict_still_writes_the_report(tmp_path): + out = write_report_tree(_state(), "AAPL", tmp_path, settings={"llm_provider": "openai"}) + assert "openai" in out.read_text() + + +@pytest.mark.unit +def test_run_settings_name_the_version_of_the_running_code(): + import tradingagents + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts, graph.config = ("market",), {} + assert graph.run_settings()["version"] == tradingagents.__version__ diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index ec8f6b887..0c3e59b95 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Any +import tradingagents from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.rating import run_rating from tradingagents.dataflows.config import run_config, set_config @@ -241,6 +242,26 @@ class TradingAgentsGraph: self._run_signature(asset_type, portfolio), ) + def run_settings(self) -> dict: + """What produces this graph's runs, for the saved report and state log. + + An allowlist: endpoints (a backend_url can carry credentials), keys and + local paths are never recorded. + """ + cfg = self.config + return { + "version": tradingagents.__version__, + "llm_provider": cfg.get("llm_provider"), + "deep_think_llm": cfg.get("deep_think_llm"), + "quick_think_llm": cfg.get("quick_think_llm"), + "analysts": list(self.selected_analysts), + "max_debate_rounds": cfg.get("max_debate_rounds"), + "max_risk_discuss_rounds": cfg.get("max_risk_discuss_rounds"), + "output_language": cfg.get("output_language"), + "data_vendors": dict(cfg.get("data_vendors") or {}), + "tool_vendors": dict(cfg.get("tool_vendors") or {}), + } + def save_reports(self, final_state, ticker, save_path=None) -> Path: """Write the markdown report tree for a completed run, like the CLI does. @@ -254,7 +275,7 @@ class TradingAgentsGraph: / "reports" / f"{safe_ticker_component(ticker)}_{stamp}" ) - return write_report_tree(final_state, ticker, save_path) + return write_report_tree(final_state, ticker, save_path, settings=self.run_settings()) 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. @@ -370,6 +391,7 @@ class TradingAgentsGraph: "investment_plan": final_state["investment_plan"], "final_trade_decision": final_state["final_trade_decision"], "final_rating": run_rating(final_state), + "run_settings": self.run_settings(), } # A ticker that would escape the results directory is rejected. diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index 62759b875..214e1df20 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -10,8 +10,31 @@ from datetime import datetime from pathlib import Path -def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: - """Save a completed run's reports to ``save_path``; return the complete-report path.""" +def _header(ticker: str, final_state: dict, settings: dict | None) -> str: + """The report's title and what produced it: analysis date, version, models, analysts, vendors.""" + lines = [f"# Trading Analysis Report: {ticker}", ""] + if final_state.get("trade_date"): + lines.append(f"- Analysis date: {final_state['trade_date']}") + lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + if settings: + s = settings.get + lines.append(f"- TradingAgents {s('version', '?')}: {s('llm_provider', '?')}, " + f"deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}") + lines.append(f"- Analysts: {', '.join(s('analysts') or [])}; " + f"research debate rounds {s('max_debate_rounds', '?')}, " + f"risk debate rounds {s('max_risk_discuss_rounds', '?')}") + 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())) + return "\n".join(lines) + "\n\n" + + +def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict | None = None) -> Path: + """Save a completed run's reports to ``save_path``; return the complete-report path. + + ``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run + to the report's header. + """ save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) sections = [] @@ -96,6 +119,7 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{final_state['final_trade_decision']}") # Write consolidated report - header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - (save_path / "complete_report.md").write_text(header + "\n\n".join(sections), encoding="utf-8") + (save_path / "complete_report.md").write_text( + _header(ticker, final_state, settings) + "\n\n".join(sections), encoding="utf-8" + ) return save_path / "complete_report.md"