feat(cli): run an analysis without questions from flags (#1127, #1133)

- --ticker, --date and --analysts answer their steps, checked as the prompts check them; --save/--no-save and --show/--no-show answer the questions after the run
- with no terminal, the run stops before it starts and names every flag or TRADINGAGENTS_* variable still needed; a missing API key and an announcement no longer wait for input
- README shows an unattended run
This commit is contained in:
Yijia-Xiao
2026-09-24 20:42:51 +00:00
parent a1b3b5bab8
commit 0c602846ba
11 changed files with 347 additions and 59 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import getpass
import sys
import requests
from rich.console import Console
@@ -46,7 +47,7 @@ def display_announcements(console: Console, data: dict) -> None:
)
console.print(panel)
if require_attention:
if require_attention and sys.stdin.isatty():
getpass.getpass("Press Enter to continue...")
else:
console.print()
+18 -2
View File
@@ -47,8 +47,23 @@ def analyze(
help="JSON file with current holdings and cash, so the trader, risk and "
"portfolio agents size against your actual position.",
),
ticker: str = typer.Option(None, "--ticker", help="Ticker to analyze, e.g. NVDA or 0700.HK; skips the prompt"),
date: str = typer.Option(None, "--date", help="Analysis date, YYYY-MM-DD; skips the prompt"),
analysts: str = typer.Option(
None, "--analysts", help="Comma-separated analysts, e.g. market,news; skips the prompt"
),
save: bool | None = typer.Option(
None, "--save/--no-save", help="Save the report under results_dir without asking"
),
show: bool | None = typer.Option(
None, "--show/--no-show", help="Show the full report at the end without asking"
),
):
"""Run an analysis. This is what a bare `tradingagents` does."""
"""Run an analysis. This is what a bare `tradingagents` does.
Flags answer their questions; with provider, models, depth and language also
set through TRADINGAGENTS_* variables, the run asks nothing.
"""
if ctx.invoked_subcommand is not None:
return
if clear_checkpoints:
@@ -64,7 +79,8 @@ def analyze(
raise typer.Exit(code=1) from None
try:
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context)
flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show}
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags)
except _NO_CONSOLE_ERRORS:
# A terminal with no console buffer cannot host the interactive prompts.
# Emit one actionable line on stderr instead of a prompt_toolkit
+42
View File
@@ -1,7 +1,10 @@
import datetime
import os
import sys
from pathlib import Path
import questionary
import typer
from dotenv import find_dotenv, set_key
from cli.display import console
@@ -60,6 +63,41 @@ def get_ticker() -> str:
return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY"
def parse_ticker(value: str) -> str:
"""A ticker given on the command line, canonical; empty or malformed is refused."""
if not value.strip() or not is_valid_ticker_input(value):
raise ValueError(f"not a ticker symbol: {value!r} (e.g. {TICKER_INPUT_EXAMPLES})")
return normalize_ticker_symbol(value)
def parse_analysis_date(value: str) -> str:
"""An analysis date as YYYY-MM-DD, today or earlier."""
try:
day = datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date()
except ValueError:
raise ValueError(f"not a date: {value!r}; use YYYY-MM-DD") from None
if day > datetime.date.today():
raise ValueError(f"{value} is in the future")
return day.isoformat()
def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]:
"""Comma-separated analyst names, in the canonical order, checked against the asset."""
# "sentiment" is the name users see; the analyst's key is "social".
names = [{"sentiment": "social"}.get(n, n) for n in (n.strip().lower() for n in value.split(",")) if n]
if not names:
raise ValueError("name at least one analyst")
known = {a.value: a for a in AnalystType}
unknown = [n for n in names if n not in known]
if unknown:
raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}")
available = filter_analysts_for_asset_type(list(known.values()), asset_type)
unavailable = [n for n in names if known[n] not in available]
if unavailable:
raise ValueError(f"{', '.join(unavailable)} is not available for {asset_type.value}")
return [a for a in available if a.value in names]
def normalize_ticker_symbol(ticker: str) -> str:
"""Resolve user input to its canonical Yahoo symbol (single source of truth).
@@ -611,6 +649,10 @@ def ensure_api_key(provider: str) -> str | None:
if existing:
return existing
if not sys.stdin.isatty():
console.print(f"[red]{env_var} is not set; there is no terminal to ask for it.[/red]")
raise typer.Exit(code=1)
console.print(
f"\n[yellow]{env_var} is not set in your environment.[/yellow]"
)
+35 -18
View File
@@ -2,6 +2,7 @@
import datetime
import os
import sys
import time
from functools import wraps
from pathlib import Path
@@ -21,7 +22,7 @@ from cli.display import (
update_display,
update_research_team_status,
)
from cli.selections import get_user_selections
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
@@ -93,9 +94,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
return config
def run_analysis(checkpoint: bool | None = None, portfolio=None):
# First get all user selections
selections = get_user_selections()
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)
@@ -366,29 +377,35 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
)
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
# Prompt to save report
save_choice = typer.prompt("Save report?", default="Y").strip().upper()
if save_choice in ("Y", "YES", ""):
_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.
default_path = (Path(config["results_dir"]) / "reports"
/ f"{safe_ticker_component(selections['ticker'])}_{timestamp}")
save_path_str = typer.prompt(
"Save path (press Enter for default)",
default=str(default_path)
).strip()
save_path = Path(save_path_str)
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, selections["ticker"], save_path,
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]")
# Prompt to display full report
display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper()
if display_choice in ("Y", "YES", ""):
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)
+67 -35
View File
@@ -25,6 +25,9 @@ from cli.prompts import (
detect_asset_type,
ensure_api_key,
get_ticker,
parse_analysis_date,
parse_analysts,
parse_ticker,
prompt_openai_compatible_url,
resolve_backend_url,
select_analysts,
@@ -36,15 +39,40 @@ from cli.prompts import (
from tradingagents.default_config import DEFAULT_CONFIG
def get_user_selections():
def get_user_selections(flags=None):
"""Ask for the run's settings, offering the previous run's answers."""
selections = _prompt_selections(load_last_run())
selections = _prompt_selections(load_last_run(), flags or {})
save_last_run(selections)
return selections
def _prompt_selections(prefs):
"""Walk the selection steps. ``prefs`` prefills, the environment skips."""
def unattended_gaps(flags) -> list[str]:
"""The flags and environment variables a run with no terminal still needs."""
env = os.environ.get
gaps = [f"--{name}" for name in ("ticker", "date", "analysts") if flags.get(name) is None]
gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None]
if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"):
gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE")
if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")):
gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS")
if not env("TRADINGAGENTS_LLM_PROVIDER"):
gaps.append("TRADINGAGENTS_LLM_PROVIDER")
if not (env("TRADINGAGENTS_QUICK_THINK_LLM") or env("TRADINGAGENTS_DEEP_THINK_LLM")):
gaps.append("TRADINGAGENTS_QUICK_THINK_LLM or TRADINGAGENTS_DEEP_THINK_LLM")
return gaps
def _from_flag(parse, value, *args):
"""A flag's value through the same check its prompt applies; a bad one ends the run."""
try:
return parse(value, *args)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
def _prompt_selections(prefs, flags):
"""Walk the selection steps. ``prefs`` prefills; flags and the environment skip."""
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
welcome_ascii = f.read()
@@ -93,14 +121,18 @@ def _prompt_selections(prefs):
return prompt_fn()
# Step 1: Ticker symbol
console.print(
create_question_box(
"Step 1: Ticker Symbol",
"Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)",
"SPY",
if flags.get("ticker") is not None:
selected_ticker = _from_flag(parse_ticker, flags["ticker"])
console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}")
else:
console.print(
create_question_box(
"Step 1: Ticker Symbol",
"Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)",
"SPY",
)
)
)
selected_ticker = get_ticker()
selected_ticker = get_ticker()
asset_type = detect_asset_type(selected_ticker)
# Only announce when it's not the default stock path, to avoid printing
# "stock" on every run.
@@ -110,15 +142,19 @@ def _prompt_selections(prefs):
)
# Step 2: Analysis date
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
console.print(
create_question_box(
"Step 2: Analysis Date",
"Enter the analysis date (YYYY-MM-DD)",
default_date,
if flags.get("date") is not None:
analysis_date = _from_flag(parse_analysis_date, flags["date"])
console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}")
else:
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
console.print(
create_question_box(
"Step 2: Analysis Date",
"Enter the analysis date (YYYY-MM-DD)",
default_date,
)
)
)
analysis_date = get_analysis_date()
analysis_date = get_analysis_date()
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
@@ -136,13 +172,16 @@ def _prompt_selections(prefs):
output_language = ask_output_language(prefs.get("output_language"))
# Step 4: Select analysts
console.print(
create_question_box(
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
)
)
prefs = sanitize(prefs, asset_type.value)
selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
if flags.get("analysts") is not None:
selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type)
else:
console.print(
create_question_box(
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
)
)
selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
console.print(
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
)
@@ -303,13 +342,6 @@ def get_analysis_date():
"", default=datetime.datetime.now().strftime("%Y-%m-%d")
)
try:
# Validate date format and ensure it's not in the future
analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d")
if analysis_date.date() > datetime.datetime.now().date():
console.print("[red]Error: Analysis date cannot be in the future[/red]")
continue
return date_str
except ValueError:
console.print(
"[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]"
)
return parse_analysis_date(date_str)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")