diff --git a/.env.example b/.env.example index b6f14ed41..d17ac1c2b 100644 --- a/.env.example +++ b/.env.example @@ -16,57 +16,49 @@ MOONSHOT_API_KEY= GROQ_API_KEY= NVIDIA_API_KEY= -# FRED (Federal Reserve macro data: rates, inflation, labor, growth). Free key: https://fred.stlouisfed.org/docs/api/api_key.html +# SEC EDGAR (US company filings, point-in-time). No key; a contact address SEC can reach you at. +#SEC_EDGAR_USER_AGENT=Your Name your@email.com + +# FRED (Federal Reserve macro data). Free key: https://fred.stlouisfed.org/docs/api/api_key.html #FRED_API_KEY= -# Optional: a custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp, -# relay). Select provider "openai_compatible" and set the base URL; the key is -# optional (local servers need none). +# Custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp). Local servers need no key. #OPENAI_COMPATIBLE_API_KEY= -# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]"). -# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS -# credential chain (env keys / ~/.aws/credentials / IAM role / AWS_PROFILE). Set -# the region either way; a bearer token takes precedence when both are present. +# AWS Bedrock (pip install ".[bedrock]"). Bearer token, or the AWS credential chain; set the region either way. #AWS_BEARER_TOKEN_BEDROCK= #AWS_DEFAULT_REGION=us-west-2 #AWS_PROFILE= -# Optional: point at a remote Ollama server. When unset, defaults to -# the local instance at http://localhost:11434/v1. Convention follows -# the broader Ollama ecosystem; both the CLI dropdown and programmatic -# client pick this up. +# Remote Ollama server. Unset uses http://localhost:11434/v1. #OLLAMA_BASE_URL=http://your-ollama-host:11434/v1 -# Optional: override DEFAULT_CONFIG without editing code. -# Any TRADINGAGENTS_* variable below, when set, replaces the matching key -# in tradingagents/default_config.py. Values are coerced to the type of -# the existing default (bool / int / str), so "true"/"3" work as expected. -# In the CLI, setting the LLM provider / models / backend URL / language -# also skips the matching interactive selection step (useful for -# OpenAI-compatible endpoints like opencode or LM Studio, and unattended runs). +# Override these DEFAULT_CONFIG keys. Provider, models, language and round counts also skip their CLI prompt. #TRADINGAGENTS_LLM_PROVIDER=openai -#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4 -#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini +#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.6 +#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.6-luna #TRADINGAGENTS_LLM_BACKEND_URL= #TRADINGAGENTS_OUTPUT_LANGUAGE=English #TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 #TRADINGAGENTS_MAX_RISK_ROUNDS=1 #TRADINGAGENTS_CHECKPOINT_ENABLED=false -# Sampling temperature (lower = less run-to-run variation on models that -# honor it). Unset leaves each provider at its default. See the README -# "Reproducibility" note — no setting makes LLM output fully deterministic. + +# Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index. +#TRADINGAGENTS_RESULTS_DIR= +#TRADINGAGENTS_CACHE_DIR= +#TRADINGAGENTS_MEMORY_LOG_PATH= +#TRADINGAGENTS_BENCHMARK_TICKER=SPY + +# Lower temperature means less run-to-run variation on models that honor it. #TRADINGAGENTS_TEMPERATURE=0.0 -# LLM SDK retry budget forwarded to every provider. Unset leaves each SDK at its -# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling -# on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run. + +# Retry budget for every LLM SDK. Raise it to ride out 429 throttling. #TRADINGAGENTS_LLM_MAX_RETRIES=6 -# Cap on output tokens forwarded to every provider (Gemini's max_output_tokens -# too). Unset leaves each provider at its default. Set it to bound a model that -# emits unbounded reasoning/output and hangs or trips a gateway idle timeout. + +# Cap output tokens to bound a model that runs long and trips a timeout. #TRADINGAGENTS_MAX_TOKENS=8192 -# Provider-specific reasoning/thinking depth (optional; unset = provider -# default). Setting one also skips the matching interactive prompt. + +# Reasoning depth per provider; setting one skips its prompt. #TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium #TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high #TRADINGAGENTS_ANTHROPIC_EFFORT=high diff --git a/CHANGELOG.md b/CHANGELOG.md index 9071c0445..6bd57ecf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Breaking changes within the 0.x line are called out explicitly. +## [0.5.0] — 2026-09-18 + +Point-in-time integrity across every dated path, decisions that are recorded as +they were made, backtesting over a grid of tickers and dates, the caller's +portfolio as run input, and SEC EDGAR fundamentals served as filed. + +### Highlights + +- **Fundamentals as filed.** SEC EDGAR serves US company statements as they stood on the run's date: a period that has ended but has not been filed is not served, and a figure restated later still reads as first reported. Keyless, opt-in via the vendor chain. +- **Backtesting.** `run_backtest` runs the pipeline over a ticker and date grid into its own decision log, and `summarize` scores the settled cells; `tradingagents backtest` does the same from the CLI. +- **Portfolio context.** `propagate(..., portfolio=...)` and `--portfolio` let the trader, risk and portfolio agents size against real holdings. A run without one is never treated as a flat book. +- **Decisions are recorded as made.** An unreadable decision is flagged for review everywhere instead of becoming a tradeable Hold, and a rating argued against is no longer read as the call. + +### Point-in-time and honest attribution + +- Dated tools take the run's date from graph state, so an omitted or later date cannot reach a vendor. (#1331, #1319, #1118) +- Insider filings and prediction-market odds are bounded by the run date; insider rows state that a trade becomes public when its Form 4 is filed. +- A feed that never observed a window reports it as unavailable rather than as an absence, across news, Reddit and StockTwits. +- The resolved company identity says when it describes today rather than the run date. +- The verification snapshot quotes the prices the vendor reported, never a gap-filled value. +- A vendor failure is a vendor failure: yfinance raises instead of returning its errors as text, an outage is not reported as a company with no data, and a chain where every vendor is unavailable says so instead of ending the run. +- The macro vintage pin is clamped to the vendor's own clock, so a run dated today cannot ask for a vintage it does not have. +- A historical run is not served a present-day company profile by either fundamentals vendor. (#1300) + +### Decisions and evaluation + +- The labelled rating decides, whatever separates it, and prose naming several ratings is reviewed rather than guessed. +- Decision prompts state the shape of their answer, so a provider without structured output still returns a readable decision. +- A report that was not produced says so, instead of appearing as an empty section. +- Backtest scoring reads the direction each rating claimed: a Sell that fell is a hit, and Hold reports no hit rate. +- The outcome window is configurable (`holding_period_days`), and reflection states the window it judges. +- A settled decision is not logged twice, and a failed reflection no longer stops the next run. (#645) +- The trader states entry and stop levels as prices, so a percentage no longer fails the whole proposal. (#1288) + +### CLI + +- `tradingagents backtest`, with `--run-id` to continue an interrupted sweep. (#1234) +- The previous run's selections come back as prompt defaults. (#1236, #920) +- A run with no readable rating says so; the live view no longer scrolls; messages that read like Python values are shown. (#649, #784) +- The state log keeps non-ASCII readable. (#1081) + +### Data sources + +- SEC EDGAR fundamentals vendor (US filers, keyless). +- Hong Kong and Shanghai tickers resolve to the symbols Yahoo serves. (#1342, #957, #1260) +- Reddit is fetched as one combined request per run. (#1286) +- One OHLCV cache file per symbol. (#1330) + +### Models + +- Current lineups for every provider: GPT-6 Astra and the GPT-5.6 family, Gemini 3.8 Flash, Claude Opus 5 and Fable 5.1, Grok 4.6, DeepSeek Flash, Qwen 3.8, GLM-5.3, MiniMax M3, Kimi K3 and the current Mistral snapshots. +- Every provider accepts a model ID the picker does not list. +- GLM traffic goes to the platform its key belongs to, and Ollama structured output no longer sends a tool_choice it rejects. (#1062) + +### Changed + +- The memory log records `REVIEW` for a decision with no readable rating, where it previously recorded `Hold`. +- Optional fields the model did not provide are named as such rather than omitted. +- Removed dependencies nothing imports: backtrader, redis, setuptools, langchain-experimental, parsel, tqdm. (#1353, #1070) + +### Contributors + +Thanks to everyone who reported these or sent a fix: + +[@akashkpfreelancer](https://github.com/akashkpfreelancer), [@angziii](https://github.com/angziii), [@anupamme](https://github.com/anupamme), [@AyushKar2005](https://github.com/AyushKar2005), [@bulkypanda](https://github.com/bulkypanda), [@CadeYu](https://github.com/CadeYu), [@chiang21fcb](https://github.com/chiang21fcb), [@dajiaohuang](https://github.com/dajiaohuang), [@dewrama](https://github.com/dewrama), [@DogInfantry](https://github.com/DogInfantry), [@emitov](https://github.com/emitov), [@farukerdem34](https://github.com/farukerdem34), [@flydragon2018](https://github.com/flydragon2018), [@fusshell](https://github.com/fusshell), [@Ganesh1729-ui](https://github.com/Ganesh1729-ui), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@hamzabudeir](https://github.com/hamzabudeir), [@ihsieh31](https://github.com/ihsieh31), [@jaylew20250206](https://github.com/jaylew20250206), [@kaushik-yadav](https://github.com/kaushik-yadav), [@kbnnf](https://github.com/kbnnf), [@kevinkda](https://github.com/kevinkda), [@LudwigJMarx](https://github.com/LudwigJMarx), [@lx7720](https://github.com/lx7720), [@malandrindev](https://github.com/malandrindev), [@mhd325ic-hash](https://github.com/mhd325ic-hash), [@minhdn90](https://github.com/minhdn90), [@miznan](https://github.com/miznan), [@mmssix](https://github.com/mmssix), [@mrbob-git](https://github.com/mrbob-git), [@newnewself](https://github.com/newnewself), [@prithvirajrh](https://github.com/prithvirajrh), [@PyriteResearch](https://github.com/PyriteResearch), [@Rajatendu1](https://github.com/Rajatendu1), [@Recnelis0](https://github.com/Recnelis0), [@Rodvask](https://github.com/Rodvask), [@samhoooo](https://github.com/samhoooo), [@sheiun-xu](https://github.com/sheiun-xu), [@shivsin25](https://github.com/shivsin25), [@SmileShaun](https://github.com/SmileShaun), [@SonnyRajagopalan](https://github.com/SonnyRajagopalan), [@taro0915](https://github.com/taro0915), [@wupengbo125](https://github.com/wupengbo125), [@wxggzz](https://github.com/wxggzz), [@Yixiang-Wu](https://github.com/Yixiang-Wu), [@ZahirBodrike](https://github.com/ZahirBodrike), [@ZHUYAWEI](https://github.com/ZHUYAWEI), [@zkwang616](https://github.com/zkwang616). + ## [0.4.0] — 2026-08-31 Look-ahead and point-in-time fixes across the data and memory layers, clearer diff --git a/README.md b/README.md index 505b69df4..6cd125f3e 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,13 @@ # TradingAgents: Multi-Agents LLM Financial Trading Framework ## News -- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. See [CHANGELOG.md](CHANGELOG.md) for the full list. +- [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider. See [CHANGELOG.md](CHANGELOG.md) for the full list. +- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. - [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. + +
+Earlier releases + - [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate. - [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening. - [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix. @@ -40,6 +45,8 @@ - [2026-02] **TradingAgents v0.2.0** released with multi-provider LLM support (GPT-5.x, Gemini 3.x, Claude 4.x, Grok 4.x) and improved system architecture. - [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon. +
+
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation) @@ -110,7 +117,13 @@ conda create -n tradingagents python=3.12 conda activate tradingagents ``` -Install the package and its dependencies: +Or with [uv](https://docs.astral.sh/uv/): +```bash +uv venv --python 3.12 +source .venv/bin/activate +``` + +Install the package and its dependencies (`uv pip install .` with uv): ```bash pip install . ``` @@ -123,6 +136,8 @@ cp .env.example .env # add your API keys docker compose run --rm tradingagents ``` +After updating the repository, rebuild the image with `docker compose build`. + For local models with Ollama: ```bash docker compose --profile ollama run --rm tradingagents-ollama @@ -145,6 +160,11 @@ export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn) export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io) export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com) export OPENROUTER_API_KEY=... # OpenRouter +export MISTRAL_API_KEY=... # Mistral +export MOONSHOT_API_KEY=... # Kimi (Moonshot) +export GROQ_API_KEY=... # Groq +export NVIDIA_API_KEY=... # NVIDIA NIM +export FRED_API_KEY=... # FRED macro data (free, optional) export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage ``` @@ -168,7 +188,7 @@ Launch the interactive CLI: tradingagents # installed command python -m cli.main # alternative: run directly from source ``` -You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. +You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely. ### Markets and tickers @@ -211,7 +231,7 @@ from tradingagents.default_config import DEFAULT_CONFIG ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy()) # forward propagate -_, decision = ta.propagate("NVDA", "2026-01-15") +_, decision = ta.propagate("NVDA", "2026-09-01") print(decision) ``` @@ -228,40 +248,98 @@ config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks config["max_debate_rounds"] = 2 ta = TradingAgentsGraph(debug=True, config=config) -_, decision = ta.propagate("NVDA", "2026-01-15") +_, decision = ta.propagate("NVDA", "2026-09-01") print(decision) ``` See `tradingagents/default_config.py` for all configuration options. +### Fundamentals as filed + +US company statements can come from SEC EDGAR, which records the date every figure was filed. A run dated in the past then reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B. + +EDGAR needs no account or API key. Add the vendor to the chain: + +```python +config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance" +``` + +SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project: + +```bash +SEC_EDGAR_USER_AGENT="Your Name your@email.com" +``` + +It covers companies that file with the SEC, including foreign companies listed in the US. Anything else, such as Hong Kong or A-share listings, falls through to the next vendor in the chain. EDGAR's machine-readable filings begin in 2009, and a fourth quarter is reported as unavailable rather than derived, because filers publish it only inside the annual figure. + +### Current holdings + +By default the agents do not know what you hold, so their guidance is written for a reader who applies it to their own position. Pass a portfolio to have the trader, the risk analysts and the portfolio manager work against your actual book. + +```python +from tradingagents.portfolio import PortfolioContext + +portfolio = PortfolioContext.model_validate({ + "cash": 25000.0, + "currency": "USD", + "positions": [{"ticker": "NVDA", "quantity": 120, "average_price": 150.0}], +}) +_, decision = ta.propagate("NVDA", "2026-09-01", portfolio=portfolio) +``` + +The CLI takes the same content as a JSON file: `tradingagents --portfolio my_book.json`. + +An empty `positions` list means a flat book, which is different from passing nothing. A run without a portfolio is never treated as flat. + ## Persistence and Recovery TradingAgents persists two kinds of state across runs. ### Decision log -The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw and alpha vs SPY), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. +The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`. ### Checkpoint resume -Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. On a resume run you will see `Resuming from step N for on ` in the logs; on a new run you will see `Starting fresh`. Checkpoints are cleared automatically on successful completion. +Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. The run view says whether it resumed a saved run or started fresh. Checkpoints are cleared automatically on successful completion. Per-ticker SQLite databases live at `~/.tradingagents/cache/checkpoints/.db` (override the base with `TRADINGAGENTS_CACHE_DIR`). Use `--clear-checkpoints` to reset all of them before a run. ```bash -tradingagents analyze --checkpoint # enable for this run -tradingagents analyze --clear-checkpoints # reset before running +tradingagents --checkpoint # enable for this run +tradingagents --clear-checkpoints # reset before running ``` ```python config = DEFAULT_CONFIG.copy() config["checkpoint_enabled"] = True ta = TradingAgentsGraph(config=config) -_, decision = ta.propagate("NVDA", "2026-01-15") +_, decision = ta.propagate("NVDA", "2026-09-01") ``` +## Evaluating decisions over time + +One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a decision log of its own, and scores the decisions whose holding window has since traded. + +```python +from tradingagents.backtest import iter_grid, run_backtest, summarize +from tradingagents.agents.utils.memory import TradingMemoryLog + +dates = iter_grid("2026-06-01", "2026-08-01", every_n_days=7) +result = run_backtest(["NVDA", "AAPL"], dates, config, selected_analysts=["market", "news"]) +print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render()) +``` + +From the CLI: + +```bash +tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7 +``` + +Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own decision log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. + ## Reproducibility TradingAgents is LLM-driven, so two runs of the same ticker and date can differ. This is expected for a research tool built on language models, not a defect. The variation comes from a few distinct sources, and it helps to separate them. @@ -270,14 +348,14 @@ Language model sampling is non-deterministic. Even at a fixed temperature, provi Live data moves. News, StockTwits, and Reddit return different content as time passes, so a run today sees different inputs than a run last week even for the same historical trade date. Pin the analysis date to hold the price and indicator window fixed, but the social and news sources still reflect "now". -To reduce variation you can lower the sampling temperature. Set `temperature` in your config (or `TRADINGAGENTS_TEMPERATURE` in `.env`); lower values make models that honor it more repeatable. The current curated models are reasoning-first and largely ignore temperature, so for tighter reproducibility use a non-reasoning model, which you can set explicitly via the Custom model ID option. +To reduce variation you can lower the sampling temperature. Set `temperature` in your config (or `TRADINGAGENTS_TEMPERATURE` in `.env`); lower values make models that honor it more repeatable. The current curated models are reasoning-first and largely ignore temperature, so for tighter reproducibility name a non-reasoning model in your config, or in `TRADINGAGENTS_DEEP_THINK_LLM` and `TRADINGAGENTS_QUICK_THINK_LLM`. Any model ID your provider serves is accepted, whether or not the picker lists it. ```python config = DEFAULT_CONFIG.copy() config["llm_provider"] = "openai" config["temperature"] = 0.0 -# Reasoning models ignore temperature. For tighter reproducibility, set a -# non-reasoning deep/quick model explicitly (e.g. via the Custom model ID option). +# Reasoning models ignore temperature. For tighter reproducibility, name a +# non-reasoning model in deep_think_llm / quick_think_llm. ``` What does not vary anymore: the analyzed company identity is resolved deterministically from the ticker before any agent runs, and the market analyst grounds exact price and indicator claims in a verified data snapshot. Earlier reports of "different companies" or fabricated price levels across runs are addressed by these two mechanisms. diff --git a/cli/main.py b/cli/main.py index b64487367..174740ad6 100644 --- a/cli/main.py +++ b/cli/main.py @@ -20,6 +20,7 @@ from rich.table import Table from rich.text import Text from cli.announcements import display_announcements, fetch_announcements +from cli.prefs import load_last_run, sanitize, save_last_run from cli.stats_handler import StatsCallbackHandler from cli.utils import ( ask_anthropic_effort, @@ -41,6 +42,9 @@ from cli.utils import ( select_research_depth, select_shallow_thinking_agent, ) +from tradingagents.agents.utils.rating import is_review +from tradingagents.backtest import iter_grid, run_backtest, summarize +from tradingagents.dataflows.utils import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.graph.analyst_execution import ( AnalystWallTimeTracker, @@ -49,6 +53,7 @@ from tradingagents.graph.analyst_execution import ( sync_analyst_tracker_from_chunk, ) from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.portfolio import load_portfolio from tradingagents.reporting import write_report_tree console = Console() @@ -493,7 +498,14 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non def get_user_selections(): - """Get all user selections before starting the analysis display.""" + """Ask for the run's settings, offering the previous run's answers.""" + selections = _prompt_selections(load_last_run()) + save_last_run(selections) + return selections + + +def _prompt_selections(prefs): + """Walk the selection steps. ``prefs`` prefills, the environment skips.""" # Display ASCII art welcome message with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f: welcome_ascii = f.read() @@ -586,7 +598,7 @@ def get_user_selections(): "Select the language for analyst reports and final decision" ) ) - output_language = ask_output_language() + output_language = ask_output_language(prefs.get("output_language")) # Step 4: Select analysts console.print( @@ -594,7 +606,8 @@ def get_user_selections(): "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" ) ) - selected_analysts = select_analysts(asset_type) + prefs = sanitize(prefs, asset_type.value) + selected_analysts = select_analysts(asset_type, prefs.get("analysts")) console.print( f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" ) @@ -619,7 +632,7 @@ def get_user_selections(): "Step 5: Research Depth", "Select your research depth level" ) ) - selected_research_depth = select_research_depth() + selected_research_depth = select_research_depth(prefs.get("research_depth")) # Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER). # The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set, @@ -641,7 +654,7 @@ def get_user_selections(): "Step 6: LLM Provider", "Select your LLM provider" ) ) - selected_llm_provider, backend_url = select_llm_provider() + selected_llm_provider, backend_url = select_llm_provider(prefs.get("llm_provider")) # Providers with regional endpoints prompt for the region as a secondary # step so the main dropdown stays clean (mainland China and international @@ -662,7 +675,9 @@ def get_user_selections(): # The generic OpenAI-compatible endpoint has no default; ask for it if # neither the menu nor the environment supplied one. if selected_llm_provider == "openai_compatible" and not backend_url: - backend_url = prompt_openai_compatible_url() + remembered_url = (prefs.get("backend_url") + if prefs.get("llm_provider") == selected_llm_provider else None) + backend_url = prompt_openai_compatible_url(remembered_url) # For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default) # before model selection so it's obvious where we're connecting. @@ -688,8 +703,13 @@ def get_user_selections(): "Step 7: Thinking Agents", "Select your thinking agents for analysis" ) ) - selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) - selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) + remembered = prefs if prefs.get("llm_provider") == selected_llm_provider else {} + selected_shallow_thinker = select_shallow_thinking_agent( + selected_llm_provider, remembered.get("quick_think_llm") + ) + selected_deep_thinker = select_deep_thinking_agent( + selected_llm_provider, remembered.get("deep_think_llm") + ) # Step 8: Provider-specific reasoning/thinking configuration. Each knob is # settable via its TRADINGAGENTS_* env var; when that var is set (or the @@ -732,8 +752,8 @@ def get_user_selections(): "research_depth": selected_research_depth, "llm_provider": selected_llm_provider.lower(), "backend_url": backend_url, - "shallow_thinker": selected_shallow_thinker, - "deep_thinker": selected_deep_thinker, + "quick_think_llm": selected_shallow_thinker, + "deep_think_llm": selected_deep_thinker, "google_thinking_level": thinking_level, "openai_reasoning_effort": reasoning_effort, "anthropic_effort": anthropic_effort, @@ -900,21 +920,16 @@ def extract_content_string(content): """Extract string content from various message formats. Returns None if no meaningful text content is found. """ - import ast - def is_empty(val): - """Check if value is empty using Python's truthiness.""" - if val is None or val == '': - return True + """Whether a value carries nothing to show. + + Text is judged by whether anything was written, not by what it would + mean as Python: a report saying "0" or "None" is a message the run + produced, and reading it as a falsy literal dropped it from the display. + """ if isinstance(val, str): - s = val.strip() - if not s: - return True - try: - return not bool(ast.literal_eval(s)) - except (ValueError, SyntaxError): - return False # Can't parse = real text - return not bool(val) + return not val.strip() + return val is None or not bool(val) if is_empty(content): return None @@ -971,6 +986,29 @@ def format_tool_args(args, max_length=80) -> str: return result[:max_length - 3] + "..." return result +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. @@ -981,12 +1019,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: # 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). - if not os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS"): - config["max_debate_rounds"] = selections["research_depth"] - if not os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS"): - config["max_risk_discuss_rounds"] = selections["research_depth"] - config["quick_think_llm"] = selections["shallow_thinker"] - config["deep_think_llm"] = selections["deep_thinker"] + 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 @@ -1001,7 +1046,7 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: return config -def run_analysis(checkpoint: bool | None = None): +def run_analysis(checkpoint: bool | None = None, portfolio=None): # First get all user selections selections = get_user_selections() @@ -1031,7 +1076,7 @@ def run_analysis(checkpoint: bool | None = None): start_time = time.time() # Create result directory - results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"] + 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) @@ -1081,7 +1126,9 @@ def run_analysis(checkpoint: bool | None = None): # Now start the display layout layout = create_layout() - with Live(layout, refresh_per_second=4): + # 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) @@ -1110,18 +1157,10 @@ def run_analysis(checkpoint: bool | None = None): ) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) - # Initialize state and get graph args with callbacks. - # Resolve the instrument identity once here so all agents anchor to - # the real company (#814); the CLI builds state directly rather than - # going through propagate(), so this must happen on the CLI path too. - instrument_context = graph.resolve_instrument_context( - selections["ticker"], selections["asset_type"] - ) - init_agent_state = graph.propagator.create_initial_state( - selections["ticker"], - selections["analysis_date"], - asset_type=selections["asset_type"], - instrument_context=instrument_context, + # The same initial state propagate() builds: settled decision 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) @@ -1131,7 +1170,7 @@ def run_analysis(checkpoint: bool | None = None): # 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"] + selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio ) if checkpoint_tid is not None: args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid @@ -1243,21 +1282,23 @@ def run_analysis(checkpoint: bool | None = None): trace.append(chunk) - # Clean run: drop this run's checkpoint so a later run starts fresh. - # A mid-stream failure skips this, keeping the checkpoint for resume. + # 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"] + selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio ) finally: # Always restore the plain uncheckpointed graph, even on failure. graph.end_checkpoint() - # 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) - # Update all agent statuses to completed for agent in message_buffer.agent_status: message_buffer.update_agent_status(agent, "completed") @@ -1276,13 +1317,26 @@ def run_analysis(checkpoint: bool | None = None): # 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(graph.process_signal(final_state.get("final_trade_decision", ""))): + 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]") # Prompt to save report save_choice = typer.prompt("Save report?", default="Y").strip().upper() if save_choice in ("Y", "YES", ""): timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}" + # 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) @@ -1301,8 +1355,9 @@ def run_analysis(checkpoint: bool | None = None): display_complete_report(final_state) -@app.command() +@app.callback(invoke_without_command=True) def analyze( + ctx: typer.Context, checkpoint: bool | None = typer.Option( None, "--checkpoint/--no-checkpoint", @@ -1314,13 +1369,31 @@ def analyze( "--clear-checkpoints", help="Delete all saved checkpoints before running (force fresh start).", ), + portfolio: str = typer.Option( + None, + "--portfolio", + help="JSON file with current holdings and cash, so the trader, risk and " + "portfolio agents size against your actual position.", + ), ): + """Run an analysis. This is what a bare `tradingagents` does.""" + if ctx.invoked_subcommand is not None: + return if clear_checkpoints: from tradingagents.graph.checkpointer import clear_all_checkpoints n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"]) console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]") + portfolio_context = None + if portfolio: + from tradingagents.portfolio import load_portfolio + try: + portfolio_context = load_portfolio(portfolio) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + try: - run_analysis(checkpoint=checkpoint) + run_analysis(checkpoint=checkpoint, portfolio=portfolio_context) 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 @@ -1334,5 +1407,54 @@ def analyze( raise typer.Exit(code=1) from None +@app.command() +def backtest( + tickers: str = typer.Argument(..., help="Comma-separated tickers, e.g. NVDA,AAPL"), + start: str = typer.Option(..., "--start", help="First analysis date, YYYY-MM-DD"), + end: str = typer.Option(..., "--end", help="Last analysis date, YYYY-MM-DD"), + every: int = typer.Option(7, "--every", help="Days between analysis dates"), + analysts: str = typer.Option( + None, "--analysts", help="Comma-separated analysts to run; omit for all four" + ), + asset_type: str = typer.Option("stock", "--asset-type", help="stock or crypto"), + portfolio: str = typer.Option( + None, "--portfolio", help="JSON file with holdings and cash, held constant across the grid" + ), + run_id: str = typer.Option( + None, "--run-id", help="Continue an earlier sweep: its cells are skipped and its log reused" + ), +): + """Score past decisions over a grid of tickers and dates.""" + from tradingagents.agents.utils.memory import TradingMemoryLog + + try: + dates = iter_grid(start, end, every) + book = load_portfolio(portfolio) if portfolio else None + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + + names = [t.strip() for t in tickers.split(",") if t.strip()] + if not names: + console.print("[red]No ticker to analyze; pass them comma-separated, e.g. NVDA,AAPL[/red]") + raise typer.Exit(code=1) + + kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id} + if analysts: + kwargs["selected_analysts"] = [a.strip().lower() for a in analysts.split(",") if a.strip()] + + try: + result = run_backtest(names, dates, DEFAULT_CONFIG, **kwargs) + except Exception as exc: # a missing key or an unknown analyst is a setup error + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + console.print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render()) + console.print(f"\nRan {result.cells_run} cells, skipped {result.skipped}. Log: {result.log_path}") + for ticker, date, reason in result.failures: + console.print(f"[yellow]failed:[/yellow] {ticker} {date}: {reason}") + for ticker, reason in result.settlement_failures: + console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}") + + if __name__ == "__main__": app() diff --git a/cli/prefs.py b/cli/prefs.py new file mode 100644 index 000000000..711756f85 --- /dev/null +++ b/cli/prefs.py @@ -0,0 +1,91 @@ +"""What the last run chose, offered back as the next run's defaults. + +The interactive flow asks the same questions every time, and only some of them +have an environment variable to skip them (the analyst set has none). Remembered +answers prefill the prompts so Enter accepts them; they never skip a step, so a +run always starts on choices the user has seen. + +Only answers that are stable between runs are kept. The ticker and the analysis +date are not: they change every run, and a remembered date would quietly offer a +stale one. + +Every value is checked against the current choices on the way out, because +models and providers are added and retired between versions. A remembered model +that is no longer offered is dropped rather than shown. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from cli.models import AnalystType, AssetType +from cli.utils import _llm_provider_table, filter_analysts_for_asset_type +from tradingagents.llm_clients.model_catalog import get_model_options + +_PREFS_PATH = Path(os.path.expanduser("~")) / ".tradingagents" / "cli_prefs.json" + +REMEMBERED = ( + "output_language", "analysts", "research_depth", "llm_provider", + "quick_think_llm", "deep_think_llm", "backend_url", +) + + +def load_last_run() -> dict: + """The previous run's answers, or an empty dict when there is nothing usable. + + Convenience state: an unreadable or corrupt file means no defaults, never an + error in the user's way. + """ + try: + data = json.loads(_PREFS_PATH.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def save_last_run(selections: dict) -> None: + """Record the answers worth offering next time; failure is never fatal.""" + kept = {k: v for k, v in selections.items() if k in REMEMBERED and v not in (None, "", [])} + kept["analysts"] = [getattr(a, "value", a) for a in kept.get("analysts", [])] or None + kept = {k: v for k, v in kept.items() if v is not None} + try: + _PREFS_PATH.parent.mkdir(parents=True, exist_ok=True) + temp = _PREFS_PATH.with_suffix(".tmp") + temp.write_text(json.dumps(kept, indent=2), encoding="utf-8") + os.replace(temp, _PREFS_PATH) # a concurrent run reads one file or the other + except OSError: + return + + +def sanitize(prefs: dict, asset_type) -> dict: + """Keep only the remembered answers that are still choosable now.""" + kept: dict = {} + if isinstance(prefs.get("output_language"), str): + kept["output_language"] = prefs["output_language"] + if prefs.get("research_depth") in (1, 3, 5): + kept["research_depth"] = prefs["research_depth"] + + known = {a.value for a in AnalystType} + analysts = [a for a in prefs.get("analysts") or [] if a in known] + allowed = filter_analysts_for_asset_type([AnalystType(a) for a in analysts], AssetType(asset_type)) + if allowed: + kept["analysts"] = [a.value for a in allowed] + + provider = prefs.get("llm_provider") + # Region-specific providers (qwen-cn) are picked in a second prompt, so the + # base key is what the provider menu matches. + base = (provider or "").split("-cn")[0] + if base and base in {key for _, key, _ in _llm_provider_table()}: + kept["llm_provider"] = provider + if isinstance(prefs.get("backend_url"), str) and prefs["backend_url"]: + kept["backend_url"] = prefs["backend_url"] + for field, mode in (("quick_think_llm", "quick"), ("deep_think_llm", "deep")): + try: + offered = {model for _, model in get_model_options(base, mode)} + except KeyError: + continue + if prefs.get(field) in offered: + kept[field] = prefs[field] + return kept diff --git a/cli/utils.py b/cli/utils.py index da8524d2d..d3cca0cdf 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -132,8 +132,16 @@ def get_analysis_date() -> str: return date.strip() -def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType]: - """Select analysts using an interactive checkbox.""" +def _matching_choice(options, default): + """The option value equal to ``default``, or None to leave the menu as is.""" + return next((value for _, value in options if value == default), None) + + +def select_analysts(asset_type: AssetType = AssetType.STOCK, default=None) -> list[AnalystType]: + """Select analysts using an interactive checkbox. + + ``default`` pre-checks the previous run's analysts; the prompt still shows. + """ available_analysts = filter_analysts_for_asset_type( [value for _, value in ANALYST_ORDER], asset_type, @@ -141,7 +149,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType choices = questionary.checkbox( "Select Your [Analysts Team]:", choices=[ - questionary.Choice(display, value=value) + questionary.Choice(display, value=value, checked=value.value in (default or [])) for display, value in ANALYST_ORDER if value in available_analysts ], @@ -164,7 +172,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType return choices -def select_research_depth() -> int: +def select_research_depth(default=None) -> int: """Select research depth using an interactive selection.""" # Define research depth options with their corresponding values @@ -179,6 +187,7 @@ def select_research_depth() -> int: choices=[ questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS ], + default=_matching_choice(DEPTH_OPTIONS, default), instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( [ @@ -289,7 +298,7 @@ def _prompt_custom_model_id() -> str: return _require_text("Enter model ID:", "Please enter a model ID.") -def _select_model(provider: str, mode: str) -> str: +def _select_model(provider: str, mode: str, default=None) -> str: """Select a model for the given provider and mode (quick/deep).""" if provider.lower() == "openrouter": return select_openrouter_model(mode) @@ -306,6 +315,7 @@ def _select_model(provider: str, mode: str) -> str: questionary.Choice(display, value=value) for display, value in get_model_options(provider, mode) ], + default=_matching_choice(get_model_options(provider, mode), default), instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( [ @@ -326,14 +336,14 @@ def _select_model(provider: str, mode: str) -> str: return choice -def select_shallow_thinking_agent(provider) -> str: +def select_shallow_thinking_agent(provider, default=None) -> str: """Select shallow thinking llm engine using an interactive selection.""" - return _select_model(provider, "quick") + return _select_model(provider, "quick", default) -def select_deep_thinking_agent(provider) -> str: +def select_deep_thinking_agent(provider, default=None) -> str: """Select deep thinking llm engine using an interactive selection.""" - return _select_model(provider, "deep") + return _select_model(provider, "deep", default) def _llm_provider_table() -> list[tuple[str, str, str | None]]: """(display_name, provider_key, base_url) for every supported provider. @@ -352,7 +362,9 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]: ("xAI", "xai", "https://api.x.ai/v1"), ("DeepSeek", "deepseek", "https://api.deepseek.com"), ("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), - ("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"), + # Z.AI international, the platform ZHIPU_API_KEY belongs to; the CN + # platform is the separate glm-cn key, chosen in the region prompt. + ("GLM", "glm", "https://api.z.ai/api/paas/v4/"), ("MiniMax", "minimax", "https://api.minimax.io/v1"), ("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"), ("Mistral", "mistral", "https://api.mistral.ai/v1"), @@ -388,11 +400,12 @@ def resolve_backend_url( return env_url or menu_url or provider_default_url(provider) -def prompt_openai_compatible_url() -> str: +def prompt_openai_compatible_url(default=None) -> str: """Prompt for a custom OpenAI-compatible endpoint base URL.""" url = questionary.text( "Enter the OpenAI-compatible base URL " "(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):", + default=default or "", validate=lambda x: x.strip().startswith(("http://", "https://")) or "Enter a URL starting with http:// or https://", ).ask() @@ -402,9 +415,15 @@ def prompt_openai_compatible_url() -> str: return url.strip() -def select_llm_provider() -> tuple[str, str | None]: +def select_llm_provider(default=None) -> tuple[str, str | None]: """Select the LLM provider and its API endpoint.""" PROVIDERS = _llm_provider_table() + # A region-specific key (qwen-cn) is chosen in a later prompt; the menu + # lists the base provider. + base = (default or "").split("-cn")[0] + preselected = next( + ((key, url) for _, key, url in PROVIDERS if key == base), None + ) choice = questionary.select( "Select your LLM Provider:", @@ -412,6 +431,7 @@ def select_llm_provider() -> tuple[str, str | None]: questionary.Choice(display, value=(provider_key, url)) for display, provider_key, url in PROVIDERS ], + default=preselected, instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style( [ @@ -458,9 +478,9 @@ def ask_anthropic_effort() -> str | None: return questionary.select( "Select Effort Level:", choices=[ - questionary.Choice("High (recommended)", "high"), - questionary.Choice("Medium (balanced)", "medium"), - questionary.Choice("Low (faster, cheaper)", "low"), + questionary.Choice("High (recommended)", "high"), + questionary.Choice("Medium (balanced)", "medium"), + questionary.Choice("Low (faster, cheaper)", "low"), ], style=questionary.Style([ ("selected", "fg:cyan noinherit"), @@ -479,8 +499,8 @@ def ask_gemini_thinking_config() -> str | None: return questionary.select( "Select Thinking Mode:", choices=[ - questionary.Choice("Enable Thinking (recommended)", "high"), - questionary.Choice("Minimal/Disable Thinking", "minimal"), + questionary.Choice("Enable Thinking (recommended)", "high"), + questionary.Choice("Minimal/Disable Thinking", "minimal"), ], style=questionary.Style([ ("selected", "fg:green noinherit"), @@ -643,31 +663,41 @@ def ensure_api_key(provider: str) -> str | None: return None env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env") - Path(env_path).touch(exist_ok=True) + # The file holds credentials, so make it owner-only before writing: create + # it 0600 when absent, and tighten an existing one (set_key keeps the mode). + if not os.path.exists(env_path): + os.close(os.open(env_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)) + os.chmod(env_path, 0o600) set_key(env_path, env_var, key) os.environ[env_var] = key console.print(f"[green]Saved {env_var} to {env_path}[/green]") return key -def ask_output_language() -> str: - """Ask for report output language.""" +def ask_output_language(default=None) -> str: + """Ask for report output language. + + ``default`` is offered only when it is one of the listed languages: a custom + one entered last time is free text, which the menu cannot preselect. + """ + choices = [ + questionary.Choice("English (default)", "English"), + questionary.Choice("Chinese (中文)", "Chinese"), + questionary.Choice("Japanese (日本語)", "Japanese"), + questionary.Choice("Korean (한국어)", "Korean"), + questionary.Choice("Hindi (हिन्दी)", "Hindi"), + questionary.Choice("Spanish (Español)", "Spanish"), + questionary.Choice("Portuguese (Português)", "Portuguese"), + questionary.Choice("French (Français)", "French"), + questionary.Choice("German (Deutsch)", "German"), + questionary.Choice("Arabic (العربية)", "Arabic"), + questionary.Choice("Russian (Русский)", "Russian"), + questionary.Choice("Custom language", "custom"), + ] choice = questionary.select( "Select Output Language:", - choices=[ - questionary.Choice("English (default)", "English"), - questionary.Choice("Chinese (中文)", "Chinese"), - questionary.Choice("Japanese (日本語)", "Japanese"), - questionary.Choice("Korean (한국어)", "Korean"), - questionary.Choice("Hindi (हिन्दी)", "Hindi"), - questionary.Choice("Spanish (Español)", "Spanish"), - questionary.Choice("Portuguese (Português)", "Portuguese"), - questionary.Choice("French (Français)", "French"), - questionary.Choice("German (Deutsch)", "German"), - questionary.Choice("Arabic (العربية)", "Arabic"), - questionary.Choice("Russian (Русский)", "Russian"), - questionary.Choice("Custom language", "custom"), - ], + choices=choices, + default=_matching_choice([(c.title, c.value) for c in choices], default), style=questionary.Style([ ("selected", "fg:yellow noinherit"), ("highlighted", "fg:yellow noinherit"), diff --git a/main.py b/main.py index c9d3ab038..d3a13d678 100644 --- a/main.py +++ b/main.py @@ -12,8 +12,7 @@ config = DEFAULT_CONFIG.copy() ta = TradingAgentsGraph(debug=True, config=config) # forward propagate -_, decision = ta.propagate("NVDA", "2024-05-10") +_, decision = ta.propagate("NVDA", "2026-09-01") print(decision) # Memorize mistakes and reflect -# ta.reflect_and_remember(1000) # parameter is the position returns diff --git a/pyproject.toml b/pyproject.toml index a543311e5..1b15d65b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,31 +4,25 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents" -version = "0.4.0" +version = "0.5.0" description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.10" dependencies = [ "langchain-core>=0.3.81", - "backtrader>=1.9.78.123", "langchain-anthropic>=0.3.15", - "langchain-experimental>=0.3.4", "langchain-google-genai>=4.0.0", "langchain-openai>=0.3.23", "langgraph>=0.4.8", "langgraph-checkpoint-sqlite>=2.0.0", "pandas>=2.3.0", - "parsel>=1.10.0", "python-dotenv>=1.0.0", "pytz>=2025.2", "questionary>=2.1.0", - "redis>=6.2.0", "requests>=2.32.4", "rich>=14.0.0", "typer>=0.21.0", - "setuptools>=80.9.0", "stockstats>=0.6.5", - "tqdm>=4.67.1", "typing-extensions>=4.14.0", "yfinance>=1.4.1", ] diff --git a/scripts/smoke_structured_output.py b/scripts/smoke_structured_output.py deleted file mode 100644 index f225381f9..000000000 --- a/scripts/smoke_structured_output.py +++ /dev/null @@ -1,174 +0,0 @@ -"""End-to-end smoke for structured-output agents against a real LLM provider. - -Runs the three decision-making agents (Research Manager, Trader, Portfolio -Manager) directly with their structured-output bindings and prints the -typed Pydantic instance + the rendered markdown for each. Use this to -verify a provider's native structured-output mode (json_schema for -OpenAI / xAI / DeepSeek / Qwen / GLM, response_schema for Gemini, tool-use -for Anthropic) returns clean instances on the schemas we ship. - -Usage: - OPENAI_API_KEY=... python scripts/smoke_structured_output.py openai - GOOGLE_API_KEY=... python scripts/smoke_structured_output.py google - ANTHROPIC_API_KEY=... python scripts/smoke_structured_output.py anthropic - DEEPSEEK_API_KEY=... python scripts/smoke_structured_output.py deepseek - -The script does NOT call propagate(), to keep the surface tight and the -cost low — it exercises only the three structured-output calls we just -added, plus the heuristic SignalProcessor. -""" - -from __future__ import annotations - -import argparse -import sys - -from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager -from tradingagents.agents.managers.research_manager import create_research_manager -from tradingagents.agents.trader.trader import create_trader -from tradingagents.graph.signal_processing import SignalProcessor -from tradingagents.llm_clients import create_llm_client - -PROVIDER_DEFAULTS = { - "openai": ("gpt-5.4-mini", None), - "google": ("gemini-3.5-flash", None), - "anthropic": ("claude-sonnet-4-6", None), - "deepseek": ("deepseek-v4-flash", None), - "qwen": ("qwen3.7-plus", None), - "glm": ("glm-5", None), - "xai": ("grok-4.3", None), -} - - -# Minimal but realistic state for the three agents. -DEBATE_HISTORY = """ -Bull Analyst: NVDA's data-center revenue grew 60% YoY last quarter, driven by -Blackwell ramp; sovereign AI deals with multiple governments add a $40B+ -multi-year tailwind. Margins remain above peer average. - -Bear Analyst: Concentration risk is real — top three customers are >40% of -revenue. Any pause in hyperscaler capex would compress the multiple. China -export restrictions still cap a meaningful portion of demand. -""" - - -def _make_rm_state(): - return { - "company_of_interest": "NVDA", - "investment_debate_state": { - "history": DEBATE_HISTORY, - "bull_history": "Bull Analyst: NVDA's data-center revenue grew 60% YoY...", - "bear_history": "Bear Analyst: Concentration risk is real...", - "current_response": "", - "judge_decision": "", - "count": 1, - }, - } - - -def _make_trader_state(investment_plan: str): - return { - "company_of_interest": "NVDA", - "investment_plan": investment_plan, - } - - -def _make_pm_state(investment_plan: str, trader_plan: str): - return { - "company_of_interest": "NVDA", - "past_context": "", - "risk_debate_state": { - "history": "Aggressive: lean in. Conservative: trim. Neutral: balanced sizing.", - "aggressive_history": "Aggressive: ...", - "conservative_history": "Conservative: ...", - "neutral_history": "Neutral: ...", - "judge_decision": "", - "current_aggressive_response": "", - "current_conservative_response": "", - "current_neutral_response": "", - "count": 1, - }, - "market_report": "Market report.", - "sentiment_report": "Sentiment report.", - "news_report": "News report.", - "fundamentals_report": "Fundamentals report.", - "investment_plan": investment_plan, - "trader_investment_plan": trader_plan, - } - - -def _print_section(title: str, content: str) -> None: - bar = "=" * 70 - print(f"\n{bar}\n{title}\n{bar}\n{content}") - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("provider", choices=list(PROVIDER_DEFAULTS.keys())) - parser.add_argument("--deep-model", default=None, help="Override deep_think_llm") - parser.add_argument("--quick-model", default=None, help="Override quick_think_llm") - args = parser.parse_args() - - default_model, _ = PROVIDER_DEFAULTS[args.provider] - deep_model = args.deep_model or default_model - quick_model = args.quick_model or default_model - - print(f"Provider: {args.provider}") - print(f"Deep model: {deep_model}") - print(f"Quick model: {quick_model}") - - # Build the LLM clients via the framework's factory. - deep_client = create_llm_client(provider=args.provider, model=deep_model) - quick_client = create_llm_client(provider=args.provider, model=quick_model) - deep_llm = deep_client.get_llm() - quick_llm = quick_client.get_llm() - - # 1) Research Manager - rm = create_research_manager(deep_llm) - rm_result = rm(_make_rm_state()) - investment_plan = rm_result["investment_plan"] - _print_section("[1] Research Manager — investment_plan", investment_plan) - - # 2) Trader (consumes RM's plan) - trader = create_trader(quick_llm) - trader_result = trader(_make_trader_state(investment_plan)) - trader_plan = trader_result["trader_investment_plan"] - _print_section("[2] Trader — trader_investment_plan", trader_plan) - - # 3) Portfolio Manager (consumes both) - pm = create_portfolio_manager(deep_llm) - pm_result = pm(_make_pm_state(investment_plan, trader_plan)) - final_decision = pm_result["final_trade_decision"] - _print_section("[3] Portfolio Manager — final_trade_decision", final_decision) - - # 4) SignalProcessor extracts the rating with zero LLM calls. - sp = SignalProcessor() - rating = sp.process_signal(final_decision) - _print_section("[4] SignalProcessor → rating", rating) - - # 5) Lightweight checks: each rendered output should carry the expected - # section headers so downstream consumers (memory log, CLI display, - # saved reports) keep working. - checks = [ - ("Research Manager", investment_plan, ["**Recommendation**:"]), - ("Trader", trader_plan, ["**Action**:", "FINAL TRANSACTION PROPOSAL:"]), - ("Portfolio Manager", final_decision, ["**Rating**:", "**Executive Summary**:", "**Investment Thesis**:"]), - ] - print("\n" + "=" * 70 + "\nStructure checks\n" + "=" * 70) - failures = 0 - for name, text, required in checks: - for marker in required: - ok = marker in text - print(f" {'PASS' if ok else 'FAIL'} {name}: contains {marker!r}") - failures += int(not ok) - - print() - if failures: - print(f"Smoke FAILED: {failures} structure check(s) missing.") - return 1 - print("Smoke PASSED: structured output → rendered markdown chain works for", args.provider) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test.py b/test.py deleted file mode 100644 index f0b93184c..000000000 --- a/test.py +++ /dev/null @@ -1,14 +0,0 @@ -import time - -from tradingagents.dataflows.y_finance import ( - get_stock_stats_indicators_window, -) - -print("Testing optimized implementation with 30-day lookback:") -start_time = time.time() -result = get_stock_stats_indicators_window("AAPL", "macd", "2024-11-01", 30) -end_time = time.time() - -print(f"Execution time: {end_time - start_time:.2f} seconds") -print(f"Result length: {len(result)} characters") -print(result) diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index 494f2bac6..3bc90164f 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -13,9 +13,12 @@ import pytest import tradingagents.dataflows.alpha_vantage_common as av import tradingagents.dataflows.alpha_vantage_fundamentals as avf import tradingagents.dataflows.alpha_vantage_stock as avs +import tradingagents.dataflows.utils as utils class _FakeResponse: + status_code = 200 + def __init__(self, text): self.text = text @@ -34,7 +37,7 @@ def _patched_get(body, capture=None): @pytest.mark.unit def test_request_passes_timeout(monkeypatch): captured = {} - monkeypatch.setattr(av.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured)) + monkeypatch.setattr(utils.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured)) av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"}) assert captured.get("timeout") == av.REQUEST_TIMEOUT # #990 @@ -42,7 +45,7 @@ def test_request_passes_timeout(monkeypatch): @pytest.mark.unit def test_rate_limit_detected(monkeypatch): body = '{"Information": "Our standard API rate limit is 25 requests per day. ... your API key ..."}' - monkeypatch.setattr(av.requests, "get", _patched_get(body)) + monkeypatch.setattr(utils.requests, "get", _patched_get(body)) with pytest.raises(av.AlphaVantageRateLimitError): av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"}) @@ -53,11 +56,11 @@ def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch): # (transient) rate limit, but surface as a real configuration error (#991). body = ('{"Information": "the parameter apikey is invalid or missing. ' 'Please claim your free API key on (https://www.alphavantage.co/support/#api-key)."}') - monkeypatch.setattr(av.requests, "get", _patched_get(body)) + monkeypatch.setattr(utils.requests, "get", _patched_get(body)) with pytest.raises(av.AlphaVantageNotConfiguredError): av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"}) with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct - monkeypatch.setattr(av.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}')) + monkeypatch.setattr(utils.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}')) av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"}) @@ -133,3 +136,78 @@ def test_unparseable_body_is_never_served_untrimmed(monkeypatch): def test_empty_body_still_passes_through(monkeypatch): monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: "") assert avs.get_stock("IBM", "2024-05-09", "2024-05-10") == "" + + +def test_request_error_message_carries_no_key(monkeypatch): + # Alpha Vantage also sends its key in the URL (#1324). + import requests + key = "AVKEY1234567890XYZ" + monkeypatch.setenv("ALPHA_VANTAGE_API_KEY", key) + + def boom(*a, **k): + raise requests.Timeout(f"Read timed out. url: https://www.alphavantage.co/query?apikey={key}") + + monkeypatch.setattr(utils.requests, "get", boom) + with pytest.raises(requests.Timeout) as caught: + av._make_api_request("OVERVIEW", {"symbol": "IBM"}) + assert key not in str(caught.value) + + +@pytest.mark.unit +def test_global_news_omitted_optionals_use_the_configured_defaults(monkeypatch): + """The tool passes None for an omitted look_back_days or limit (#1326).""" + from tradingagents.dataflows import alpha_vantage_news + + monkeypatch.setattr(alpha_vantage_news, "get_config", + lambda: {"global_news_lookback_days": 3, "global_news_article_limit": 9}) + seen = {} + monkeypatch.setattr(alpha_vantage_news, "_make_api_request", lambda fn, params: seen.update(params) or "{}") + + alpha_vantage_news.get_global_news("2026-08-14", None, None) + + assert seen["time_from"].startswith("20260811") and seen["limit"] == "9" + + +@pytest.mark.unit +def test_the_news_window_includes_the_analysis_day(monkeypatch): + """time_to was midnight at the start of the end date, so everything + published during the analysis day, the most decision-relevant day, was + excluded. The yfinance path includes it.""" + from tradingagents.dataflows import alpha_vantage_news + + seen = {} + monkeypatch.setattr(alpha_vantage_news, "_make_api_request", + lambda fn, params: seen.update(params) or "{}") + + alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14") + + assert seen["time_from"] == "20260310T0000" + assert seen["time_to"] == "20260314T2359" + + +@pytest.mark.unit +@pytest.mark.parametrize("indicator", ["vwma", "mfi"]) +def test_an_indicator_this_vendor_lacks_lets_the_next_one_serve_it(indicator): + """Returning prose counts as success to the router, so the chain stops at a + vendor that cannot compute the indicator while the next one can.""" + from tradingagents.dataflows import alpha_vantage_indicator + from tradingagents.dataflows.errors import VendorError + + with pytest.raises(VendorError): + alpha_vantage_indicator.get_indicator("AAPL", indicator, "2026-05-08", 30) + + +@pytest.mark.unit +def test_ticker_news_asks_for_only_as_many_articles_as_configured(monkeypatch): + """The endpoint returns 50 articles with per-article sentiment arrays by + default, and the whole payload went into the prompt.""" + from tradingagents.dataflows import alpha_vantage_news + + monkeypatch.setattr(alpha_vantage_news, "get_config", lambda: {"news_article_limit": 8}) + seen = {} + monkeypatch.setattr(alpha_vantage_news, "_make_api_request", + lambda fn, params: seen.update(params) or "{}") + + alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14") + + assert seen["limit"] == "8" diff --git a/tests/test_anthropic_effort.py b/tests/test_anthropic_effort.py index 04db66d4c..7d98c7d78 100644 --- a/tests/test_anthropic_effort.py +++ b/tests/test_anthropic_effort.py @@ -60,7 +60,7 @@ class TestEffortGate: @pytest.mark.parametrize( "model", # Claude 5 family uses single-number version IDs; all are effort-capable. - ["claude-sonnet-5", "claude-fable-5", "claude-mythos-5"], + ["claude-sonnet-5", "claude-fable-5", "claude-mythos-5", "claude-opus-5", "claude-fable-5-1"], ) def test_claude_5_family_receives_effort(self, monkeypatch, model): captured = _capture_kwargs(monkeypatch) diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py index 7361ea7b5..8bd0154a6 100644 --- a/tests/test_api_key_env.py +++ b/tests/test_api_key_env.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import stat from unittest.mock import patch import pytest @@ -146,3 +147,47 @@ def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_uti assert "OPENAI_API_KEY" in content and "sk-existing" in content assert "OTHER=value" in content assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content + + +def _prompt_key(cli_utils, monkeypatch, tmp_path, key="sk-typed-in"): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(cli_utils, "find_dotenv", lambda **k: "") + with patch.object(cli_utils, "questionary") as mock_q: + mock_q.password.return_value.ask.return_value = key + cli_utils.ensure_api_key("openai") + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_saved_key_file_is_owner_only(monkeypatch, cli_utils, tmp_path): + # The prompt writes a real credential; the file must not be readable by + # other local users whatever the umask is. + old = os.umask(0o002) + try: + _prompt_key(cli_utils, monkeypatch, tmp_path) + finally: + os.umask(old) + env = tmp_path / ".env" + assert "sk-typed-in" in env.read_text() + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_existing_key_file_is_tightened_before_writing(monkeypatch, cli_utils, tmp_path): + env = tmp_path / ".env" + env.write_text("OTHER=1\n") + os.chmod(env, 0o664) + _prompt_key(cli_utils, monkeypatch, tmp_path) + assert stat.S_IMODE(env.stat().st_mode) == 0o600 + assert "OTHER=1" in env.read_text() + + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes") +def test_read_only_key_file_is_still_updated(monkeypatch, cli_utils, tmp_path): + env = tmp_path / ".env" + env.write_text("OTHER=1\n") + os.chmod(env, 0o400) + _prompt_key(cli_utils, monkeypatch, tmp_path) + assert "sk-typed-in" in env.read_text() + assert stat.S_IMODE(env.stat().st_mode) == 0o600 diff --git a/tests/test_backtest.py b/tests/test_backtest.py new file mode 100644 index 000000000..cebb63134 --- /dev/null +++ b/tests/test_backtest.py @@ -0,0 +1,248 @@ +"""Backtesting: many single-shot decisions, scored by the decision log. + +A run already records its rating and later settles it with realized and alpha +return against the regional benchmark. A backtest is that machinery over a grid +of tickers and dates, aggregated. It evaluates decision quality; it does not +simulate a portfolio, so there is no execution, no fees and no equity curve. +""" + +from __future__ import annotations + +import pytest + +from tradingagents.agents.utils.memory import TradingMemoryLog +from tradingagents.backtest import iter_grid, run_backtest, summarize + +DECISION = "Rating: Buy\n\nbuy it" + + +@pytest.mark.unit +def test_grid_spacing_and_canonical_dates(): + assert iter_grid("2026-01-05", "2026-01-20", every_n_days=7) == ["2026-01-05", "2026-01-12", "2026-01-19"] + + +@pytest.mark.unit +def test_grid_stops_at_today(monkeypatch): + import tradingagents.backtest as bt + + monkeypatch.setattr(bt, "get_current_date", lambda: "2026-01-10") + assert iter_grid("2026-01-05", "2026-02-20", every_n_days=5) == ["2026-01-05", "2026-01-10"] + + +@pytest.mark.unit +def test_grid_rejects_a_non_canonical_date(): + with pytest.raises(ValueError, match="YYYY-MM-DD"): + iter_grid("2026-1-5", "2026-01-20") + + +class _FakeGraph: + """Stands in for TradingAgentsGraph, writing to the log the harness gave it.""" + + instances: list = [] + fail_on: set = set() + + def __init__(self, selected_analysts=None, config=None, **kw): + self.analysts = list(selected_analysts) if selected_analysts else None + self.config = config + self.memory_log = TradingMemoryLog(config) + self.calls = [] + self.settled = [] + _FakeGraph.instances.append(self) + + def propagate(self, ticker, trade_date, asset_type="stock", portfolio=None): + self.calls.append((ticker, trade_date)) + if (ticker, trade_date) in _FakeGraph.fail_on: + raise RuntimeError("vendor exploded") + self.memory_log.store_decision(ticker, trade_date, DECISION) + return {"final_trade_decision": DECISION}, "Buy" + + def settle_pending(self, ticker): + self.settled.append(ticker) + + +@pytest.fixture(autouse=True) +def _fake_graph(monkeypatch, tmp_path): + import tradingagents.backtest as bt + + _FakeGraph.instances = [] + _FakeGraph.fail_on = set() + monkeypatch.setattr(bt, "TradingAgentsGraph", _FakeGraph) + return _FakeGraph + + +def _config(tmp_path): + return {"results_dir": str(tmp_path / "results"), + "memory_log_path": str(tmp_path / "live_trading_memory.md")} + + +@pytest.mark.unit +def test_the_live_decision_log_is_never_written(tmp_path): + config = _config(tmp_path) + result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config) + + assert not (tmp_path / "live_trading_memory.md").exists() + assert result.log_path.exists() and result.cells_run == 2 + + +@pytest.mark.unit +def test_a_cell_already_in_the_log_is_not_run_again(tmp_path): + config = _config(tmp_path) + first = run_backtest(["NVDA"], ["2026-01-05"], config) + + again = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config, run_id=first.run_id) + + assert again.cells_run == 1 and again.skipped == 1 + assert _FakeGraph.instances[-1].calls == [("NVDA", "2026-01-12")] + + +@pytest.mark.unit +def test_every_ticker_is_settled_after_the_grid(tmp_path): + """Settlement runs at the start of the next same-ticker run, so the last + date of each ticker would stay pending without an explicit pass.""" + run_backtest(["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path)) + assert sorted(_FakeGraph.instances[-1].settled) == ["AAPL", "NVDA"] + + +@pytest.mark.unit +def test_a_failed_cell_does_not_abort_the_sweep(tmp_path): + _FakeGraph.fail_on = {("NVDA", "2026-01-05")} + result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], _config(tmp_path)) + + assert result.cells_run == 1 + assert result.failures == [("NVDA", "2026-01-05", "vendor exploded")] + + +# --- reading the result ------------------------------------------------------ + +def _log_with(tmp_path, rows): + log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + for ticker, date, decision, outcome in rows: + log.store_decision(ticker, date, decision) + if outcome is not None: + log.update_with_outcome(ticker, date, outcome[0], outcome[1], 5, "note", "2026-02-01") + return log + + +@pytest.mark.unit +def test_summary_scores_resolved_cells_and_keeps_pending_out_of_the_average(tmp_path): + log = _log_with(tmp_path, [ + ("NVDA", "2026-01-05", "Rating: Buy\n\nx", (0.10, 0.04)), + ("NVDA", "2026-01-12", "Rating: Buy\n\nx", (-0.02, -0.02)), + ("AAPL", "2026-01-05", "Rating: Sell\n\nx", None), + ]) + + summary = summarize(log) + + assert summary.resolved == 2 and summary.pending == 1 + buys = summary.by_rating["Buy"] + assert buys.count == 2 and buys.hit_rate == 0.5 and round(buys.mean_alpha, 4) == 0.01 + assert "Sell" not in summary.by_rating # unsettled: nothing to score yet + + +@pytest.mark.unit +def test_summary_states_what_it_cannot_prove(tmp_path): + text = summarize(_log_with(tmp_path, [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))])).render() + assert "not archived" in text + assert "one" in text.lower() and "sampl" in text.lower() + + +@pytest.mark.unit +def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path): + """A backtest of a two-analyst setup must not silently run four.""" + run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), selected_analysts=["market", "news"]) + assert _FakeGraph.instances[-1].analysts == ["market", "news"] + + +@pytest.mark.unit +def test_a_run_id_cannot_escape_the_results_directory(tmp_path): + """run_id becomes a path segment, so it is validated like a ticker is.""" + with pytest.raises(ValueError): + run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="../../escaped") + with pytest.raises(ValueError): + run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="/etc/cron.d/x") + + +@pytest.mark.unit +def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monkeypatch): + """Settlement reflects with an LLM, so it can fail; the sweep still returns + its result and every other ticker still gets settled.""" + settled = [] + + def _settle(self, ticker): + if ticker == "NVDA": + raise RuntimeError("reflector timed out") + settled.append(ticker) + + monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False) + result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path)) + + 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() + + +# --- scoring reads the direction the rating claimed --------------------------- + +def _scored(tmp_path, rows): + log = _log_with(tmp_path, rows) + return summarize(log).by_rating + + +@pytest.mark.unit +def test_a_bearish_call_that_fell_counts_as_right(tmp_path): + """Alpha below the benchmark is the outcome a Sell predicted; scoring it as + a miss reported the system as wrong exactly when it was right.""" + scores = _scored(tmp_path, [ + ("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (-0.08, -0.05)), + ("AAPL", "2026-01-05", "**Rating**: Underweight\n\nx", (-0.03, -0.02)), + ]) + assert scores["Sell"].hit_rate == 1.0 + assert scores["Underweight"].hit_rate == 1.0 + + +@pytest.mark.unit +def test_a_bearish_call_that_rose_counts_as_wrong(tmp_path): + scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (0.08, 0.05))]) + assert scores["Sell"].hit_rate == 0.0 + + +@pytest.mark.unit +def test_a_bullish_call_is_scored_the_same_way_as_before(tmp_path): + scores = _scored(tmp_path, [ + ("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.10, 0.04)), + ("AAPL", "2026-01-05", "**Rating**: Buy\n\nx", (-0.02, -0.02)), + ]) + assert scores["Buy"].hit_rate == 0.5 + + +@pytest.mark.unit +def test_hold_claims_no_direction_so_it_gets_no_hit_rate(tmp_path): + scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Hold\n\nx", (0.01, 0.005))]) + assert scores["Hold"].hit_rate is None + assert scores["Hold"].mean_alpha == 0.005 + + +@pytest.mark.unit +def test_the_report_names_the_window_the_scores_cover(tmp_path): + text = summarize(_log_with(tmp_path, [ + ("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.1, 0.05))])).render() + assert "5" in text and "day" in text.lower() + assert "Hold" not in text or "no direction" in text.lower() + + +@pytest.mark.unit +def test_the_window_reported_is_the_one_the_outcomes_used(tmp_path): + """The log records the window each outcome was measured over; the summary + must not claim a different one.""" + log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") + log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 21, "note", "2026-02-01") + + assert "21 trading days" in summarize(log).render() diff --git a/tests/test_checkpoint_lifecycle.py b/tests/test_checkpoint_lifecycle.py index 1e5e2adf5..4933b5b67 100644 --- a/tests/test_checkpoint_lifecycle.py +++ b/tests/test_checkpoint_lifecycle.py @@ -153,3 +153,20 @@ def test_cli_style_usage_saves_then_resumes(): # Cleared on success -> a later run starts fresh. assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None + + +@pytest.mark.unit +def test_clearing_removes_the_database_sidecars(tmp_path): + """SQLite writes -wal and -shm next to the database; leaving them behind + means a cleared checkpoint still has committed state on disk.""" + from tradingagents.graph.checkpointer import clear_all_checkpoints + + cp = tmp_path / "checkpoints" + cp.mkdir(parents=True) + for suffix in (".db", ".db-wal", ".db-shm"): + (cp / f"NVDA{suffix}").write_text("x") + + cleared = clear_all_checkpoints(str(tmp_path)) + + assert cleared == 1 + assert list(cp.iterdir()) == [] diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 000000000..c0fb5f727 --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,153 @@ +"""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 re + 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:] + # Where the terminal takes colour, help styles each option and splits + # "--start" across escape sequences, so read the text without them. + plain = re.sub(r"\x1b\[[0-9;]*m", "", out.stdout) + assert "--start" in plain + + +@pytest.mark.unit +def test_the_cli_says_whether_a_run_resumed(monkeypatch): + """The README promises the user can tell a resumed run from a fresh one. + The graph logs it, but nothing configures logging, so it was never shown.""" + import cli.main as m + + messages = [] + monkeypatch.setattr(m.message_buffer, "add_message", + lambda kind, text: messages.append(text), raising=False) + + m._announce_checkpoint_state(type("G", (), {"_resuming": True})(), "NVDA", "2026-01-10") + m._announce_checkpoint_state(type("G", (), {"_resuming": False})(), "NVDA", "2026-01-10") + + assert any("resum" in text.lower() for text in messages) + assert any("fresh" in text.lower() for text in messages) + + +@pytest.mark.unit +def test_backtest_can_continue_an_interrupted_sweep(runner, monkeypatch, tmp_path): + """Resuming is what makes a long sweep practical, and the Python API has it.""" + swept = [] + monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append(kw) or _Result(tmp_path)) + monkeypatch.setattr(m, "summarize", lambda log: _Summary()) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", + "--end", "2026-06-08", "--run-id", "20260617_120000"]) + + assert result.exit_code == 0, result.output + assert swept[0]["run_id"] == "20260617_120000" + + +@pytest.mark.unit +@pytest.mark.parametrize("args, expected", [ + (["backtest", "NVDA", "--start", "2026-08-01", "--end", "2026-06-08"], "before"), + (["backtest", ",,", "--start", "2026-06-01", "--end", "2026-06-08"], "ticker"), +]) +def test_backtest_rejects_input_that_would_sweep_nothing(runner, args, expected): + """An inverted range or an empty ticker list reported a clean zero-cell run, + which reads as 'nothing to find' rather than 'you asked for nothing'.""" + result = runner.invoke(m.app, args) + assert result.exit_code == 1 + assert expected in result.output.lower() + + +@pytest.mark.unit +def test_backtest_reports_a_setup_failure_in_one_line(runner, monkeypatch): + """A missing key or a bad analyst name produced a raw traceback.""" + def _explode(*a, **kw): + raise ValueError("API key for provider 'openai' is not set") + + monkeypatch.setattr(m, "run_backtest", _explode) + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-08"]) + + assert result.exit_code == 1 + assert "API key" in result.output + assert "Traceback" not in result.output diff --git a/tests/test_cli_config_precedence.py b/tests/test_cli_config_precedence.py index e2ba90d14..873810098 100644 --- a/tests/test_cli_config_precedence.py +++ b/tests/test_cli_config_precedence.py @@ -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, @@ -67,3 +67,41 @@ def test_checkpoint_flag_overrides_env(flag): with mock.patch.object(m, "DEFAULT_CONFIG", patched): cfg = m._build_run_config(SELECTIONS, checkpoint=flag) assert cfg["checkpoint_enabled"] is flag + + +@pytest.mark.unit +def test_glm_resolves_to_the_endpoint_its_key_belongs_to(): + """The provider table, the client registry and the key mapping must name the + same platform: glm is Z.AI international (ZHIPU_API_KEY) and glm-cn is + BigModel China. A mismatch sends the key to the other platform and every + call fails auth.""" + from cli.utils import resolve_backend_url + from tradingagents.llm_clients.api_key_env import get_api_key_env + from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS + + assert resolve_backend_url("glm", None, None) == OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url + assert get_api_key_env("glm") == "ZHIPU_API_KEY" + assert "z.ai" in OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url + assert "bigmodel.cn" in OPENAI_COMPATIBLE_PROVIDERS["glm-cn"].base_url + + +@pytest.mark.unit +def test_a_half_set_round_count_says_which_value_won(capsys, monkeypatch): + """With only one of the two round-count variables set, the depth prompt is + still shown but half the answer is discarded; the user was never told.""" + import cli.main as m + + monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1") + monkeypatch.delenv("TRADINGAGENTS_MAX_RISK_ROUNDS", raising=False) + printed = [] + monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(str(a[0]) if a else "")) + + config = m._build_run_config({ + "ticker": "NVDA", "analysis_date": "2026-09-01", "asset_type": "stock", + "analysts": [], "research_depth": 5, "llm_provider": "openai", + "quick_think_llm": "gpt-5.6-luna", "deep_think_llm": "gpt-5.6", + "backend_url": None, "output_language": "English", + }, None) + + assert config["max_risk_discuss_rounds"] == 5 + assert any("TRADINGAGENTS_MAX_DEBATE_ROUNDS" in line for line in printed), printed diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_decision_log.py new file mode 100644 index 000000000..719fcba29 --- /dev/null +++ b/tests/test_cli_decision_log.py @@ -0,0 +1,167 @@ +"""The CLI must use the decision log the same way propagate() does. + +The CLI streams the graph itself instead of calling propagate(), so memory steps +that lived only in propagate() never ran on the primary entry point: pending +decisions were not settled, the Portfolio Manager got no past context, and the +finished decision was not recorded. Both paths now build their initial state and +record their decision through the same graph methods. +""" + +from __future__ import annotations + +import pytest + +from tradingagents.agents.utils.memory import TradingMemoryLog +from tradingagents.graph.trading_graph import TradingAgentsGraph + + +def _bare_graph(tmp_path): + """A graph without __init__ (no LLM clients), wired to a temp log.""" + graph = object.__new__(TradingAgentsGraph) + graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")} + graph.memory_log = TradingMemoryLog(graph.config) + return graph + + +@pytest.mark.unit +def test_create_run_state_settles_pending_and_carries_context(tmp_path, monkeypatch): + from tradingagents.graph.propagation import Propagator + + graph = _bare_graph(tmp_path) + graph.propagator = Propagator() + settled = [] + monkeypatch.setattr(graph, "_resolve_pending_entries", settled.append, raising=False) + monkeypatch.setattr(graph, "resolve_instrument_context", lambda t, a="stock", d=None: f"id:{t}", raising=False) + monkeypatch.setattr(graph, "_memory_as_of", lambda d: d, raising=False) + graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call") + graph.memory_log.update_with_outcome("NVDA", "2026-01-05", 0.01, 0.005, 5, "great trade", "2026-01-12") + + state = graph.create_run_state("NVDA", "2026-02-01") + + assert settled == ["NVDA"] + assert "great trade" in state["past_context"] + assert state["instrument_context"] == "id:NVDA" + assert state["company_of_interest"] == "NVDA" + + +@pytest.mark.unit +def test_record_decision_appends_a_pending_entry(tmp_path): + graph = _bare_graph(tmp_path) + graph.record_decision("NVDA", "2026-01-10", {"final_trade_decision": "Rating: Buy\n\nBuy NVDA."}) + entries = graph.memory_log.load_entries() + assert [(e["ticker"], e["pending"], e["rating"]) for e in entries] == [("NVDA", True, "Buy")] + + +@pytest.mark.unit +def test_record_decision_skips_a_run_without_a_decision(tmp_path): + graph = _bare_graph(tmp_path) + graph.record_decision("NVDA", "2026-01-10", {}) + assert graph.memory_log.load_entries() == [] + + +# --- the CLI path ---------------------------------------------------------------- + +class _FakeGraph: + """Records the lifecycle calls run_analysis makes.""" + + def __init__(self): + self.calls = [] + self.graph = self + self.propagator = self + + def create_run_state(self, ticker, trade_date, asset_type="stock", portfolio=None): + self.calls.append(("create_run_state", ticker, trade_date)) + return {"messages": [], "company_of_interest": ticker} + + def process_signal(self, text): + from tradingagents.graph.signal_processing import SignalProcessor + return SignalProcessor.process_signal(None, text) + + def record_decision(self, ticker, trade_date, final_state): + self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision"))) + + def get_graph_args(self, callbacks=None): + return {} + + def begin_checkpoint(self, *a, **k): + return None + + def checkpoint_input(self, state): + return state + + def clear_checkpoint_on_success(self, *a, **k): + self.calls.append(("clear_checkpoint",)) + + def end_checkpoint(self): + pass + + def stream(self, graph_input, **kwargs): + yield {"messages": [], "market_report": "M"} + yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."} + + +class _NullLive: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +class _FakeBuffer: + def __init__(self): + self.messages = [] + self.tool_calls = [] + self.report_sections = {} + self.agent_status = {} + self.selected_analysts = [] + self._processed_message_ids = set() + + def init_for_analysis(self, selected_analysts): + self.selected_analysts = [a.lower() for a in selected_analysts] + + def add_message(self, kind, content): + self.messages.append((0.0, kind, content)) + + def add_tool_call(self, name, args): + self.tool_calls.append((0.0, name, args)) + + def update_report_section(self, *a): + pass + + def update_agent_status(self, agent, status): + self.agent_status[agent] = status + + +@pytest.mark.unit +def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch): + import cli.main as m + from cli.models import AnalystType + + fake = _FakeGraph() + monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake) + monkeypatch.setattr(m, "message_buffer", _FakeBuffer()) + monkeypatch.setattr(m, "create_layout", lambda: None) + monkeypatch.setattr(m, "update_display", lambda *a, **k: None) + monkeypatch.setattr(m, "Live", _NullLive) + monkeypatch.setattr(m, "get_user_selections", lambda: { + "ticker": "NVDA", "analysis_date": "2026-01-10", + "analysts": [AnalystType.MARKET], "asset_type": "stock", + }) + monkeypatch.setattr(m, "_build_run_config", lambda selections, checkpoint: { + "data_cache_dir": str(tmp_path / "cache"), "results_dir": str(tmp_path / "results"), + }) + monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") + + m.run_analysis() + + assert fake.calls == [ + ("create_run_state", "NVDA", "2026-01-10"), + # The decision is recorded from the merged stream, before the checkpoint + # is cleared, matching propagate(). + ("record_decision", "NVDA", "2026-01-10", "Rating: Buy\n\nBuy NVDA."), + ("clear_checkpoint",), + ] diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py new file mode 100644 index 000000000..6c7eadd65 --- /dev/null +++ b/tests/test_cli_display.py @@ -0,0 +1,77 @@ +"""What the live display shows, and what the run log keeps. + +The display drops a message it judges empty, and the state log is written for a +person to read afterwards. Both got that wrong in ways that hide real content. +""" + +from __future__ import annotations + +import json + +import pytest + +from cli.main import extract_content_string + + +@pytest.mark.unit +@pytest.mark.parametrize("text", ["0", "False", "None", "[]", "{}", "0.0"]) +def test_a_message_that_reads_like_a_python_value_is_still_text(text): + """These were parsed as Python and judged empty, so the message vanished.""" + assert extract_content_string(text) == text + + +@pytest.mark.unit +@pytest.mark.parametrize("value, expected", [ + (" Hold ", "Hold"), + ("", None), + (" ", None), + (None, None), + ([], None), + ({}, None), + ({"text": "from a dict"}, "from a dict"), + ([{"type": "text", "text": "part one"}, {"type": "text", "text": "part two"}], "part one part two"), +]) +def test_the_other_shapes_are_unchanged(value, expected): + assert extract_content_string(value) == expected + + +@pytest.mark.unit +def test_the_state_log_keeps_non_ascii_readable(tmp_path): + """Reports can be in any language; the log is read by a person.""" + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.ticker = "600519.SS" + graph.log_states_dict = {} + + graph._log_state("2026-09-01", { + "company_of_interest": "600519.SS", "trade_date": "2026-09-01", + "market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻", + "fundamentals_report": "基本面", "investment_plan": "计划", + "trader_investment_plan": "交易计划", "final_trade_decision": "评级: 买入", + "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", + "current_response": "", "judge_decision": "", "count": 0}, + "risk_debate_state": {"aggressive_history": "", "conservative_history": "", + "neutral_history": "", "history": "", "judge_decision": "", + "latest_speaker": "", "current_aggressive_response": "", + "current_conservative_response": "", "current_neutral_response": "", + "count": 0}, + }) + + written = next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8") + assert "买入" in written + assert "\\u" not in written + assert json.loads(written) # still valid JSON + + +@pytest.mark.unit +def test_the_live_display_does_not_scroll_the_terminal(): + """A layout taller than the window makes rich redraw by scrolling, which + reads as flicker; the alternate screen holds it in place (#784). The final + report prints after the live view ends, so nothing is lost when it closes.""" + import inspect + + import cli.main as m + + assert "screen=True" in inspect.getsource(m.run_analysis) diff --git a/tests/test_cli_env_skip.py b/tests/test_cli_env_skip.py index c98c24549..b3d50d956 100644 --- a/tests/test_cli_env_skip.py +++ b/tests/test_cli_env_skip.py @@ -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") diff --git a/tests/test_cli_prefs.py b/tests/test_cli_prefs.py new file mode 100644 index 000000000..1ba6c1f50 --- /dev/null +++ b/tests/test_cli_prefs.py @@ -0,0 +1,169 @@ +"""The CLI remembers what you chose last time and offers it back. + +Prefill only: every prompt still appears, so a run never starts on a choice the +user did not see. Environment variables keep skipping their step outright and +win over anything remembered. Remembered values are validated against the +current choices each time, since models and providers come and go between +versions and a stale one must not be offered. +""" + +from __future__ import annotations + +from unittest import mock + +import pytest + +from cli.models import AnalystType +from cli.prefs import load_last_run, sanitize, save_last_run + +SAVED = { + "output_language": "English", + "analysts": ["market", "fundamentals"], + "research_depth": 3, + "llm_provider": "openai", + "quick_think_llm": "gpt-5.6-mini", + "deep_think_llm": "gpt-5.6", +} + + +@pytest.fixture(autouse=True) +def _home(tmp_path, monkeypatch): + monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json") + return tmp_path + + +@pytest.mark.unit +def test_round_trip(): + save_last_run(SAVED) + assert load_last_run() == SAVED + + +@pytest.mark.unit +def test_missing_file_is_not_an_error(): + assert load_last_run() == {} + + +@pytest.mark.unit +def test_a_corrupt_file_degrades_to_no_memory(_home): + (_home / "cli_prefs.json").write_text("{not json") + assert load_last_run() == {} + + +@pytest.mark.unit +def test_a_half_written_file_cannot_be_observed(_home): + """Two runs finishing together must never leave a torn file behind.""" + save_last_run(SAVED) + save_last_run({**SAVED, "research_depth": 5}) + assert load_last_run()["research_depth"] == 5 + assert list((_home).glob("*.tmp*")) == [] + + +# --- validation against the current choices --------------------------------- + +@pytest.mark.unit +def test_a_model_that_no_longer_exists_is_dropped(): + # gpt-5.4 is still accepted by config, but is no longer in the picker's list. + kept = sanitize({**SAVED, "quick_think_llm": "gpt-5.4"}, "stock") + assert "quick_think_llm" not in kept + assert kept["deep_think_llm"] == "gpt-5.6" # the valid sibling survives + + +@pytest.mark.unit +def test_an_unknown_provider_drops_itself_and_its_models(): + kept = sanitize({**SAVED, "llm_provider": "no-such-provider"}, "stock") + assert "llm_provider" not in kept + assert "quick_think_llm" not in kept and "deep_think_llm" not in kept + + +@pytest.mark.unit +def test_analysts_are_narrowed_to_the_asset_type(): + kept = sanitize(SAVED, "crypto") + assert AnalystType.FUNDAMENTALS.value not in kept["analysts"] + assert AnalystType.MARKET.value in kept["analysts"] + + +@pytest.mark.unit +def test_junk_values_are_dropped_rather_than_offered(): + kept = sanitize({"research_depth": 99, "analysts": ["astrology"], "output_language": 5}, "stock") + assert kept == {} + + +@pytest.mark.unit +def test_a_region_specific_provider_survives(): + kept = sanitize({**SAVED, "llm_provider": "qwen-cn", "quick_think_llm": None}, "stock") + assert kept["llm_provider"] == "qwen-cn" + + +# --- wiring ------------------------------------------------------------------ + +def _answer_every_prompt(monkeypatch): + """Drive the real selection flow, answering each prompt with a fixed value.""" + import cli.main as m + + monkeypatch.setattr(m, "fetch_announcements", lambda: []) + monkeypatch.setattr(m, "display_announcements", lambda *a: None) + monkeypatch.setattr(m, "get_ticker", lambda: "NVDA") + monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01") + monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English") + monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET]) + monkeypatch.setattr(m, "select_research_depth", lambda default=None: 3) + monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai", None)) + monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "gpt-5.6-mini") + monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "gpt-5.6") + monkeypatch.setattr(m, "ask_openai_reasoning_effort", lambda: "medium") + return m + + +@pytest.mark.unit +def test_selections_are_remembered_after_a_run(monkeypatch): + """Drives the real flow: a stubbed selections dict would hide a key mismatch.""" + m = _answer_every_prompt(monkeypatch) + + m.get_user_selections() + + remembered = load_last_run() + assert remembered["analysts"] == ["market"] + assert remembered["quick_think_llm"] == "gpt-5.6-mini" + assert remembered["deep_think_llm"] == "gpt-5.6" + assert remembered["llm_provider"] == "openai" + assert "ticker" not in remembered # changes every run; never remembered + assert "analysis_date" not in remembered # a stale date must not be offered + + +@pytest.mark.unit +def test_a_custom_language_is_remembered_without_breaking_the_next_run(): + """A free-text answer is not one of the menu's choices, and questionary + rejects a default it cannot find, so offering it back would crash startup.""" + from cli.utils import ask_output_language + + save_last_run({"output_language": "Turkish"}) + with mock.patch("cli.utils.questionary.select") as select: + select.return_value.ask.return_value = "English" + ask_output_language(load_last_run()["output_language"]) + assert select.call_args.kwargs["default"] is None + + +@pytest.mark.unit +def test_a_remembered_endpoint_is_offered_back(monkeypatch): + """Users of a local or custom endpoint retyped the URL every run: it was + remembered and validated, then never read.""" + import cli.main as m + + save_last_run({"llm_provider": "openai_compatible", "backend_url": "http://localhost:1234/v1"}) + offered = {} + monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai_compatible", None)) + monkeypatch.setattr(m, "prompt_openai_compatible_url", + lambda default=None: offered.setdefault("default", default) or "http://x/v1") + monkeypatch.setattr(m, "fetch_announcements", lambda: []) + monkeypatch.setattr(m, "display_announcements", lambda *a: None) + monkeypatch.setattr(m, "get_ticker", lambda: "NVDA") + monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01") + monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English") + monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET]) + monkeypatch.setattr(m, "select_research_depth", lambda default=None: 1) + monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "local-model") + monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "local-model") + + m.get_user_selections() + + assert offered["default"] == "http://localhost:1234/v1" diff --git a/tests/test_cli_symbol_handling.py b/tests/test_cli_symbol_handling.py index e599a4e09..950468cdc 100644 --- a/tests/test_cli_symbol_handling.py +++ b/tests/test_cli_symbol_handling.py @@ -60,3 +60,16 @@ def test_cli_normalize_delegates_to_data_layer(): # CLI must produce the same canonical symbol the data path will price. for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"): assert normalize_ticker_symbol(raw) == normalize_symbol(raw) + + +@pytest.mark.unit +def test_the_run_directory_cannot_escape_the_results_directory(tmp_path, monkeypatch): + """Every other path that interpolates a ticker validates it first; the CLI's + own results tree did not, so a ticker of '..' wrote a level up.""" + import cli.main as m + + with pytest.raises(ValueError): + m._run_directory({"results_dir": str(tmp_path)}, "..", "2026-09-01") + + ok = m._run_directory({"results_dir": str(tmp_path)}, "NVDA", "2026-09-01") + assert str(ok).startswith(str(tmp_path)) diff --git a/tests/test_fred.py b/tests/test_fred.py index a4ffbd213..a4ec29962 100644 --- a/tests/test_fred.py +++ b/tests/test_fred.py @@ -8,6 +8,7 @@ import unittest from unittest import mock import pytest +import requests import tradingagents.dataflows.config as config_module import tradingagents.default_config as default_config @@ -233,3 +234,53 @@ class FredRoutingTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +_KEY = "abcdef0123456789abcdef0123456789" + + +@pytest.mark.unit +class TestKeyKeptOutOfErrors: + """The key travels as a query parameter, and requests quotes the full URL in + its error messages, so any log or traceback would carry it (#1324).""" + + def _raises(self, side_effect): + with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \ + mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=side_effect), \ + pytest.raises(requests.RequestException) as caught: + fred._request("series", {"series_id": "DGS10"}) + return caught.value + + def test_http_error_message_carries_no_key(self): + response = mock.Mock(status_code=502) + response.raise_for_status.side_effect = requests.HTTPError( + f"502 Server Error for url: https://api.stlouisfed.org/fred/series?api_key={_KEY}", + response=response, + ) + with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \ + mock.patch("tradingagents.dataflows.utils.requests.get", return_value=response), \ + pytest.raises(requests.HTTPError) as caught: + fred._request("series", {"series_id": "DGS10"}) + exc = caught.value + assert _KEY not in str(exc) and _KEY not in repr(exc) + # The response and request carry the full URL, so they are not attached. + assert exc.response is None and exc.request is None + assert exc.__cause__ is None and exc.__context__ is None # no chain holds the key + + def test_connection_error_before_any_response_carries_no_key(self): + exc = self._raises(requests.ConnectionError( + f"Max retries exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}")) + assert isinstance(exc, requests.ConnectionError) + assert _KEY not in str(exc) and exc.__context__ is None + + +@pytest.mark.unit +def test_error_without_the_key_in_its_message_still_drops_the_request(): + # Some timeout messages omit the URL, but the attached request still has it. + import requests as rq + req = rq.Request("GET", f"https://api.stlouisfed.org/fred/series?api_key={_KEY}").prepare() + with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \ + mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=rq.Timeout("Read timed out.", request=req)), \ + pytest.raises(rq.Timeout) as caught: + fred._request("series", {"series_id": "DGS10"}) + assert caught.value.request is None diff --git a/tests/test_google_thinking_level.py b/tests/test_google_thinking_level.py index f24ceff2f..e711c2aab 100644 --- a/tests/test_google_thinking_level.py +++ b/tests/test_google_thinking_level.py @@ -1,8 +1,8 @@ """Gemini thinking_level forwarding (Gemini 3.x). The catalog is Gemini 3.x only, which takes the string ``thinking_level`` -directly. Pro accepts low/high; Flash also accepts minimal/medium — an -unsupported "minimal" on Pro is mapped to "low". +directly. Pro, Gemini 3.8+ and the -latest aliases reject "minimal" with a 400, +so it is mapped to "low" there; numbered Flash models before 3.8 accept it. """ from unittest import mock @@ -35,6 +35,22 @@ def test_pro_remaps_minimal_to_low(): assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal" +def test_flash_38_remaps_minimal_to_low(): + kw = _captured_kwargs("gemini-3.8-flash", thinking_level="minimal") + assert kw["thinking_level"] == "low" # 3.8 Flash 400s on "minimal" + + +def test_flash_38_keeps_supported_levels(): + kw = _captured_kwargs("gemini-3.8-flash", thinking_level="high") + assert kw["thinking_level"] == "high" + + +@pytest.mark.parametrize("alias", ["gemini-flash-latest", "gemini-pro-latest"]) +def test_latest_alias_remaps_minimal_to_low(alias): + # Aliases move between generations; gemini-flash-latest 400s on "minimal". + assert _captured_kwargs(alias, thinking_level="minimal")["thinking_level"] == "low" + + def test_pro_keeps_high(): kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high") assert kw["thinking_level"] == "high" diff --git a/tests/test_market_data_validator.py b/tests/test_market_data_validator.py index 40b6349e2..c26d6f537 100644 --- a/tests/test_market_data_validator.py +++ b/tests/test_market_data_validator.py @@ -29,7 +29,7 @@ class TestVerifiedSnapshot: pd.DataFrame({"Date": [pd.Timestamp("2026-06-01")], "Open": [999.0], "High": [999.0], "Low": [999.0], "Close": [999.0], "Volume": [999]}), ], ignore_index=True) - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: data) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: data) snap = validator.build_verified_market_snapshot("COF", "2026-05-13") assert "Verified market data snapshot for COF" in snap @@ -39,24 +39,24 @@ class TestVerifiedSnapshot: assert "boll_lb" in snap # indicators present def test_uses_previous_trading_day_when_date_is_weekend(self, monkeypatch): - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv()) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) # 2026-05-16 is a Saturday; latest row should be Fri 2026-05-15 snap = validator.build_verified_market_snapshot("COF", "2026-05-16") assert "Latest trading row used: 2026-05-15" in snap assert "Recent verified closes" in snap def test_raises_when_no_rows_on_or_before_date(self, monkeypatch): - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv()) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) with pytest.raises(ValueError): validator.build_verified_market_snapshot("COF", "2020-01-01") def test_raises_on_empty_data(self, monkeypatch): - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: pd.DataFrame()) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: pd.DataFrame()) with pytest.raises(ValueError): validator.build_verified_market_snapshot("COF", "2026-05-13") def test_look_back_window_capped_at_30(self, monkeypatch): - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv()) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) snap = validator.build_verified_market_snapshot("COF", "2026-05-20", look_back_days=999) # last-N closes table has at most 30 data rows close_rows = [ln for ln in snap.splitlines() if ln.startswith("| 2026-")] @@ -69,7 +69,7 @@ class TestTool: from tradingagents.agents.utils.market_data_validation_tools import ( get_verified_market_snapshot, ) - monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv()) + monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) out = get_verified_market_snapshot.invoke( {"symbol": "COF", "curr_date": "2026-05-20"} ) diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 3db0ac531..b4d6af6f3 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -136,6 +136,23 @@ class TestTradingMemoryLogCore: log.store_decision("NVDA", "2026-01-10", DECISION_BUY) assert len(log.load_entries()) == 1 + def test_store_decision_idempotent_after_the_entry_resolves(self, tmp_path): + """A settled entry still blocks a duplicate. + + The guard matched only pending entries, so re-running a ticker and date + whose outcome had already been settled appended a second entry: the same + decision counted twice in past context and in any aggregate over the log. + """ + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.update_with_outcome("NVDA", "2026-01-10", 0.05, 0.02, 5, "worked", "2026-01-17") + + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + + entries = log.load_entries() + assert len(entries) == 1 + assert entries[0]["pending"] is False # the settled record is kept, not replaced + def test_batch_update_resolves_multiple_entries(self, tmp_path): """batch_update_with_outcomes resolves multiple pending entries in one write.""" log = make_log(tmp_path) @@ -176,10 +193,14 @@ class TestTradingMemoryLogCore: log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT) assert log.load_entries()[0]["rating"] == "Overweight" - def test_rating_fallback_hold(self, tmp_path): + def test_an_unreadable_decision_is_tagged_for_review(self, tmp_path): + """Not a Hold: a fabricated rating is quoted back to the next run as a + call that was never made, and counted in the backtest figures.""" + from tradingagents.agents.utils.rating import RATING_REVIEW + log = make_log(tmp_path) log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING) - assert log.load_entries()[0]["rating"] == "Hold" + assert log.load_entries()[0]["rating"] == RATING_REVIEW def test_rating_priority_over_prose(self, tmp_path): """'Rating: X' label wins even when an opposing rating word appears earlier in prose.""" @@ -589,6 +610,13 @@ class TestDeferredReflection: assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI" assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE" + def test_explicit_benchmark_is_resolved_like_any_other_symbol(self): + """A configured benchmark takes the same alias mapping as the ticker, or + the return lookup finds nothing and the decision never settles.""" + mock_graph = MagicMock(spec=TradingAgentsGraph) + mock_graph.config = {"benchmark_ticker": "SPX500", "benchmark_map": {"": "SPY"}} + assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "^GSPC" + def test_resolve_benchmark_china_a_shares(self): """A-share tickers route to their exchange composite (uses the real default benchmark_map, since A-share support relies on it).""" @@ -598,6 +626,8 @@ class TestDeferredReflection: "benchmark_map": DEFAULT_CONFIG["benchmark_map"]} assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS" assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ" + # .SH is the exchange's own suffix; Yahoo spells Shanghai .SS (#1260) + assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SH") == "000001.SS" def test_resolve_benchmark_us_ticker_defaults_to_spy(self): """US tickers (no dotted suffix) take the empty-suffix entry.""" @@ -665,6 +695,7 @@ class TestDeferredReflection: log = make_log(tmp_path) log.store_decision("AAPL", "2026-01-10", DECISION_BUY) mock_graph = MagicMock(spec=TradingAgentsGraph) + mock_graph.config = {} mock_graph.memory_log = log mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12")) TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") @@ -678,6 +709,7 @@ class TestDeferredReflection: mock_reflector = MagicMock() mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed." mock_graph = MagicMock(spec=TradingAgentsGraph) + mock_graph.config = {} mock_graph.memory_log = log mock_graph.reflector = mock_reflector mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12")) @@ -697,6 +729,7 @@ class TestDeferredReflection: log.store_decision("NVDA", "2026-01-05", DECISION_BUY) mock_reflector = MagicMock() mock_graph = MagicMock(spec=TradingAgentsGraph) + mock_graph.config = {} mock_graph.memory_log = log mock_graph.reflector = mock_reflector mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None)) @@ -903,8 +936,107 @@ class TestLegacyRemoval: mock_graph._run_graph = functools.partial( TradingAgentsGraph._run_graph, mock_graph ) + mock_graph.record_decision = functools.partial( + TradingAgentsGraph.record_decision, mock_graph + ) TradingAgentsGraph.propagate(mock_graph, "NVDA", "2026-01-10") entries = mock_graph.memory_log.load_entries() assert len(entries) == 1 assert entries[0]["ticker"] == "NVDA" assert entries[0]["pending"] is True + + +@pytest.mark.unit +def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch): + """Settling past decisions happens on the way into a new run, and reflection + calls an LLM. A transient failure there must not stop the new analysis.""" + from tradingagents.agents.utils.memory import TradingMemoryLog + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"memory_log_path": str(tmp_path / "m.md")} + graph.memory_log = TradingMemoryLog(graph.config) + graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\n\nx") + graph.memory_log.store_decision("NVDA", "2026-01-12", "Rating: Sell\n\ny") + monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False) + monkeypatch.setattr(graph, "_fetch_returns", + lambda t, d, holding_days=5, benchmark=None: (0.01, 0.005, holding_days, "2026-01-19"), raising=False) + + class _Reflector: + calls = 0 + + def reflect_on_final_decision(self, **kw): + _Reflector.calls += 1 + if _Reflector.calls == 1: + raise RuntimeError("provider timed out") + return "second one worked" + + graph.reflector = _Reflector() + + graph._resolve_pending_entries("NVDA") # must not raise + + entries = graph.memory_log.load_entries() + assert [e["pending"] for e in entries] == [True, False] # the failed one waits for next time + + +@pytest.mark.unit +def test_the_holding_window_is_configurable(tmp_path, monkeypatch): + """A decision written for months should not be graded at a week without the + operator choosing that window.""" + from tradingagents.agents.utils.memory import TradingMemoryLog + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21} + graph.memory_log = TradingMemoryLog(graph.config) + graph.memory_log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") + monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False) + asked = {} + + def _returns(ticker, date, holding_days=5, benchmark=None): + asked["holding_days"] = holding_days + return 0.05, 0.02, holding_days, "2026-02-02" + + monkeypatch.setattr(graph, "_fetch_returns", _returns, raising=False) + graph.reflector = type("R", (), {"reflect_on_final_decision": lambda self, **kw: "lesson"})() + + graph._resolve_pending_entries("NVDA") + + assert asked["holding_days"] == 21 + assert graph.memory_log.load_entries()[0]["holding"] == "21d" + + +@pytest.mark.unit +def test_the_reflection_states_the_window_it_judges(): + """Judging a months-long thesis on a week's alpha, without saying so, turns + a scope mismatch into a lesson that the call was wrong.""" + from tradingagents.graph.reflection import Reflector + + prompt = Reflector(None)._system_prompt(holding_days=5) + assert "5" in prompt and "trading day" in prompt + + +@pytest.mark.unit +def test_a_longer_window_asks_for_enough_price_history(monkeypatch): + """Trading days are not calendar days: a 21-day window needs about a month + of bars, and asking for 28 days left every outcome unsettled.""" + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + asked = {} + + class _Ticker: + def __init__(self, symbol): + self.symbol = symbol + + def history(self, start, end): + asked["start"], asked["end"] = start, end + import pandas as pd + days = pd.bdate_range(start, end) + return pd.DataFrame({"Close": range(len(days))}, index=days) + + monkeypatch.setattr("tradingagents.graph.trading_graph.yf.Ticker", _Ticker) + + raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY") + + assert days == 21 and resolved is not None, (raw, alpha, days, resolved) diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 5392d7cd9..4f6ded8fa 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -53,3 +53,48 @@ class ModelValidationTests(unittest.TestCase): client.get_llm() self.assertEqual(caught, []) + + +def test_legacy_ids_stay_valid_without_being_offered(): + from tradingagents.llm_clients.model_catalog import LEGACY_MODELS, MODEL_OPTIONS + from tradingagents.llm_clients.validators import validate_model + + for provider, ids in LEGACY_MODELS.items(): + offered = {v for opts in MODEL_OPTIONS[provider].values() for _, v in opts} + for model in ids: + assert validate_model(provider, model), model + assert model not in offered, f"{model} is legacy but still in the picker" + + +@pytest.mark.unit +def test_an_explicit_alias_of_a_listed_model_is_known(): + """gpt-5.6 is served under its own name and as gpt-5.6-sol; naming the + explicit one should not warn that the model is unknown.""" + from tradingagents.llm_clients.validators import validate_model + + assert validate_model("openai", "gpt-5.6-sol") + + +@pytest.mark.unit +@pytest.mark.parametrize("provider", ["openai", "anthropic", "google", "xai"]) +@pytest.mark.parametrize("mode", ["quick", "deep"]) +def test_every_provider_lets_you_name_your_own_model(provider, mode): + """The docs tell users to name any model their provider serves; the picker + has to offer that too, or a new model is unreachable until we ship a list.""" + from tradingagents.llm_clients.model_catalog import get_model_options + + assert "custom" in [value for _, value in get_model_options(provider, mode)] + + +@pytest.mark.unit +@pytest.mark.parametrize("provider, model", [ + ("xai", "grok-4.20-0309-reasoning"), + ("deepseek", "deepseek-v4-flash"), + ("qwen", "qwen3.7-max"), +]) +def test_a_retired_model_id_still_runs_without_a_warning(provider, model): + """A config written against an earlier release keeps working: the provider + still serves these, they are just no longer offered in the picker.""" + from tradingagents.llm_clients.validators import validate_model + + assert validate_model(provider, model) diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py index 367566edc..2d7106415 100644 --- a/tests/test_news_lookahead.py +++ b/tests/test_news_lookahead.py @@ -101,5 +101,159 @@ def test_global_news_empty_after_filter_is_informative(monkeypatch): monkeypatch.setattr(ynews.yf, "Search", FakeSearch) out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10) - assert "No global news found" in out assert "###" not in out # no empty article body + # Only a later article came back, so the feed does not reach this window. + assert "unavailable" in out and "not an absence" in out + + + +def _ticker_with(articles, monkeypatch): + class FakeTicker: + def __init__(self, *a, **k): + pass + + def get_news(self, count=20): + return articles + + monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker) + + +@pytest.mark.unit +def test_ticker_news_window_before_feed_coverage_is_unavailable(monkeypatch): + # Yahoo serves only recent articles: a historical window gets none of them, + # which must read as "cannot answer", not "no news happened". + recent = [{"title": "RECENT", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2026-09-10")}] + _ticker_with(recent, monkeypatch) + out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") + assert "RECENT" not in out + assert "unavailable" in out and "not an absence" in out + assert "2026-09-10" in out # says how far back the feed actually reaches + + +@pytest.mark.unit +def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch): + articles = [{"title": "RECENT", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2026-09-10")}, + {"title": "OLDER", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2026-07-01")}] + _ticker_with(articles, monkeypatch) + out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") + assert "No news found" in out + assert "unavailable" not in out + + +@pytest.mark.unit +@pytest.mark.parametrize("dates, expect_gap", [ + ([], True), # empty feed: covers at most now + ([None], True), # undated only: same + ([datetime(2026, 5, 20, tzinfo=timezone.utc)], True), # all after the window + ([datetime(2026, 5, 4, tzinfo=timezone.utc)], True), # starts mid-window: partial + ([datetime(2026, 5, 1, 18, tzinfo=timezone.utc)], False), # reaches the first day + ([datetime(2026, 5, 20, tzinfo=timezone.utc), + datetime(2026, 4, 1, tzinfo=timezone.utc)], False), # coverage reaches back +]) +def test_coverage_gap_boundaries(dates, expect_gap): + from tradingagents.dataflows.date_window import coverage_gap + + out = coverage_gap(dates, "2026-05-01", "2026-05-08", "Feed", "items") + assert (out is not None) is expect_gap + if expect_gap: + assert "unavailable for 2026-05-01..2026-05-08" in out and "not an absence" in out + + + +@pytest.mark.unit +def test_ticker_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch): + _ticker_with([], monkeypatch) + out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") + assert "unavailable" in out and "not an absence" in out + + +@pytest.mark.unit +def test_ticker_news_null_feed_is_handled(monkeypatch): + # Yahoo can return None instead of a list; that is unavailability, not an error. + _ticker_with(None, monkeypatch) + out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14") + assert "unavailable" in out and "Error" not in out + + +@pytest.mark.unit +def test_global_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch): + class FakeSearch: + def __init__(self, *a, **k): + self.news = [] + + monkeypatch.setattr(ynews.yf, "Search", FakeSearch) + out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10) + assert "unavailable" in out and "not an absence" in out + + +@pytest.mark.unit +def test_global_news_does_not_infer_coverage_from_a_stale_search_hit(monkeypatch): + # Global news merges fuzzy searches; one old hit before the window says + # nothing about the days in between, so the window stays unavailable. + stale = {"title": "STALE", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-01-01")} + fresh = {"title": "FRESH", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-06-01")} + + class FakeSearch: + def __init__(self, *a, **k): + self.news = [fresh, stale] + + monkeypatch.setattr(ynews.yf, "Search", FakeSearch) + out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10) + assert "unavailable" in out and "No global news found" not in out + + +@pytest.mark.unit +def test_coverage_gap_future_window_is_unavailable(): + from datetime import timedelta + + from tradingagents.dataflows.date_window import coverage_gap + today = datetime.now(timezone.utc).date() + out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items") + assert out is not None and "past today" in out + + +@pytest.mark.unit +def test_out_of_window_articles_do_not_consume_the_article_budget(monkeypatch): + """The limit counts articles the run may see, not candidates fetched (#1356). + + Out-of-window items were counted first, so they filled the budget, stopped + the remaining searches, and the in-window news was reported as absent. + """ + stale = [{"title": f"OLD {i}", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2025-01-01")} for i in range(2)] + wanted = {"title": "IN WINDOW", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2025-05-08")} + pages = [stale, [wanted]] + + class FakeSearch: + def __init__(self, *a, **k): + self.news = pages.pop(0) if pages else [] + + monkeypatch.setattr(ynews.yf, "Search", FakeSearch) + monkeypatch.setattr(ynews, "get_config", lambda: { + "global_news_lookback_days": 7, "global_news_article_limit": 2, + "global_news_queries": ["markets", "economy"], + }) + + out = ynews.get_global_news_yfinance("2025-05-09") + + assert "IN WINDOW" in out + assert "OLD 0" not in out + + +@pytest.mark.unit +def test_the_article_limit_still_caps_what_is_returned(monkeypatch): + articles = [{"title": f"NEWS {i}", "publisher": "P", "link": "l", + "providerPublishTime": _epoch("2025-05-08")} for i in range(5)] + + class FakeSearch: + def __init__(self, *a, **k): + self.news = articles + + monkeypatch.setattr(ynews.yf, "Search", FakeSearch) + out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=3) + + assert out.count("### ") == 3 diff --git a/tests/test_ohlcv_cache_freshness.py b/tests/test_ohlcv_cache_freshness.py index 65a417f26..a30dc78d7 100644 --- a/tests/test_ohlcv_cache_freshness.py +++ b/tests/test_ohlcv_cache_freshness.py @@ -1,80 +1,72 @@ -"""Same-day OHLCV cache must not serve a stale snapshot all day (#1150). +"""The OHLCV cache: one file per symbol, fresh only on the day it was written. -The cache file is keyed per day, so a run started before the day's bar was final -would be reused by every later run, feeding a stale close into technical -analysis. Two cases matter for a current-day request: the bar may be missing, or -present but still in progress (Yahoo publishes a partial daily candle intraday). -Refresh is bounded by a TTL so repeated runs cannot hammer the vendor. +A current-day request also refetches past a TTL, so a run started before the +day's bar was final is not served that snapshot all day (#1150). Keying the file +by symbol rather than by day keeps the cache from growing a file per symbol per +day (#1330). """ from __future__ import annotations import os -import time import pandas as pd import pytest import tradingagents.dataflows.stockstats_utils as su -TODAY = pd.Timestamp("2026-07-18") +NOW = pd.Timestamp("2026-07-18 12:00") STALE = su.OHLCV_CACHE_TTL_SECONDS + 60 -def _write(tmp_path, name="cache.csv", age_seconds=0.0, last_date="2026-07-17"): +def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026-07-17"): f = tmp_path / name - pd.DataFrame({"Date": [last_date], "Close": [1.0]}).to_csv(f, index=False) - if age_seconds: - old = time.time() - age_seconds - os.utime(f, (old, old)) - return str(f) + pd.DataFrame({"Date": [last_date], "Close": [100.0]}).to_csv(f, index=False) + written = NOW.timestamp() - age_seconds + os.utime(f, (written, written)) + return f + + +def _load(tmp_path, monkeypatch, curr_date, download): + monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: NOW)) + monkeypatch.setattr(su.yf, "download", download) + return su.load_ohlcv("AAPL", curr_date) + + +def _fail_download(*a, **k): + raise AssertionError("fresh cache must not refetch") @pytest.mark.unit -def test_current_day_cache_past_ttl_is_refreshed(tmp_path): - # Bar missing (rows stop at yesterday) and file older than the TTL -> refetch. - assert su._needs_same_day_refresh(_write(tmp_path, age_seconds=STALE), TODAY, TODAY) is True - - -@pytest.mark.unit -def test_partial_current_day_bar_is_still_refreshed(tmp_path): - # Today's row is present but may be an in-progress candle whose Close is not - # the closing price. Row inspection can't distinguish it, so the TTL governs. +def test_current_day_cache_past_ttl_is_not_fresh(tmp_path): + # Today's bar missing or still in progress: row inspection can't tell, so the TTL governs. + assert su._cache_is_fresh(_write(tmp_path, age_seconds=STALE), NOW.normalize(), NOW) is False f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18") - assert su._needs_same_day_refresh(f, TODAY, TODAY) is True + assert su._cache_is_fresh(f, NOW.normalize(), NOW) is False @pytest.mark.unit -def test_recent_cache_is_not_refetched(tmp_path): +def test_recent_cache_is_fresh(tmp_path): # Written moments ago: don't hammer the vendor (weekend/holiday guard). - assert su._needs_same_day_refresh(_write(tmp_path), TODAY, TODAY) is False + assert su._cache_is_fresh(_write(tmp_path), NOW.normalize(), NOW) is True @pytest.mark.unit -def test_historical_request_always_uses_cache(tmp_path): - # Past dates are immutable: never refetch, however old the file is. - past = pd.Timestamp("2026-05-01") +def test_historical_request_uses_todays_cache_past_the_ttl(tmp_path): f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30") - assert su._needs_same_day_refresh(f, past, TODAY) is False + assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is True + + +@pytest.mark.unit +def test_a_download_from_an_earlier_day_is_not_fresh(tmp_path): + f = _write(tmp_path, age_seconds=13 * 3600) # yesterday 23:00 + assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is False @pytest.mark.unit def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch): - """End-to-end: the helper is actually wired into load_ohlcv's cache branch. - - Without this, the unit tests above would still pass if the helper were never - called from the real code path. - """ - monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) - monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY)) - - # Pre-seed the cache file load_ohlcv will look for, aged past the TTL. - start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d") - end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d") - cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv" - pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False) - old = time.time() - STALE - os.utime(cache_file, (old, old)) - + """End-to-end: the freshness check is wired into load_ohlcv's cache branch.""" + _write(tmp_path, age_seconds=STALE) calls = [] def _fake_download(*a, **k): @@ -83,27 +75,31 @@ def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch): {"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]} ).set_index("Date") - monkeypatch.setattr(su.yf, "download", _fake_download) - - out = su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d")) - + out = _load(tmp_path, monkeypatch, "2026-07-18", _fake_download) assert calls, "stale same-day cache must trigger a refetch" assert 222.0 in out["Close"].values, "refreshed close must reach the caller" @pytest.mark.unit def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch): - # Mirror image: a fresh cache must NOT trigger a download. + _write(tmp_path, last_date="2026-07-18") + _load(tmp_path, monkeypatch, "2026-07-18", _fail_download) + + +@pytest.mark.unit +def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch): + """A later day's download replaces the symbol's file instead of adding one (#1330).""" monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) - monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY)) + frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]}) + downloads = [] + monkeypatch.setattr(su.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date")) - start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d") - end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d") - cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv" - pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False) + for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"): + now = pd.Timestamp(day) + monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda now=now: now)) + su.load_ohlcv("AAPL", "2026-07-17") + written = list(tmp_path.glob("AAPL-*.csv")) + os.utime(written[0], (now.timestamp(), now.timestamp())) - def _fail_download(*a, **k): - raise AssertionError("fresh cache must not refetch") - - monkeypatch.setattr(su.yf, "download", _fail_download) - su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d")) + assert len(downloads) == 3, "each new day refetches" + assert [p.name for p in tmp_path.iterdir()] == ["AAPL-YFin-data.csv"] diff --git a/tests/test_ohlcv_latest_bar.py b/tests/test_ohlcv_latest_bar.py index cf9bca676..548264cc5 100644 --- a/tests/test_ohlcv_latest_bar.py +++ b/tests/test_ohlcv_latest_bar.py @@ -13,6 +13,8 @@ anywhere counts as no data and the staleness check judges the rest. """ from __future__ import annotations +import os + import pandas as pd import pytest @@ -89,9 +91,9 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date): monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) today = pd.Timestamp(curr_date) monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today)) - start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d") - end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d") - (tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False)) + cache_file = tmp_path / "AAPL-YFin-data.csv" + cache_file.write_text(frame.to_csv(index=False)) + os.utime(cache_file, (today.timestamp(), today.timestamp())) def _fail_download(*a, **k): raise AssertionError("should use the seeded cache, not download") @@ -165,3 +167,34 @@ def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path): out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08") assert out["Close"].iloc[-1] == 101.5 assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08") + + +@pytest.mark.unit +def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, tmp_path): + """Gap filling exists so indicators compute on a continuous series. The + verification snapshot is the one place a number must be what the vendor + reported, or the module built to stop invented prices supplies them.""" + from tradingagents.dataflows import market_data_validator as mdv, stockstats_utils as su + + frame = pd.DataFrame({ + "Date": ["2026-05-06", "2026-05-07", "2026-05-08"], + "Open": [100.0, 104.5, ""], # the latest bar has not settled + "High": [101.0, 105.5, ""], + "Low": [99.0, 103.5, ""], + "Close": [100.5, 105.0, 106.0], + "Volume": [1000000, 1000000, ""], + }) + today = pd.Timestamp("2026-05-08 12:00") + monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today)) + cache = tmp_path / "AAPL-YFin-data.csv" + cache.write_text(frame.to_csv(index=False)) + os.utime(cache, (today.timestamp(), today.timestamp())) + monkeypatch.setattr(su.yf, "download", lambda *a, **k: (_ for _ in ()).throw( + AssertionError("should read the seeded cache"))) + + out = mdv.build_verified_market_snapshot("AAPL", "2026-05-08", 3) + + row = out.split("Latest verified OHLCV row")[1].split("###")[0] + assert "104.50" not in row and "105.50" not in row # the previous session's numbers + assert "106.00" in row # the close the vendor did report diff --git a/tests/test_ollama_base_url.py b/tests/test_ollama_base_url.py index c54162da6..3c949e63c 100644 --- a/tests/test_ollama_base_url.py +++ b/tests/test_ollama_base_url.py @@ -197,3 +197,28 @@ def test_ollama_offers_custom_model_id(): assert "custom" in values, f"Ollama {mode!r} missing 'custom' option: {entries}" # Custom option is last so it doesn't push the curated defaults off-screen assert values[-1] == "custom", f"'custom' should be last entry: {values}" + + +@pytest.mark.unit +def test_structured_output_suppresses_object_tool_choice(monkeypatch): + """Ollama rejects the object-form tool_choice like other local servers + (#1062), and a local model ID has no capability entry saying otherwise, so + it takes the same client as the generic local endpoint.""" + from langchain_openai import ChatOpenAI + from pydantic import BaseModel + + from tradingagents.llm_clients import create_llm_client + + class Schema(BaseModel): + x: int + + captured = {} + monkeypatch.setattr( + ChatOpenAI, + "with_structured_output", + lambda self, schema, method=None, **kw: captured.update({"method": method, **kw}) or "BOUND", + ) + + create_llm_client(provider="ollama", model="qwen3:30b").get_llm().with_structured_output(Schema) + + assert captured["tool_choice"] is None diff --git a/tests/test_openai_reasoning_effort.py b/tests/test_openai_reasoning_effort.py index f58a4e20c..1d452ba27 100644 --- a/tests/test_openai_reasoning_effort.py +++ b/tests/test_openai_reasoning_effort.py @@ -17,9 +17,10 @@ from tradingagents.llm_clients.openai_client import ( "model,expected", [ ("gpt-5.5", True), ("gpt-5.4", True), ("gpt-5.4-mini", True), - ("gpt-5.5-pro", True), ("o1", True), ("o3-mini", True), + ("gpt-5.5-pro", True), ("gpt-6-astra", True), ("o1", True), ("o3-mini", True), ("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False), - ("gpt-3.5-turbo", False), + ("gpt-3.5-turbo", False), ("gpt-10", True), + ("gpt-5foo", False), ("gpt-60x", False), ("o3rd-party", False), ], ) def test_supports_reasoning_effort(model, expected): diff --git a/tests/test_portfolio_context.py b/tests/test_portfolio_context.py new file mode 100644 index 000000000..12305a8ec --- /dev/null +++ b/tests/test_portfolio_context.py @@ -0,0 +1,240 @@ +"""Portfolio context: what the caller holds, threaded into the decision agents. + +Decisions were made with no knowledge of the current book, so "add to a full +position" and "open a new one" read alike. The context is optional and carries +three distinct states: a position, a flat book, and no context at all. Nothing +may present the third as the second. The research team stays blind so the bull +and bear cases are not anchored by the caller's position. +""" + +from __future__ import annotations + +import json + +import pytest + +from tradingagents.agents.utils.agent_utils import get_portfolio_context_from_state +from tradingagents.portfolio import PortfolioContext, load_portfolio + +HOLDING = { + "cash": 25000.0, + "currency": "USD", + "positions": [ + {"ticker": "AAPL", "quantity": 120, "average_price": 150.0}, + {"ticker": "MSFT", "quantity": 10}, + ], +} + + +@pytest.mark.unit +def test_position_in_the_analyzed_instrument_leads_the_render(): + text = PortfolioContext.model_validate(HOLDING).render("AAPL") + assert "120" in text and "150" in text + assert "MSFT" in text and "25,000" in text and "USD" in text + + +@pytest.mark.unit +def test_flat_book_says_no_position_rather_than_omitting_it(): + text = PortfolioContext.model_validate({"cash": 1000.0, "positions": []}).render("AAPL") + assert "No current position in AAPL" in text + + +@pytest.mark.unit +def test_a_ticker_held_under_another_spelling_is_matched(): + text = PortfolioContext.model_validate({"positions": [{"ticker": "aapl", "quantity": 5}]}).render("AAPL") + assert "No current position" not in text and "5" in text + + +@pytest.mark.unit +def test_absent_context_is_reported_as_not_provided(): + notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"}) + assert "not provided" in notice.lower() + assert "no position" not in notice.lower() # missing must not read as flat + + +@pytest.mark.unit +def test_rendered_context_reaches_the_agents_from_state(): + block = get_portfolio_context_from_state({"portfolio_context": "Portfolio: flat", "company_of_interest": "AAPL"}) + assert block == "Portfolio: flat" + + +@pytest.mark.unit +def test_load_rejects_a_malformed_file_with_a_clear_error(tmp_path): + bad = tmp_path / "p.json" + bad.write_text(json.dumps({"positions": [{"quantity": 5}]})) + with pytest.raises(ValueError, match="portfolio"): + load_portfolio(bad) + + +@pytest.mark.unit +def test_load_reads_a_valid_file(tmp_path): + good = tmp_path / "p.json" + good.write_text(json.dumps(HOLDING)) + assert load_portfolio(good).positions[0].ticker == "AAPL" + + +# --- threading through the graph -------------------------------------------- + +def _bare_graph(tmp_path): + from tradingagents.agents.utils.memory import TradingMemoryLog + from tradingagents.graph.propagation import Propagator + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1, + "max_risk_discuss_rounds": 1} + graph.memory_log = TradingMemoryLog(graph.config) + graph.propagator = Propagator() + graph.selected_analysts = ["market"] + graph._resolve_pending_entries = lambda t: None + graph.resolve_instrument_context = lambda t, a="stock", d=None: "" + graph._memory_as_of = lambda d: None + return graph + + +@pytest.mark.unit +def test_create_run_state_renders_the_portfolio_once(tmp_path): + graph = _bare_graph(tmp_path) + state = graph.create_run_state("AAPL", "2026-08-14", portfolio=PortfolioContext.model_validate(HOLDING)) + assert "120" in state["portfolio_context"] + assert graph.create_run_state("AAPL", "2026-08-14")["portfolio_context"] == "" + + +@pytest.mark.unit +def test_checkpoint_signature_changes_with_the_portfolio(tmp_path): + graph = _bare_graph(tmp_path) + none = graph._run_signature("stock") + flat = graph._run_signature("stock", PortfolioContext()) + held = graph._run_signature("stock", PortfolioContext.model_validate(HOLDING)) + assert len({none, flat, held}) == 3 + + +@pytest.mark.unit +@pytest.mark.parametrize("module, factory", [ + ("tradingagents.agents.trader.trader", "create_trader"), + ("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager"), + ("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"), + ("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"), + ("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"), +]) +def test_decision_agents_see_the_portfolio(module, factory, monkeypatch): + """The prompt each decision agent sends carries the portfolio block.""" + import importlib + + mod = importlib.import_module(module) + seen = [] + + class _LLM: + def invoke(self, prompt, *a, **k): + seen.append(prompt if isinstance(prompt, str) else json.dumps(str(prompt))) + from langchain_core.messages import AIMessage + return AIMessage("Rating: Hold\n\nnothing to do") + + def with_structured_output(self, *a, **k): + raise NotImplementedError # force the free-text path + + state = { + "company_of_interest": "AAPL", "trade_date": "2026-08-14", "asset_type": "stock", + "instrument_context": "", "market_report": "M", "sentiment_report": "S", + "news_report": "N", "fundamentals_report": "F", "investment_plan": "P", + "trader_investment_plan": "T", "past_context": "", + "portfolio_context": "PORTFOLIO_BLOCK_MARKER", + "investment_debate_state": {"history": "", "judge_decision": "", "count": 0}, + "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, + "aggressive_history": "", "conservative_history": "", "neutral_history": "", + "current_aggressive_response": "", "current_conservative_response": "", + "current_neutral_response": "", "judge_decision": ""}, + } + node = getattr(mod, factory)(_LLM()) + node(state) + assert any("PORTFOLIO_BLOCK_MARKER" in p for p in seen), f"{factory} prompt lacks the portfolio block" + + +@pytest.mark.unit +def test_research_team_stays_blind_to_the_portfolio(): + import inspect + + from tradingagents.agents.researchers import bear_researcher, bull_researcher + for mod in (bull_researcher, bear_researcher): + assert "portfolio_context" not in inspect.getsource(mod) + + +@pytest.mark.unit +def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch): + """The clear must key on the same portfolio the run was checkpointed under. + + Keyed on a different one it deletes nothing, and the next identical call + resumes the finished thread and returns the old decision without running. + """ + import tradingagents.graph.trading_graph as tg + + graph = _bare_graph(tmp_path) + graph.config.update({"checkpoint_enabled": True, "data_cache_dir": str(tmp_path), + "results_dir": str(tmp_path)}) + graph.debug = False + graph._resuming = False + graph.propagator.get_graph_args = lambda callbacks=None: {} + graph.process_signal = lambda d: "Hold" + graph._log_state = lambda *a, **k: None + graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})() + book = PortfolioContext.model_validate(HOLDING) + + written = graph._run_signature("stock", book) # what begin_checkpoint keys on + cleared = [] + monkeypatch.setattr(tg, "clear_checkpoint", lambda d, t, dt, signature: cleared.append(signature)) + + graph._run_graph("AAPL", "2026-08-14", "stock", checkpoint_thread_id=None, portfolio=book) + + assert cleared == [written] + + +@pytest.mark.unit +def test_research_layer_sizes_against_a_standard_allocation(): + """The research team is blind to the book, so its plan cannot promise + position-relative sizing: it sizes against a standard allocation instead.""" + from tradingagents.agents.schemas import ResearchPlan + + description = ResearchPlan.model_fields["strategic_actions"].description + assert "standard allocation" in description + assert "does not see the caller's holdings" in description + + +@pytest.mark.unit +def test_partial_portfolio_states_only_what_it_was_given(): + """Cash omitted is not cash zero; the line is absent rather than invented.""" + text = PortfolioContext.model_validate({"positions": [{"ticker": "AAPL", "quantity": 5}]}).render("AAPL") + assert "Cash" not in text + assert "5" in text + + +@pytest.mark.unit +def test_cli_rejects_an_unusable_portfolio_file_before_running(tmp_path, monkeypatch): + from typer.testing import CliRunner + + import cli.main as m + + bad = tmp_path / "bad.json" + bad.write_text('{"positions": [{"quantity": 5}]}') + ran = [] + monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k)) + + result = CliRunner().invoke(m.app, ["--portfolio", str(bad)]) + + assert result.exit_code == 1 and ran == [] + + +@pytest.mark.unit +def test_cli_passes_a_valid_portfolio_into_the_run(tmp_path, monkeypatch): + from typer.testing import CliRunner + + import cli.main as m + + good = tmp_path / "good.json" + good.write_text(json.dumps(HOLDING)) + ran = [] + monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k)) + + result = CliRunner().invoke(m.app, ["--portfolio", str(good)]) + + assert result.exit_code == 0 + assert ran[0]["portfolio"].position_in("AAPL").quantity == 120 diff --git a/tests/test_prompt_integrity.py b/tests/test_prompt_integrity.py new file mode 100644 index 000000000..e26e72185 --- /dev/null +++ b/tests/test_prompt_integrity.py @@ -0,0 +1,87 @@ +"""What the agents are actually told. + +Three problems the audit found: one analyst's brief reached the model as a Python +tuple, every analyst was asked for a trade call that nothing reads, and a report +that was never produced was presented as an empty labelled section, which invites +the next agent to fill it in from nothing. +""" + +from __future__ import annotations + +import importlib + +import pytest + +ANALYSTS = ["market_analyst", "sentiment_analyst", "news_analyst", "fundamentals_analyst"] + + +@pytest.mark.unit +@pytest.mark.parametrize("name", ANALYSTS) +def test_an_analyst_brief_is_text_not_a_python_object(name): + """A trailing comma made one brief a tuple, so the model was handed its repr + (quotes, parens and all) instead of the instruction.""" + import ast + import inspect + + mod = importlib.import_module(f"tradingagents.agents.analysts.{name}") + tree = ast.parse(inspect.getsource(mod)) + briefs = [node.value for node in ast.walk(tree) + if isinstance(node, ast.Assign) + and getattr(node.targets[0], "id", "") == "system_message"] + assert briefs, f"{name} has no system_message" + for brief in briefs: + assert not isinstance(brief, ast.Tuple), "the brief is a tuple, not text" + + +@pytest.mark.unit +@pytest.mark.parametrize("name", ANALYSTS) +def test_an_analyst_is_not_asked_for_a_trade_call_nothing_reads(name): + """The stop signal is never consumed, and asking for it makes an analyst + open with a direction that then travels as evidence.""" + import inspect + + mod = importlib.import_module(f"tradingagents.agents.analysts.{name}") + assert "FINAL TRANSACTION PROPOSAL" not in inspect.getsource(mod) + + +@pytest.mark.unit +@pytest.mark.parametrize("module, factory", [ + ("tradingagents.agents.researchers.bull_researcher", "create_bull_researcher"), + ("tradingagents.agents.researchers.bear_researcher", "create_bear_researcher"), + ("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"), + ("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"), + ("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"), +]) +def test_a_report_that_was_never_produced_says_so(module, factory): + """`--analysts market` leaves three reports empty; presenting them as blank + sections invites the model to invent the contents.""" + from langchain_core.messages import AIMessage + + mod = importlib.import_module(module) + seen = [] + + class _LLM: + def invoke(self, prompt, *a, **k): + seen.append(prompt if isinstance(prompt, str) else str(prompt)) + return AIMessage("argument") + + def with_structured_output(self, *a, **k): + raise NotImplementedError + + state = { + "company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock", + "instrument_context": "", "portfolio_context": "", "past_context": "", + "market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "", + "fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T", + "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", + "current_response": "", "judge_decision": "", "count": 0}, + "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, + "aggressive_history": "", "conservative_history": "", "neutral_history": "", + "current_aggressive_response": "", "current_conservative_response": "", + "current_neutral_response": "", "judge_decision": ""}, + } + getattr(mod, factory)(_LLM())(state) + + prompt = " ".join(seen) + assert "not part of this run" in prompt or "not available" in prompt, prompt[:400] + assert "RSI 61" in prompt # the report that does exist is still passed through diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 596108a77..36bd4fcbb 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -7,6 +7,7 @@ import pytest from tradingagents.llm_clients.openai_client import ( OPENAI_COMPATIBLE_PROVIDERS, DeepSeekChatOpenAI, + LocalCompatibleChatOpenAI, MinimaxChatOpenAI, NormalizedChatOpenAI, is_openai_compatible, @@ -39,7 +40,7 @@ def test_registry_membership(): ("kimi", "https://api.moonshot.ai/v1", NormalizedChatOpenAI, False), ("groq", "https://api.groq.com/openai/v1", NormalizedChatOpenAI, False), ("nvidia", "https://integrate.api.nvidia.com/v1", NormalizedChatOpenAI, False), - ("ollama", "http://localhost:11434/v1", NormalizedChatOpenAI, False), + ("ollama", "http://localhost:11434/v1", LocalCompatibleChatOpenAI, False), ]) def test_registry_spec(provider, base_url, chat_class, responses): spec = OPENAI_COMPATIBLE_PROVIDERS[provider] diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py new file mode 100644 index 000000000..8d8fcb0cf --- /dev/null +++ b/tests/test_rating_integrity.py @@ -0,0 +1,211 @@ +"""A decision is recorded as the call that was made, or as needing review. + +Two readers used to disagree about the same text: the signal said REVIEW while +the memory log wrote a fabricated Hold. Worse, prose that argued against a Buy +before concluding Underweight was read as Buy, because the parser took the first +rating word anywhere in the document. A wrong direction is worse than no +direction, so an unclear decision is REVIEW everywhere. +""" + +from __future__ import annotations + +import pytest + +from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating, parse_rating + +INVERTED = ("The aggressive analyst pushed hard for a Buy on the AI backlog, but the " + "conservative case on margin compression carried the debate. " + "Final rating — Underweight. Trim to half weight over the next two weeks.") +REFUSAL = "I'm sorry, I can't provide a rating for this security." + + +@pytest.mark.unit +@pytest.mark.parametrize("separator", [":", "-", "—", "–", ":", ": **"]) +def test_the_labelled_rating_wins_whatever_separates_it(separator): + text = f"Buy arguments were raised and rejected.\n\nRating{separator}Underweight\n\nTrim." + assert extract_rating(text) == "Underweight" + + +@pytest.mark.unit +def test_a_rating_argued_against_is_not_read_as_the_decision(): + assert extract_rating(INVERTED) == "Underweight" + + +@pytest.mark.unit +def test_prose_naming_several_ratings_without_a_label_needs_review(): + """Nothing in the text says which one is the call, so guessing risks + reporting the opposite of the decision.""" + text = "The bull wants Buy, the bear wants Sell, and the committee was split." + assert extract_rating(text) is None + + +@pytest.mark.unit +def test_prose_naming_one_rating_is_taken_as_the_call(): + assert extract_rating("On balance we stay Underweight until margins recover.") == "Underweight" + + +@pytest.mark.unit +def test_a_refusal_has_no_rating_and_is_not_defaulted(): + assert extract_rating(REFUSAL) is None + assert parse_rating(REFUSAL) == RATING_REVIEW + + +@pytest.mark.unit +def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating(): + """A free-text answer that echoes the rating scale was read as the first + tier listed in it.""" + text = ("**Rating Scale**: Buy, Overweight, Hold, Underweight, Sell.\n\n" + "**Rating**: Sell\n\nExit the position.") + assert extract_rating(text) == "Sell" + + +# --- the readers agree ------------------------------------------------------ + +@pytest.mark.unit +def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): + from tradingagents.agents.utils.memory import TradingMemoryLog + + log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + log.store_decision("NVDA", "2026-01-05", REFUSAL) + + entry = log.load_entries()[0] + assert entry["rating"] == RATING_REVIEW + + +@pytest.mark.unit +def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): + from tradingagents.agents.utils.memory import TradingMemoryLog + from tradingagents.graph.signal_processing import SignalProcessor + + log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."): + log.store_decision("NVDA", f"2026-01-0{len(log.load_entries()) + 1}", text) + + signals = [SignalProcessor.process_signal(None, text) + for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate.")] + assert [e["rating"] for e in log.load_entries()] == signals + + +@pytest.mark.unit +def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path): + """REVIEW has no direction, so it cannot count for or against the system.""" + from tradingagents.agents.utils.memory import TradingMemoryLog + from tradingagents.backtest import summarize + + log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") + log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01") + log.store_decision("AAPL", "2026-01-05", REFUSAL) + log.update_with_outcome("AAPL", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01") + + summary = summarize(log) + assert set(summary.by_rating) == {"Buy"} + + +@pytest.mark.unit +def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path, capsys): + """The CLI is the primary entry point; an unreadable decision must be + visible there, not only in the log.""" + import cli.main as m + from cli.models import AnalystType + + printed = [] + + class _Graph: + graph = propagator = None + + def create_run_state(self, *a, **k): + return {"messages": []} + + def record_decision(self, *a, **k): + pass + + def process_signal(self, text): + from tradingagents.graph.signal_processing import SignalProcessor + return SignalProcessor.process_signal(None, text) + + def get_graph_args(self, callbacks=None): + return {} + + def begin_checkpoint(self, *a, **k): + return None + + def checkpoint_input(self, state): + return state + + def clear_checkpoint_on_success(self, *a, **k): + pass + + def end_checkpoint(self): + pass + + def stream(self, *a, **k): + yield {"messages": [], "final_trade_decision": REFUSAL} + + fake = _Graph() + fake.graph = fake + fake.propagator = fake + monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake) + monkeypatch.setattr(m, "create_layout", lambda: None) + monkeypatch.setattr(m, "update_display", lambda *a, **k: None) + monkeypatch.setattr(m, "Live", type("L", (), {"__init__": lambda s, *a, **k: None, + "__enter__": lambda s: s, + "__exit__": lambda s, *a: False})) + monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a))) + monkeypatch.setattr(m, "display_complete_report", lambda *a, **k: None) + monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") + monkeypatch.setattr(m, "get_user_selections", lambda: { + "ticker": "NVDA", "analysis_date": "2026-01-10", + "analysts": [AnalystType.MARKET], "asset_type": "stock", + }) + monkeypatch.setattr(m, "_build_run_config", lambda s, c: { + "data_cache_dir": str(tmp_path / "c"), "results_dir": str(tmp_path / "r")}) + + m.run_analysis() + + assert any("review" in line.lower() for line in printed), printed[-5:] + + +@pytest.mark.unit +@pytest.mark.parametrize("module, factory, must_name", [ + ("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager", "Rating"), + ("tradingagents.agents.managers.research_manager", "create_research_manager", "Recommendation"), + ("tradingagents.agents.trader.trader", "create_trader", "Action"), +]) +def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_name): + """The field descriptions live in the schema, which a provider without + structured output never sees. Without the format in the prompt body, the + fallback answer is prose nobody can read a rating from.""" + import importlib + + from langchain_core.messages import AIMessage + + mod = importlib.import_module(module) + seen = [] + + class _LLM: + def invoke(self, prompt, *a, **k): + seen.append(prompt if isinstance(prompt, str) else str(prompt)) + return AIMessage("**Rating**: Hold\n\nnothing to do") + + def with_structured_output(self, *a, **k): + raise NotImplementedError # force the free-text path + + state = { + "company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock", + "instrument_context": "", "market_report": "M", "sentiment_report": "S", + "news_report": "N", "fundamentals_report": "F", "investment_plan": "P", + "trader_investment_plan": "T", "past_context": "", "portfolio_context": "", + "investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h", + "current_response": "", "judge_decision": "", "count": 2}, + "risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3, + "aggressive_history": "", "conservative_history": "", "neutral_history": "", + "current_aggressive_response": "", "current_conservative_response": "", + "current_neutral_response": "", "judge_decision": ""}, + } + getattr(mod, factory)(_LLM())(state) + + prompt = " ".join(seen) + assert "## Output" in prompt, "no output-format section in the prompt" + section = prompt.split("## Output", 1)[1] + assert f"**{must_name}**" in section, section[:300] diff --git a/tests/test_reddit_fallback.py b/tests/test_reddit_fallback.py index a2bc2cc25..fedd08160 100644 --- a/tests/test_reddit_fallback.py +++ b/tests/test_reddit_fallback.py @@ -1,5 +1,5 @@ -"""Tests for the RSS-first Reddit fetcher, its 429 backoff, the opt-in JSON -path's degradation (#862), and chunked-transfer error handling (#1024).""" +"""Tests for the Reddit RSS fetcher: one combined request, its 429 backoff, and +chunked-transfer error handling (#1024).""" from __future__ import annotations @@ -80,48 +80,15 @@ class TestRssParsing: posts = reddit._fetch_subreddit_rss("NVDA", "stocks", limit=5, timeout=5.0) assert len(posts) == 2 assert posts[0]["title"] == "NVDA earnings beat, stock pops" - assert posts[0]["source"] == "rss" - assert posts[0]["score"] is None - assert posts[0]["num_comments"] is None assert posts[0]["created_utc"] > 0 assert "datacenter unit" in posts[0]["selftext"] + assert posts[0]["subreddit"] == "stocks" def test_malformed_xml_reports_unavailable(self): with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<>")): assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None -@pytest.mark.unit -class TestFetchSubredditIsRssFirst: - """The default per-subreddit fetch goes straight to RSS — it must not hit - the WAF-blocked JSON endpoint, which only burned rate-limit budget.""" - - def test_delegates_to_rss_without_touching_json(self): - sentinel = [{"title": "x", "source": "rss", "score": None, - "num_comments": None, "created_utc": None, "selftext": ""}] - with patch.object(reddit, "_fetch_subreddit_rss", return_value=sentinel) as rss, \ - patch.object(reddit, "urlopen", - side_effect=AssertionError("JSON endpoint must not be called")): - out = reddit._fetch_subreddit("NVDA", "stocks", 5, 5.0) - rss.assert_called_once() - assert out is sentinel - - -@pytest.mark.unit -class TestJsonPathFallsBackToRss: - """The opt-in JSON path still degrades to RSS on a 403 (kept for #862).""" - - def test_403_triggers_rss(self): - err = HTTPError("url", 403, "Blocked", {}, None) - rss_posts = [{"title": "x", "source": "rss", "score": None, - "num_comments": None, "created_utc": None, "selftext": ""}] - with patch.object(reddit, "urlopen", side_effect=err), \ - patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts) as rss: - out = reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0) - rss.assert_called_once() - assert out and out[0]["source"] == "rss" - - @pytest.mark.unit class TestRss429Backoff: def test_429_then_success_retries_once(self): @@ -178,12 +145,6 @@ class TestChunkedTransferErrorsHandled: with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))): assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None - def test_json_incomplete_read_falls_back_to_rss(self): - with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \ - patch.object(reddit, "_fetch_subreddit_rss", return_value=[]) as rss: - reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0) - rss.assert_called_once() - def test_oversized_rss_feed_is_refused_not_parsed(self): # A hostile/misbehaving endpoint streaming an unbounded body must not be # read into memory before parsing; overflow degrades to an empty feed. @@ -201,25 +162,12 @@ class TestFormatterHandlesRssPosts: "created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"), "selftext": "great quarter", "source": "rss", }] - with patch.object(reddit, "_fetch_subreddit", return_value=rss_posts): - out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0) - assert "via RSS feed" in out - assert "↑" not in out # no fake score arrow + with patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts): + out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",)) + assert "↑" not in out # RSS has no scores; none are invented assert "NVDA pops" in out assert "great quarter" in out - def test_json_posts_still_show_counts(self): - json_posts = [{ - "title": "NVDA pops", "score": 1234, "num_comments": 56, - "created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"), - "selftext": "", - }] - with patch.object(reddit, "_fetch_subreddit", return_value=json_posts): - out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0) - assert "1234↑" in out - assert "56c" in out - assert "via RSS" not in out - @pytest.mark.unit class TestCryptoSearchTerm: @@ -228,12 +176,12 @@ class TestCryptoSearchTerm: def _captured_ticker(self, ticker): seen = {} - def fake_fetch(t, sub, limit, timeout, **kwargs): + def fake_fetch(t, subs, limit, timeout, **kwargs): seen["ticker"] = t return [] - with patch.object(reddit, "_fetch_subreddit", side_effect=fake_fetch): - reddit.fetch_reddit_posts(ticker, subreddits=("stocks",), inter_request_delay=0) + with patch.object(reddit, "_fetch_subreddit_rss", side_effect=fake_fetch): + reddit.fetch_reddit_posts(ticker, subreddits=("stocks",)) return seen["ticker"] def test_crypto_pair_searches_base(self): @@ -244,61 +192,83 @@ class TestCryptoSearchTerm: @pytest.mark.unit -class TestFailedFetchIsNotSilence: - """A throttled fetch must not be rendered as "no posts found" (#1295). +class TestOneRequestForAllSubreddits: + """Reddit's anonymous RSS allows about one request per minute per IP, so a + request per subreddit spent a back-off on nearly every run. One combined + feed (``r/a+b+c``) carries each entry's subreddit, so nothing is lost.""" - Returning [] for both a failed request and a genuinely empty search made the - sentiment analyst read rate limiting as real silence ("r/stocks and - r/investing are silent"), which is a signal that was never observed. - """ + def _post(self, sub, title="NVDA pops"): + return {"title": title, "score": None, "num_comments": None, + "created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"), + "selftext": "", "source": "rss", "subreddit": sub} - _POST = { - "title": "NVDA pops", "score": None, "num_comments": None, - "created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"), - "selftext": "", "source": "rss", - } + def test_all_subreddits_share_one_request(self): + calls = [] - def _run(self, results): - """Drive fetch_reddit_posts with a per-subreddit result sequence.""" - subs = tuple(f"s{i}" for i in range(len(results))) - with patch.object(reddit, "_fetch_subreddit", side_effect=list(results)): - return reddit.fetch_reddit_posts( - "NVDA", subreddits=subs, inter_request_delay=0 - ) + def record(t, subs, limit, timeout): + calls.append((subs, limit)) + return [] - def test_failed_subreddit_is_marked_unavailable_not_empty(self): - out = self._run([None, [self._POST]]) - assert "unavailable" in out - assert "no posts found" not in out.split("unavailable")[0] + with patch.object(reddit, "_fetch_subreddit_rss", side_effect=record): + reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b", "c"), limit_per_sub=5) + # One full page, so a busy subreddit cannot crowd the others out. + assert calls == [("a+b+c", reddit._FEED_PAGE)] - def test_all_sources_failing_does_not_claim_no_posts(self): - out = self._run([None, None]) + def test_posts_are_grouped_back_by_subreddit(self): + posts = [self._post("b", "FROM B"), self._post("a", "FROM A")] + with patch.object(reddit, "_fetch_subreddit_rss", return_value=posts): + out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b")) + assert out.index("r/a") < out.index("FROM A") < out.index("r/b") < out.index("FROM B") + + def test_failed_request_is_unavailable_not_silence(self): + # #1295: a throttled fetch must not read as "no posts found". + with patch.object(reddit, "_fetch_subreddit_rss", return_value=None): + out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b")) assert "Reddit unavailable" in out assert "no Reddit posts found" not in out - def test_mixed_failure_and_empty_only_claims_silence_for_searched_subs(self): - # s0 failed, s1 genuinely returned nothing: the "no posts" claim must - # cover only s1, with s0 reported separately as unavailable. - out = self._run([None, []]) - assert "r/s1" in out.split("unavailable (fetch failed)")[0] - assert "unavailable (fetch failed): r/s0" in out - def test_genuine_empty_still_reports_no_posts(self): - out = self._run([[], []]) + with patch.object(reddit, "_fetch_subreddit_rss", return_value=[]): + out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b")) assert "no Reddit posts found" in out assert "unavailable" not in out - def test_retry_is_not_spent_again_after_a_failure(self): - # The 60s back-off must be paid at most once per run, so subsequent - # subreddits are fetched with retry disabled rather than stalling. - seen = [] + def test_subreddit_with_no_posts_is_listed_when_others_have_some(self): + with patch.object(reddit, "_fetch_subreddit_rss", return_value=[self._post("a")]): + out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b")) + assert "r/b: