mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-29 19:26:24 +03:00
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.
This commit is contained in:
90
cli/main.py
90
cli/main.py
@@ -48,6 +48,7 @@ from tradingagents.graph.analyst_execution import (
|
|||||||
sync_analyst_tracker_from_chunk,
|
sync_analyst_tracker_from_chunk,
|
||||||
)
|
)
|
||||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
from tradingagents.reporting import write_report_tree
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -747,93 +748,8 @@ def get_analysis_date():
|
|||||||
|
|
||||||
|
|
||||||
def save_report_to_disk(final_state, ticker: str, save_path: Path):
|
def save_report_to_disk(final_state, ticker: str, save_path: Path):
|
||||||
"""Save complete analysis report to disk with organized subfolders."""
|
"""Save the complete analysis report to disk (shared CLI/API writer)."""
|
||||||
save_path.mkdir(parents=True, exist_ok=True)
|
return write_report_tree(final_state, ticker, save_path)
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def display_complete_report(final_state):
|
def display_complete_report(final_state):
|
||||||
|
|||||||
50
tests/test_reporting.py
Normal file
50
tests/test_reporting.py
Normal file
@@ -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_<stamp>/...
|
||||||
|
assert out.parent.name.startswith("AAPL_")
|
||||||
@@ -32,6 +32,7 @@ from tradingagents.dataflows.config import set_config
|
|||||||
from tradingagents.dataflows.utils import safe_ticker_component
|
from tradingagents.dataflows.utils import safe_ticker_component
|
||||||
from tradingagents.default_config import DEFAULT_CONFIG
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
from tradingagents.llm_clients import create_llm_client
|
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 .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id
|
||||||
from .conditional_logic import ConditionalLogic
|
from .conditional_logic import ConditionalLogic
|
||||||
@@ -358,6 +359,21 @@ class TradingAgentsGraph:
|
|||||||
self._checkpointer_ctx = None
|
self._checkpointer_ctx = None
|
||||||
self.graph = self.workflow.compile()
|
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"):
|
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."""
|
"""Execute the graph and write the resulting state to disk and memory log."""
|
||||||
# Initialize state — inject memory log context for PM and the
|
# Initialize state — inject memory log context for PM and the
|
||||||
|
|||||||
101
tradingagents/reporting.py
Normal file
101
tradingagents/reporting.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user