From 56a98d9df7dc07ead3833ae3b85eed054f59bffe Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 1 Oct 2026 05:51:03 +0000 Subject: [PATCH] feat(reports): save the report as one HTML page as well (#1419) - a saved report also includes complete_report.html: the report as one self-contained page with its sections listed beside the text, readable in a browser, on a phone and in print - answering the save question at the prompt also asks about the page and offers to open it in a local browser window; --no-html and save_reports(..., html=False) skip it - the Tauric Research logo leads the header at the top left, and a line at the foot credits TradingAgents and Tauric Research - the page loads nothing and runs nothing (inline styles, a content-security policy); report text is rendered without raw HTML or images - markdown-it-py is declared as a dependency --- README.md | 2 + cli/main.py | 6 +- cli/run.py | 57 ++++- pyproject.toml | 2 + tests/test_cli_commands.py | 2 +- tests/test_cli_headless.py | 148 ++++++++++++- tests/test_report_html.py | 174 +++++++++++++++ tradingagents/assets/tauric-logo.svg | 67 ++++++ tradingagents/graph/trading_graph.py | 9 +- tradingagents/report_html.py | 304 +++++++++++++++++++++++++++ tradingagents/reporting.py | 142 ++++++------- 11 files changed, 820 insertions(+), 93 deletions(-) create mode 100644 tests/test_report_html.py create mode 100644 tradingagents/assets/tauric-logo.svg create mode 100644 tradingagents/report_html.py diff --git a/README.md b/README.md index 16268c846..bbfdea404 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamental ``` Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set. +A saved report also includes `complete_report.html`, the report as one page with its sections listed beside the text, for reading in a browser, on a phone or in print. Answering the save question at the prompt also asks about the page and can open it in your browser; `--no-html` skips it, and so does `ta.save_reports(state, "NVDA", html=False)` from Python. + ### Markets and tickers TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market. diff --git a/cli/main.py b/cli/main.py index 7fbb53b15..7c13acaed 100644 --- a/cli/main.py +++ b/cli/main.py @@ -60,6 +60,10 @@ def analyze( show: bool | None = typer.Option( None, "--show/--no-show", help="Show the full report at the end without asking" ), + html: bool | None = typer.Option( + None, "--html/--no-html", + help="Save the report as one HTML page too, complete_report.html, without asking (default: yes)", + ), ): """Run an analysis. This is what a bare `tradingagents` does. @@ -81,7 +85,7 @@ def analyze( raise typer.Exit(code=1) from None try: - flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show} + flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show, "html": html} run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags) except _NO_CONSOLE_ERRORS: # A terminal with no console buffer cannot host the interactive prompts. diff --git a/cli/run.py b/cli/run.py index c7ac64acd..77bf95558 100644 --- a/cli/run.py +++ b/cli/run.py @@ -3,6 +3,7 @@ import os import sys import time +import webbrowser from functools import wraps from pathlib import Path @@ -384,11 +385,50 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") _offer_reports(final_state, graph, selections["ticker"], - save=flags.get("save"), show=flags.get("show")) + save=flags.get("save"), show=flags.get("show"), html=flags.get("html")) -def _offer_reports(final_state, graph, ticker, save=None, show=None): - """Save the report tree and show it; ``save``/``show`` answer the questions when given.""" +def _yes(question: str) -> bool: + return typer.prompt(question, default="Y").strip().upper() in ("Y", "YES", "") + + +def _graphical_browser(): + """A browser that opens a page in its own window on this machine, or None. + + Over SSH the page sits on the remote machine, and a terminal browser (lynx, + w3m, elinks) would take over the terminal, so neither is offered. + """ + if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): + return None + try: + browser = webbrowser.get() + except webbrowser.Error: + return None + if type(browser) is webbrowser.GenericBrowser or isinstance(browser, webbrowser.Elinks): + return None + return browser + + +def _open_page(page: Path) -> None: + """Offer to open the saved page in a browser window, and say where it is if opening fails.""" + browser = _graphical_browser() + if browser is None or not _yes("Open it in your browser?"): + return + try: + opened = browser.open(page.as_uri()) + except (webbrowser.Error, OSError): + opened = False + if not opened: + console.print(f" [dim]Could not open a browser; the page is at:[/dim] {page}") + + +def _offer_reports(final_state, graph, ticker, save=None, show=None, html=None): + """Save the report tree and show it; ``save``/``show``/``html`` answer the questions when given. + + A saved report includes the HTML page unless ``html`` is False. Someone + answering the save question at the prompt is also asked about the page and + offered to open it; a run whose flags answer the save question asks neither. + """ asked = save is None if asked: save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "") @@ -401,12 +441,21 @@ def _offer_reports(final_state, graph, ticker, save=None, show=None): save_path = Path(typer.prompt( "Save path (press Enter for default)", default=str(save_path) ).strip()) + if html is None: + html = _yes("Also save it as an HTML page?") if asked else True + saved = False try: - report_file = graph.save_reports(final_state, ticker, save_path) + report_file = graph.save_reports(final_state, ticker, save_path, html=html) + saved = True 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 saved and html: + page = (save_path / "complete_report.html").resolve() + console.print(f" [dim]HTML report:[/dim] {page.name}") + if asked: + _open_page(page) if show is None: show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "") diff --git a/pyproject.toml b/pyproject.toml index 47c4269b1..5dedc530c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "langchain-openai>=1.6.6", "langgraph>=1.2.12", "langgraph-checkpoint-sqlite>=3.1.1", + "markdown-it-py>=4.0", "pandas>=3.0.6", "python-dotenv>=1.0.0", "pytz>=2025.2", @@ -50,6 +51,7 @@ include = ["tradingagents*", "cli*"] [tool.setuptools.package-data] cli = ["static/*"] +tradingagents = ["assets/*.svg"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index a0bd36a9a..37c9c2859 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -25,7 +25,7 @@ calls: list = [] @pytest.mark.unit def test_no_arguments_still_runs_an_analysis(runner): assert runner.invoke(m.app, []).exit_code == 0 - no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None} + no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None, "html": None} assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})] diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index e08543cfb..a800ecfe3 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -87,12 +87,22 @@ def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeyp assert selections.unattended_gaps(FLAGS) == [] +class _Browser: + def __init__(self): + self.opened = [] + + def open(self, url): + self.opened.append(url) + return True + + @pytest.mark.unit def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path): def no_prompt(*a, **k): raise AssertionError("prompted although --save/--show answered") monkeypatch.setattr(run.typer, "prompt", no_prompt) + monkeypatch.setattr(run, "_graphical_browser", lambda: _Browser()) # even at a desktop shown = [] monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state)) from tradingagents.graph.trading_graph import TradingAgentsGraph @@ -145,7 +155,7 @@ def test_the_command_passes_its_flags_to_the_run(monkeypatch): "--analysts", "market,news", "--save", "--no-show"]) assert result.exit_code == 0, result.output - assert seen["flags"] == FLAGS + assert seen["flags"] == {**FLAGS, "html": None} @pytest.mark.unit @@ -220,6 +230,126 @@ def test_each_tier_provider_s_key_is_checked_before_the_run(monkeypatch): assert checked == ["openai", "anthropic"] +def _report_graph(tmp_path): + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.run_settings = lambda: {} + return graph + + +STATE = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"} + + +@pytest.mark.unit +def test_a_saved_report_includes_the_html_page_unless_declined(monkeypatch, tmp_path): + monkeypatch.setattr(run, "display_complete_report", lambda state: None) + + run._offer_reports(STATE, _report_graph(tmp_path / "a"), "NVDA", save=True, show=False) + run._offer_reports(STATE, _report_graph(tmp_path / "b"), "NVDA", save=True, show=False, html=False) + + assert list((tmp_path / "a").glob("reports/NVDA_*/complete_report.html")) + assert list((tmp_path / "b").glob("reports/NVDA_*/complete_report.md")) + assert not list((tmp_path / "b").glob("reports/NVDA_*/complete_report.html")) + + +def _answer(monkeypatch, answers): + asked = [] + + def prompt(text, default=None, **k): + asked.append(text) + for question, answer in answers.items(): + if question in text: + return default if answer is None else answer + raise AssertionError(f"unexpected question: {text}") + + monkeypatch.setattr(run.typer, "prompt", prompt) + monkeypatch.setattr(run, "display_complete_report", lambda state: None) + return asked + + +@pytest.mark.unit +def test_an_interactive_run_asks_for_the_html_page_and_opens_it(monkeypatch, tmp_path): + asked = _answer(monkeypatch, {"Save report": "Y", "Save path": "rel/out", "HTML": "Y", + "browser": "Y", "Display full report": "N"}) + browser = _Browser() + monkeypatch.setattr(run, "_graphical_browser", lambda: browser) + monkeypatch.chdir(tmp_path) + + run._offer_reports(STATE, _report_graph(tmp_path), "NVDA") + + page = tmp_path / "rel" / "out" / "complete_report.html" + assert page.exists() and browser.opened == [page.resolve().as_uri()] + assert [q for q in asked if "HTML" in q] and [q for q in asked if "browser" in q] + + +@pytest.mark.unit +def test_without_a_browser_window_the_page_is_not_offered_for_opening(monkeypatch, tmp_path): + asked = _answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "Y", + "Display full report": "N"}) + monkeypatch.setattr(run, "_graphical_browser", lambda: None) + + run._offer_reports(STATE, _report_graph(tmp_path), "NVDA") + + assert not [q for q in asked if "browser" in q] + assert list(tmp_path.glob("reports/NVDA_*/complete_report.html")) + + +@pytest.mark.unit +def test_declining_the_html_page_saves_only_the_markdown(monkeypatch, tmp_path): + asked = _answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "N", + "Display full report": "N"}) + monkeypatch.setattr(run, "_graphical_browser", lambda: _Browser()) + + run._offer_reports(STATE, _report_graph(tmp_path), "NVDA") + + assert not list(tmp_path.glob("reports/NVDA_*/complete_report.html")) + assert not [q for q in asked if "browser" in q] + + +@pytest.mark.unit +@pytest.mark.parametrize("env, browser, offered", [ + ({}, "background", True), + ({"SSH_CONNECTION": "10.0.0.1 22 10.0.0.2 22"}, "background", False), # the page is on the remote + ({"SSH_TTY": "/dev/pts/0"}, "background", False), + ({}, "terminal", False), # a text browser would take over the terminal + ({}, "elinks", False), + ({}, None, False), # no browser at all +]) +def test_a_page_is_opened_only_in_a_browser_window_on_this_machine(monkeypatch, env, browser, offered): + import webbrowser + + for name in ("SSH_CONNECTION", "SSH_TTY"): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + + def get(): + if browser is None: + raise webbrowser.Error("no runnable browser") + return {"background": lambda: webbrowser.BackgroundBrowser("firefox"), + "terminal": lambda: webbrowser.GenericBrowser("lynx"), + "elinks": lambda: webbrowser.Elinks("elinks")}[browser]() + + monkeypatch.setattr(run.webbrowser, "get", get) + assert (run._graphical_browser() is not None) is offered + + +@pytest.mark.unit +@pytest.mark.parametrize("args, expected", [([], None), (["--no-html"], False), (["--html"], True)]) +def test_the_html_flag_reaches_the_run(monkeypatch, args, expected): + from typer.testing import CliRunner + + from cli import main as cli_main + + seen = {} + monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw)) + result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--save", "--no-show", *args]) + + assert result.exit_code == 0 and seen["flags"]["html"] is expected + + @pytest.mark.unit def test_only_the_providers_the_tiers_use_need_a_key(monkeypatch): """Both tiers on a local Ollama: the main provider's key is not needed.""" @@ -231,3 +361,19 @@ def test_only_the_providers_the_tiers_use_need_a_key(monkeypatch): selections._prompt_selections({}, FLAGS) assert checked == ["ollama"] + + +@pytest.mark.unit +def test_a_page_the_browser_cannot_open_is_pointed_to(monkeypatch, tmp_path, capsys): + class Failing(_Browser): + def open(self, url): + return False + + _answer(monkeypatch, {"Save report": "Y", "Save path": None, "HTML": "Y", "browser": "Y", + "Display full report": "N"}) + monkeypatch.setattr(run, "_graphical_browser", lambda: Failing()) + monkeypatch.setattr(run.console, "print", lambda *a, **k: print(*a)) + + run._offer_reports(STATE, _report_graph(tmp_path), "NVDA") + + assert "complete_report.html" in capsys.readouterr().out.split("the page is at:")[-1] diff --git a/tests/test_report_html.py b/tests/test_report_html.py new file mode 100644 index 000000000..d7a6dfdc7 --- /dev/null +++ b/tests/test_report_html.py @@ -0,0 +1,174 @@ +"""An optional HTML copy of the report, for readers who do not open Markdown (#1419). + +It is one self-contained file: no script, no remote asset, nothing a report's +text can inject; the Markdown report is unchanged. +""" + +import re + +import pytest + +from tradingagents.reporting import write_report_tree + +SETTINGS = {"version": "1.2.3", "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": 1, "data_vendors": {"core_stock_apis": "yfinance"}} + + +def _state(**overrides): + state = {"trade_date": "2026-09-26", + "market_report": "# Market view\n\nPrice | Level\n--- | ---\nClose | 181.2\n", + "news_report": "News text.", + "final_trade_decision": "**Rating**: Overweight\n\nAdd on weakness."} + state.update(overrides) + return state + + +def _html(tmp_path, **overrides): + write_report_tree(_state(**overrides), "NVDA", tmp_path, settings=SETTINGS, html=True) + return (tmp_path / "complete_report.html").read_text(encoding="utf-8") + + +@pytest.mark.unit +def test_the_html_page_is_written_unless_declined(tmp_path): + write_report_tree(_state(), "NVDA", tmp_path / "plain", settings=SETTINGS, html=False) + assert not (tmp_path / "plain" / "complete_report.html").exists() + write_report_tree(_state(), "NVDA", tmp_path / "default", settings=SETTINGS) + assert (tmp_path / "default" / "complete_report.html").exists() + + page = _html(tmp_path / "html") + assert "' in page and "181.2" in page + + +@pytest.mark.unit +def test_the_markdown_report_is_the_same_with_or_without_html(tmp_path): + write_report_tree(_state(), "NVDA", tmp_path / "a", settings=SETTINGS, html=False) + write_report_tree(_state(), "NVDA", tmp_path / "b", settings=SETTINGS) + def undated(path): + return re.sub(r"- Generated: .*\n", "", path.read_text(encoding="utf-8")) + + assert undated(tmp_path / "a" / "complete_report.md") == undated(tmp_path / "b" / "complete_report.md") + + +@pytest.mark.unit +def test_the_page_loads_nothing_and_runs_nothing(tmp_path): + page = _html(tmp_path) + assert "Content-Security-Policy" in page and "default-src 'none'" in page + assert "alert(1) \n\n" + "![pixel](https://tracker.example/p.png) [x](javascript:alert(1))") + page = _html(tmp_path, news_report=hostile) + assert "