From a0120e1805b00a154d56a53dac713ba1c5ba48c8 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 21 Jun 2026 23:22:30 +0000 Subject: [PATCH] feat(reporting): share the report-tree writer between the CLI and the API The per-section markdown report tree was written only by the CLI, so programmatic (TradingAgentsGraph) runs produced no saved reports. - Extract the writer into tradingagents/reporting.write_report_tree. - The CLI's save_report_to_disk delegates to it (no behavior change). - Add TradingAgentsGraph.save_reports(final_state, ticker) so headless/API callers get the same report tree, defaulting under results_dir. --- cli/main.py | 90 +----------------------- tests/test_reporting.py | 50 +++++++++++++ tradingagents/graph/trading_graph.py | 16 +++++ tradingagents/reporting.py | 101 +++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 87 deletions(-) create mode 100644 tests/test_reporting.py create mode 100644 tradingagents/reporting.py diff --git a/cli/main.py b/cli/main.py index e97399001..c14f538d1 100644 --- a/cli/main.py +++ b/cli/main.py @@ -48,6 +48,7 @@ from tradingagents.graph.analyst_execution import ( sync_analyst_tracker_from_chunk, ) from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.reporting import write_report_tree console = Console() @@ -747,93 +748,8 @@ def get_analysis_date(): def save_report_to_disk(final_state, ticker: str, save_path: Path): - """Save complete analysis report to disk with organized subfolders.""" - save_path.mkdir(parents=True, exist_ok=True) - sections = [] - - # 1. Analysts - analysts_dir = save_path / "1_analysts" - analyst_parts = [] - if final_state.get("market_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "market.md").write_text(final_state["market_report"], encoding="utf-8") - analyst_parts.append(("Market Analyst", final_state["market_report"])) - if final_state.get("sentiment_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "sentiment.md").write_text(final_state["sentiment_report"], encoding="utf-8") - analyst_parts.append(("Sentiment Analyst", final_state["sentiment_report"])) - if final_state.get("news_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "news.md").write_text(final_state["news_report"], encoding="utf-8") - analyst_parts.append(("News Analyst", final_state["news_report"])) - if final_state.get("fundamentals_report"): - analysts_dir.mkdir(exist_ok=True) - (analysts_dir / "fundamentals.md").write_text(final_state["fundamentals_report"], encoding="utf-8") - analyst_parts.append(("Fundamentals Analyst", final_state["fundamentals_report"])) - if analyst_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in analyst_parts) - sections.append(f"## I. Analyst Team Reports\n\n{content}") - - # 2. Research - if final_state.get("investment_debate_state"): - research_dir = save_path / "2_research" - debate = final_state["investment_debate_state"] - research_parts = [] - if debate.get("bull_history"): - research_dir.mkdir(exist_ok=True) - (research_dir / "bull.md").write_text(debate["bull_history"], encoding="utf-8") - research_parts.append(("Bull Researcher", debate["bull_history"])) - if debate.get("bear_history"): - research_dir.mkdir(exist_ok=True) - (research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8") - research_parts.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): - research_dir.mkdir(exist_ok=True) - (research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8") - research_parts.append(("Research Manager", debate["judge_decision"])) - if research_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) - sections.append(f"## II. Research Team Decision\n\n{content}") - - # 3. Trading - if final_state.get("trader_investment_plan"): - trading_dir = save_path / "3_trading" - trading_dir.mkdir(exist_ok=True) - (trading_dir / "trader.md").write_text(final_state["trader_investment_plan"], encoding="utf-8") - sections.append(f"## III. Trading Team Plan\n\n### Trader\n{final_state['trader_investment_plan']}") - - # 4. Risk Management - if final_state.get("risk_debate_state"): - risk_dir = save_path / "4_risk" - risk = final_state["risk_debate_state"] - risk_parts = [] - if risk.get("aggressive_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "aggressive.md").write_text(risk["aggressive_history"], encoding="utf-8") - risk_parts.append(("Aggressive Analyst", risk["aggressive_history"])) - if risk.get("conservative_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "conservative.md").write_text(risk["conservative_history"], encoding="utf-8") - risk_parts.append(("Conservative Analyst", risk["conservative_history"])) - if risk.get("neutral_history"): - risk_dir.mkdir(exist_ok=True) - (risk_dir / "neutral.md").write_text(risk["neutral_history"], encoding="utf-8") - risk_parts.append(("Neutral Analyst", risk["neutral_history"])) - if risk_parts: - content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts) - sections.append(f"## IV. Risk Management Team Decision\n\n{content}") - - # 5. Portfolio Manager - if risk.get("judge_decision"): - portfolio_dir = save_path / "5_portfolio" - portfolio_dir.mkdir(exist_ok=True) - (portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8") - sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}") - - # Write consolidated report - header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.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") - return save_path / "complete_report.md" + """Save the complete analysis report to disk (shared CLI/API writer).""" + return write_report_tree(final_state, ticker, save_path) def display_complete_report(final_state): diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 000000000..04a1d5a72 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,50 @@ +"""Report parity: the shared writer produces the report tree for the CLI and the +programmatic API alike (#1037).""" + +from types import SimpleNamespace + +import pytest + +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.reporting import write_report_tree + + +def _state(): + return { + "market_report": "MKT", + "news_report": "NEWS", + "investment_debate_state": {"judge_decision": "RM PLAN"}, + "trader_investment_plan": "TRADE", + "risk_debate_state": {"judge_decision": "PM DECISION"}, + } + + +@pytest.mark.unit +def test_write_report_tree_creates_files(tmp_path): + out = write_report_tree(_state(), "AAPL", tmp_path) + assert out.name == "complete_report.md" + assert (tmp_path / "1_analysts" / "market.md").read_text() == "MKT" + assert (tmp_path / "1_analysts" / "news.md").read_text() == "NEWS" + assert (tmp_path / "2_research" / "manager.md").read_text() == "RM PLAN" + assert (tmp_path / "3_trading" / "trader.md").read_text() == "TRADE" + assert (tmp_path / "5_portfolio" / "decision.md").read_text() == "PM DECISION" + complete = out.read_text() + assert "Trading Analysis Report: AAPL" in complete + assert "MKT" in complete and "PM DECISION" in complete + + +@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) + 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)}) + 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_") diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index cfbfc93d4..ece0513e1 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -32,6 +32,7 @@ from tradingagents.dataflows.config import set_config from tradingagents.dataflows.utils import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import create_llm_client +from tradingagents.reporting import write_report_tree from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id from .conditional_logic import ConditionalLogic @@ -358,6 +359,21 @@ class TradingAgentsGraph: self._checkpointer_ctx = None self.graph = self.workflow.compile() + def save_reports(self, final_state, ticker, save_path=None) -> Path: + """Write the markdown report tree for a completed run, like the CLI does. + + Programmatic callers get the same on-disk reports the CLI produces. Pass + an explicit ``save_path`` or let it default under ``results_dir``. + """ + if save_path is None: + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + save_path = ( + Path(self.config["results_dir"]) + / "reports" + / f"{safe_ticker_component(ticker)}_{stamp}" + ) + return write_report_tree(final_state, ticker, save_path) + def _run_graph(self, company_name, trade_date, asset_type: str = "stock"): """Execute the graph and write the resulting state to disk and memory log.""" # Initialize state — inject memory log context for PM and the diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py new file mode 100644 index 000000000..f89d6a04d --- /dev/null +++ b/tradingagents/reporting.py @@ -0,0 +1,101 @@ +"""Reusable report-tree writer shared by the CLI and the programmatic API. + +Writes a run's per-section markdown (analysts, research, trading, risk, +portfolio) plus a consolidated ``complete_report.md`` under ``save_path``. The +CLI and ``TradingAgentsGraph.save_reports`` both call this, so a headless / API +run produces the same on-disk report tree a CLI run does. +""" + +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.""" + save_path = Path(save_path) + save_path.mkdir(parents=True, exist_ok=True) + sections = [] + + # 1. Analysts + analysts_dir = save_path / "1_analysts" + analyst_parts = [] + if final_state.get("market_report"): + analysts_dir.mkdir(exist_ok=True) + (analysts_dir / "market.md").write_text(final_state["market_report"], encoding="utf-8") + analyst_parts.append(("Market Analyst", final_state["market_report"])) + if final_state.get("sentiment_report"): + analysts_dir.mkdir(exist_ok=True) + (analysts_dir / "sentiment.md").write_text(final_state["sentiment_report"], encoding="utf-8") + analyst_parts.append(("Sentiment Analyst", final_state["sentiment_report"])) + if final_state.get("news_report"): + analysts_dir.mkdir(exist_ok=True) + (analysts_dir / "news.md").write_text(final_state["news_report"], encoding="utf-8") + analyst_parts.append(("News Analyst", final_state["news_report"])) + if final_state.get("fundamentals_report"): + analysts_dir.mkdir(exist_ok=True) + (analysts_dir / "fundamentals.md").write_text(final_state["fundamentals_report"], encoding="utf-8") + analyst_parts.append(("Fundamentals Analyst", final_state["fundamentals_report"])) + if analyst_parts: + content = "\n\n".join(f"### {name}\n{text}" for name, text in analyst_parts) + sections.append(f"## I. Analyst Team Reports\n\n{content}") + + # 2. Research + if final_state.get("investment_debate_state"): + research_dir = save_path / "2_research" + debate = final_state["investment_debate_state"] + research_parts = [] + if debate.get("bull_history"): + research_dir.mkdir(exist_ok=True) + (research_dir / "bull.md").write_text(debate["bull_history"], encoding="utf-8") + research_parts.append(("Bull Researcher", debate["bull_history"])) + if debate.get("bear_history"): + research_dir.mkdir(exist_ok=True) + (research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8") + research_parts.append(("Bear Researcher", debate["bear_history"])) + if debate.get("judge_decision"): + research_dir.mkdir(exist_ok=True) + (research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8") + research_parts.append(("Research Manager", debate["judge_decision"])) + if research_parts: + content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) + sections.append(f"## II. Research Team Decision\n\n{content}") + + # 3. Trading + if final_state.get("trader_investment_plan"): + trading_dir = save_path / "3_trading" + trading_dir.mkdir(exist_ok=True) + (trading_dir / "trader.md").write_text(final_state["trader_investment_plan"], encoding="utf-8") + sections.append(f"## III. Trading Team Plan\n\n### Trader\n{final_state['trader_investment_plan']}") + + # 4. Risk Management + if final_state.get("risk_debate_state"): + risk_dir = save_path / "4_risk" + risk = final_state["risk_debate_state"] + risk_parts = [] + if risk.get("aggressive_history"): + risk_dir.mkdir(exist_ok=True) + (risk_dir / "aggressive.md").write_text(risk["aggressive_history"], encoding="utf-8") + risk_parts.append(("Aggressive Analyst", risk["aggressive_history"])) + if risk.get("conservative_history"): + risk_dir.mkdir(exist_ok=True) + (risk_dir / "conservative.md").write_text(risk["conservative_history"], encoding="utf-8") + risk_parts.append(("Conservative Analyst", risk["conservative_history"])) + if risk.get("neutral_history"): + risk_dir.mkdir(exist_ok=True) + (risk_dir / "neutral.md").write_text(risk["neutral_history"], encoding="utf-8") + risk_parts.append(("Neutral Analyst", risk["neutral_history"])) + if risk_parts: + content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts) + sections.append(f"## IV. Risk Management Team Decision\n\n{content}") + + # 5. Portfolio Manager + if risk.get("judge_decision"): + portfolio_dir = save_path / "5_portfolio" + portfolio_dir.mkdir(exist_ok=True) + (portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8") + sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_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") + return save_path / "complete_report.md"