Files
tradingagents/cli/run.py
T
Yijia-Xiao 9968bd8dd1 feat(graph): run the analysts at the same time (#1255)
- 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
2026-09-25 06:35:54 +00:00

417 lines
20 KiB
Python

"""Running one analysis from the CLI: build the graph, stream it into the live view, save the report."""
import datetime
import os
import sys
import time
from functools import wraps
from pathlib import Path
import typer
from rich.live import Live
from cli.display import (
ANALYST_ORDER,
AnalystWallTimeTracker,
classify_message_type,
console,
create_layout,
display_complete_report,
message_buffer,
update_analyst_statuses,
update_display,
update_research_team_status,
)
from cli.selections import get_user_selections, unattended_gaps
from cli.stats_handler import StatsCallbackHandler
from tradingagents.agents.rating import is_review, run_rating
from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.graph.analyst_execution import (
build_analyst_execution_plan,
)
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.reporting import write_report_tree
def _run_directory(config: dict, ticker: str, trade_date: str) -> Path:
"""Where this run writes, with the ticker validated as a path component.
Every other path that interpolates a ticker checks it first; a value of
".." here would place the run outside the results directory.
"""
return Path(config["results_dir"]) / safe_ticker_component(ticker) / trade_date
def _announce_checkpoint_state(graph, ticker: str, trade_date: str) -> None:
"""Say whether this run resumed a saved one, where the user can see it.
The graph logs this, but nothing in the CLI configures logging and the live
view owns the screen, so a resume was invisible.
"""
if getattr(graph, "_resuming", False):
message_buffer.add_message(
"System", f"Resuming the saved run for {ticker} on {trade_date}"
)
else:
message_buffer.add_message("System", f"Starting fresh for {ticker} on {trade_date}")
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
"""Assemble the run config from interactive selections, honoring env precedence.
Round counts and checkpoint follow "explicit env/flag wins": an env-applied
value on DEFAULT_CONFIG is preserved unless the user overrode it on the CLI.
"""
config = DEFAULT_CONFIG.copy()
# Research depth sets both round counts, but an explicit env override
# (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the
# interactive selection — leave the env-applied value in place (#977).
for env_var, key in (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"),
("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")):
if os.environ.get(env_var):
# The depth prompt still appeared (it is skipped only when both are
# set), so say which half of the answer the environment overrode.
console.print(
f"[green]✓ {key} from environment:[/green] {config[key]} "
f"(set by {env_var}, so the research depth you chose does not apply to it)"
)
else:
config[key] = selections["research_depth"]
config["quick_think_llm"] = selections["quick_think_llm"]
config["deep_think_llm"] = selections["deep_think_llm"]
config["backend_url"] = selections["backend_url"]
config["llm_provider"] = selections["llm_provider"].lower()
# Provider-specific thinking configuration
config["google_thinking_level"] = selections.get("google_thinking_level")
config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort")
config["anthropic_effort"] = selections.get("anthropic_effort")
config["output_language"] = selections.get("output_language", "English")
# --checkpoint/--no-checkpoint overrides only when explicitly given; omitting
# the flag preserves TRADINGAGENTS_CHECKPOINT_ENABLED / the default (#976).
if checkpoint is not None:
config["checkpoint_enabled"] = checkpoint
return config
def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None):
flags = flags or {}
# With no terminal nothing can answer a prompt: name every question still
# open before any model is called, rather than stopping at the first one.
if not (sys.stdin and sys.stdin.isatty()):
gaps = unattended_gaps(flags)
if gaps:
console.print("[red]No terminal to answer the setup questions. Set:[/red]")
for gap in gaps:
console.print(f" {gap}")
raise typer.Exit(code=1)
selections = get_user_selections(flags)
config = _build_run_config(selections, checkpoint)
stats_handler = StatsCallbackHandler()
# Normalize analyst selection to predefined order (selection is a 'set', order is fixed)
selected_set = {analyst.value for analyst in selections["analysts"]}
selected_analyst_keys = [a for a in ANALYST_ORDER if a in selected_set]
analyst_execution_plan = build_analyst_execution_plan(selected_analyst_keys)
analyst_wall_time_tracker = AnalystWallTimeTracker(analyst_execution_plan)
graph = TradingAgentsGraph(
selected_analyst_keys,
config=config,
debug=True,
callbacks=[stats_handler],
)
message_buffer.init_for_analysis(selected_analyst_keys)
# Track start time for elapsed display
start_time = time.time()
results_dir = _run_directory(config, selections["ticker"], selections["analysis_date"])
results_dir.mkdir(parents=True, exist_ok=True)
report_dir = results_dir / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
log_file = results_dir / "message_tool.log"
log_file.touch(exist_ok=True)
def save_message_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(*args, **kwargs):
func(*args, **kwargs)
timestamp, message_type, content = obj.messages[-1]
content = content.replace("\n", " ") # Replace newlines with spaces
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"{timestamp} [{message_type}] {content}\n")
return wrapper
def save_tool_call_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(*args, **kwargs):
func(*args, **kwargs)
timestamp, tool_name, args = obj.tool_calls[-1]
args_str = ", ".join(f"{k}={v}" for k, v in args.items())
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n")
return wrapper
def save_report_section_decorator(obj, func_name):
func = getattr(obj, func_name)
@wraps(func)
def wrapper(section_name, content):
func(section_name, content)
if section_name in obj.report_sections and obj.report_sections[section_name] is not None:
content = obj.report_sections[section_name]
if content:
file_name = f"{section_name}.md"
text = "\n".join(str(item) for item in content) if isinstance(content, list) else content
with open(report_dir / file_name, "w", encoding="utf-8") as f:
f.write(text)
return wrapper
message_buffer.add_message = save_message_decorator(message_buffer, "add_message")
message_buffer.add_tool_call = save_tool_call_decorator(message_buffer, "add_tool_call")
message_buffer.update_report_section = save_report_section_decorator(message_buffer, "update_report_section")
layout = create_layout()
# The alternate screen keeps a layout taller than the window from redrawing
# by scrolling; the final report prints after this block, on the normal screen.
with Live(layout, refresh_per_second=4, screen=True):
# Initial display
update_display(layout, stats_handler=stats_handler, start_time=start_time)
message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}")
if selections["asset_type"] != "stock":
message_buffer.add_message("System", f"Detected asset type: {selections['asset_type']}")
message_buffer.add_message(
"System", f"Analysis date: {selections['analysis_date']}"
)
message_buffer.add_message(
"System",
f"Selected analysts: {', '.join(analyst.value for analyst in selections['analysts'])}",
)
update_display(layout, stats_handler=stats_handler, start_time=start_time)
# The analysts start together.
for spec in analyst_execution_plan.specs:
message_buffer.update_agent_status(spec.agent_node, "in_progress")
analyst_wall_time_tracker.mark_started(spec.key)
update_display(layout, stats_handler=stats_handler, start_time=start_time)
spinner_text = (
f"Analyzing {selections['ticker']} on {selections['analysis_date']}..."
)
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.
init_agent_state = graph.create_run_state(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
# Pass callbacks to graph config for tool execution tracking
# (LLM tracking is handled separately via LLM constructor)
args = graph.propagator.get_graph_args(callbacks=[stats_handler])
# Recompile with a checkpointer and inject the thread_id so --checkpoint
# actually saves and resumes on the CLI path (#1249); a no-op when
# checkpointing is disabled. Torn down in the finally below.
checkpoint_tid = graph.begin_checkpoint(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
if checkpoint_tid is not None:
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
_announce_checkpoint_state(graph, selections["ticker"], selections["analysis_date"])
# Stream the analysis. On resume, feed None so LangGraph continues the
# interrupted run instead of re-appending the initial state (#1249); the
# try/finally tears the checkpointer down even if the stream raises.
trace = []
try:
for messages, chunk in graph.stream_run(graph.checkpoint_input(init_agent_state), **args):
for message in messages:
msg_id = getattr(message, "id", None)
if msg_id is not None:
if msg_id in message_buffer._processed_message_ids:
continue
message_buffer._processed_message_ids.add(msg_id)
msg_type, content = classify_message_type(message)
if content and content.strip():
message_buffer.add_message(msg_type, content)
if hasattr(message, "tool_calls") and message.tool_calls:
for tool_call in message.tool_calls:
if isinstance(tool_call, dict):
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
else:
message_buffer.add_tool_call(tool_call.name, tool_call.args)
if chunk is None: # a step inside an analyst's graph: messages only
update_display(layout, stats_handler=stats_handler, start_time=start_time)
continue
update_analyst_statuses(
message_buffer,
chunk,
wall_time_tracker=analyst_wall_time_tracker,
)
# Research Team - Handle Investment Debate State
if chunk.get("investment_debate_state"):
debate_state = chunk["investment_debate_state"]
bull_hist = debate_state.get("bull_history", "").strip()
bear_hist = debate_state.get("bear_history", "").strip()
judge = (chunk.get("investment_plan") or "").strip()
# Only update status when there's actual content
if bull_hist or bear_hist:
update_research_team_status("in_progress")
if bull_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
)
if bear_hist:
message_buffer.update_report_section(
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
)
if judge:
message_buffer.update_report_section(
"investment_plan", f"### Research Manager Decision\n{judge}"
)
update_research_team_status("completed")
message_buffer.update_agent_status("Trader", "in_progress")
# Trading Team
if chunk.get("trader_investment_plan"):
message_buffer.update_report_section(
"trader_investment_plan", chunk["trader_investment_plan"]
)
if message_buffer.agent_status.get("Trader") != "completed":
message_buffer.update_agent_status("Trader", "completed")
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
# Risk Management Team - Handle Risk Debate State
if chunk.get("risk_debate_state"):
risk_state = chunk["risk_debate_state"]
agg_hist = risk_state.get("aggressive_history", "").strip()
con_hist = risk_state.get("conservative_history", "").strip()
neu_hist = risk_state.get("neutral_history", "").strip()
judge = (chunk.get("final_trade_decision") or "").strip()
if agg_hist:
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
)
if con_hist:
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
)
if neu_hist:
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
)
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
message_buffer.update_report_section(
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
)
message_buffer.update_agent_status("Aggressive Analyst", "completed")
message_buffer.update_agent_status("Conservative Analyst", "completed")
message_buffer.update_agent_status("Neutral Analyst", "completed")
message_buffer.update_agent_status("Portfolio Manager", "completed")
update_display(layout, stats_handler=stats_handler, start_time=start_time)
trace.append(chunk)
# Streamed chunks are per-node deltas, not full state. Merge them
# so every report field populated across the run is present.
final_state = {}
for chunk in trace:
final_state.update(chunk)
# Clean run: log the decision, then drop this run's checkpoint so a
# later run starts fresh. A mid-stream failure skips both, keeping
# the checkpoint for resume.
graph.record_decision(selections["ticker"], selections["analysis_date"], final_state)
graph.clear_checkpoint_on_success(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
)
finally:
# Always restore the plain uncheckpointed graph, even on failure.
graph.end_checkpoint()
for agent in message_buffer.agent_status:
message_buffer.update_agent_status(agent, "completed")
message_buffer.add_message(
"System", f"Completed analysis for {selections['analysis_date']}"
)
message_buffer.add_message("System", analyst_wall_time_tracker.format_summary())
for section in message_buffer.report_sections:
if section in final_state:
message_buffer.update_report_section(section, final_state[section])
update_display(layout, stats_handler=stats_handler, start_time=start_time)
# Post-analysis prompts (outside Live context for clean interaction)
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n")
# A decision nobody can read is not a position. Say so here rather than
# leaving the run to look like a normal result.
if is_review(run_rating(final_state)):
console.print(
"[yellow]No rating could be read from the final decision, so this run "
"is recorded for review rather than as a position. Re-run, or read the "
"decision text below and judge it yourself.[/yellow]\n"
)
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
_offer_reports(final_state, graph, config, selections["ticker"],
save=flags.get("save"), show=flags.get("show"))
def _offer_reports(final_state, graph, config, ticker, save=None, show=None):
"""Save the report tree and show it; ``save``/``show`` answer the questions when given."""
asked = save is None
if asked:
save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "")
if save:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
# Under results_dir, not the working directory: in Docker the working
# directory is inside the container and the report goes with it, while
# results_dir is the mounted volume the rest of the run already writes to.
save_path = (Path(config["results_dir"]) / "reports"
/ f"{safe_ticker_component(ticker)}_{timestamp}")
if asked: # someone at the prompt may pick another folder
save_path = Path(typer.prompt(
"Save path (press Enter for default)", default=str(save_path)
).strip())
try:
report_file = write_report_tree(final_state, 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:
console.print(f"[red]Error saving report: {e}[/red]")
if show is None:
show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "")
if show:
display_complete_report(final_state)