feat(cli): add a backtest command (#1234)

- analysis stays the bare invocation; backtest runs a ticker and date grid
- selections name the models as the config does
- the pending note prints only when a cell is pending
This commit is contained in:
Yijia-Xiao
2026-09-16 21:45:08 +00:00
parent 63738c8f2c
commit d8eceb6571
6 changed files with 178 additions and 21 deletions

View File

@@ -179,3 +179,10 @@ def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monke
assert result.cells_run == 2
assert settled == ["AAPL"]
assert result.settlement_failures == [("NVDA", "reflector timed out")]
@pytest.mark.unit
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
assert "Pending" not in summarize(_log_with(tmp_path, settled)).render()
assert "Pending" in summarize(_log_with(tmp_path, settled + [("AAPL", "2026-01-05", DECISION, None)])).render()

View File

@@ -0,0 +1,91 @@
"""The CLI keeps running an analysis with no arguments, and gains `backtest`.
Every documented invocation is bare (`tradingagents --checkpoint`), so analysis
has to stay the default action while a second command exists alongside it.
"""
from __future__ import annotations
import pytest
from typer.testing import CliRunner
import cli.main as m
@pytest.fixture
def runner(monkeypatch):
monkeypatch.setattr(m, "run_analysis", lambda **kw: calls.append(("analysis", kw)))
calls.clear()
return CliRunner()
calls: list = []
@pytest.mark.unit
def test_no_arguments_still_runs_an_analysis(runner):
assert runner.invoke(m.app, []).exit_code == 0
assert calls == [("analysis", {"checkpoint": None, "portfolio": None})]
@pytest.mark.unit
def test_options_still_parse_without_a_subcommand(runner):
assert runner.invoke(m.app, ["--checkpoint"]).exit_code == 0
assert calls[0][1]["checkpoint"] is True
@pytest.mark.unit
def test_backtest_does_not_also_run_an_analysis(runner, monkeypatch, tmp_path):
swept = []
monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append((a, kw)) or _Result(tmp_path))
monkeypatch.setattr(m, "summarize", lambda log: _Summary())
result = runner.invoke(m.app, ["backtest", "NVDA,AAPL", "--start", "2026-06-01",
"--end", "2026-06-15", "--every", "7"])
assert result.exit_code == 0, result.output
assert calls == [] # the interactive analysis must not run
(tickers, dates, _config), kwargs = swept[0]
assert tickers == ["NVDA", "AAPL"]
assert dates == ["2026-06-01", "2026-06-08", "2026-06-15"]
assert "scored" in result.output
@pytest.mark.unit
def test_backtest_reports_a_bad_date_instead_of_a_traceback(runner):
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "June", "--end", "2026-06-15"])
assert result.exit_code == 1
assert "YYYY-MM-DD" in result.output
@pytest.mark.unit
def test_help_lists_the_backtest_command(runner):
assert "backtest" in runner.invoke(m.app, ["--help"]).output
class _Result:
def __init__(self, tmp_path):
self.run_id = "20260916_000000"
self.log_path = tmp_path / "trading_memory.md"
self.cells_run = 2
self.skipped = 0
self.failures = []
self.settlement_failures = []
class _Summary:
def render(self):
return "scored 2 cells"
@pytest.mark.unit
def test_every_command_is_registered_when_run_as_a_module():
"""README documents `python -m cli.main`, which executes the file top to
bottom, so a command defined after the __main__ block would not exist."""
import subprocess
import sys
out = subprocess.run([sys.executable, "-m", "cli.main", "backtest", "--help"],
capture_output=True, text=True, timeout=120)
assert out.returncode == 0, out.stderr[-400:]
assert "--start" in out.stdout

View File

@@ -14,8 +14,8 @@ import cli.main as m
# Minimal selections dict shaped like get_user_selections()'s return value.
SELECTIONS = {
"research_depth": 5,
"shallow_thinker": "gpt-5.4-mini",
"deep_thinker": "gpt-5.5",
"quick_think_llm": "gpt-5.4-mini",
"deep_think_llm": "gpt-5.5",
"backend_url": None,
"llm_provider": "openai",
"google_thinking_level": None,

View File

@@ -77,8 +77,8 @@ class TestCliSkipsPromptsFromEnv(unittest.TestCase):
# The env values flow into the returned selections.
self.assertEqual(sel["llm_provider"], "openai")
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
self.assertEqual(sel["shallow_thinker"], "deepseek-v4-pro")
self.assertEqual(sel["deep_thinker"], "kimi-k2.5")
self.assertEqual(sel["quick_think_llm"], "deepseek-v4-pro")
self.assertEqual(sel["deep_think_llm"], "kimi-k2.5")
self.assertEqual(sel["output_language"], "Japanese")