mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-03 16:12:44 +02:00
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
This commit is contained in:
@@ -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})]
|
||||
|
||||
|
||||
|
||||
+147
-1
@@ -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]
|
||||
|
||||
@@ -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 "<h1" in page and "NVDA" in page and "Overweight" in page and "2026-09-26" in page
|
||||
assert '<table>' 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 "<script" not in page.lower()
|
||||
assert not re.search(r'<(img|link|iframe|object|embed)\b', page, re.IGNORECASE)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_report_text_cannot_inject_markup_or_fetch_anything(tmp_path):
|
||||
hostile = ("<script>alert(1)</script> <img src=x onerror=alert(1)>\n\n"
|
||||
" [x](javascript:alert(1))")
|
||||
page = _html(tmp_path, news_report=hostile)
|
||||
assert "<script>alert" not in page and "<img" not in page.lower()
|
||||
assert "tracker.example/p.png" not in re.findall(r'(?:src|href)="([^"]*)"', page)
|
||||
assert 'href="javascript:' not in page
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_header_values_are_escaped(tmp_path):
|
||||
write_report_tree(_state(), "<b>NV</b>", tmp_path, settings={**SETTINGS, "deep_think_llm": "<i>m</i>"}, html=True)
|
||||
page = (tmp_path / "complete_report.html").read_text(encoding="utf-8")
|
||||
assert "<b>NV</b>" not in page and "<b>NV</b>" in page and "<i>m</i>" not in page
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_agent_s_own_headings_sit_below_its_heading(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
assert page.count("<h1") == 1 # only the report's title
|
||||
assert re.search(r"<h4[^>]*>Market view</h4>", page)
|
||||
|
||||
|
||||
def _fields(fragment):
|
||||
return re.findall(r'<span class="field">([^<]*)</span>', fragment)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_run_details_show_each_value_as_its_own_field(tmp_path):
|
||||
settings = {**SETTINGS, "deep_think_provider": "anthropic", "deep_think_llm": "claude-opus-5-5",
|
||||
"data_vendors": {"core_stock_apis": "yfinance", "fundamental_data": "sec_edgar,yfinance"}}
|
||||
write_report_tree(_state(), "NVDA", tmp_path, settings=settings, html=True)
|
||||
page = (tmp_path / "complete_report.html").read_text(encoding="utf-8")
|
||||
header = page[page.index("<header"):page.index("</header>")]
|
||||
for value in ("anthropic", "claude-opus-5-5", "openai", "gpt-6-luna", "market", "news",
|
||||
"sec_edgar", "yfinance"):
|
||||
assert value in _fields(header)
|
||||
# Vendors are named by what they serve, and a chain keeps its order.
|
||||
assert "Prices" in header and "Fundamentals" in header and "fundamental_data" not in header
|
||||
assert header.index(">sec_edgar<") < header.rindex(">yfinance<")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_contents_name_each_section_once_and_list_its_agents_beneath(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
nav = page[page.index('<nav class="contents"'):page.index("</nav>")]
|
||||
assert "<ol" not in nav # no list numbers beside the sections' own I-V
|
||||
assert re.search(r'<a href="#s1"><span class="num">I</span>\s*<span class="label">Analyst Team Reports</span></a>', nav)
|
||||
assert re.search(r'<a href="#s1-1"><span class="bar"></span>\s*<span class="label">Market Analyst</span></a>', nav)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_section_rail_opens_on_hover_and_keyboard_focus(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
css = page[page.index("<style>"):page.index("</style>")]
|
||||
# Wide screens: a rail centred on the left edge whose names are hidden from
|
||||
# sight only (still read by screen readers) until it is pointed at or focused.
|
||||
assert "top: 50%" in css and "translateY(-50%)" in css
|
||||
assert ".contents:hover .label" in css and ".contents:focus-within .label" in css
|
||||
assert "display: none" not in css.split(".contents .label", 1)[1].split("}", 1)[0]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_sections_stay_within_reach(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
nav = page[page.index('<nav class="contents"'):page.index("</nav>")]
|
||||
assert "<h2" not in nav # the list needs no heading
|
||||
assert 'id="top"' in page and 'href="#top"' in page # a way back on small screens
|
||||
assert ".contents { position: fixed;" in page # beside the text on wide ones
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_page_heading_is_the_ticker_and_its_rating(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
heading = re.search(r"<h1>(.*?)</h1>", page).group(1)
|
||||
assert re.sub(r"<[^>]+>", "", heading).split() == ["NVDA", "Overweight"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_header_carries_the_official_logo_unaltered(tmp_path):
|
||||
from importlib.resources import files
|
||||
|
||||
logo = files("tradingagents").joinpath("assets/tauric-logo.svg").read_text(encoding="utf-8").strip()
|
||||
page = _html(tmp_path)
|
||||
header = page[page.index("<header"):page.index("</header>")]
|
||||
assert logo in header # drawn in the page, nothing fetched
|
||||
assert 'href="https://tauric.ai"' in header and 'aria-label="Tauric Research"' in header
|
||||
assert header.index('class="logo"') < header.index("<h1") # leads the header, above the title
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_page_credits_its_maker_in_a_line_of_text(tmp_path):
|
||||
page = _html(tmp_path)
|
||||
footer = page[page.index('<footer class="credit">'):page.index("</footer>")]
|
||||
assert re.sub(r"<[^>]+>", "", footer).split() == ["TradingAgents", "powered", "by", "Tauric", "Research"]
|
||||
# The logo already leads to tauric.ai; the credit leads to the project and the org.
|
||||
assert 'href="https://github.com/TauricResearch/TradingAgents"' in footer
|
||||
assert 'href="https://github.com/TauricResearch"' in footer and "tauric.ai" not in footer
|
||||
assert "<svg" not in footer and 'class="brand"' in footer
|
||||
assert page.index("</main>") < page.index('<footer class="credit">')
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_packaged_logo_stays_inert(tmp_path):
|
||||
# The logo is drawn inside every report, so it may only ever hold shapes:
|
||||
# no script, event handler, link, embedded document or external reference.
|
||||
from importlib.resources import files
|
||||
|
||||
logo = files("tradingagents").joinpath("assets/tauric-logo.svg").read_text(encoding="utf-8")
|
||||
tags = set(re.findall(r"<\s*([a-zA-Z][\w:-]*)", logo))
|
||||
assert tags <= {"svg", "path", "rect", "circle", "g", "defs", "clipPath", "linearGradient", "stop"}
|
||||
assert not re.search(r"\son\w+\s*=|href|url\(|@import|javascript:|<!entity|<!doctype", logo, re.IGNORECASE)
|
||||
Reference in New Issue
Block a user