diff --git a/.env.example b/.env.example index 61275aed6..cea96cb0f 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,12 @@ NVIDIA_API_KEY= #TRADINGAGENTS_OUTPUT_LANGUAGE=English #TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 #TRADINGAGENTS_MAX_RISK_ROUNDS=1 +#TRADINGAGENTS_MAX_TOOL_ROUNDS=20 #TRADINGAGENTS_CHECKPOINT_ENABLED=false +# Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume). +#TRADINGAGENTS_DATA_DIR=./data + # Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index. #TRADINGAGENTS_RESULTS_DIR= #TRADINGAGENTS_CACHE_DIR= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94fea9cc..48cd4b255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] # One run away from UTC, so a test that only passes in UTC fails here. include: - python-version: "3.12" @@ -24,8 +24,8 @@ jobs: env: TZ: ${{ matrix.tz }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install (with dev extras) @@ -39,8 +39,8 @@ jobs: name: clean-install smoke runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Fresh install (no dev extras) and import @@ -55,12 +55,12 @@ jobs: name: ruff (strict, full repo) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - name: Install ruff - run: pip install "ruff>=0.15" + run: pip install "ruff>=0.16" - name: Lint the repository # The repo is fully clean under the strict select, so we lint everything # (generated results/ is excluded via pyproject extend-exclude). diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b5278ad..35bbfb2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,60 @@ All notable changes to TradingAgents are documented here. 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). -Changes that need action when upgrading are listed first in their release. +Changes that need action when upgrading are listed under "Upgrading from" in their release. + +## [0.5.2] — 2026-09-29 + +Parallel analysts, unattended CLI runs, reports that record what produced them, +and past runs served only what was known on their date. + +### Added + +- **Parallel analysts.** The selected analysts run at the same time, so the analyst phase takes about as long as the slowest one; the CLI shows each analyst's progress and time as it finishes, and the debate starts when all reports are in. (#1255, #433) +- **Unattended CLI runs.** Flags answer the per-run questions, and `TRADINGAGENTS_*` variables the rest, so a scheduled job or script asks nothing: `tradingagents --ticker NVDA --date 2026-09-26 --analysts market,news --save --no-show`. Without a terminal, a missing answer stops the run before it starts and names what to set. (#1127, #1133) +- **Reports record what produced them.** Each report opens with the analysis date, version, provider, models, analysts, rounds and data vendors; the state log keeps them as `run_settings`. (#752, #1197) +- **Docker data folder.** `TRADINGAGENTS_DATA_DIR` keeps results, reports and the memory log in a host folder. (#865) +- **A cap on an analyst's tool calls.** After `max_tool_rounds` rounds (default 20, `TRADINGAGENTS_MAX_TOOL_ROUNDS`) the analyst writes its report from what it has. (#1420) +- `tradingagents backtest` shows each cell as it starts and the `--run-id` to continue; `--analysts` accepts `sentiment`. +- Benchmarks for Taiwan, Korea, Singapore and the main European exchanges. (#1392) +- Python 3.14 support; the Docker image runs Python 3.13. + +### Models + +- `deepseek-flash` is treated as a thinking model, so structured output works. (#1388) + +### Upgrading from 0.5.1 + +- Python 3.11 or later is required; pandas 3 is installed. +- Minimum versions: yfinance 1.7.0, langchain-core 1.6.5, langchain-openai 1.6.6, langchain-anthropic 1.7.4, langchain-google-genai 4.4.0, langgraph 1.2.12, langgraph-checkpoint-sqlite 3.1.1, langchain-aws 1.7.9 (`bedrock` extra). +- Statements default to SEC EDGAR, then Yahoo (`fundamental_data = "sec_edgar,yfinance"`). +- A past-dated run gets no Yahoo or Alpha Vantage statements or insider trades, which carry no filing date, and only the company's current name, marked as such. +- The memory log is the `tradingagents.memory` package (was `tradingagents.decision_log`); settlement and reflection moved there from `tradingagents.graph`, and `Reflector` is no longer exported from `tradingagents.graph`. +- `process_signal` is removed; the rating is `propagate()`'s second value or `final_state["final_rating"]`. +- A rating is read only from its `Rating:` label; a free-text decision without one is `REVIEW`. +- Data-layer functions take `as_of_date=` (was `curr_date=`); `build_instrument_context` and `resolve_instrument_context` take `trade_date=`. +- `VendorRateLimitError` is `VendorUnavailableError`. +- Data tools take the instrument from the run's state; tool calls no longer carry `symbol` or `ticker`. +- State log: `trader_investment_plan` (was `trader_investment_decision`), `judge_decision` removed, `final_rating` and `run_settings` added. +- Checkpoints from 0.5.1, or saved under different settings, are not resumed. + +### Fixed + +- Data tools serve the run's instrument, even when the model passes another symbol. +- A failed vendor request is reported as unavailable, not as a missing or delisted symbol; one vendor's "no data" no longer speaks for a chain in which another failed. (#1386) +- The rating is the Portfolio Manager's own, not one its thesis quotes (#1383); its line keeps an English label in other output languages (#1435). +- Backtest cells given twice run once (#1415), and settlement compares a decision with its benchmark over the same dates. +- A snapshot for a symbol with no prices no longer ends the run. +- Concurrent runs no longer drop memory log entries. +- SEC EDGAR quarterly figures filed only year to date are served, labelled with their span. +- Yahoo dividend yield and debt-to-equity print as percentages. (#1414) +- The FRED change line names the dates it spans. (#1397) +- `stream_run` serves the graph's own config to its tools. +- The test suite stays off the network and out of the user's files, and passes in any order; integration tests run with `-m integration`. (#1395) + +### Contributors + +[@1Wizzy](https://github.com/1Wizzy), [@BichengWang](https://github.com/BichengWang), [@Chaoqi31](https://github.com/Chaoqi31), [@davidalmeida90](https://github.com/davidalmeida90), [@djconnexion77](https://github.com/djconnexion77), [@fuzing](https://github.com/fuzing), [@hailampy123](https://github.com/hailampy123), [@hesam-shams](https://github.com/hesam-shams), [@HEYALT](https://github.com/HEYALT), [@Jackzigen](https://github.com/Jackzigen), [@kagura-agent](https://github.com/kagura-agent), [@loulanyue](https://github.com/loulanyue), [@mannubaveja007](https://github.com/mannubaveja007), [@olivergpt](https://github.com/olivergpt), [@rita112025-cpu](https://github.com/rita112025-cpu), [@shuyan-code](https://github.com/shuyan-code), [@SingTheCode](https://github.com/SingTheCode), [@sjq597](https://github.com/sjq597), [@xiaodu55](https://github.com/xiaodu55), [@Youholdme](https://github.com/Youholdme). ## [0.5.1] — 2026-09-24 @@ -16,12 +69,12 @@ fixes to run isolation, SEC EDGAR statements and historical runs. Some modules moved, and the old import paths are gone. Update imports as follows: -- `tradingagents.dataflows.interface` is `tradingagents.dataflows.router`, and `dataflows.symbol_utils` is `dataflows.symbols`. `dataflows.utils` is gone: `get_current_date` is in `dataflows.date_window`, `safe_ticker_component` in `dataflows.symbols`. +- `tradingagents.dataflows.interface` is `tradingagents.dataflows.router`, and `dataflows.symbol_utils` is `dataflows.symbols`. `dataflows.utils` is gone: `get_current_date` is in `dataflows.date_window`, `safe_ticker_component` in `dataflows.symbols`, and `get_scrubbed` and `vendor_reachable` in `dataflows.net`. - Vendor modules live under `tradingagents.dataflows.vendors`: `yahoo` (`ohlcv`, `market`, `fundamentals`, `news`, `snapshot`, from the former `stockstats_utils`, `y_finance`, `yfinance_news` and `market_data_validator`), `alpha_vantage` (a package, from the `alpha_vantage_*` modules), and `sec_edgar`, `fred`, `polymarket`, `reddit`, `stocktwits`. - `tradingagents.agents.utils` is gone: the agent tools are in `agents.tools`, and `agent_utils`, `agent_states`, `rating` and `structured` are `agents.context`, `agents.state`, `agents.rating` and `agents.structured`. - The decision log is `tradingagents.decision_log` (was `agents.utils.memory`), and `cli.utils` is `cli.prompts`. - `backtest.summarize` takes a `run_backtest` result or the path of a decision log, in place of a `TradingMemoryLog`. -- Removed: `SignalProcessor` (the rating is parsed by `process_signal`), the `create_social_media_analyst` alias (use `create_sentiment_analyst`), the unused `project_dir` config key, and the graph attributes `curr_state`, `ticker` and `log_states_dict`, which held the previous run's state. +- Removed: `SignalProcessor` (the rating is parsed by `process_signal`), the `create_social_media_analyst` alias (use `create_sentiment_analyst`), `symbol_utils.is_yahoo_safe`, the unused `project_dir` config key, and the graph attributes `curr_state`, `ticker` and `log_states_dict`, which held the previous run's state. ### Added diff --git a/Dockerfile b/Dockerfile index 024c7c72d..19469e5c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim AS builder +FROM python:3.13-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 @@ -10,7 +10,7 @@ WORKDIR /build COPY . . RUN pip install --no-cache-dir . -FROM python:3.12-slim +FROM python:3.13-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md index 82ca5e24d..ff99bb47a 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,16 @@ ## News +- [2026-09] **TradingAgents v0.5.2** released with analysts that work at the same time, unattended CLI runs from flags, reports that record what produced them, and past runs served only what was known on their date. - [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements. - [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. -- [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. Full release notes are in [CHANGELOG.md](CHANGELOG.md).
Earlier 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. - [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. - [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. @@ -81,6 +82,8 @@ Our framework decomposes complex trading tasks into specialized roles. - News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions. - Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements. +The selected analysts work at the same time, each on its own tools, and the research debate starts once all of their reports are in. +

@@ -117,15 +120,15 @@ git clone https://github.com/TauricResearch/TradingAgents.git cd TradingAgents ``` -Create a virtual environment in any of your favorite environment managers: +TradingAgents needs Python 3.11 or later. Create a virtual environment in any of your favorite environment managers: ```bash -conda create -n tradingagents python=3.12 +conda create -n tradingagents python=3.13 conda activate tradingagents ``` Or with [uv](https://docs.astral.sh/uv/): ```bash -uv venv --python 3.12 +uv venv --python 3.13 source .venv/bin/activate ``` @@ -144,6 +147,8 @@ docker compose run --rm tradingagents After updating the repository, rebuild the image with `docker compose build`. +Results, reports, the memory log and the cache live in the `tradingagents_data` volume. To keep them in a folder on the host instead, create the folder and point `TRADINGAGENTS_DATA_DIR` at it, in `.env` or the shell: `mkdir -p data && TRADINGAGENTS_DATA_DIR=./data docker compose run --rm tradingagents`. + For local models with Ollama: ```bash docker compose --profile ollama run --rm tradingagents-ollama @@ -159,12 +164,12 @@ export GOOGLE_API_KEY=... # Google (Gemini) export ANTHROPIC_API_KEY=... # Anthropic (Claude) export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek -export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com) -export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com) +export DASHSCOPE_API_KEY=... # Qwen (international, dashscope-intl.aliyuncs.com) +export DASHSCOPE_CN_API_KEY=... # Qwen (China, dashscope.aliyuncs.com) export ZHIPU_API_KEY=... # GLM via Z.AI (international) 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 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) @@ -177,7 +182,7 @@ export TYPESAFE_API_KEY=... # Jev social-post screening (optional) For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. -For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-4-8-v1:0`. +For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-5-5`. For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull `, and pick "Custom model ID" in the CLI for any model not listed by default. @@ -199,6 +204,14 @@ 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. 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. +To run without questions, for a scheduled job or a script, answer the per-run steps with flags and the rest with `TRADINGAGENTS_*` variables: +```bash +export TRADINGAGENTS_LLM_PROVIDER=openai TRADINGAGENTS_QUICK_THINK_LLM=gpt-6-luna TRADINGAGENTS_DEEP_THINK_LLM=gpt-6-sol +export TRADINGAGENTS_OUTPUT_LANGUAGE=English TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 TRADINGAGENTS_MAX_RISK_ROUNDS=1 +tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamentals --save --no-show +``` +Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set. + ### Markets and tickers TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market. @@ -240,8 +253,11 @@ from tradingagents.default_config import DEFAULT_CONFIG ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy()) # forward propagate -_, decision = ta.propagate("NVDA", "2026-09-01") +state, decision = ta.propagate("NVDA", "2026-09-01") print(decision) + +# the same report tree the CLI saves, under results_dir/reports +ta.save_reports(state, "NVDA") ``` You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc. @@ -265,13 +281,11 @@ 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. +US company statements come from SEC EDGAR, which records the date every figure was filed. A run dated in the past 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. -EDGAR needs no account or API key. Add the vendor to the chain: +Other companies' statements come from Yahoo Finance, which dates a statement by the period it covers rather than by when it was published. A run dated today reads them; a run dated in the past is told they are withheld, since Yahoo cannot say which figures were public by then. -```python -config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance" -``` +Insider trades are dated by when they happened, not when they were filed, so a run dated in the past is told they are withheld as well. 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: @@ -304,9 +318,9 @@ An empty `positions` list means a flat book, which is different from passing not TradingAgents persists two kinds of state across runs. -### Decision log +### Memory 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 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. +The memory 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`. @@ -330,7 +344,7 @@ _, 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. +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 memory log of its own, and scores the decisions whose holding window has since traded. ```python from tradingagents.backtest import iter_grid, run_backtest, summarize @@ -346,7 +360,7 @@ From the CLI: 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. +Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own memory 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 diff --git a/cli/announcements.py b/cli/announcements.py index 3ff70159c..a3c8223f4 100644 --- a/cli/announcements.py +++ b/cli/announcements.py @@ -1,4 +1,5 @@ import getpass +import sys import requests from rich.console import Console @@ -46,7 +47,7 @@ def display_announcements(console: Console, data: dict) -> None: ) console.print(panel) - if require_attention: + if require_attention and sys.stdin.isatty(): getpass.getpass("Press Enter to continue...") else: console.print() diff --git a/cli/display.py b/cli/display.py index cba992cc9..0f9bd0943 100644 --- a/cli/display.py +++ b/cli/display.py @@ -404,8 +404,8 @@ def display_complete_report(final_state): research.append(("Bull Researcher", debate["bull_history"])) if debate.get("bear_history"): research.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): - research.append(("Research Manager", debate["judge_decision"])) + if final_state.get("investment_plan"): + research.append(("Research Manager", final_state["investment_plan"])) if research: console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta")) for title, content in research: @@ -431,10 +431,10 @@ def display_complete_report(final_state): for title, content in risk_reports: console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) - # V. Portfolio Manager Decision - if risk.get("judge_decision"): - console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) - console.print(Panel(Markdown(risk["judge_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) + # V. Portfolio Manager Decision + if final_state.get("final_trade_decision"): + console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) + console.print(Panel(Markdown(final_state["final_trade_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) def update_research_team_status(status): @@ -463,22 +463,17 @@ ANALYST_REPORT_MAP = { def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): - """Update analyst statuses based on accumulated report state. + """Update analyst statuses from the reports filed so far. - Logic: - - Store new report content from the current chunk if present - - Check accumulated report_sections (not just current chunk) for status - - Analysts with reports = completed - - First analyst without report = in_progress - - Remaining analysts without reports = pending - - When all analysts done, set Bull Researcher to in_progress + The analysts run together: each is in progress until its own report lands. + When every selected analyst has filed, the research debate is in progress. """ selected = message_buffer.selected_analysts - found_active = False if wall_time_tracker is not None: sync_analyst_tracker_from_chunk(wall_time_tracker, chunk) + all_filed = True for analyst_key in ANALYST_ORDER: if analyst_key not in selected: continue @@ -490,20 +485,15 @@ def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): if chunk.get(report_key): message_buffer.update_report_section(report_key, chunk[report_key]) - # Determine status from accumulated sections, not just current chunk - has_report = bool(message_buffer.report_sections.get(report_key)) - - if has_report: + # Status comes from accumulated sections, not just the current chunk. + if message_buffer.report_sections.get(report_key): message_buffer.update_agent_status(agent_name, "completed") - elif not found_active: - message_buffer.update_agent_status(agent_name, "in_progress") - found_active = True else: - message_buffer.update_agent_status(agent_name, "pending") + message_buffer.update_agent_status(agent_name, "in_progress") + all_filed = False - # When all analysts complete, transition research team to in_progress if ( - not found_active + all_filed and selected and message_buffer.agent_status.get("Bull Researcher") == "pending" ): @@ -624,17 +614,9 @@ def sync_analyst_tracker_from_chunk( chunk: dict[str, str], now: float | None = None, ) -> None: + """The analysts start together; each stops its clock when its report lands.""" current_time = monotonic() if now is None else now - active_found = False - for spec in tracker.plan.specs: - has_report = bool(chunk.get(spec.report_key)) - - if has_report: - tracker.mark_started(spec.key, started_at=current_time) + tracker.mark_started(spec.key, started_at=current_time) + if chunk.get(spec.report_key): tracker.mark_completed(spec.key, completed_at=current_time) - continue - - if not active_found: - tracker.mark_started(spec.key, started_at=current_time) - active_found = True diff --git a/cli/main.py b/cli/main.py index 7ee214d01..7fbb53b15 100644 --- a/cli/main.py +++ b/cli/main.py @@ -3,6 +3,8 @@ import sys import typer from cli.display import console +from cli.models import AnalystType, AssetType +from cli.prompts import filter_analysts_for_asset_type, parse_analysts from cli.run import run_analysis from tradingagents.backtest import iter_grid, run_backtest, summarize from tradingagents.default_config import DEFAULT_CONFIG @@ -47,8 +49,23 @@ def analyze( help="JSON file with current holdings and cash, so the trader, risk and " "portfolio agents size against your actual position.", ), + ticker: str = typer.Option(None, "--ticker", help="Ticker to analyze, e.g. NVDA or 0700.HK; skips the prompt"), + date: str = typer.Option(None, "--date", help="Analysis date, YYYY-MM-DD; skips the prompt"), + analysts: str = typer.Option( + None, "--analysts", help="Comma-separated analysts, e.g. market,news; skips the prompt" + ), + save: bool | None = typer.Option( + None, "--save/--no-save", help="Save the report under results_dir without asking" + ), + show: bool | None = typer.Option( + None, "--show/--no-show", help="Show the full report at the end without asking" + ), ): - """Run an analysis. This is what a bare `tradingagents` does.""" + """Run an analysis. This is what a bare `tradingagents` does. + + Flags answer their questions; with provider, models, depth and language also + set through TRADINGAGENTS_* variables, the run asks nothing. + """ if ctx.invoked_subcommand is not None: return if clear_checkpoints: @@ -64,7 +81,8 @@ def analyze( raise typer.Exit(code=1) from None try: - run_analysis(checkpoint=checkpoint, portfolio=portfolio_context) + flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show} + run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags) except _NO_CONSOLE_ERRORS: # A terminal with no console buffer cannot host the interactive prompts. # Emit one actionable line on stderr instead of a prompt_toolkit @@ -85,7 +103,7 @@ def backtest( 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" + None, "--analysts", help="Comma-separated analysts to run: market, sentiment, news, fundamentals; omit for all the asset type allows" ), asset_type: str = typer.Option("stock", "--asset-type", help="stock or crypto"), portfolio: str = typer.Option( @@ -100,6 +118,11 @@ def backtest( try: dates = iter_grid(start, end, every) book = load_portfolio(portfolio) if portfolio else None + kind = AssetType(asset_type.strip().lower()) + # The analysts are named and checked as for an analysis; without a + # choice, every analyst the asset type allows runs. + chosen = (parse_analysts(analysts, kind) if analysts + else filter_analysts_for_asset_type(list(AnalystType), kind)) except ValueError as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(code=1) from None @@ -109,9 +132,11 @@ def backtest( 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()] + def show_progress(done, total, ticker, date): + console.print(f"[dim][{done}/{total}] {ticker} {date}[/dim]") + + kwargs = {"asset_type": kind.value, "portfolio": book, "run_id": run_id, "progress": show_progress, + "selected_analysts": [a.value for a in chosen]} try: result = run_backtest(names, dates, DEFAULT_CONFIG, **kwargs) @@ -120,6 +145,7 @@ def backtest( raise typer.Exit(code=1) from None console.print(summarize(result).render()) console.print(f"\nRan {result.cells_run} cells, skipped {result.skipped}. Log: {result.log_path}") + console.print(f"Continue or settle this sweep: --run-id {result.run_id}") for ticker, date, reason in result.failures: console.print(f"[yellow]failed:[/yellow] {ticker} {date}: {reason}") for ticker, reason in result.settlement_failures: diff --git a/cli/models.py b/cli/models.py index 1eef2b7e3..244f5d40d 100644 --- a/cli/models.py +++ b/cli/models.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class AnalystType(str, Enum): +class AnalystType(StrEnum): MARKET = "market" # Wire value stays "social" for saved-config and string-keyed-caller # back-compat; the user-facing label is "Sentiment Analyst". @@ -10,6 +10,6 @@ class AnalystType(str, Enum): FUNDAMENTALS = "fundamentals" -class AssetType(str, Enum): +class AssetType(StrEnum): STOCK = "stock" CRYPTO = "crypto" diff --git a/cli/prefs.py b/cli/prefs.py index 5cd3fbaf3..af2e8eb5c 100644 --- a/cli/prefs.py +++ b/cli/prefs.py @@ -22,6 +22,7 @@ from pathlib import Path from cli.models import AnalystType, AssetType from cli.prompts import _llm_provider_table, filter_analysts_for_asset_type +from tradingagents.dataflows.files import replace_file from tradingagents.llm_clients.model_catalog import get_model_options _PREFS_PATH = Path(os.path.expanduser("~")) / ".tradingagents" / "cli_prefs.json" @@ -52,9 +53,8 @@ def save_last_run(selections: dict) -> 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 + # A concurrent run reads one file or the other. + replace_file(_PREFS_PATH, lambda temp: Path(temp).write_text(json.dumps(kept, indent=2), encoding="utf-8")) except OSError: return diff --git a/cli/prompts.py b/cli/prompts.py index 1aae00f8e..549e63e49 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -1,7 +1,10 @@ +import datetime import os +import sys from pathlib import Path import questionary +import typer from dotenv import find_dotenv, set_key from cli.display import console @@ -60,6 +63,42 @@ def get_ticker() -> str: return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY" +def parse_ticker(value: str) -> str: + """A ticker given on the command line, canonical; empty or malformed is refused.""" + if not value.strip() or not is_valid_ticker_input(value): + raise ValueError(f"not a ticker symbol: {value!r} (e.g. {TICKER_INPUT_EXAMPLES})") + return normalize_ticker_symbol(value) + + +def parse_analysis_date(value: str) -> str: + """An analysis date as YYYY-MM-DD, today or earlier.""" + try: + day = datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date() + except ValueError: + raise ValueError(f"not a date: {value!r}; use YYYY-MM-DD") from None + if day > datetime.date.today(): + raise ValueError(f"{value} is in the future") + return day.isoformat() + + +def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]: + """Comma-separated analyst names, in the canonical order, checked against the asset.""" + # "sentiment" is the name users see; the analyst's key is "social". + names = [{"sentiment": "social"}.get(n, n) for n in (n.strip().lower() for n in value.split(",")) if n] + if not names: + raise ValueError("name at least one analyst") + known = {a.value: a for a in AnalystType} + unknown = [n for n in names if n not in known] + if unknown: + choices = ", ".join("sentiment" if name == "social" else name for name in known) + raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {choices}") + available = filter_analysts_for_asset_type(list(known.values()), asset_type) + unavailable = [n for n in names if known[n] not in available] + if unavailable: + raise ValueError(f"{', '.join(unavailable)} is not available for {asset_type.value}") + return [a for a in available if a.value in names] + + def normalize_ticker_symbol(ticker: str) -> str: """Resolve user input to its canonical Yahoo symbol (single source of truth). @@ -611,6 +650,10 @@ def ensure_api_key(provider: str) -> str | None: if existing: return existing + if not sys.stdin.isatty(): + console.print(f"[red]{env_var} is not set; there is no terminal to ask for it.[/red]") + raise typer.Exit(code=1) + console.print( f"\n[yellow]{env_var} is not set in your environment.[/yellow]" ) diff --git a/cli/run.py b/cli/run.py index 08896cf2e..b8a1792f0 100644 --- a/cli/run.py +++ b/cli/run.py @@ -1,7 +1,7 @@ """Running one analysis from the CLI: build the graph, stream it into the live view, save the report.""" -import datetime import os +import sys import time from functools import wraps from pathlib import Path @@ -21,16 +21,15 @@ from cli.display import ( update_display, update_research_team_status, ) -from cli.selections import get_user_selections +from cli.selections import depth_from_env, get_user_selections, unattended_gaps from cli.stats_handler import StatsCallbackHandler -from tradingagents.agents.rating import is_review +from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.graph.analyst_execution import ( build_analyst_execution_plan, ) from tradingagents.graph.trading_graph import TradingAgentsGraph -from tradingagents.reporting import write_report_tree def _run_directory(config: dict, ticker: str, trade_date: str) -> Path: @@ -66,17 +65,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). - 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 + rounds = (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"), + ("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")) + depth_was_asked = not depth_from_env() + for env_var, key in rounds: + if not os.environ.get(env_var): + config[key] = selections["research_depth"] + elif depth_was_asked: + # The depth question 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"] @@ -93,9 +94,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: return config -def run_analysis(checkpoint: bool | None = None, portfolio=None): - # First get all user selections - selections = get_user_selections() +def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): + flags = flags or {} + # With no terminal nothing can answer a prompt: name every question still + # open before any model is called, rather than stopping at the first one. + if not (sys.stdin and sys.stdin.isatty()): + gaps = unattended_gaps(flags) + if gaps: + console.print("[red]No terminal to answer the setup questions. Set:[/red]") + for gap in gaps: + console.print(f" {gap}") + raise typer.Exit(code=1) + + selections = get_user_selections(flags) config = _build_run_config(selections, checkpoint) @@ -189,9 +200,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) update_display(layout, stats_handler=stats_handler, start_time=start_time) - first_analyst = analyst_execution_plan.specs[0].agent_node - message_buffer.update_agent_status(first_analyst, "in_progress") - analyst_wall_time_tracker.mark_started(selected_analyst_keys[0]) + # The analysts start together. + for spec in analyst_execution_plan.specs: + message_buffer.update_agent_status(spec.agent_node, "in_progress") + analyst_wall_time_tracker.mark_started(spec.key) update_display(layout, stats_handler=stats_handler, start_time=start_time) spinner_text = ( @@ -199,7 +211,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) - # The same initial state propagate() builds: settled decision log, past + # The same initial state propagate() builds: settled memory log, past # context and resolved instrument identity. init_agent_state = graph.create_run_state( selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio @@ -223,8 +235,8 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): # try/finally tears the checkpointer down even if the stream raises. trace = [] try: - for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args): - for message in chunk.get("messages", []): + for messages, chunk in graph.stream_run(graph.checkpoint_input(init_agent_state), **args): + for message in messages: msg_id = getattr(message, "id", None) if msg_id is not None: if msg_id in message_buffer._processed_message_ids: @@ -242,6 +254,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): else: message_buffer.add_tool_call(tool_call.name, tool_call.args) + if chunk is None: # a step inside an analyst's graph: messages only + update_display(layout, stats_handler=stats_handler, start_time=start_time) + continue + update_analyst_statuses( message_buffer, chunk, @@ -253,7 +269,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): debate_state = chunk["investment_debate_state"] bull_hist = debate_state.get("bull_history", "").strip() bear_hist = debate_state.get("bear_history", "").strip() - judge = debate_state.get("judge_decision", "").strip() + judge = (chunk.get("investment_plan") or "").strip() # Only update status when there's actual content if bull_hist or bear_hist: @@ -288,7 +304,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): agg_hist = risk_state.get("aggressive_history", "").strip() con_hist = risk_state.get("conservative_history", "").strip() neu_hist = risk_state.get("neutral_history", "").strip() - judge = risk_state.get("judge_decision", "").strip() + judge = (chunk.get("final_trade_decision") or "").strip() if agg_hist: if message_buffer.agent_status.get("Aggressive Analyst") != "completed": @@ -358,7 +374,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): # 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", ""))): + if is_review(run_rating(final_state)): 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 " @@ -366,28 +382,32 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") - # Prompt to save report - save_choice = typer.prompt("Save report?", default="Y").strip().upper() - if save_choice in ("Y", "YES", ""): - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + _offer_reports(final_state, graph, selections["ticker"], + save=flags.get("save"), show=flags.get("show")) + + +def _offer_reports(final_state, graph, ticker, save=None, show=None): + """Save the report tree and show it; ``save``/``show`` answer the questions when given.""" + asked = save is None + if asked: + save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "") + if save: # Under results_dir, not the working directory: in Docker the working # directory is inside the container and the report goes with it, while # results_dir is the mounted volume the rest of the run already writes to. - default_path = (Path(config["results_dir"]) / "reports" - / f"{safe_ticker_component(selections['ticker'])}_{timestamp}") - save_path_str = typer.prompt( - "Save path (press Enter for default)", - default=str(default_path) - ).strip() - save_path = Path(save_path_str) + save_path = graph.default_report_path(ticker) + if asked: # someone at the prompt may pick another folder + save_path = Path(typer.prompt( + "Save path (press Enter for default)", default=str(save_path) + ).strip()) try: - report_file = write_report_tree(final_state, selections["ticker"], save_path) + report_file = graph.save_reports(final_state, ticker, save_path) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: console.print(f"[red]Error saving report: {e}[/red]") - # Prompt to display full report - display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() - if display_choice in ("Y", "YES", ""): + if show is None: + show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "") + if show: display_complete_report(final_state) diff --git a/cli/selections.py b/cli/selections.py index df7640481..a269158b2 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -25,6 +25,9 @@ from cli.prompts import ( detect_asset_type, ensure_api_key, get_ticker, + parse_analysis_date, + parse_analysts, + parse_ticker, prompt_openai_compatible_url, resolve_backend_url, select_analysts, @@ -36,15 +39,46 @@ from cli.prompts import ( from tradingagents.default_config import DEFAULT_CONFIG -def get_user_selections(): +def get_user_selections(flags=None): """Ask for the run's settings, offering the previous run's answers.""" - selections = _prompt_selections(load_last_run()) + selections = _prompt_selections(load_last_run(), flags or {}) save_last_run(selections) return selections -def _prompt_selections(prefs): - """Walk the selection steps. ``prefs`` prefills, the environment skips.""" +def depth_from_env() -> bool: + """Both round counts come from the environment, so the depth question is skipped.""" + return bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS") + and os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS")) + + +def unattended_gaps(flags) -> list[str]: + """The flags and environment variables a run with no terminal still needs.""" + env = os.environ.get + gaps = [f"--{name}" for name in ("ticker", "date", "analysts") if flags.get(name) is None] + gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None] + if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"): + gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE") + if not depth_from_env(): + gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS") + if not env("TRADINGAGENTS_LLM_PROVIDER"): + gaps.append("TRADINGAGENTS_LLM_PROVIDER") + if not (env("TRADINGAGENTS_QUICK_THINK_LLM") or env("TRADINGAGENTS_DEEP_THINK_LLM")): + gaps.append("TRADINGAGENTS_QUICK_THINK_LLM or TRADINGAGENTS_DEEP_THINK_LLM") + return gaps + + +def _from_flag(parse, value, *args): + """A flag's value through the same check its prompt applies; a bad one ends the run.""" + try: + return parse(value, *args) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + + +def _prompt_selections(prefs, flags): + """Walk the selection steps. ``prefs`` prefills; flags and the environment skip.""" with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f: welcome_ascii = f.read() @@ -93,14 +127,18 @@ def _prompt_selections(prefs): return prompt_fn() # Step 1: Ticker symbol - console.print( - create_question_box( - "Step 1: Ticker Symbol", - "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", - "SPY", + if flags.get("ticker") is not None: + selected_ticker = _from_flag(parse_ticker, flags["ticker"]) + console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}") + else: + console.print( + create_question_box( + "Step 1: Ticker Symbol", + "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", + "SPY", + ) ) - ) - selected_ticker = get_ticker() + selected_ticker = get_ticker() asset_type = detect_asset_type(selected_ticker) # Only announce when it's not the default stock path, to avoid printing # "stock" on every run. @@ -110,15 +148,19 @@ def _prompt_selections(prefs): ) # Step 2: Analysis date - default_date = datetime.datetime.now().strftime("%Y-%m-%d") - console.print( - create_question_box( - "Step 2: Analysis Date", - "Enter the analysis date (YYYY-MM-DD)", - default_date, + if flags.get("date") is not None: + analysis_date = _from_flag(parse_analysis_date, flags["date"]) + console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}") + else: + default_date = datetime.datetime.now().strftime("%Y-%m-%d") + console.print( + create_question_box( + "Step 2: Analysis Date", + "Enter the analysis date (YYYY-MM-DD)", + default_date, + ) ) - ) - analysis_date = get_analysis_date() + analysis_date = get_analysis_date() # Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE) if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"): @@ -136,13 +178,16 @@ def _prompt_selections(prefs): output_language = ask_output_language(prefs.get("output_language")) # Step 4: Select analysts - console.print( - create_question_box( - "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" - ) - ) prefs = sanitize(prefs, asset_type.value) - selected_analysts = select_analysts(asset_type, prefs.get("analysts")) + if flags.get("analysts") is not None: + selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type) + else: + console.print( + create_question_box( + "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" + ) + ) + selected_analysts = select_analysts(asset_type, prefs.get("analysts")) console.print( f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" ) @@ -151,10 +196,7 @@ def _prompt_selections(prefs): # Research depth maps to the debate + risk round counts; when both are # supplied through TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS we keep # the run non-interactive and honor the env values (#977). - depth_from_env = bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS")) and bool( - os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS") - ) - if depth_from_env: + if depth_from_env(): selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"] console.print( f"[green]✓ Research depth from environment:[/green] " @@ -303,13 +345,6 @@ def get_analysis_date(): "", default=datetime.datetime.now().strftime("%Y-%m-%d") ) try: - # Validate date format and ensure it's not in the future - analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") - if analysis_date.date() > datetime.datetime.now().date(): - console.print("[red]Error: Analysis date cannot be in the future[/red]") - continue - return date_str - except ValueError: - console.print( - "[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]" - ) + return parse_analysis_date(date_str) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") diff --git a/docker-compose.yml b/docker-compose.yml index 411c989c8..76cf0fe20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: env_file: - .env volumes: - - tradingagents_data:/home/appuser/.tradingagents + - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents tty: true stdin_open: true @@ -23,7 +23,7 @@ services: - TRADINGAGENTS_LLM_PROVIDER=ollama - OLLAMA_BASE_URL=http://ollama:11434/v1 volumes: - - tradingagents_data:/home/appuser/.tradingagents + - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents depends_on: - ollama tty: true diff --git a/main.py b/main.py index d3a13d678..105e24f0e 100644 --- a/main.py +++ b/main.py @@ -14,5 +14,3 @@ ta = TradingAgentsGraph(debug=True, config=config) # forward propagate _, decision = ta.propagate("NVDA", "2026-09-01") print(decision) - -# Memorize mistakes and reflect diff --git a/pyproject.toml b/pyproject.toml index f084d002e..47c4269b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,18 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents" -version = "0.5.1" +dynamic = ["version"] description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ - "langchain-core>=0.3.81", - "langchain-anthropic>=0.3.15", - "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", + "langchain-core>=1.6.5", + "langchain-anthropic>=1.7.4", + "langchain-google-genai>=4.4.0", + "langchain-openai>=1.6.6", + "langgraph>=1.2.12", + "langgraph-checkpoint-sqlite>=3.1.1", + "pandas>=3.0.6", "python-dotenv>=1.0.0", "pytz>=2025.2", "questionary>=2.1.0", @@ -24,24 +24,27 @@ dependencies = [ "typer>=0.21.0", "stockstats>=0.6.5", "typing-extensions>=4.14.0", - "yfinance>=1.4.1", + "yfinance>=1.7.0", ] [project.optional-dependencies] dev = [ - "ruff>=0.15", + "ruff>=0.16", "pytest>=8.0", "pytest-subtests>=0.13", ] # Amazon Bedrock support (AWS SigV4 auth + boto3). Optional so the core install # stays lean: pip install "tradingagents[bedrock]". bedrock = [ - "langchain-aws>=1.5.0", + "langchain-aws>=1.7.9", ] [project.scripts] tradingagents = "cli.main:app" +[tool.setuptools.dynamic] +version = {attr = "tradingagents.__version__"} + [tool.setuptools.packages.find] include = ["tradingagents*", "cli*"] @@ -50,10 +53,10 @@ cli = ["static/*"] [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-ra --strict-markers" +addopts = "-ra --strict-markers -m \"not integration\"" markers = [ "unit: fast isolated unit tests", - "integration: tests requiring external services", + "integration: tests requiring external services; run only when asked for, with -m integration", "smoke: quick sanity-check tests", ] filterwarnings = [ @@ -62,7 +65,7 @@ filterwarnings = [ [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" extend-exclude = ["results"] [tool.ruff.lint] diff --git a/tests/conftest.py b/tests/conftest.py index acbd85276..ffd70a3c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,9 +27,33 @@ def _blank_settings_overlay(): _blank_settings_overlay() +def _own_file_locations(): + """Keep the suite's results, cache and memory log in a directory of its own. + + Set before the package is imported, whose defaults put them in the user's + home: a test reading the user's cache would see what their runs fetched. + """ + import tempfile + + home = tempfile.mkdtemp(prefix="tradingagents-tests-") + os.environ["TRADINGAGENTS_RESULTS_DIR"] = os.path.join(home, "logs") + os.environ["TRADINGAGENTS_CACHE_DIR"] = os.path.join(home, "cache") + os.environ["TRADINGAGENTS_MEMORY_LOG_PATH"] = os.path.join(home, "memory", "trading_memory.md") + + +_own_file_locations() + + def pytest_configure(config): for marker in ("unit", "integration", "smoke"): config.addinivalue_line("markers", f"{marker}: {marker}-level tests") + # yfinance caches each symbol's time zone on disk, in the home directory + # by default; the suite keeps it in a directory of its own. + import tempfile + + import yfinance + + yfinance.set_tz_cache_location(tempfile.mkdtemp(prefix="tradingagents-tests-yf-")) @pytest.fixture(autouse=True) @@ -38,11 +62,32 @@ def _no_network(request, monkeypatch): if request.node.get_closest_marker("integration"): return - def refuse(self, address): - raise OSError(f"test tried to reach the network: {address}") + def refuse(*args, **kwargs): + raise OSError(f"test tried to reach the network: {args[1:] or kwargs}") monkeypatch.setattr(socket.socket, "connect", refuse) monkeypatch.setattr(socket.socket, "connect_ex", refuse) + monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: refuse(None, *a)) + # yfinance's HTTP client is libcurl, which never touches Python's sockets. + from curl_cffi import requests as curl_requests + + monkeypatch.setattr(curl_requests.Session, "request", refuse) + monkeypatch.setattr(curl_requests.AsyncSession, "request", refuse) + + +@pytest.fixture(autouse=True) +def _at_a_terminal(monkeypatch): + """Tests of the interactive steps run as if at a terminal; pytest's stdin is + not one. A test of an unattended run sets isatty to False itself.""" + import sys + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + +@pytest.fixture(autouse=True) +def _own_cli_prefs(tmp_path, monkeypatch): + """The CLI keeps the last run's selections in the user's home; tests keep theirs apart.""" + monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json") _API_KEY_ENV_VARS = ( diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index 0d15bf12b..02c73e933 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -77,32 +77,14 @@ _FUNDAMENTALS_JSON = json.dumps({ }) -@pytest.mark.unit -def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch): - # #1115: the payload arrives as a JSON *string*; the old dict-only guard let - # future-dated fiscal periods leak into historical runs. - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) - out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01") - assert isinstance(out, str) # callers still receive a str - parsed = json.loads(out) - assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"] - assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"] - - @pytest.mark.unit def test_fundamentals_no_curr_date_passes_through(monkeypatch): monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON -@pytest.mark.unit -def test_fundamentals_non_json_body_unchanged(monkeypatch): - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json") - assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json" - - # --------------------------------------------------------------------------- -# Date trim (see the rationale on the unguarded trim in alpha_vantage_common) +# Date trim (see the rationale on the unguarded trim in alpha_vantage.common) # --------------------------------------------------------------------------- _DAILY_CSV = ( diff --git a/tests/test_analyst_execution.py b/tests/test_analyst_execution.py index f668b3b16..213189c4b 100644 --- a/tests/test_analyst_execution.py +++ b/tests/test_analyst_execution.py @@ -11,8 +11,8 @@ class AnalystExecutionPlanTests(unittest.TestCase): self.assertEqual([spec.key for spec in plan.specs], ["news", "market"]) self.assertEqual(plan.specs[0].agent_node, "News Analyst") - self.assertEqual(plan.specs[0].tool_node, "tools_news") - self.assertEqual(plan.specs[0].clear_node, "Msg Clear News") + self.assertEqual(plan.specs[0].report_key, "news_report") + self.assertFalse(hasattr(plan.specs[0], "clear_node")) def test_rejects_unknown_analyst_keys(self): with self.assertRaises(ValueError): diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py index bdec18441..e973032ce 100644 --- a/tests/test_api_key_env.py +++ b/tests/test_api_key_env.py @@ -67,12 +67,10 @@ def test_case_insensitive_lookup(): @pytest.fixture -def prompts(monkeypatch): - """Import cli.prompts with a fresh environment so module-level state is consistent.""" - import importlib - +def prompts(): + """cli.prompts, which reads the environment when it is called.""" import cli.prompts as prompts_module - return importlib.reload(prompts_module) + return prompts_module def test_ensure_api_key_returns_existing(monkeypatch, prompts): diff --git a/tests/test_backtest.py b/tests/test_backtest.py index e86d40973..6911f3574 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -1,4 +1,4 @@ -"""Backtesting: many single-shot decisions, scored by the decision log. +"""Backtesting: many single-shot decisions, scored by the memory 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 @@ -11,7 +11,7 @@ from __future__ import annotations import pytest from tradingagents.backtest import iter_grid, run_backtest, summarize -from tradingagents.decision_log import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog DECISION = "Rating: Buy\n\nbuy it" @@ -76,7 +76,7 @@ def _config(tmp_path): @pytest.mark.unit -def test_the_live_decision_log_is_never_written(tmp_path): +def test_the_live_memory_log_is_never_written(tmp_path): config = _config(tmp_path) result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config) @@ -274,3 +274,33 @@ def test_a_result_whose_cells_all_failed_summarizes_as_empty(tmp_path): result = BacktestResult(run_id="r", log_path=tmp_path / "never-written.md") assert summarize(result).resolved == 0 + + +@pytest.mark.unit +def test_progress_is_reported_before_each_cell(tmp_path): + seen = [] + + run_backtest(["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path), + progress=lambda done, total, ticker, date: seen.append((done, total, ticker, date))) + + assert seen == [(1, 4, "NVDA", "2026-01-05"), (2, 4, "NVDA", "2026-01-12"), + (3, 4, "AAPL", "2026-01-05"), (4, 4, "AAPL", "2026-01-12")] + + +@pytest.mark.unit +def test_a_resumed_sweep_reports_only_the_cells_it_runs(tmp_path): + first = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path)) + seen = [] + + run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], _config(tmp_path), run_id=first.run_id, + progress=lambda done, total, ticker, date: seen.append((done, total, date))) + + assert seen == [(1, 1, "2026-01-12")] + + +@pytest.mark.unit +def test_a_ticker_or_date_given_twice_runs_and_settles_once(tmp_path): + run_backtest(["NVDA", "NVDA"], ["2026-01-05", "2026-01-05"], _config(tmp_path)) + graph = _FakeGraph.instances[-1] + assert graph.calls == [("NVDA", "2026-01-05")] + assert graph.settled == ["NVDA"] diff --git a/tests/test_cache_writes.py b/tests/test_cache_writes.py new file mode 100644 index 000000000..a9bf7d94e --- /dev/null +++ b/tests/test_cache_writes.py @@ -0,0 +1,128 @@ +"""Cache files are replaced whole: a reader never sees a half-written file, and +concurrent writers (tool calls run in parallel) never share a temp file.""" + +import os +from pathlib import Path + +import pandas as pd +import pytest + +from tradingagents.dataflows import files +from tradingagents.dataflows.vendors import sec_edgar +from tradingagents.dataflows.vendors.yahoo import ohlcv + + +def _recording_replace(monkeypatch): + moves = [] + real = os.replace + + def replace(src, dst): + moves.append((Path(src), Path(dst))) + real(src, dst) + + monkeypatch.setattr(os, "replace", replace) + return moves + + +@pytest.mark.unit +def test_sec_edgar_writers_never_share_a_temp_file(monkeypatch, tmp_path): + monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: {"url": url}) + moves = _recording_replace(monkeypatch) + + sec_edgar._cached_json("https://example/a", "facts.json") + (tmp_path / "sec_edgar" / "facts.json").unlink() + sec_edgar._cached_json("https://example/a", "facts.json") + + temps = [src for src, _ in moves] + assert len(set(temps)) == 2 + assert all(src.parent == dst.parent for src, dst in moves) + + +@pytest.mark.unit +def test_the_price_cache_is_replaced_whole(monkeypatch, tmp_path): + frame = pd.DataFrame({"Date": pd.bdate_range("2026-01-02", periods=3), "Open": 1.0, "High": 1.0, + "Low": 1.0, "Close": 1.0, "Volume": 1}) + monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: frame.set_index("Date")})()) + moves = _recording_replace(monkeypatch) + + ohlcv.load_ohlcv("AAPL", "2026-01-06") + + assert [dst.name for _, dst in moves] == ["AAPL-YFin-data.csv"] + assert moves[0][0].parent == tmp_path and moves[0][0] != moves[0][1] + assert not list(tmp_path.glob("*.tmp*")) + + +@pytest.mark.unit +def test_a_replaced_file_keeps_the_usual_permissions(tmp_path): + from tradingagents.dataflows.files import replace_file + + plain = tmp_path / "plain.txt" + plain.write_text("x") + replaced = tmp_path / "replaced.txt" + replace_file(replaced, lambda temp: Path(temp).write_text("x")) + + assert replaced.stat().st_mode & 0o777 == plain.stat().st_mode & 0o777 + + +@pytest.mark.unit +def test_a_file_held_open_elsewhere_keeps_its_old_content(monkeypatch, tmp_path): + """On Windows a file another reader has open cannot be replaced; the cache + write is skipped rather than failing the call that produced the data.""" + from tradingagents.dataflows import files + + target = tmp_path / "cache.csv" + target.write_text("old") + + def locked(src, dst): + raise PermissionError("in use") + + monkeypatch.setattr(files.os, "replace", locked) + files.replace_file(target, lambda temp: Path(temp).write_text("new")) + + assert target.read_text() == "old" + assert list(tmp_path.iterdir()) == [target] + + +def _fake_msvcrt(results): + """msvcrt whose locking() raises the next error in ``results``, or succeeds on None.""" + import types + + calls = [] + + def locking(fd, mode, nbytes): + calls.append(mode) + if mode == 2 and results: # LK_LOCK; unlocking always succeeds + error = results.pop(0) + if error is not None: + raise error + + return types.SimpleNamespace(LK_LOCK=2, LK_UNLCK=0, locking=locking), calls + + +@pytest.mark.unit +def test_on_windows_the_lock_is_waited_for_while_another_writer_holds_it(tmp_path, monkeypatch): + import errno + import sys + + fake, calls = _fake_msvcrt([OSError(errno.EDEADLOCK, "held"), None]) + monkeypatch.setitem(sys.modules, "msvcrt", fake) + + with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle): + pass + + assert calls == [2, 2, 0] + + +@pytest.mark.unit +def test_on_windows_a_lock_that_cannot_be_taken_is_reported_not_retried_forever(tmp_path, monkeypatch): + import errno + import sys + + fake, _ = _fake_msvcrt([OSError(errno.EINVAL, "bad handle")] * 3) + monkeypatch.setitem(sys.modules, "msvcrt", fake) + + with pytest.raises(OSError, match="bad handle"), open(tmp_path / "log.md.lock", "a+b") as handle, \ + files._hold_windows(handle): + pass diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 2ab28dc2d..1cd396eaa 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -25,6 +25,17 @@ class TestExactIdMatches: assert caps.supports_tool_choice is False assert caps.requires_reasoning_content_roundtrip is True + def test_deepseek_flash_alias_rejects_tool_choice(self): + """``deepseek-flash`` is what the model picker offers for V4.1 Flash. + + It was falling through to _DEFAULT (tool_choice on), so every + structured-output call 400'd with "Thinking mode does not support + this tool_choice" and burned a retry as free text. + """ + caps = get_capabilities("deepseek-flash") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + def test_deepseek_v4_pro_rejects_tool_choice(self): caps = get_capabilities("deepseek-v4-pro") assert caps.supports_tool_choice is False @@ -48,6 +59,10 @@ class TestPatternMatches: caps = get_capabilities("deepseek-reasoner-pro") assert caps.supports_tool_choice is False + def test_future_flash_variant_inherits_thinking_quirks(self): + caps = get_capabilities("deepseek-flash-lite") + assert caps.supports_tool_choice is False + def test_minimax_m3_inherits_thinking_quirks(self): caps = get_capabilities("MiniMax-M3") assert caps.supports_tool_choice is False @@ -128,6 +143,9 @@ class TestOpenRouterDeepSeekNamespace: def test_prefixed_reasoner_suppresses_tool_choice(self): assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False + def test_prefixed_flash_alias_suppresses_tool_choice(self): + assert get_capabilities("deepseek/deepseek-flash").supports_tool_choice is False + def test_prefixed_chat_selects_deepseek_chat_not_default(self): # Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False # is what distinguishes them (both keep tool_choice). diff --git a/tests/test_checkpoint_resume.py b/tests/test_checkpoint_resume.py index 6d134f6bb..ae21500f0 100644 --- a/tests/test_checkpoint_resume.py +++ b/tests/test_checkpoint_resume.py @@ -190,6 +190,34 @@ class TestCheckpointSignature(unittest.TestCase): # sig1's checkpoint remains untouched. self.assertIsNotNone(checkpoint_step(self.tmpdir, self.ticker, self.date, sig1)) + def test_a_resume_under_other_settings_starts_fresh(self): + """The report names one provider, model set, language and vendor chain; + a resume must not carry reports another of them produced.""" + from tradingagents.graph.trading_graph import TradingAgentsGraph + + g = object.__new__(TradingAgentsGraph) + g.selected_analysts = ("market", "news") + base_config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "llm_provider": "openai", + "quick_think_llm": "q", "deep_think_llm": "d", "output_language": "English", + "data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {}} + g.config = dict(base_config) + base = g._run_signature("stock") + for key, value in (("llm_provider", "google"), ("quick_think_llm", "q2"), ("deep_think_llm", "d2"), + ("output_language", "Deutsch"), + ("data_vendors", {"core_stock_apis": "alpha_vantage"}), + ("tool_vendors", {"get_news": "alpha_vantage"}), + ("backend_url", "http://other-endpoint/v1"), ("max_tool_rounds", 30), + ("temperature", 0.2), ("openai_reasoning_effort", "high")): + g.config = {**base_config, key: value} + self.assertNotEqual(base, g._run_signature("stock"), key) + # Where a run keeps its files, and how it retries, do not change what it writes. + for key, value in (("results_dir", "/elsewhere"), ("data_cache_dir", "/cache"), + ("memory_log_path", "/log.md"), ("checkpoint_enabled", True), ("llm_max_retries", 9)): + g.config = {**base_config, key: value} + self.assertEqual(base, g._run_signature("stock"), key) + g.config = dict(base_config) + self.assertEqual(base, g._run_signature("stock")) + def test_run_signature_captures_graph_shape(self): from tradingagents.graph.trading_graph import TradingAgentsGraph @@ -210,6 +238,10 @@ class TestCheckpointSignature(unittest.TestCase): # Stable for identical inputs. g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1} self.assertEqual(base, g._run_signature("stock")) + # A checkpoint saved by the sequential layout is not resumed on the + # parallel one: its pending node no longer exists, and the join would + # never fire. + self.assertIn("analysts=parallel", base) if __name__ == "__main__": diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6ad20dac0..a0bd36a9a 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -25,7 +25,8 @@ 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})] + no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None} + assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})] @pytest.mark.unit @@ -134,3 +135,48 @@ def test_backtest_reports_a_setup_failure_in_one_line(runner, monkeypatch): assert result.exit_code == 1 assert "API key" in result.output assert "Traceback" not in result.output + + +@pytest.mark.unit +def test_backtest_shows_each_cell_and_how_to_continue(runner, monkeypatch, tmp_path): + def sweep(tickers, dates, config, progress=None, **kw): + for i, date in enumerate(dates, 1): + progress(i, len(dates), tickers[0], date) + return _Result(tmp_path) + + monkeypatch.setattr(m, "run_backtest", sweep) + monkeypatch.setattr(m, "summarize", lambda log: _Summary()) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-08"]) + + assert "[1/2] NVDA 2026-06-01" in result.output and "[2/2] NVDA 2026-06-08" in result.output + assert f"--run-id {_Result(tmp_path).run_id}" in result.output + + +@pytest.mark.unit +@pytest.mark.parametrize("args, analysts", [ + pytest.param(["--analysts", "sentiment,market"], ["market", "social"], id="named_as_in_analyze"), + pytest.param(["--asset-type", "crypto"], ["market", "social", "news"], id="crypto_default"), +]) +def test_backtest_takes_its_analysts_as_an_analysis_does(runner, monkeypatch, tmp_path, args, analysts): + 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-01", *args]) + + assert result.exit_code == 0, result.output + assert swept[0]["selected_analysts"] == analysts + + +@pytest.mark.unit +@pytest.mark.parametrize("args", [ + pytest.param(["--analysts", "fundamentals", "--asset-type", "crypto"], id="unavailable_for_crypto"), + pytest.param(["--asset-type", "bond"], id="unknown_asset_type"), +]) +def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args): + monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: pytest.fail("the sweep started")) + + result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args]) + + assert result.exit_code == 1 diff --git a/tests/test_cli_display.py b/tests/test_cli_display.py index 52d419e07..702a31c8f 100644 --- a/tests/test_cli_display.py +++ b/tests/test_cli_display.py @@ -46,11 +46,11 @@ def _state(ticker, final="评级: 买入"): "company_of_interest": ticker, "trade_date": "2026-09-01", "market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻", "fundamentals_report": "基本面", "investment_plan": "计划", - "trader_investment_plan": "交易计划", "final_trade_decision": final, + "trader_investment_plan": "交易计划", "final_trade_decision": final, "final_rating": "REVIEW", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", - "current_response": "", "judge_decision": "", "count": 0}, + "current_response": "", "count": 0}, "risk_debate_state": {"aggressive_history": "", "conservative_history": "", - "neutral_history": "", "history": "", "judge_decision": "", + "neutral_history": "", "history": "", "latest_speaker": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", "count": 0}, @@ -61,7 +61,10 @@ def _bare_graph(tmp_path): from tradingagents.graph.trading_graph import TradingAgentsGraph graph = object.__new__(TradingAgentsGraph) - graph.config = {"results_dir": str(tmp_path)} + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) return graph @@ -76,6 +79,20 @@ def test_the_state_log_keeps_non_ascii_readable(tmp_path): assert json.loads(written) # still valid JSON +@pytest.mark.unit +def test_the_state_log_names_each_field_as_the_state_does(tmp_path): + """One name per field: the Trader's plan under its state key, and no second + copy of the managers' decisions under the debate states.""" + _bare_graph(tmp_path)._log_state("2026-09-01", _state("NVDA")) + + logged = json.loads(next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8")) + assert logged["trader_investment_plan"] == "交易计划" + assert logged["investment_plan"] == "计划" + assert "trader_investment_decision" not in logged + assert "judge_decision" not in json.dumps(logged) + assert logged["run_settings"]["deep_think_llm"] == "d" + + @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 @@ -112,23 +129,28 @@ class AnalystWallTimeTrackerTests(unittest.TestCase): "Analyst wall time: News 4.00s | Market 2.25s", ) - def test_syncs_wall_time_from_sequential_chunks(self): + def test_analysts_run_together_and_finish_on_their_own_reports(self): plan = build_analyst_execution_plan(["market", "news"]) tracker = AnalystWallTimeTracker(plan) sync_analyst_tracker_from_chunk(tracker, {}, now=10.0) self.assertEqual(tracker.format_summary(), "Analyst wall time: pending") - sync_analyst_tracker_from_chunk( - tracker, - {"market_report": "done"}, - now=13.0, - ) - self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s") + sync_analyst_tracker_from_chunk(tracker, {"news_report": "done"}, now=13.0) + self.assertEqual(tracker.format_summary(), "Analyst wall time: News 3.00s") - sync_analyst_tracker_from_chunk( - tracker, - {"market_report": "done", "news_report": "done"}, - now=18.0, - ) - self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s | News 5.00s") + sync_analyst_tracker_from_chunk(tracker, {"news_report": "done", "market_report": "done"}, now=18.0) + self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 8.00s | News 3.00s") + + +@pytest.mark.unit +def test_every_selected_analyst_is_in_progress_until_its_report_lands(): + from cli.display import MessageBuffer, update_analyst_statuses + + buffer = MessageBuffer() + buffer.init_for_analysis(["market", "news", "fundamentals"]) + update_analyst_statuses(buffer, {"news_report": "done"}) + + assert buffer.agent_status["Market Analyst"] == "in_progress" + assert buffer.agent_status["Fundamentals Analyst"] == "in_progress" + assert buffer.agent_status["News Analyst"] == "completed" diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py new file mode 100644 index 000000000..3ba31b20f --- /dev/null +++ b/tests/test_cli_headless.py @@ -0,0 +1,187 @@ +"""Unattended CLI runs: flags answer the per-run questions, and a run with no +terminal stops up front, naming what to set, instead of waiting on a prompt.""" + +import datetime + +import pytest +import typer + +from cli import prompts, run, selections +from cli.models import AnalystType, AssetType + +UNATTENDED_ENV = { + "TRADINGAGENTS_OUTPUT_LANGUAGE": "English", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "1", + "TRADINGAGENTS_LLM_PROVIDER": "openai", + "TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna", +} +FLAGS = {"ticker": "NVDA", "date": "2026-09-23", "analysts": "market,news", "save": True, "show": False} + + +@pytest.mark.unit +class TestFlagValues: + def test_a_ticker_is_normalised_and_an_empty_one_refused(self): + assert prompts.parse_ticker("0700.hk") == "0700.HK" + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker(" ") + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker("NV DA") + + def test_a_date_must_be_a_past_or_present_day(self): + assert prompts.parse_analysis_date("2026-09-23") == "2026-09-23" + tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).isoformat() + with pytest.raises(ValueError, match="future"): + prompts.parse_analysis_date(tomorrow) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + prompts.parse_analysis_date("23/09/2026") + + def test_analysts_are_named_and_checked_against_the_asset(self): + assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS] + with pytest.raises(ValueError, match="market, sentiment, news, fundamentals"): + prompts.parse_analysts("market,macro", AssetType.STOCK) + with pytest.raises(ValueError, match="crypto"): + prompts.parse_analysts("fundamentals", AssetType.CRYPTO) + with pytest.raises(ValueError, match="at least one"): + prompts.parse_analysts(" , ", AssetType.STOCK) + + +@pytest.mark.unit +def test_flags_skip_exactly_their_own_steps(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + + def no_prompt(*a, **k): + raise AssertionError("prompted although a flag answered the step") + + for step in ("get_ticker", "get_analysis_date", "select_analysts"): + monkeypatch.setattr(selections, step, no_prompt) + monkeypatch.setattr(selections, "fetch_announcements", lambda: None) + monkeypatch.setattr(selections, "display_announcements", lambda *a: None) + + chosen = selections._prompt_selections({}, FLAGS) + + assert chosen["ticker"] == "NVDA" and chosen["analysis_date"] == "2026-09-23" + assert chosen["analysts"] == [AnalystType.MARKET, AnalystType.NEWS] + + +@pytest.mark.unit +def test_without_a_terminal_every_unanswered_question_is_named_before_the_run(monkeypatch, capsys): + for name in UNATTENDED_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(run.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + run.run_analysis(flags={"ticker": "NVDA"}) + + out = capsys.readouterr().out + for needed in ("--date", "--analysts", "--save", "--show", "TRADINGAGENTS_LLM_PROVIDER"): + assert needed in out + assert "--ticker" not in out + + +@pytest.mark.unit +def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + assert selections.unattended_gaps(FLAGS) == [] + + +@pytest.mark.unit +def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path): + def no_prompt(*a, **k): + raise AssertionError("prompted although --save/--show answered") + + monkeypatch.setattr(run.typer, "prompt", no_prompt) + shown = [] + monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state)) + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.run_settings = lambda: {} + state = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"} + + run._offer_reports(state, graph, "NVDA", save=True, show=False) + + assert list(tmp_path.glob("reports/NVDA_*/complete_report.md")) + assert shown == [] + + + +@pytest.mark.unit +def test_an_announcement_does_not_wait_for_enter_without_a_terminal(monkeypatch): + from cli import announcements + from cli.display import console + + def no_wait(*a): + raise AssertionError("waited for Enter with no terminal") + + monkeypatch.setattr(announcements.getpass, "getpass", no_wait) + monkeypatch.setattr(announcements.sys.stdin, "isatty", lambda: False) + announcements.display_announcements(console, {"content": "Maintenance tonight", "require_attention": True}) + + +@pytest.mark.unit +def test_a_missing_key_without_a_terminal_names_the_variable(monkeypatch, capsys): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(prompts.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + prompts.ensure_api_key("openai") + assert "OPENAI_API_KEY" in capsys.readouterr().out + + +@pytest.mark.unit +def test_the_command_passes_its_flags_to_the_run(monkeypatch): + from typer.testing import CliRunner + + from cli import main as cli_main + + seen = {} + monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw)) + + result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--date", "2026-09-23", + "--analysts", "market,news", "--save", "--no-show"]) + + assert result.exit_code == 0, result.output + assert seen["flags"] == FLAGS + + +@pytest.mark.unit +def test_sentiment_names_the_sentiment_analyst(): + assert prompts.parse_analysts("sentiment,market", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.SOCIAL] + + +@pytest.mark.unit +def test_an_empty_flag_is_checked_like_its_step_not_reported_missing(monkeypatch): + assert "--ticker" not in selections.unattended_gaps(dict(FLAGS, ticker="")) + + +@pytest.mark.unit +def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys): + monkeypatch.setattr(run.sys, "stdin", None) + with pytest.raises(typer.Exit): + run.run_analysis(flags={}) + assert "--ticker" in capsys.readouterr().out + + +@pytest.mark.unit +def test_rounds_from_the_environment_do_not_claim_a_depth_was_chosen(monkeypatch, capsys): + """With both round counts set, the depth question never appeared.""" + monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1") + monkeypatch.setenv("TRADINGAGENTS_MAX_RISK_ROUNDS", "1") + selections = {"research_depth": 3, "quick_think_llm": "q", "deep_think_llm": "d", "backend_url": None, + "llm_provider": "openai", "google_thinking_level": None, "openai_reasoning_effort": None, + "anthropic_effort": None, "output_language": "English"} + + run._build_run_config(selections, None) + + assert "the research depth you chose" not in capsys.readouterr().out + + +@pytest.mark.unit +def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them(): + with pytest.raises(ValueError) as caught: + prompts.parse_analysts("macro", AssetType.STOCK) + assert "sentiment" in str(caught.value) and "social" not in str(caught.value) diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_memory_log.py similarity index 76% rename from tests/test_cli_decision_log.py rename to tests/test_cli_memory_log.py index be695b095..4171492ec 100644 --- a/tests/test_cli_decision_log.py +++ b/tests/test_cli_memory_log.py @@ -1,4 +1,4 @@ -"""The CLI must use the decision log the same way propagate() does. +"""The CLI must use the memory 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 @@ -12,8 +12,8 @@ from __future__ import annotations import pytest import cli.run as cli_run -from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog def _bare_graph(tmp_path): @@ -21,6 +21,7 @@ def _bare_graph(tmp_path): graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")} graph.memory_log = TradingMemoryLog(graph.config) + graph._log_state = lambda *a: None # these tests are about the memory log return graph @@ -76,10 +77,6 @@ class _FakeGraph: self.calls.append(("create_run_state", ticker, trade_date)) return {"messages": [], "company_of_interest": ticker} - def process_signal(self, text): - from tradingagents.agents.rating import parse_rating - return parse_rating(text) - def record_decision(self, ticker, trade_date, final_state): self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision"))) @@ -99,9 +96,9 @@ class _FakeGraph: 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."} + def stream_run(self, graph_input, **kwargs): + yield [], {"messages": [], "market_report": "M"} + yield [], {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"} class _NullLive: @@ -151,7 +148,7 @@ def _run_cli(monkeypatch, tmp_path, fake): monkeypatch.setattr(cli_run, "create_layout", lambda: None) monkeypatch.setattr(cli_run, "update_display", lambda *a, **k: None) monkeypatch.setattr(cli_run, "Live", _NullLive) - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", }) @@ -164,7 +161,7 @@ def _run_cli(monkeypatch, tmp_path, fake): @pytest.mark.unit -def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch): +def test_cli_run_uses_the_memory_log_like_propagate(tmp_path, monkeypatch): fake = _FakeGraph() _run_cli(monkeypatch, tmp_path, fake) @@ -191,3 +188,26 @@ def test_a_run_without_checkpointing_says_nothing_about_resuming(tmp_path, monke buffer = _run_cli(monkeypatch, tmp_path, _FakeGraph()) assert not any("resum" in text.lower() or "fresh" in text.lower() for _, _, text in buffer.messages) + + +@pytest.mark.unit +def test_recording_a_run_writes_its_state_log(tmp_path): + """The CLI records a run through record_decision, so the state log is written there.""" + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d", + "quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + "output_language": "English", "data_vendors": {}, "tool_vendors": {}} + graph.selected_analysts = ("market",) + graph.memory_log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) + state = {"company_of_interest": "NVDA", "trade_date": "2026-09-23", "market_report": "M", + "sentiment_report": "", "news_report": "", "fundamentals_report": "", + "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", + "current_response": ""}, + "trader_investment_plan": "T", "investment_plan": "P", + "risk_debate_state": {"aggressive_history": "", "conservative_history": "", + "neutral_history": "", "history": ""}, + "final_trade_decision": "**Rating**: Hold", "final_rating": "Hold"} + + graph.record_decision("NVDA", "2026-09-23", state) + + assert list(tmp_path.glob("NVDA/TradingAgentsStrategy_logs/full_states_log_2026-09-23.json")) diff --git a/tests/test_cli_prefs.py b/tests/test_cli_prefs.py index a833c52e1..1dbb8a149 100644 --- a/tests/test_cli_prefs.py +++ b/tests/test_cli_prefs.py @@ -27,12 +27,6 @@ SAVED = { } -@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) @@ -45,18 +39,18 @@ def test_missing_file_is_not_an_error(): @pytest.mark.unit -def test_a_corrupt_file_degrades_to_no_memory(_home): - (_home / "cli_prefs.json").write_text("{not json") +def test_a_corrupt_file_degrades_to_no_memory(tmp_path): + (tmp_path / "cli_prefs.json").write_text("{not json") assert load_last_run() == {} @pytest.mark.unit -def test_a_half_written_file_cannot_be_observed(_home): +def test_a_half_written_file_cannot_be_observed(tmp_path): """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*")) == [] + assert list(tmp_path.glob("*.tmp*")) == [] # --- validation against the current choices --------------------------------- diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py index fd1ef37e5..03b691e92 100644 --- a/tests/test_dataflows_config.py +++ b/tests/test_dataflows_config.py @@ -49,7 +49,7 @@ class DataflowsConfigIsolationTests(unittest.TestCase): fresh = get_config() self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage") self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance") - self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance") + self.assertEqual(fresh["data_vendors"]["fundamental_data"], "sec_edgar,yfinance") self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance") def test_nested_dict_updates_merge_one_level_deep(self): @@ -95,7 +95,7 @@ def test_a_run_reads_its_own_graphs_vendors_not_the_last_graph_built(): set_config(first) # graph A is built second = _graph(copy.deepcopy(default_config.DEFAULT_CONFIG)) - assert _vendors_seen_by_a_run(second) == ["yfinance"] + assert _vendors_seen_by_a_run(second) == ["sec_edgar,yfinance"] @pytest.mark.unit @@ -148,7 +148,7 @@ def test_settling_reads_the_graphs_own_config(monkeypatch): graph = _graph(config) graph.memory_log = graph.reflector = None # the settlement below is a stand-in seen = [] - from tradingagents.graph import settlement + from tradingagents.memory import settlement monkeypatch.setattr(settlement, "settle_pending", lambda *a: seen.append(get_vendor("core_stock_apis", "get_stock_data"))) diff --git a/tests/test_date_boundaries.py b/tests/test_date_boundaries.py index 80d0f08e1..9d5300882 100644 --- a/tests/test_date_boundaries.py +++ b/tests/test_date_boundaries.py @@ -4,6 +4,8 @@ requested end_date (and the current day) is actually included. Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date row omitted). """ +from types import SimpleNamespace + import pandas as pd import pytest @@ -44,7 +46,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path): set_config({"data_cache_dir": str(tmp_path)}) captured = {} - def fake_download(symbol, start, end, **kwargs): + def fake_history(start, end, **kwargs): captured["end"] = end idx = pd.to_datetime([pd.Timestamp.today().normalize()]) return pd.DataFrame( @@ -53,7 +55,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path): index=idx, ) - monkeypatch.setattr(ohlcv.yf, "download", fake_download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=fake_history)) today = pd.Timestamp.today().strftime("%Y-%m-%d") ohlcv.load_ohlcv("AAPL", today) diff --git a/tests/test_env_overrides.py b/tests/test_env_overrides.py index 8dde7b492..3680f7ad6 100644 --- a/tests/test_env_overrides.py +++ b/tests/test_env_overrides.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib import os import pytest @@ -10,27 +9,27 @@ import pytest import tradingagents.default_config as default_config_module -def _reload_with_env(monkeypatch, **overrides): - """Set/clear env vars then reload default_config to re-evaluate DEFAULT_CONFIG.""" +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() def test_no_env_uses_built_in_defaults(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" - assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-6-sol" - assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-6-luna" - assert dc.DEFAULT_CONFIG["backend_url"] is None - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 - assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False + config = _config_with_env(monkeypatch) + assert config["llm_provider"] == "openai" + assert config["deep_think_llm"] == "gpt-6-sol" + assert config["quick_think_llm"] == "gpt-6-luna" + assert config["backend_url"] is None + assert config["max_debate_rounds"] == 1 + assert config["checkpoint_enabled"] is False def test_string_overrides(monkeypatch): - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_LLM_PROVIDER="google", TRADINGAGENTS_DEEP_THINK_LLM="gemini-3-pro-preview", @@ -38,23 +37,23 @@ def test_string_overrides(monkeypatch): TRADINGAGENTS_LLM_BACKEND_URL="https://example.invalid/v1", TRADINGAGENTS_OUTPUT_LANGUAGE="Chinese", ) - assert dc.DEFAULT_CONFIG["llm_provider"] == "google" - assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gemini-3-pro-preview" - assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gemini-3-flash-preview" - assert dc.DEFAULT_CONFIG["backend_url"] == "https://example.invalid/v1" - assert dc.DEFAULT_CONFIG["output_language"] == "Chinese" + assert config["llm_provider"] == "google" + assert config["deep_think_llm"] == "gemini-3-pro-preview" + assert config["quick_think_llm"] == "gemini-3-flash-preview" + assert config["backend_url"] == "https://example.invalid/v1" + assert config["output_language"] == "Chinese" def test_int_coercion(monkeypatch): - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_MAX_DEBATE_ROUNDS="3", TRADINGAGENTS_MAX_RISK_ROUNDS="2", ) - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 3 - assert isinstance(dc.DEFAULT_CONFIG["max_debate_rounds"], int) - assert dc.DEFAULT_CONFIG["max_risk_discuss_rounds"] == 2 - assert isinstance(dc.DEFAULT_CONFIG["max_risk_discuss_rounds"], int) + assert config["max_debate_rounds"] == 3 + assert isinstance(config["max_debate_rounds"], int) + assert config["max_risk_discuss_rounds"] == 2 + assert isinstance(config["max_risk_discuss_rounds"], int) @pytest.mark.parametrize( @@ -65,65 +64,62 @@ def test_int_coercion(monkeypatch): ], ) def test_bool_coercion(monkeypatch, raw, expected): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw) - assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is expected + config = _config_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw) + assert config["checkpoint_enabled"] is expected def test_reasoning_thinking_overrides(monkeypatch): """The provider reasoning/thinking knobs are env-configurable (non-interactive runs).""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_OPENAI_REASONING_EFFORT="high", TRADINGAGENTS_GOOGLE_THINKING_LEVEL="minimal", TRADINGAGENTS_ANTHROPIC_EFFORT="low", ) - assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] == "high" - assert dc.DEFAULT_CONFIG["google_thinking_level"] == "minimal" - assert dc.DEFAULT_CONFIG["anthropic_effort"] == "low" + assert config["openai_reasoning_effort"] == "high" + assert config["google_thinking_level"] == "minimal" + assert config["anthropic_effort"] == "low" def test_reasoning_effort_defaults_to_none(monkeypatch): """Unset reasoning/thinking knobs stay None so each provider uses its own default.""" - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] is None - assert dc.DEFAULT_CONFIG["google_thinking_level"] is None - assert dc.DEFAULT_CONFIG["anthropic_effort"] is None + config = _config_with_env(monkeypatch) + assert config["openai_reasoning_effort"] is None + assert config["google_thinking_level"] is None + assert config["anthropic_effort"] is None def test_empty_env_value_is_passthrough(monkeypatch): """Empty TRADINGAGENTS_* values must not clobber the built-in default.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_LLM_PROVIDER="", TRADINGAGENTS_MAX_DEBATE_ROUNDS="", ) - assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" - assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 + assert config["llm_provider"] == "openai" + assert config["max_debate_rounds"] == 1 def test_empty_path_value_keeps_the_default_path(monkeypatch): """.env.example lists the path variables blank; uncommenting one made the path empty, and the graph failed creating its directories.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_RESULTS_DIR="", TRADINGAGENTS_CACHE_DIR="", TRADINGAGENTS_MEMORY_LOG_PATH="", ) - home = dc._TRADINGAGENTS_HOME - assert dc.DEFAULT_CONFIG["results_dir"] == os.path.join(home, "logs") - assert dc.DEFAULT_CONFIG["data_cache_dir"] == os.path.join(home, "cache") - assert dc.DEFAULT_CONFIG["memory_log_path"] == os.path.join(home, "memory", "trading_memory.md") + home = default_config_module._TRADINGAGENTS_HOME + assert config["results_dir"] == os.path.join(home, "logs") + assert config["data_cache_dir"] == os.path.join(home, "cache") + assert config["memory_log_path"] == os.path.join(home, "memory", "trading_memory.md") def test_invalid_int_raises(monkeypatch): """Garbage int values should surface a ValueError at import, not silently misconfigure.""" monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "not-a-number") with pytest.raises(ValueError, match="TRADINGAGENTS_MAX_DEBATE_ROUNDS"): - importlib.reload(default_config_module) - # Restore module state for subsequent tests in this process - monkeypatch.delenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", raising=False) - importlib.reload(default_config_module) + default_config_module.build_default_config() @pytest.mark.parametrize("bad", ["treu", "flase", "maybe", "2", "enabled"]) @@ -131,15 +127,13 @@ def test_invalid_bool_raises(monkeypatch, bad): """A misspelled boolean must fail loudly (like ints) instead of silently False.""" monkeypatch.setenv("TRADINGAGENTS_CHECKPOINT_ENABLED", bad) with pytest.raises(ValueError, match="TRADINGAGENTS_CHECKPOINT_ENABLED"): - importlib.reload(default_config_module) - monkeypatch.delenv("TRADINGAGENTS_CHECKPOINT_ENABLED", raising=False) - importlib.reload(default_config_module) + default_config_module.build_default_config() def test_unknown_env_var_is_ignored(monkeypatch): """Env vars outside _ENV_OVERRIDES must not bleed into DEFAULT_CONFIG.""" - dc = _reload_with_env( + config = _config_with_env( monkeypatch, TRADINGAGENTS_NONEXISTENT_KEY="oops", ) - assert "nonexistent_key" not in dc.DEFAULT_CONFIG + assert "nonexistent_key" not in config diff --git a/tests/test_fred.py b/tests/test_fred.py index 6bcb6bbfc..b27ba680d 100644 --- a/tests/test_fred.py +++ b/tests/test_fred.py @@ -98,8 +98,10 @@ class FredFormattingTests(unittest.TestCase): self.assertIn("Units: %", out) self.assertIn("Frequency: Monthly (SA)", out) self.assertIn("**Latest:** 4.4 (2025-09-01)", out) - # change over the window: 4.4 - 4.1 = +0.30 - self.assertIn("+0.30", out) + # The change names the observations it spans, not the lookback window, + # so a 3-month move on a monthly series cannot read as year on year. + self.assertIn("**Change from 2025-06-01 to 2025-09-01:** +0.30 (+7.32%), from 4.1", out) + self.assertNotIn("Change over window", out) self.assertIn("| 2025-06-01 | 4.1 |", out) def test_missing_value_is_skipped(self): @@ -133,13 +135,13 @@ class FredFormattingTests(unittest.TestCase): with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)): out = fred.get_macro_data("unemployment", "2025-12-31", 365) self.assertIn(f"most recent {fred.MAX_ROWS}", out) - # change-over-window must reference the true first (0) and last value - self.assertIn("from 0 ", out) + # the change must reference the true first (0) and last value + self.assertIn("+49.00, from 0\n", out) body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")] self.assertEqual(len(body_rows), fred.MAX_ROWS) def test_window_is_lookahead_safe(self): - # observation_end must equal curr_date so a past date never pulls future data. + # observation_end must equal as_of_date so a past date never pulls future data. captured = {} def _capture(path, params): @@ -154,9 +156,9 @@ class FredFormattingTests(unittest.TestCase): def test_requests_pin_the_data_vintage(self): # #1275: both the metadata and observations requests must pin the vintage - # to curr_date (clamped to FRED's today), or FRED serves the latest + # to as_of_date (clamped to FRED's today), or FRED serves the latest # revision and revision-prone series leak future information. A past - # curr_date sits below FRED's today, so it pins through unchanged. + # as_of_date sits below FRED's today, so it pins through unchanged. captured = {} def _capture(path, params): @@ -172,11 +174,11 @@ class FredFormattingTests(unittest.TestCase): self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path) def test_future_curr_date_clamps_vintage_to_fred_today(self): - # #1275 regression: on a live run curr_date is the caller's LOCAL date, + # #1275 regression: on a live run as_of_date is the caller's LOCAL date, # which can be a day ahead of FRED's US-Central clock. Pinning the vintage # to that future date 400s, and the routing layer then drops macro data # silently. The pin must clamp to FRED's today; the observation window - # (future bars can't exist yet) stays at curr_date. + # (future bars can't exist yet) stays at as_of_date. captured = {} def _capture(path, params): @@ -190,7 +192,7 @@ class FredFormattingTests(unittest.TestCase): for path in ("series", "series/observations"): self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path) self.assertEqual(captured[path]["realtime_end"], "2026-08-31", path) - # the observation window still tracks curr_date, not the clamped vintage + # the observation window still tracks as_of_date, not the clamped vintage self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01") diff --git a/tests/test_fundamentals_lookahead.py b/tests/test_fundamentals_lookahead.py index d604c330e..6852dc3d1 100644 --- a/tests/test_fundamentals_lookahead.py +++ b/tests/test_fundamentals_lookahead.py @@ -10,7 +10,7 @@ FRED (#1275), social (#1220) and memory (#1251) leaks. Both vendors withhold on one shared rule (``date_window.withhold_live_profile``) so switching ``fundamental_data`` between them cannot reintroduce the leak. The -statement tools stay point-in-time by filtering on ``curr_date``, and a live run +statement tools stay point-in-time by filtering on ``as_of_date``, and a live run is unchanged. All API access is mocked. """ from __future__ import annotations @@ -45,19 +45,19 @@ _LEAKY = ("3500000000000", "34.2", "260.1", "391000000000", "Apple Inc.", "Technology", "Consumer Electronics") -def _yf(curr_date, info=_INFO, today=_TODAY): +def _yf(as_of_date, info=_INFO, today=_TODAY): with mock.patch.object(date_window, "get_current_date", return_value=today), \ mock.patch.object(yahoo_fundamentals, "yf_retry", lambda fn: info), \ mock.patch.object(yahoo_market.yf, "Ticker"): - return yahoo_fundamentals.get_fundamentals("AAPL", curr_date) + return yahoo_fundamentals.get_fundamentals("AAPL", as_of_date) -def _av(curr_date, today=_TODAY): +def _av(as_of_date, today=_TODAY): """Alpha Vantage path; the API call is mocked so a leak would be visible.""" with mock.patch.object(date_window, "get_current_date", return_value=today), \ mock.patch.object(av, "_make_api_request", return_value="MarketCapitalization: 3500000000000") as req: - return av.get_fundamentals("AAPL", curr_date), req + return av.get_fundamentals("AAPL", as_of_date), req @pytest.mark.unit @@ -125,10 +125,13 @@ class TestLiveRunUnchanged: @pytest.mark.unit class TestNoUsableFieldsStillRaises: - def test_stub_payload_raises_no_market_data(self): + def test_stub_payload_raises_no_market_data(self, monkeypatch): # yfinance returns {"trailingPegRatio": None} for unknown symbols; on a # live run that must stay a hard "no data", not a bare header. from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import common + + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): _yf(_TODAY, info={"trailingPegRatio": None}) diff --git a/tests/test_fundamentals_units.py b/tests/test_fundamentals_units.py new file mode 100644 index 000000000..f960ad6c6 --- /dev/null +++ b/tests/test_fundamentals_units.py @@ -0,0 +1,39 @@ +"""Yahoo's percent-valued fields say they are percentages (#1414). + +``Ticker.info`` gives ``dividendYield`` and ``debtToEquity`` in percent (0.41 is +0.41%; 78.4 is 78.4%, a ratio of 0.78) but margins and returns as fractions +(0.27 is 27%). Printed side by side without a unit, one scale reads as the other. +""" + +from unittest import mock + +import pytest + +from tradingagents.dataflows import date_window +from tradingagents.dataflows.vendors.yahoo import fundamentals + +TODAY = "2026-09-26" + + +def _fundamentals(info): + with mock.patch.object(date_window, "get_current_date", return_value=TODAY), \ + mock.patch.object(fundamentals, "yf_retry", lambda fn: info): + return fundamentals.get_fundamentals("AAPL", TODAY) + + +@pytest.mark.unit +def test_percent_fields_carry_their_unit_and_fractions_are_unchanged(): + out = _fundamentals({"longName": "Apple Inc.", "dividendYield": 0.32, "debtToEquity": 78.445, + "profitMargins": 0.27619, "returnOnEquity": 1.4875101}) + + assert "Dividend Yield: 0.32%" in out + assert "Debt to Equity: 78.445% (0.78x)" in out + assert "Profit Margin: 0.27619" in out + assert "Return on Equity: 1.4875101" in out + + +@pytest.mark.unit +def test_an_absent_percent_field_is_left_out(): + out = _fundamentals({"longName": "JPMorgan Chase & Co.", "profitMargins": 0.35}) + + assert "Dividend Yield" not in out and "Debt to Equity" not in out diff --git a/tests/test_get_scrubbed.py b/tests/test_get_scrubbed.py new file mode 100644 index 000000000..d15418020 --- /dev/null +++ b/tests/test_get_scrubbed.py @@ -0,0 +1,65 @@ +"""``get_scrubbed``: the vendor key must not survive into an error. + +FRED and Alpha Vantage authenticate with a query parameter, so requests quotes +the key back in the text of HTTP, connection and timeout errors, and in the +``request`` and ``response`` it attaches (#1324). +""" + +from unittest import mock + +import pytest +import requests + +from tradingagents.dataflows.net import get_scrubbed + +# A fabricated value in the shape of a FRED key. +_KEY = "deadbeefdeadbeefdeadbeefdeadbeef" +_LEAK = ( + "HTTPSConnectionPool(host='api.stlouisfed.org', port=443): Max retries " + f"exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}&file_type=json" +) + + +def _response(status_code): + response = mock.Mock(spec=requests.Response) + response.status_code = status_code + response.raise_for_status.side_effect = requests.HTTPError(_LEAK) if status_code >= 400 else None + return response + + +def _call(**kwargs): + return get_scrubbed("https://api.stlouisfed.org/fred/series", + params={"api_key": _KEY}, timeout=5, secret=_KEY, **kwargs) + + +@pytest.mark.unit +@pytest.mark.parametrize("failure", [ + {"side_effect": requests.ConnectionError(_LEAK)}, + {"return_value": _response(500)}, +], ids=["transport", "status"]) +def test_the_key_is_masked_in_the_error(failure): + with mock.patch.object(requests, "get", **failure), pytest.raises(requests.RequestException) as caught: + _call() + assert _KEY not in str(caught.value) and _KEY not in repr(caught.value.args) + assert "api_key=***" in str(caught.value) + + +@pytest.mark.unit +def test_nothing_that_holds_the_url_travels_with_the_error(): + """The request, the response and a chained original each carry the full URL.""" + leaky = requests.ConnectionError(_LEAK) + leaky.request = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}") + leaky.response = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}") + with mock.patch.object(requests, "get", side_effect=leaky), pytest.raises(requests.ConnectionError) as caught: + _call() + error = caught.value + assert error.request is None and error.response is None + assert error.__cause__ is None and error.__context__ is None + + +@pytest.mark.unit +def test_a_passthrough_status_is_returned_for_the_caller(): + response = _response(400) + with mock.patch.object(requests, "get", return_value=response): + assert _call(passthrough=(400,)) is response + response.raise_for_status.assert_not_called() diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 2bd846b89..3c6ef016f 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -12,7 +12,7 @@ import copy import pandas as pd import pytest from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.runnables import RunnableLambda from pydantic import Field @@ -34,8 +34,10 @@ STRUCTURED = { schemas.ResearchPlan: schemas.ResearchPlan( recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"), schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"), + # The thesis quotes another party's rating; the decision is still the PM's own. schemas.PortfolioDecision: schemas.PortfolioDecision( - rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", investment_thesis="t"), + rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", + investment_thesis="Street consensus rating: Buy (28 of 35 analysts)."), schemas.SentimentReport: schemas.SentimentReport( overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"), } @@ -50,6 +52,7 @@ class ScriptedModel(BaseChatModel): structured: bool = False tools: tuple = () calls: list = Field(default_factory=list) # shared across bound copies + threads: set = Field(default_factory=set) # threads that served a tool-bound call fail_at: int | None = None # raise on this call, once @property @@ -71,6 +74,11 @@ class ScriptedModel(BaseChatModel): def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: self._count() + if self.tools: + import threading + import time + self.threads.add(threading.current_thread().name) + time.sleep(0.05) # long enough for concurrent analysts to overlap if self.tools and not isinstance(messages[-1], ToolMessage): calls = [{"name": t.name, "id": f"call_{i}", "args": {k: v for k, v in ARGS.items() @@ -107,16 +115,16 @@ def offline(monkeypatch, tmp_path): monkeypatch.setattr(sentiment_analyst, "fetch_stocktwits_messages", lambda *a, **k: "no posts") monkeypatch.setattr(sentiment_analyst, "fetch_reddit_posts", lambda *a, **k: "no posts") monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: type("T", (), {"info": {"longName": "NVIDIA"}})()) - context.resolve_instrument_identity.cache_clear() + context._identity.cache_clear() return called -def _graph(tmp_path, monkeypatch, model, **config): +def _graph(tmp_path, monkeypatch, model, debug=False, **config): cfg = copy.deepcopy(DEFAULT_CONFIG) cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"), memory_log_path=str(tmp_path / "log.md"), **config) monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model)) - return trading_graph.TradingAgentsGraph(config=cfg) + return trading_graph.TradingAgentsGraph(config=cfg, debug=debug) @pytest.mark.unit @@ -126,7 +134,7 @@ def test_a_full_run_reaches_a_logged_decision(tmp_path, monkeypatch, offline, st state, signal = graph.propagate("NVDA", TRADE_DATE) - assert signal == "Overweight" + assert signal == state["final_rating"] == "Overweight" for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report", "investment_plan", "trader_investment_plan", "final_trade_decision"): assert state[key].strip(), key @@ -167,3 +175,115 @@ def test_a_graph_reused_across_runs_keeps_no_run_state(tmp_path, monkeypatch, of held = [v for v in vars(graph).values() if isinstance(v, dict) and TRADE_DATE in v] assert held == [] assert len(list(tmp_path.glob("results/NVDA/TradingAgentsStrategy_logs/*.json"))) == 2 + + +@pytest.mark.unit +def test_the_analysts_run_at_the_same_time(tmp_path, monkeypatch, offline): + model = ScriptedModel() + graph = _graph(tmp_path, monkeypatch, model) + + assert not [n for n in graph.graph.get_graph().nodes if n.startswith("Msg Clear")] + graph.propagate("NVDA", TRADE_DATE) + + assert len(model.threads) > 1 + + +@pytest.mark.unit +def test_a_debug_run_prints_the_analysts_work_and_reaches_the_same_decision(tmp_path, monkeypatch, offline, capsys): + """Debug mode streams the analysts' own graphs, so their tool calls still print.""" + graph = _graph(tmp_path, monkeypatch, ScriptedModel(), debug=True) + + state, signal = graph.propagate("NVDA", TRADE_DATE) + + assert signal == "Overweight" + assert state["market_report"].strip() and state["fundamentals_report"].strip() + printed = capsys.readouterr().out + assert "get_stock_data" in printed and "get_balance_sheet" in printed + + +@pytest.mark.unit +def test_each_report_streams_as_soon_as_its_analyst_files_it(tmp_path, monkeypatch, offline): + """The main state takes the analysts' reports only when the slowest one is + done; the CLI shows each report, and stops each clock, as it lands.""" + graph = _graph(tmp_path, monkeypatch, ScriptedModel()) + reports = ("market_report", "sentiment_report", "news_report", "fundamentals_report") + + first = next(state for _, state in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE), + **graph.propagator.get_graph_args()) + if state and any(state.get(k) for k in reports)) + + assert sum(bool(first.get(k)) for k in reports) == 1 + + +@pytest.mark.unit +def test_a_streamed_run_reads_its_own_graph_config(tmp_path, monkeypatch, offline): + """The CLI streams the run; a process-wide config set elsewhere must not reach + its tools, and the graph's config must not reach the caller between steps.""" + from tradingagents.dataflows.config import get_config, set_config + + graph = _graph(tmp_path, monkeypatch, ScriptedModel(), output_language="French") + set_config({"output_language": "German"}) + seen = [] + monkeypatch.setitem(router.VENDOR_METHODS["get_stock_data"], "yfinance", + lambda *a, **k: seen.append(get_config()["output_language"]) or "prices") + + between = [get_config()["output_language"] + for _ in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE), **graph.propagator.get_graph_args())] + + assert seen and set(seen) == {"French"} + assert set(between) == {"German"} + + +class LoopingModel(ScriptedModel): + """Calls a tool on every turn it is offered one (#1420).""" + + tool_turns: list = Field(default_factory=list) # shared across bound copies + last_turns: list = Field(default_factory=list) # histories of the turns offered no tools + + def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: + if not self.tools: + self.last_turns.append(messages) + return super()._generate(messages, stop, run_manager, **kwargs) + self.tool_turns.append(1) + tool = self.tools[0] + call = {"name": tool.name, "id": f"call_{len(self.tool_turns)}", + "args": {k: v for k, v in ARGS.items() + if k in tool.tool_call_schema.model_json_schema()["properties"]}} + return ChatResult(generations=[ChatGeneration(message=AIMessage(content="", tool_calls=[call]))]) + + +@pytest.mark.unit +def test_an_analyst_that_keeps_calling_tools_writes_its_report_at_the_limit(tmp_path, monkeypatch, offline): + """A model that does not stop calling tools must not end the run (#1420).""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=3) + + final_state, rating = graph.propagate("NVDA", TRADE_DATE) + + assert len(model.tool_turns) == 3 * 3 # three tool-using analysts, three rounds each + for key in ("market_report", "news_report", "fundamentals_report"): + assert final_state[key] == TEXT + assert rating == "Overweight" + + +@pytest.mark.unit +def test_the_last_turn_is_offered_no_tools_and_reads_its_tool_results_as_text(tmp_path, monkeypatch, offline): + """Offered tools, a model may call one whatever it is told; a history holding + tool calls may be refused by a provider when no tools are bound.""" + model = LoopingModel(structured=True) + graph = _graph(tmp_path, monkeypatch, model, max_tool_rounds=2) + + graph.propagate("NVDA", TRADE_DATE) + + wrap_ups = [h for h in model.last_turns if isinstance(h[-1], HumanMessage) and "tool round" in h[-1].content] + assert len(wrap_ups) == 3 + for history in wrap_ups: + assert not any(isinstance(m, ToolMessage) or getattr(m, "tool_calls", None) for m in history) + assert any("returned]" in str(m.content) for m in history) + assert "tool rounds are spent" in history[0].content # the system prompt lists no tools + + +@pytest.mark.unit +def test_a_tool_limit_the_recursion_limit_cannot_hold_is_refused(tmp_path, monkeypatch): + with pytest.raises(ValueError, match="max_tool_rounds"): + _graph(tmp_path, monkeypatch, ScriptedModel(), max_tool_rounds=60, max_recur_limit=100) diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py index e101dd1df..4a989302c 100644 --- a/tests/test_i18n_coverage.py +++ b/tests/test_i18n_coverage.py @@ -46,6 +46,15 @@ class TestLanguageInstruction: assert "中文" in out assert "entire response" in out + def test_the_labelled_lines_stay_as_the_format_gives_them(self): + # The rating is read from its "**Rating**:" line; a translated label + # leaves the reader a translated negation ("不建议卖出 (Sell)") to find + # instead, and a Buy reads as Sell (#1435). + from tradingagents.dataflows.config import set_config + set_config({"output_language": "中文"}) + out = get_language_instruction() + assert "**Rating**:" in out and "FINAL TRANSACTION PROPOSAL" in out + @pytest.mark.unit @pytest.mark.parametrize("rel", REPORT_AGENTS) diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py index 4c12a4769..c4216f110 100644 --- a/tests/test_instrument_identity.py +++ b/tests/test_instrument_identity.py @@ -5,11 +5,10 @@ import unittest from unittest.mock import patch import pytest -from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage from tradingagents.agents.context import ( + _identity, build_instrument_context, - create_msg_delete, get_instrument_context_from_state, resolve_instrument_identity, ) @@ -18,7 +17,7 @@ from tradingagents.agents.context import ( @pytest.mark.unit class ResolveInstrumentIdentityTests(unittest.TestCase): def setUp(self): - resolve_instrument_identity.cache_clear() + _identity.cache_clear() def test_resolves_company_metadata_from_yfinance(self): with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: @@ -56,6 +55,15 @@ class ResolveInstrumentIdentityTests(unittest.TestCase): ): self.assertEqual(resolve_instrument_identity("TOTDY"), {}) + def test_a_failed_lookup_is_asked_again(self): + # A long process (a backtest) must not run every later cell without an + # identity because one request failed. + with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: + type(mock.return_value).info = property(lambda self: (_ for _ in ()).throw(TimeoutError())) + self.assertEqual(resolve_instrument_identity("TOTDY"), {}) + type(mock.return_value).info = {"longName": "TOTO LTD."} + self.assertEqual(resolve_instrument_identity("TOTDY")["company_name"], "TOTO LTD.") + def test_result_is_cached(self): with patch("tradingagents.dataflows.vendors.yahoo.market.yf.Ticker") as mock: mock.return_value.info = {"longName": "TOTO LTD."} @@ -118,53 +126,5 @@ class GetInstrumentContextFromStateTests(unittest.TestCase): self.assertIn("crypto asset", context) -@pytest.mark.unit -class ContextAnchoredPlaceholderTests(unittest.TestCase): - """#888 — the message-clear placeholder must not be a bare 'Continue'.""" - - def _run(self, state_extra): - state = { - "messages": [ - HumanMessage(content="old", id="h1"), - AIMessage(content="reply", id="a1"), - ], - **state_extra, - } - return create_msg_delete()(state) - - def test_placeholder_is_not_bare_continue(self): - result = self._run( - {"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"} - ) - placeholder = result["messages"][-1] - self.assertIsInstance(placeholder, HumanMessage) - self.assertNotEqual(placeholder.content.strip(), "Continue") - - def test_placeholder_carries_resolved_identity(self): - result = self._run( - { - "company_of_interest": "EC", - "instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.", - "trade_date": "2026-05-28", - } - ) - content = result["messages"][-1].content - self.assertIn("Ecopetrol", content) - self.assertIn("2026-05-28", content) - - def test_old_messages_are_removed(self): - result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"}) - removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)] - humans = [m for m in result["messages"] if isinstance(m, HumanMessage)] - self.assertEqual(len(removals), 2) - self.assertEqual(len(humans), 1) - - def test_safe_defaults_when_state_minimal(self): - result = create_msg_delete()({"messages": [], "company_of_interest": "EC"}) - placeholder = result["messages"][-1] - self.assertNotEqual(placeholder.content.strip(), "Continue") - self.assertIn("EC", placeholder.content) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_llm_max_retries.py b/tests/test_llm_max_retries.py index bac0c8bd1..1baad0905 100644 --- a/tests/test_llm_max_retries.py +++ b/tests/test_llm_max_retries.py @@ -6,8 +6,6 @@ opt-in llm_max_retries knob forwarded to every provider chat client. """ from __future__ import annotations -import importlib - import pytest import tradingagents.default_config as default_config_module @@ -74,23 +72,24 @@ def test_invalid_config_value_fails_loudly(): # --- env overlay ----------------------------------------------------------- -def _reload_with_env(monkeypatch, **overrides): +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() @pytest.mark.unit def test_default_is_none(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["llm_max_retries"] is None + config = _config_with_env(monkeypatch) + assert config["llm_max_retries"] is None @pytest.mark.unit def test_env_override_sets_config(monkeypatch): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8") + config = _config_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8") # None-default key: env value arrives as a string and is coerced downstream. - assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8" - assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8 + assert config["llm_max_retries"] == "8" + assert _coerce_max_retries(config["llm_max_retries"]) == 8 diff --git a/tests/test_llm_max_tokens.py b/tests/test_llm_max_tokens.py index 2715e179e..d4007f164 100644 --- a/tests/test_llm_max_tokens.py +++ b/tests/test_llm_max_tokens.py @@ -8,8 +8,6 @@ right key per provider. """ from __future__ import annotations -import importlib - import pytest import tradingagents.default_config as default_config_module @@ -97,22 +95,23 @@ def test_openai_and_google_clients_accept_the_kwarg(): # --- env overlay ----------------------------------------------------------- -def _reload_with_env(monkeypatch, **overrides): +def _config_with_env(monkeypatch, **overrides): + """The defaults as the environment given here would set them.""" for key in list(default_config_module._ENV_OVERRIDES): monkeypatch.delenv(key, raising=False) for key, val in overrides.items(): monkeypatch.setenv(key, val) - return importlib.reload(default_config_module) + return default_config_module.build_default_config() @pytest.mark.unit def test_default_is_none(monkeypatch): - dc = _reload_with_env(monkeypatch) - assert dc.DEFAULT_CONFIG["max_tokens"] is None + config = _config_with_env(monkeypatch) + assert config["max_tokens"] is None @pytest.mark.unit def test_env_override_sets_config(monkeypatch): - dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192") - assert dc.DEFAULT_CONFIG["max_tokens"] == "8192" - assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192 + config = _config_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192") + assert config["max_tokens"] == "8192" + assert _coerce_max_tokens(config["max_tokens"]) == 8192 diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 42dc34b11..b3516b3fb 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -7,11 +7,10 @@ import pytest from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating -from tradingagents.decision_log import TradingMemoryLog -from tradingagents.graph import settlement from tradingagents.graph.propagation import Propagator -from tradingagents.graph.reflection import Reflector from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog, settlement +from tradingagents.memory.reflection import Reflector _SEP = TradingMemoryLog._SEPARATOR @@ -75,7 +74,6 @@ def _make_pm_state(past_context=""): "aggressive_history": "", "conservative_history": "", "neutral_history": "", - "judge_decision": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", @@ -628,6 +626,16 @@ class TestDeferredReflection: "benchmark_map": DEFAULT_CONFIG["benchmark_map"]} assert settlement.resolve_benchmark("PETR4.SA", config) == "^BVSP" + @pytest.mark.parametrize(("ticker", "index"), [ + ("2330.TW", "^TWII"), ("6488.TWO", "^TWII"), ("005930.KS", "^KS11"), ("247540.KQ", "^KQ11"), ("D05.SI", "^STI"), ("SAP.DE", "^GDAXI"), + ("MC.PA", "^FCHI"), ("ASML.AS", "^AEX"), ("NESN.SW", "^SSMI"), ("ENI.MI", "FTSEMIB.MI"), + ]) + def test_resolve_benchmark_regional_indexes(self, ticker, index): + """Taiwan, Korea, Singapore and the main European exchanges were measured against SPY.""" + from tradingagents.default_config import DEFAULT_CONFIG + config = {"benchmark_ticker": None, "benchmark_map": DEFAULT_CONFIG["benchmark_map"]} + assert settlement.resolve_benchmark(ticker, config) == index + def test_resolve_benchmark_us_ticker_defaults_to_spy(self): """US tickers (no dotted suffix) take the empty-suffix entry.""" config = { @@ -863,12 +871,12 @@ class TestLegacyRemoval: def test_financial_situation_memory_removed(self): """FinancialSituationMemory must not be importable from the memory module.""" - import tradingagents.decision_log as m + import tradingagents.memory as m assert not hasattr(m, "FinancialSituationMemory") def test_bm25_not_imported(self): """rank_bm25 must not be present in the memory module namespace.""" - import tradingagents.decision_log as m + import tradingagents.memory as m assert not hasattr(m, "BM25Okapi") def test_reflect_and_remember_removed(self): @@ -888,6 +896,7 @@ class TestLegacyRemoval: fake_state = { "final_trade_decision": "Rating: Buy\nBuy NVDA.", + "final_rating": "Buy", "company_of_interest": "NVDA", "trade_date": "2026-01-10", "market_report": "", @@ -896,13 +905,13 @@ class TestLegacyRemoval: "fundamentals_report": "", "investment_debate_state": { "bull_history": "", "bear_history": "", "history": "", - "current_response": "", "judge_decision": "", + "current_response": "", }, "investment_plan": "", "trader_investment_plan": "", "risk_debate_state": { "aggressive_history": "", "conservative_history": "", - "neutral_history": "", "history": "", "judge_decision": "", + "neutral_history": "", "history": "", "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", "count": 1, "latest_speaker": "", }, @@ -914,7 +923,6 @@ class TestLegacyRemoval: mock_graph.graph.invoke.return_value = fake_state mock_graph.propagator.create_initial_state.return_value = fake_state mock_graph.propagator.get_graph_args.return_value = {} - mock_graph.process_signal.return_value = "Buy" # Bind the real _run_graph so propagate's call to self._run_graph executes # the actual write path instead of the auto-MagicMock. mock_graph._run_graph = functools.partial( @@ -934,8 +942,8 @@ class TestLegacyRemoval: 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.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md")} @@ -967,8 +975,8 @@ def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp 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.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21} @@ -994,7 +1002,7 @@ def test_the_holding_window_is_configurable(tmp_path, monkeypatch): 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 + from tradingagents.memory.reflection import Reflector prompt = Reflector(None)._system_prompt(holding_days=5) assert "5" in prompt and "trading day" in prompt @@ -1021,3 +1029,107 @@ def test_a_longer_window_asks_for_enough_price_history(monkeypatch): raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY") assert days == 21 and resolved is not None, (raw, alpha, days, resolved) + + +@pytest.mark.unit +def test_concurrent_writers_keep_every_decision_and_outcome(tmp_path): + """Graphs sharing one log record and settle at the same time; none may lose another's write.""" + import threading + + path = tmp_path / "memory.md" + tickers = [f"T{i}" for i in range(8)] + dates = [f"2026-01-{day:02d}" for day in range(1, 13)] + errors = [] + + def work(ticker): + log = TradingMemoryLog({"memory_log_path": str(path)}) + try: + for date in dates: + log.store_decision(ticker, date, "**Rating**: Buy") + log.update_with_outcome(ticker, date, 0.01, 0.005, 5, "held up") + except Exception as exc: # noqa: BLE001 — collected for the assertion + errors.append(exc) + + threads = [threading.Thread(target=work, args=(t,)) for t in tickers] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries() + assert errors == [] + assert len(entries) == len(tickers) * len(dates) + assert all(not e["pending"] for e in entries) + + +def _closes(by_symbol): + """A get_closes stand-in serving each symbol's series between the dates asked.""" + def get_closes(symbol, start_date, end_date): + series = by_symbol[symbol] + return series[(series.index >= start_date) & (series.index < end_date)] + return get_closes + + +@pytest.mark.unit +class TestSettlementWindow: + """Stock and benchmark are measured over the same dates, whatever their calendars.""" + + def test_a_seven_day_market_is_scored_against_the_benchmark_over_the_same_dates(self, monkeypatch): + coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D")) + weekdays = pd.bdate_range("2025-12-29", "2026-01-16") + spy = pd.Series([400.0 + 2 * i for i in range(len(weekdays))], index=weekdays) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + raw, alpha, days, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") + + # The coin is held Sat 3 -> Thu 8; SPY over the same span is Fri 2 -> Thu 8. + assert resolved == "2026-01-08" + assert raw == pytest.approx(105.0 / 100.0 - 1) + assert alpha == pytest.approx(raw - (spy["2026-01-08"] / spy["2026-01-02"] - 1)) + + def test_bars_stamped_in_different_time_zones_meet_on_their_dates(self, monkeypatch): + # Yahoo stamps a coin's daily bar at midnight UTC and SPY's at midnight + # New York, five hours later: the same day, not the day after. + coin = pd.Series([100.0 + i for i in range(10)], + index=pd.date_range("2026-01-03", periods=10, freq="D", tz="UTC")) + weekdays = pd.bdate_range("2025-12-29", "2026-01-16", tz="America/New_York") + spy = pd.Series([400.0 + 2 * i for i in range(len(weekdays))], index=weekdays) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + raw, alpha, _, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") + + assert resolved == "2026-01-08" + spy_by_day = spy.tz_localize(None) + assert alpha == pytest.approx(raw - (spy_by_day["2026-01-08"] / spy_by_day["2026-01-02"] - 1)) + + def test_the_outcome_waits_for_the_benchmark_to_trade_through_the_window(self, monkeypatch): + coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D")) + spy = pd.Series([400.0, 401.0, 402.0], index=pd.to_datetime(["2026-01-02", "2026-01-05", "2026-01-06"])) + monkeypatch.setattr(settlement, "get_closes", _closes({"BTC-USD": coin, "SPY": spy})) + + assert settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") == (None, None, None, None) + + def test_a_missing_close_is_skipped_not_scored(self, monkeypatch): + idx = pd.bdate_range("2026-01-05", periods=8) + stock = pd.Series([100.0, float("nan"), 101.0, 102.0, 103.0, 104.0, 105.0, 106.0], index=idx) + spy = pd.Series([400.0 + i for i in range(8)], index=idx) + monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy})) + + raw, alpha, _, resolved = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") + + assert raw == pytest.approx(105.0 / 100.0 - 1) + assert resolved == "2026-01-13" + assert alpha == pytest.approx(raw - (spy["2026-01-13"] / spy["2026-01-05"] - 1)) + + +@pytest.mark.unit +def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch): + """A zero close would score as an infinite return and be written to the memory log.""" + idx = pd.bdate_range("2026-01-05", periods=8) + stock = pd.Series([0.0, 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0], index=idx) + spy = pd.Series([400.0 + i for i in range(8)], index=idx) + monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy})) + + raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") + + assert raw == pytest.approx(105.0 / 100.0 - 1) # the window opens on the first real close diff --git a/tests/test_memory_pointintime.py b/tests/test_memory_pointintime.py index 6adaf082d..c61a1d707 100644 --- a/tests/test_memory_pointintime.py +++ b/tests/test_memory_pointintime.py @@ -10,7 +10,7 @@ from __future__ import annotations import pytest -from tradingagents.decision_log import TradingMemoryLog +from tradingagents.memory import TradingMemoryLog def _log(tmp_path): diff --git a/tests/test_news_analyst_prompt.py b/tests/test_news_analyst_prompt.py index 4af6b04e8..9b024121a 100644 --- a/tests/test_news_analyst_prompt.py +++ b/tests/test_news_analyst_prompt.py @@ -1,7 +1,8 @@ """Guard the news analyst prompt against tool-signature drift (#1116). -The prompt used to advertise ``get_news(query, ...)`` while the tool takes a -``ticker``, tricking the LLM into hallucinating free-text query calls. +The prompt used to advertise ``get_news(query, ...)``, tricking the LLM into +hallucinating free-text query calls. The instrument now comes from the run's +state, so the model passes only the window. """ import inspect @@ -12,14 +13,13 @@ from tradingagents.agents.tools import get_news @pytest.mark.unit -def test_get_news_takes_ticker_not_query(): - arg_names = set(get_news.args.keys()) - assert "ticker" in arg_names - assert "query" not in arg_names +def test_get_news_takes_the_window_not_a_query(): + arg_names = set(get_news.tool_call_schema.model_json_schema()["properties"]) + assert arg_names == {"start_date", "end_date"} @pytest.mark.unit def test_news_prompt_matches_get_news_signature(): src = inspect.getsource(na) - assert "get_news(ticker, start_date, end_date)" in src + assert "get_news(start_date, end_date)" in src assert "get_news(query" not in src diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py index 5c5c1c420..9d96dfbab 100644 --- a/tests/test_news_lookahead.py +++ b/tests/test_news_lookahead.py @@ -6,7 +6,7 @@ news injected future articles), #993 (empty-after-filter returned a blank body), and #1126 (inclusive upper bound leaked the midnight-after article; host-local timestamp parsing made filtering machine-dependent). """ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -16,7 +16,7 @@ from tradingagents.dataflows.date_window import in_window def _epoch(date_str): """Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent).""" - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -28,7 +28,7 @@ def test_flat_article_publish_time_is_parsed(): ) assert data["pub_date"] is not None assert data["pub_date"].tzinfo is not None - assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc) + assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=UTC) @pytest.mark.unit @@ -45,7 +45,7 @@ def test_window_excludes_future_and_undated_in_backtest(): @pytest.mark.unit def test_window_keeps_undated_in_live_window(): # Live window (reaches today): undated articles can't be "future", so keep them. - now = datetime.now(timezone.utc) + now = datetime.now(UTC) assert in_window(None, now, now) is True @@ -55,8 +55,8 @@ def test_upper_bound_is_exclusive(): # the old inclusive bound; the whole of end_date itself must still be kept. start = datetime(2025, 5, 1) end = datetime(2025, 5, 9) - midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc) - last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc) + midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=UTC) + last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=UTC) assert in_window(midnight_after, start, end) is False assert in_window(last_moment, start, end) is True @@ -146,11 +146,11 @@ def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch): @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 + ([datetime(2026, 5, 20, tzinfo=UTC)], True), # all after the window + ([datetime(2026, 5, 4, tzinfo=UTC)], True), # starts mid-window: partial + ([datetime(2026, 5, 1, 18, tzinfo=UTC)], False), # reaches the first day + ([datetime(2026, 5, 20, tzinfo=UTC), + datetime(2026, 4, 1, tzinfo=UTC)], False), # coverage reaches back ]) def test_coverage_gap_boundaries(dates, expect_gap): from tradingagents.dataflows.date_window import coverage_gap @@ -208,7 +208,7 @@ 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() + today = datetime.now(UTC).date() out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items") assert out is not None and "past today" in out diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py index b70ff3354..744e024bc 100644 --- a/tests/test_no_data_handling.py +++ b/tests/test_no_data_handling.py @@ -16,8 +16,9 @@ import pytest from tradingagents.dataflows import router from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.errors import NoMarketDataError -from tradingagents.dataflows.vendors.yahoo import ohlcv +from tradingagents.dataflows.vendors.yahoo import common, ohlcv @pytest.mark.unit @@ -33,19 +34,19 @@ class TestLoadOhlcvNoPoison(unittest.TestCase): os.rmdir(self._tmp) def test_empty_download_raises_and_does_not_cache(self): - empty = pd.DataFrame() + empty = mock.Mock(history=mock.Mock(return_value=pd.DataFrame())) # Yahoo answers, so an empty download means the symbol has no data. - reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True) + reachable = mock.patch.object(common, "vendor_reachable", return_value=True) reachable.start() self.addCleanup(reachable.stop) - with mock.patch.object(ohlcv.yf, "download", return_value=empty), \ + with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \ self.assertRaises(NoMarketDataError): ohlcv.load_ohlcv("FAKE", "2026-01-01") # Nothing should have been written to the cache. self.assertEqual(os.listdir(self._tmp), []) # A second call must re-attempt the fetch (no poisoned cache served). - with mock.patch.object(ohlcv.yf, "download", return_value=empty) as dl2: + with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty) as dl2: with self.assertRaises(NoMarketDataError): ohlcv.load_ohlcv("FAKE", "2026-01-01") self.assertTrue(dl2.called) @@ -95,11 +96,11 @@ if __name__ == "__main__": @pytest.mark.unit def test_an_unreachable_yahoo_is_not_reported_as_a_symbol_without_insider_data(): - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.vendors.yahoo import fundamentals ticker = type("T", (), {"insider_transactions": pd.DataFrame()})() with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \ mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \ - pytest.raises(VendorRateLimitError): - fundamentals.get_insider_transactions("AAPL", curr_date="2026-09-21") + pytest.raises(VendorUnavailableError): + fundamentals.get_insider_transactions("AAPL", as_of_date=get_current_date()) diff --git a/tests/test_ohlcv_cache_freshness.py b/tests/test_ohlcv_cache_freshness.py index c7718b41c..da905e383 100644 --- a/tests/test_ohlcv_cache_freshness.py +++ b/tests/test_ohlcv_cache_freshness.py @@ -8,6 +8,7 @@ day (#1330). from __future__ import annotations import os +from types import SimpleNamespace import pandas as pd import pytest @@ -35,7 +36,7 @@ def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026 def _load(tmp_path, monkeypatch, curr_date, download): monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW)) - monkeypatch.setattr(ohlcv.yf, "download", download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=download)) return ohlcv.load_ohlcv("AAPL", curr_date) @@ -98,7 +99,8 @@ def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch): monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]}) downloads = [] - monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date")) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace( + history=lambda *a, **k: downloads.append(1) or frame.set_index("Date"))) for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"): now = pd.Timestamp(day) diff --git a/tests/test_ohlcv_latest_bar.py b/tests/test_ohlcv_latest_bar.py index 1fb82d866..7b48b5894 100644 --- a/tests/test_ohlcv_latest_bar.py +++ b/tests/test_ohlcv_latest_bar.py @@ -14,6 +14,7 @@ anywhere counts as no data and the staleness check judges the rest. from __future__ import annotations import os +from types import SimpleNamespace import pandas as pd import pytest @@ -105,7 +106,7 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date): def _fail_download(*a, **k): raise AssertionError("should use the seeded cache, not download") - monkeypatch.setattr(ohlcv.yf, "download", _fail_download) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=_fail_download)) return ohlcv.load_ohlcv("AAPL", curr_date) @@ -198,8 +199,8 @@ def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, t cache = tmp_path / "AAPL-YFin-data.csv" cache.write_text(frame.to_csv(index=False)) _stamp(cache, today) - monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: (_ for _ in ()).throw( - AssertionError("should read the seeded cache"))) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace( + history=lambda *a, **k: (_ for _ in ()).throw(AssertionError("should read the seeded cache")))) out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3) diff --git a/tests/test_ollama_base_url.py b/tests/test_ollama_base_url.py index 6cac1f55e..f3511590a 100644 --- a/tests/test_ollama_base_url.py +++ b/tests/test_ollama_base_url.py @@ -2,7 +2,6 @@ from __future__ import annotations -import importlib import re import pytest @@ -18,30 +17,12 @@ def _console_out(capsys) -> str: return _ANSI.sub("", capsys.readouterr().out) -@pytest.fixture(scope="module", autouse=True) -def _resync_reloaded_modules(): - """Restore module state after this file's importlib.reload() calls. - - Several tests below reload ``cli.prompts`` to re-evaluate OLLAMA_BASE_URL. - That leaves the modules importing from it (cli.selections, then cli.run and - cli.main) bound to the pre-reload functions, which breaks identity checks in - unrelated tests that run afterward. Re-sync them in import order on teardown. - """ - yield - import cli.main - import cli.prompts - import cli.run - import cli.selections - for module in (cli.prompts, cli.selections, cli.run, cli.main): - importlib.reload(module) - - # ---- openai_client side: registry-driven base_url resolution -------------- -def _reload_client(): +def _client_module(): import tradingagents.llm_clients.openai_client as mod - return importlib.reload(mod) + return mod def _base_url(mod, provider, **kwargs): @@ -50,20 +31,20 @@ def _base_url(mod, provider, **kwargs): def test_resolver_returns_default_when_env_unset(monkeypatch): monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "ollama") == "http://localhost:11434/v1" def test_resolver_returns_env_when_set(monkeypatch): monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-ollama:11434/v1") - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "ollama") == "http://remote-ollama:11434/v1" def test_resolver_evaluation_is_call_time(monkeypatch): """Setting the env AFTER module import must still take effect.""" monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - mod = _reload_client() + mod = _client_module() monkeypatch.setenv("OLLAMA_BASE_URL", "http://late-set:11434/v1") assert _base_url(mod, "ollama") == "http://late-set:11434/v1" @@ -71,7 +52,7 @@ def test_resolver_evaluation_is_call_time(monkeypatch): def test_resolver_does_not_affect_other_providers(monkeypatch): """OLLAMA_BASE_URL should NOT leak into xai/deepseek/etc.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://elsewhere/v1") - mod = _reload_client() + mod = _client_module() assert _base_url(mod, "xai") == "https://api.x.ai/v1" assert _base_url(mod, "deepseek") == "https://api.deepseek.com" @@ -79,7 +60,7 @@ def test_resolver_does_not_affect_other_providers(monkeypatch): def test_client_get_llm_picks_up_env(monkeypatch): """End-to-end: OllamaClient.get_llm() respects OLLAMA_BASE_URL.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://my-ollama:11434/v1") - mod = _reload_client() + mod = _client_module() client = mod.OpenAIClient(model="llama3.1", provider="ollama") llm = client.get_llm() assert "my-ollama" in str(llm.openai_api_base) @@ -88,7 +69,7 @@ def test_client_get_llm_picks_up_env(monkeypatch): def test_explicit_base_url_overrides_env(monkeypatch): """An explicit base_url passed to the client wins over the env var.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-set:11434/v1") - mod = _reload_client() + mod = _client_module() client = mod.OpenAIClient( model="llama3.1", provider="ollama", @@ -99,40 +80,12 @@ def test_explicit_base_url_overrides_env(monkeypatch): assert "env-set" not in str(llm.openai_api_base) -# ---- cli.prompts side: select_llm_provider dropdown ------------------------- - - -def test_cli_dropdown_uses_env(monkeypatch): - """The Ollama entry in the CLI dropdown must reflect OLLAMA_BASE_URL.""" - monkeypatch.setenv("OLLAMA_BASE_URL", "http://cli-remote:11434/v1") - from cli import prompts - importlib.reload(prompts) - # Reach inside the function via the same env-read it does at call time - ollama_url = ( - __import__("os").environ.get("OLLAMA_BASE_URL") - or "http://localhost:11434/v1" - ) - assert ollama_url == "http://cli-remote:11434/v1" - - -def test_cli_dropdown_default_when_unset(monkeypatch): - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - from cli import prompts - importlib.reload(prompts) - ollama_url = ( - __import__("os").environ.get("OLLAMA_BASE_URL") - or "http://localhost:11434/v1" - ) - assert ollama_url == "http://localhost:11434/v1" - - # ---- confirm_ollama_endpoint UX ------------------------------------------- def test_confirm_endpoint_shows_default(monkeypatch, capsys): monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://localhost:11434/v1") out = _console_out(capsys) assert "http://localhost:11434/v1" in out @@ -143,7 +96,6 @@ def test_confirm_endpoint_shows_default(monkeypatch, capsys): def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys): monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host:11434/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://remote-host:11434/v1") out = _console_out(capsys) assert "http://remote-host:11434/v1" in out @@ -154,7 +106,6 @@ def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys): """If user sets OLLAMA_BASE_URL=0.0.0.128, advise on the expected shape.""" monkeypatch.setenv("OLLAMA_BASE_URL", "0.0.0.128") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("0.0.0.128") out = _console_out(capsys) assert "missing a scheme" in out @@ -165,7 +116,6 @@ def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys): """A remote host with no :11434 gets a soft hint about port mismatch.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://remote-host/v1") out = _console_out(capsys) assert "port 11434" in out @@ -175,7 +125,6 @@ def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys): """Local host without port shouldn't trigger the remote-port hint.""" monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost/v1") from cli import prompts - importlib.reload(prompts) prompts.confirm_ollama_endpoint("http://localhost/v1") out = _console_out(capsys) assert "Note" not in out # localhost is fine without explicit port diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py index f23c4e64f..f43989709 100644 --- a/tests/test_openai_compatible_provider.py +++ b/tests/test_openai_compatible_provider.py @@ -9,18 +9,16 @@ import pytest from tradingagents.llm_clients.api_key_env import get_api_key_env from tradingagents.llm_clients.factory import create_llm_client +from tradingagents.llm_clients.openai_client import LocalCompatibleChatOpenAI, OpenAIClient from tradingagents.llm_clients.validators import validate_model -# Note: assert by class NAME, not isinstance — other tests reload the -# openai_client module, which would otherwise create a second class identity. - @pytest.mark.unit def test_factory_routes_to_openai_client(): client = create_llm_client( provider="openai_compatible", model="my-model", base_url="http://localhost:8000/v1" ) - assert type(client).__name__ == "OpenAIClient" + assert isinstance(client, OpenAIClient) @pytest.mark.unit @@ -36,7 +34,7 @@ def test_keyless_local_uses_placeholder_and_chat_completions(monkeypatch): llm = create_llm_client( provider="openai_compatible", model="qwen2.5", base_url="http://localhost:8000/v1" ).get_llm() - assert type(llm).__name__ == "LocalCompatibleChatOpenAI" + assert isinstance(llm, LocalCompatibleChatOpenAI) assert str(llm.openai_api_base) == "http://localhost:8000/v1" # keyless local servers: a placeholder key is sent key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key diff --git a/tests/test_portfolio_context.py b/tests/test_portfolio_context.py index b48481e39..4b89637d2 100644 --- a/tests/test_portfolio_context.py +++ b/tests/test_portfolio_context.py @@ -76,9 +76,9 @@ def test_load_reads_a_valid_file(tmp_path): # --- threading through the graph -------------------------------------------- def _bare_graph(tmp_path): - from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.propagation import Propagator from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.memory import TradingMemoryLog graph = object.__new__(TradingAgentsGraph) graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1, @@ -139,11 +139,11 @@ def test_decision_agents_see_the_portfolio(module, factory, monkeypatch): "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}, + "investment_debate_state": {"history": "", "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": ""}, + "current_neutral_response": ""}, } node = getattr(mod, factory)(_LLM()) node(state) @@ -174,9 +174,8 @@ def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch): 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"}})() + graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx", "final_rating": "Hold"}})() book = PortfolioContext.model_validate(HOLDING) written = graph._run_signature("stock", book) # what begin_checkpoint keys on diff --git a/tests/test_prompt_integrity.py b/tests/test_prompt_integrity.py index e26e72185..db9f38a27 100644 --- a/tests/test_prompt_integrity.py +++ b/tests/test_prompt_integrity.py @@ -74,11 +74,11 @@ def test_a_report_that_was_never_produced_says_so(module, factory): "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}, + "current_response": "", "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": ""}, + "current_neutral_response": ""}, } getattr(mod, factory)(_LLM())(state) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 1ee0a4c87..9fad9d5fe 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -41,8 +41,15 @@ def test_prose_naming_several_ratings_without_a_label_needs_review(): @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.parametrize("prose", [ + "We would not Sell here; the dip is a chance to add.", + "**评级**:买入\n\n不建议卖出 (Sell)。", # a label in another language, and a negated Sell + "On balance we stay Underweight until margins recover.", +]) +def test_a_rating_word_without_a_label_is_not_read_as_the_call(prose): + """A rating word in prose may be one the text argues against (#1435).""" + assert extract_rating(prose) is None + assert parse_rating(prose) == RATING_REVIEW @pytest.mark.unit @@ -64,7 +71,7 @@ def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating(): @pytest.mark.unit def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log.store_decision("NVDA", "2026-01-05", REFUSAL) @@ -76,7 +83,7 @@ def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): @pytest.mark.unit def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): from tradingagents.agents.rating import parse_rating - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."): @@ -91,7 +98,7 @@ def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): 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.backtest import summarize - from tradingagents.decision_log import TradingMemoryLog + from tradingagents.memory import TradingMemoryLog log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") @@ -121,10 +128,6 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path def record_decision(self, *a, **k): pass - def process_signal(self, text): - from tradingagents.agents.rating import parse_rating - return parse_rating(text) - def get_graph_args(self, callbacks=None): return {} @@ -140,8 +143,8 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path def end_checkpoint(self): pass - def stream(self, *a, **k): - yield {"messages": [], "final_trade_decision": REFUSAL} + def stream_run(self, *a, **k): + yield [], {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW} fake = _Graph() fake.graph = fake @@ -155,7 +158,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a))) monkeypatch.setattr(cli_run, "display_complete_report", lambda *a, **k: None) monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", }) @@ -198,11 +201,11 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_ "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}, + "current_response": "", "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": ""}, + "current_neutral_response": ""}, } getattr(mod, factory)(_LLM())(state) @@ -210,3 +213,41 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_ 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] + + +@pytest.mark.unit +def test_a_state_without_the_typed_rating_reads_it_from_the_decision(): + """A run finished by an older version and resumed from its checkpoint has + no final_rating; every reader falls back the same way instead of one + raising and another reporting REVIEW.""" + from tradingagents.agents.rating import run_rating + + assert run_rating({"final_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold" + assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell" + assert run_rating({}) == RATING_REVIEW + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "Street consensus rating: Buy (28 of 35 analysts).", + "Moody's affirmed the credit rating: Buy-side demand for the bonds stayed firm.", + "Operating margin: Sell-side estimates sit below guidance.", +]) +def test_a_rating_the_text_quotes_does_not_replace_the_decision(quoted): + """A free-text decision opens with its own rating line; a rating it quotes + as evidence, or a word merely ending in 'rating', is not the call.""" + text = f"**Rating**: Hold\n\n**Investment Thesis**: {quoted} We wait for margins." + assert extract_rating(text) == "Hold" + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "- Rating: Buy (Goldman Sachs, 12m target 180)", + "| Rating: Buy | Morgan Stanley |", + "> Rating: Buy, per the sell-side note", + "Street consensus rating: Buy", + "Consensus rating: Buy (28 of 35 analysts)", +]) +def test_a_quoted_rating_in_a_list_table_or_quote_is_not_the_decision(quoted): + text = f"Our rating: Hold\n\nWhat others say:\n{quoted}\n\nWe wait for margins." + assert extract_rating(text) == "Hold" diff --git a/tests/test_reporting.py b/tests/test_reporting.py index 04a1d5a72..1f634a89c 100644 --- a/tests/test_reporting.py +++ b/tests/test_reporting.py @@ -13,12 +13,19 @@ def _state(): return { "market_report": "MKT", "news_report": "NEWS", - "investment_debate_state": {"judge_decision": "RM PLAN"}, + "investment_debate_state": {"bull_history": "BULL"}, + "investment_plan": "RM PLAN", "trader_investment_plan": "TRADE", - "risk_debate_state": {"judge_decision": "PM DECISION"}, + "risk_debate_state": {"neutral_history": "NEUTRAL"}, + "final_trade_decision": "PM DECISION", } +SETTINGS = {"version": "0.5.2", "llm_provider": "openai", "deep_think_llm": "gpt-6-sol", + "quick_think_llm": "gpt-6-luna", "analysts": ["market", "news"], "max_debate_rounds": 1, + "max_risk_discuss_rounds": 2, "data_vendors": {"core_stock_apis": "yfinance"}} + + @pytest.mark.unit def test_write_report_tree_creates_files(tmp_path): out = write_report_tree(_state(), "AAPL", tmp_path) @@ -35,16 +42,67 @@ def test_write_report_tree_creates_files(tmp_path): @pytest.mark.unit def test_save_reports_explicit_path(tmp_path): - # Unbound: with an explicit save_path, the method doesn't touch self/config. - out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path) + graph = SimpleNamespace(run_settings=lambda: SETTINGS) + out = TradingAgentsGraph.save_reports(graph, _state(), "AAPL", save_path=tmp_path) assert (tmp_path / "complete_report.md").exists() assert out == tmp_path / "complete_report.md" @pytest.mark.unit def test_save_reports_defaults_under_results_dir(tmp_path): - mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}) - out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL") + graph = object.__new__(TradingAgentsGraph) + graph.config = {"results_dir": str(tmp_path)} + graph.run_settings = lambda: SETTINGS + out = graph.save_reports(_state(), "AAPL") assert out.exists() assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_/... assert out.parent.name.startswith("AAPL_") + + + +@pytest.mark.unit +def test_the_report_names_the_analysis_date_and_what_produced_it(tmp_path): + state = dict(_state(), trade_date="2026-09-23") + + header = write_report_tree(state, "NVDA", tmp_path, settings=SETTINGS).read_text().split("## ")[0] + + assert "Analysis date: 2026-09-23" in header + assert "TradingAgents 0.5.2" in header + assert "openai, deep gpt-6-sol, quick gpt-6-luna" in header + assert "Analysts: market, news" in header + assert "research debate rounds 1, risk debate rounds 2" in header + assert "core_stock_apis yfinance" in header + + +@pytest.mark.unit +def test_run_settings_record_the_run_without_endpoints_or_paths(): + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts = ("market", "news") + graph.config = {"llm_provider": "openai", "deep_think_llm": "gpt-6-sol", "quick_think_llm": "gpt-6-luna", + "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "output_language": "English", + "data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {}, + "backend_url": "https://user:secret@relay.example/v1", "results_dir": "/home/me/results"} + + settings = graph.run_settings() + + assert settings["analysts"] == ["market", "news"] + assert settings["deep_think_llm"] == "gpt-6-sol" + assert "secret" not in str(settings) and "/home/me" not in str(settings) + + +@pytest.mark.unit +def test_a_partial_settings_dict_still_writes_the_report(tmp_path): + out = write_report_tree(_state(), "AAPL", tmp_path, settings={"llm_provider": "openai"}) + assert "openai" in out.read_text() + + +@pytest.mark.unit +def test_run_settings_name_the_version_of_the_running_code(): + import tradingagents + from tradingagents.graph.trading_graph import TradingAgentsGraph + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts, graph.config = ("market",), {} + assert graph.run_settings()["version"] == tradingagents.__version__ diff --git a/tests/test_sec_edgar.py b/tests/test_sec_edgar.py index 40b2def9f..be6e007f9 100644 --- a/tests/test_sec_edgar.py +++ b/tests/test_sec_edgar.py @@ -144,13 +144,13 @@ def test_a_throttle_lets_the_next_vendor_try(monkeypatch): """SEC throttles by refusing the request; the router then tries yfinance.""" import requests - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.errors import VendorUnavailableError def _throttled(*a, **k): raise requests.HTTPError(response=mock.Mock(status_code=429)) monkeypatch.setattr(sec_edgar.requests, "get", _throttled) - with pytest.raises(VendorRateLimitError): + with pytest.raises(VendorUnavailableError): _REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json") @@ -205,8 +205,8 @@ def test_older_periods_fall_back_to_the_tag_the_filer_used_then(): "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [ _fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30")]}}} values, unit = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax", - "Revenues"), "2026-01-01", (300, 400)) - assert values == {"2015-09-26": 233_715_000_000, "2024-09-28": 391_035_000_000} + "Revenues"), "2026-01-01", ((300, 400),)) + assert values == {("2015-09-26", 0): 233_715_000_000, ("2024-09-28", 0): 391_035_000_000} assert unit == "USD" @@ -216,27 +216,17 @@ def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both(): "RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [ _fact("2024-09-28", 999, "2024-11-01", start="2023-09-30")]}}} values, _ = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax", - "Revenues"), "2026-01-01", (300, 400)) - assert values == {"2024-09-28": 999} + "Revenues"), "2026-01-01", ((300, 400),)) + assert values == {("2024-09-28", 0): 999} @pytest.mark.unit -def test_the_default_identification_tracks_the_installed_version(monkeypatch): - """A release should identify itself, not a version frozen in the source.""" +def test_the_default_identification_names_the_package_version(monkeypatch): + """SEC asks automated clients to identify themselves; a release names its own version.""" + import tradingagents + monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False) - monkeypatch.setattr(sec_edgar.metadata, "version", lambda name: "9.9.9") - assert sec_edgar._user_agent() == "TradingAgents/9.9.9 (contact@example.com)" - - -@pytest.mark.unit -def test_an_uninstalled_checkout_still_identifies_itself(monkeypatch): - monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False) - - def _missing(name): - raise sec_edgar.metadata.PackageNotFoundError(name) - - monkeypatch.setattr(sec_edgar.metadata, "version", _missing) - assert "@" in sec_edgar._user_agent() + assert sec_edgar._user_agent() == f"TradingAgents/{tradingagents.__version__} (contact@example.com)" @pytest.mark.unit @@ -293,3 +283,41 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp assert eps("2019-01-01") == "16.97" assert eps("2020-01-01") == "2.12" + + +@pytest.mark.unit +def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch): + """Many filers tag a 10-Q's cash flows only year to date (3, 6, then 9 months): + a quarterly table of discrete quarters alone would stop at each first quarter.""" + ytd = [ + _fact("2025-12-27", 30_000_000_000, "2026-01-30", form="10-Q", fp="Q1", start="2025-09-28"), + _fact("2026-03-28", 53_000_000_000, "2026-05-01", form="10-Q", fp="Q2", start="2025-09-28"), + _fact("2026-06-27", 81_000_000_000, "2026-08-01", form="10-Q", fp="Q3", start="2025-09-28"), + ] + facts = {"facts": {"us-gaap": {"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": ytd}}}}} + monkeypatch.setattr(sec_edgar, "_fetch_json", + lambda url: TICKER_MAP if "company_tickers" in url else facts) + + out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28") + + assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"] + assert [r for r in out.splitlines() if r.startswith("Operating Cash Flow")] == ["Operating Cash Flow,30000,53000,81000"] + + +@pytest.mark.unit +def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_quarter(monkeypatch): + facts = {"facts": {"us-gaap": { + "NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": [ + _fact("2026-06-30", 120_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-01-01")]}}, + "PaymentsToAcquirePropertyPlantAndEquipment": {"units": {"USD": [ + _fact("2026-06-30", 10_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-04-01")]}}, + }}} + monkeypatch.setattr(sec_edgar, "_fetch_json", + lambda url: TICKER_MAP if "company_tickers" in url else facts) + + out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28") + + assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"] + rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]} + assert rows["Operating Cash Flow"] == ["", "120"] + assert rows["Capital Expenditure"] == ["10", ""] diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index 4a0440c6f..7976b8239 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -75,20 +75,3 @@ class TestExtractRating: # The memory log tags an unreadable decision REVIEW, never a tradeable rating. assert parse_rating("No rating here.") == RATING_REVIEW assert parse_rating("No rating here.", default="Underweight") == "Underweight" - - -@pytest.mark.unit -class TestGraphSignalContract: - """The graph-facing signal (TradingAgentsGraph.process_signal) honors the - documented "5-tier or REVIEW" contract, not just the parser in isolation.""" - - def _bare_graph(self): - from tradingagents.graph.trading_graph import TradingAgentsGraph - g = object.__new__(TradingAgentsGraph) - return g - - def test_graph_surfaces_review(self): - assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW - - def test_graph_returns_rating(self): - assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell" diff --git a/tests/test_social_lookahead.py b/tests/test_social_lookahead.py index ca58e1a37..11e62c42d 100644 --- a/tests/test_social_lookahead.py +++ b/tests/test_social_lookahead.py @@ -8,7 +8,7 @@ as-of date. All three sources share dataflows.date_window.in_window. from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -38,10 +38,10 @@ class _JsonResp: def test_in_window_bounds_and_exclusive_upper(): start = datetime(2026, 5, 1) end = datetime(2026, 5, 9) - assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True - assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True + assert in_window(datetime(2026, 5, 5, tzinfo=UTC), start, end) is True + assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=UTC), start, end) is True # exactly midnight after end -> excluded (no leak) - assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False + assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=UTC), start, end) is False # offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True @@ -50,7 +50,7 @@ def test_in_window_bounds_and_exclusive_upper(): def test_in_window_undated_excluded_in_backtest_kept_live(): old = datetime(2026, 5, 9) assert in_window(None, datetime(2026, 5, 1), old) is False # historical - now = datetime.now(timezone.utc) + now = datetime.now(UTC) assert in_window(None, now, now) is True # live @@ -96,7 +96,7 @@ def test_stocktwits_no_window_is_unfiltered(monkeypatch): # --- Reddit ----------------------------------------------------------------- def _epoch(date_str): - return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC).timestamp()) @pytest.mark.unit @@ -184,7 +184,7 @@ def test_reddit_window_straddling_the_lookback_is_unavailable(monkeypatch): # Ten days ago through five days ago: the week-long search never reaches the # first three days, so an empty result cannot stand for the whole window. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -198,7 +198,7 @@ def test_reddit_standard_week_window_empty_is_a_real_absence(monkeypatch): # The graph's window is [trade_date - 7, trade_date]; the week-long search # covers it, so an empty result is genuine silence. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: []) out = reddit.fetch_reddit_posts( "AAPL", subreddits=("stocks",), @@ -212,7 +212,7 @@ def test_reddit_full_page_does_not_vouch_for_older_days(monkeypatch): # 100 posts from today say nothing about five days ago: the page may have # cut older matches off, so the window stays unavailable. from datetime import timedelta - today = datetime.now(timezone.utc).date() + today = datetime.now(UTC).date() ts = _epoch(str(today)) page = [{"title": f"T{i}", "created_utc": ts, "subreddit": "stocks"} for i in range(reddit._FEED_PAGE)] monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: page) diff --git a/tests/test_statement_knowledge_time.py b/tests/test_statement_knowledge_time.py new file mode 100644 index 000000000..2a75b2e58 --- /dev/null +++ b/tests/test_statement_knowledge_time.py @@ -0,0 +1,78 @@ +"""A historical run is served statements only with a known filing time. + +SEC EDGAR dates every filing, so it serves a past date as filed. Yahoo and Alpha +Vantage date a statement by the period it covers, which a company publishes weeks +later, so a past run is told they are withheld rather than served figures that may +not yet have been public. +""" + +import copy +from unittest import mock + +import pytest + +from tradingagents import default_config +from tradingagents.dataflows import router +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.vendors.alpha_vantage import fundamentals as alpha_vantage +from tradingagents.dataflows.vendors.yahoo import fundamentals as yahoo + +PAST = "2024-03-14" + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [yahoo.get_balance_sheet, yahoo.get_cashflow, yahoo.get_income_statement]) +def test_yahoo_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(yahoo, "yf_retry", side_effect=AssertionError("requested")): + out = statement("SAP.DE", "quarterly", PAST) + assert "withheld" in out and PAST in out and "filing date" in out + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [alpha_vantage.get_balance_sheet, alpha_vantage.get_cashflow, + alpha_vantage.get_income_statement]) +def test_alpha_vantage_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(alpha_vantage, "_make_api_request", side_effect=AssertionError("requested")): + out = statement("IBM", "quarterly", PAST) + assert "withheld" in out and PAST in out + + +@pytest.mark.unit +def test_a_current_run_is_still_served_yahoo_statements(): + import pandas as pd + + from tradingagents.dataflows.date_window import get_current_date + + frame = pd.DataFrame({pd.Timestamp("2026-06-30"): [1.0]}, index=["Total Assets"]) + with mock.patch.object(yahoo, "yf_retry", return_value=frame): + out = yahoo.get_balance_sheet("SAP.DE", "quarterly", get_current_date()) + assert "Total Assets" in out and "withheld" not in out + + +@pytest.mark.unit +def test_statements_come_from_sec_edgar_first_by_default(): + set_config(copy.deepcopy(default_config.DEFAULT_CONFIG)) + served = [] + chain = {name: (lambda *a, _n=name, **k: served.append(_n) or f"{_n} statements") + for name in router.VENDOR_METHODS["get_balance_sheet"]} + with mock.patch.dict(router.VENDOR_METHODS, {"get_balance_sheet": chain}): + router.route_to_vendor("get_balance_sheet", "AAPL", "quarterly", PAST) + assert served == ["sec_edgar"] + + +@pytest.mark.unit +def test_yahoo_withholds_insider_trades_from_a_past_run_without_asking(): + """A trade is dated when it happened; it became public with its Form 4, up to + two business days later, and Yahoo reports no filing date.""" + with mock.patch.object(yahoo, "yf_retry", side_effect=AssertionError("requested")): + out = yahoo.get_insider_transactions("AAPL", PAST) + assert "withheld" in out and PAST in out + + +@pytest.mark.unit +def test_alpha_vantage_withholds_insider_trades_from_a_past_run_without_asking(): + from tradingagents.dataflows.vendors.alpha_vantage import news as alpha_vantage_news + + with mock.patch.object(alpha_vantage_news, "_make_api_request", side_effect=AssertionError("requested")): + out = alpha_vantage_news.get_insider_transactions("IBM", PAST) + assert "withheld" in out and PAST in out diff --git a/tests/test_structured_agent_prompts.py b/tests/test_structured_agent_prompts.py index c89fae767..47b8559ad 100644 --- a/tests/test_structured_agent_prompts.py +++ b/tests/test_structured_agent_prompts.py @@ -72,7 +72,7 @@ def test_research_manager_prompt_states_constraint(): "company_of_interest": "NVDA", "investment_debate_state": { "history": "h", "bull_history": "b", "bear_history": "r", - "current_response": "", "judge_decision": "", "count": 1, + "current_response": "", "count": 1, }, }) assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) diff --git a/tests/test_structured_agents.py b/tests/test_structured_agents.py index d385e0ea3..e168c12cd 100644 --- a/tests/test_structured_agents.py +++ b/tests/test_structured_agents.py @@ -292,7 +292,6 @@ def _make_rm_state(): "bull_history": "Bull says...", "bear_history": "Bear says...", "current_response": "", - "judge_decision": "", "count": 1, }, } diff --git a/tests/test_suite_isolation.py b/tests/test_suite_isolation.py index dd325c984..a14590d43 100644 --- a/tests/test_suite_isolation.py +++ b/tests/test_suite_isolation.py @@ -1,4 +1,4 @@ -"""The suite runs the same on any machine: no test reaches the network.""" +"""The suite runs the same on any machine: no test reaches the network or the user's files.""" import socket @@ -9,9 +9,46 @@ import pytest @pytest.mark.parametrize("connect", [ lambda: socket.create_connection(("192.0.2.1", 80), timeout=1), lambda: socket.socket().connect_ex(("192.0.2.1", 80)), -], ids=["connect", "connect_ex"]) + lambda: socket.getaddrinfo("example.com", 443), + # yfinance's HTTP client is libcurl, which never touches Python's sockets. + lambda: __import__("curl_cffi.requests").requests.Session().get("http://192.0.2.1", timeout=1), +], ids=["connect", "connect_ex", "dns", "curl_cffi"]) def test_a_test_cannot_reach_the_network(connect): """A test that silently depends on a live vendor passes or fails with the machine it runs on; conftest refuses the connection instead.""" with pytest.raises(OSError, match="reach the network"): connect() + + +@pytest.mark.unit +def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path): + """The CLI remembers the last run's selections in the user's home; a test + that runs the selection flow would otherwise overwrite them.""" + from cli import prefs + + prefs.save_last_run({"analysts": ["market"]}) + + assert prefs._PREFS_PATH.is_relative_to(tmp_path) + assert prefs._PREFS_PATH.exists() + + +@pytest.mark.unit +def test_yfinance_keeps_its_cache_out_of_the_users_home(): + """yfinance caches each symbol's time zone on disk, in the home directory by default.""" + from pathlib import Path + + from yfinance.cache import _TzDBManager + + assert not Path(_TzDBManager.get_location()).is_relative_to(Path.home()) + + +@pytest.mark.unit +@pytest.mark.parametrize("key", ["results_dir", "data_cache_dir", "memory_log_path"]) +def test_the_suite_keeps_its_files_out_of_the_users_home(key): + """A test that reads the user's cache sees what their runs fetched, and passes + or fails with it; one that writes there changes their data.""" + from pathlib import Path + + from tradingagents.default_config import DEFAULT_CONFIG + + assert not Path(DEFAULT_CONFIG[key]).is_relative_to(Path.home() / ".tradingagents") diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index f802ae53b..eb8a4dc35 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -10,7 +10,7 @@ import pandas as pd import tradingagents.agents.context as au import tradingagents.dataflows.vendors.yahoo.market as yahoo_market import tradingagents.dataflows.vendors.yahoo.news as ynews -from tradingagents.graph import settlement +from tradingagents.memory import settlement def test_identity_lookup_normalizes_symbol(monkeypatch): @@ -25,7 +25,7 @@ def test_identity_lookup_normalizes_symbol(monkeypatch): return {"longName": "Gold Futures", "quoteType": "FUTURE"} monkeypatch.setattr(yahoo_market.yf, "Ticker", FakeTicker) - au.resolve_instrument_identity.cache_clear() + au._identity.cache_clear() identity = au.resolve_instrument_identity("XAUUSD") diff --git a/tests/test_temperature_config.py b/tests/test_temperature_config.py index 502182fd1..714eb5244 100644 --- a/tests/test_temperature_config.py +++ b/tests/test_temperature_config.py @@ -4,7 +4,6 @@ Temperature is a cross-provider knob: when set it must reach the underlying chat client; when unset the provider keeps its own default. """ -import importlib import pytest @@ -45,18 +44,15 @@ class TestTemperatureEnvOverlay: def test_env_sets_temperature(self, monkeypatch): import tradingagents.default_config as dc monkeypatch.setenv("TRADINGAGENTS_TEMPERATURE", "0.2") - importlib.reload(dc) + config = dc.build_default_config() # Stored on config (string from env is fine; consumed via float()). - assert dc.DEFAULT_CONFIG["temperature"] in ("0.2", 0.2) - assert float(dc.DEFAULT_CONFIG["temperature"]) == 0.2 - monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False) - importlib.reload(dc) + assert config["temperature"] in ("0.2", 0.2) + assert float(config["temperature"]) == 0.2 def test_default_temperature_is_none(self, monkeypatch): import tradingagents.default_config as dc monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False) - importlib.reload(dc) - assert dc.DEFAULT_CONFIG["temperature"] is None + assert dc.build_default_config()["temperature"] is None @pytest.mark.unit diff --git a/tests/test_tool_date_enforcement.py b/tests/test_tool_date_enforcement.py index c313a2db7..cb968d688 100644 --- a/tests/test_tool_date_enforcement.py +++ b/tests/test_tool_date_enforcement.py @@ -68,8 +68,31 @@ def test_trade_date_is_hidden_from_the_model(tool): assert "trade_date" not in tool.tool_call_schema.model_json_schema()["properties"] +INSTRUMENT_TOOLS = [ + tools.get_stock_data, tools.get_indicators, tools.get_verified_market_snapshot, + tools.get_fundamentals, tools.get_balance_sheet, tools.get_cashflow, + tools.get_income_statement, tools.get_news, tools.get_insider_transactions, +] + + +@pytest.mark.unit +@pytest.mark.parametrize("tool", INSTRUMENT_TOOLS, ids=lambda t: t.name) +def test_the_instrument_is_hidden_from_the_model(tool): + """The model once passed an indicator name as the symbol ("rsi", "atr" are + real tickers), and another company's data reached the report.""" + properties = tool.tool_call_schema.model_json_schema()["properties"] + assert "symbol" not in properties and "ticker" not in properties + + +@pytest.mark.unit +def test_a_tool_serves_the_run_instrument_whatever_the_model_passes(): + args = _run(tools.get_indicators, {"symbol": "RSI", "indicator": "rsi", "curr_date": TRADE_DATE}, tools) + assert args[1] == "NVDA" + + class _State(MessagesState): trade_date: str + company_of_interest: str def _run(tool, args, module): @@ -82,28 +105,27 @@ def _run(tool, args, module): graph.compile().invoke({ "messages": [AIMessage("", tool_calls=[{"name": tool.name, "args": args, "id": "1"}])], "trade_date": TRADE_DATE, + "company_of_interest": "NVDA", }) return routed.call_args.args @pytest.mark.unit def test_statement_tool_with_omitted_date_uses_the_run_date(): - args = _run(tools.get_balance_sheet, {"ticker": "AAPL"}, tools) + args = _run(tools.get_balance_sheet, {}, tools) assert args[-1] == TRADE_DATE # #1331: an omitted date no longer means unfiltered @pytest.mark.unit def test_future_curr_date_from_the_model_is_clamped(): - args = _run(tools.get_fundamentals, - {"ticker": "AAPL", "curr_date": "2026-09-14"}, tools) - assert args == ("get_fundamentals", "AAPL", TRADE_DATE) + args = _run(tools.get_fundamentals, {"curr_date": "2026-09-14"}, tools) + assert args == ("get_fundamentals", "NVDA", TRADE_DATE) @pytest.mark.unit def test_future_window_from_the_model_is_clamped(): - args = _run(tools.get_stock_data, - {"symbol": "AAPL", "start_date": "2026-08-01", "end_date": "2026-09-14"}, tools) - assert args == ("get_stock_data", "AAPL", "2026-08-01", TRADE_DATE) + args = _run(tools.get_stock_data, {"start_date": "2026-08-01", "end_date": "2026-09-14"}, tools) + assert args == ("get_stock_data", "NVDA", "2026-08-01", TRADE_DATE) @pytest.mark.unit diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index 2ece8366d..b4b5ed920 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -7,15 +7,14 @@ only live odds, so a historical run withholds them. from __future__ import annotations -import json from unittest import mock import pandas as pd import pytest from tradingagents.agents import tools +from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.vendors import polymarket -from tradingagents.dataflows.vendors.alpha_vantage import news as alpha_vantage_news from tradingagents.dataflows.vendors.yahoo import ( fundamentals as yahoo_fundamentals, market as yahoo_market, @@ -36,48 +35,23 @@ def _yf_insider(frame, curr_date): return yahoo_fundamentals.get_insider_transactions("AAPL", curr_date) -@pytest.mark.unit -def test_yfinance_insider_filings_after_the_date_are_dropped(): - out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02", "2025-05-30", "2025-01-10"), "2025-06-01") - assert "2026-09-08" not in out and "2025-06-02" not in out - assert "2025-05-30" in out and "2025-01-10" in out - - -@pytest.mark.unit -def test_yfinance_insider_date_before_coverage_is_unavailable_not_absent(): - out = _yf_insider(_insider_frame("2026-09-08", "2025-06-02"), "2024-01-01") - assert "unavailable" in out and "No insider transactions reported" not in out - assert "2025-06-02" not in out # a transaction after the run date - - @pytest.mark.unit def test_yfinance_insider_without_a_date_is_unfiltered(): out = _yf_insider(_insider_frame("2026-09-08", "2025-01-10"), None) assert "2026-09-08" in out and "2025-01-10" in out -@pytest.mark.unit -def test_alpha_vantage_insider_filings_after_the_date_are_dropped(): - body = json.dumps({"data": [ - {"transaction_date": "2026-09-08", "executive": "A"}, - {"transaction_date": "2025-05-30", "executive": "B"}, - ]}) - with mock.patch.object(alpha_vantage_news, "_make_api_request", return_value=body): - out = json.loads(alpha_vantage_news.get_insider_transactions("AAPL", "2025-06-01")) - assert [t["executive"] for t in out["data"]] == ["B"] - - @pytest.mark.unit def test_polymarket_withholds_live_odds_from_a_historical_run(): with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")): - out = polymarket.get_prediction_markets("Fed rate cut", curr_date="2025-06-01") + out = polymarket.get_prediction_markets("Fed rate cut", as_of_date="2025-06-01") assert "withheld" in out @pytest.mark.unit def test_polymarket_serves_a_current_run(): with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req: - polymarket.get_prediction_markets("Fed rate cut", curr_date=polymarket.get_current_date()) + polymarket.get_prediction_markets("Fed rate cut", as_of_date=get_current_date()) req.assert_called_once() @@ -93,19 +67,20 @@ def test_trade_date_is_injected_not_model_visible(tool): # --- the instrument's identity ------------------------------------------------- @pytest.mark.unit -def test_a_historical_run_is_told_the_identity_is_current(monkeypatch): - """The company name, sector and industry come from today's vendor profile. - They are usually right for a past date, but a company that renamed or was - reclassified since would read wrong, and every agent is told to anchor to - this identity, so the run has to know which date it describes.""" +def test_a_historical_run_gets_the_current_name_only_as_an_identifier(monkeypatch): + """The profile is today's. The name still tells the company apart from others + (#814), so a past run keeps it, marked as today's; a sector, industry or + exchange that may not have held on the run's date is not given.""" from tradingagents.agents.context import build_instrument_context identity = {"company_name": "Example Corp", "sector": "Technology", "industry": "Software", "exchange": "NMS"} - historical = build_instrument_context("EXMP", "stock", identity, curr_date="2024-03-14") - assert "Example Corp" in historical - assert "2024-03-14" in historical and "today" in historical.lower() + historical = build_instrument_context("EXMP", "stock", identity, trade_date="2024-03-14") + assert "Example Corp" in historical and "current name" in historical + assert "2024-03-14" in historical + for later in ("Technology", "Software", "NMS"): + assert later not in historical @pytest.mark.unit @@ -114,30 +89,9 @@ def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch): from tradingagents.dataflows.date_window import get_current_date today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"}, - curr_date=get_current_date()) + trade_date=get_current_date()) assert "Example Corp" in today - assert "resolved today" not in today.lower() - - -@pytest.mark.unit -def test_insider_rows_are_dated_by_the_trade_not_the_filing(): - """yfinance reports the transaction date and carries no filing date. A trade - becomes public when the Form 4 is filed, up to two business days later, so a - run must not be told these rows were public on their transaction date.""" - import pandas as pd - - frame = pd.DataFrame({ - "Shares": [100, 200], - "Text": ["Sale at price 10.00 per share.", "Sale at price 11.00 per share."], - "Start Date": pd.to_datetime(["2026-05-01", "2026-05-20"]), - }) - ticker = mock.Mock(insider_transactions=frame) - with mock.patch.object(yahoo_market.yf, "Ticker", return_value=ticker): - out = yahoo_fundamentals.get_insider_transactions("AAPL", "2026-05-10") - - assert "2026-05-01" in out and "2026-05-20" not in out # still bounded by the date - assert "transaction date" in out.lower() # and says what the date means - assert "filed" in out.lower() # and that filing comes later + assert "current name" not in today @pytest.mark.unit @@ -158,10 +112,11 @@ def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): # A past date withholds the live profile before any request, so the # fundamentals case is exercised on the date it does fetch. ("get_fundamentals", ("AAPL", None)), - ("get_balance_sheet", ("AAPL", "annual", "2026-09-01")), - ("get_cashflow", ("AAPL", "annual", "2026-09-01")), - ("get_income_statement", ("AAPL", "annual", "2026-09-01")), - ("get_insider_transactions", ("AAPL", "2026-09-01")), + # Statements are requested only for a run dated today; a past one withholds them. + ("get_balance_sheet", ("AAPL", "annual", get_current_date())), + ("get_cashflow", ("AAPL", "annual", get_current_date())), + ("get_income_statement", ("AAPL", "annual", get_current_date())), + ("get_insider_transactions", ("AAPL", get_current_date())), ]) def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args): """Returning the failure as text makes the router count it as an answer, so @@ -196,19 +151,19 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): company has no balance sheet, when the truth is we could not ask.""" import pandas as pd - from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError - from tradingagents.dataflows.vendors.yahoo import ohlcv + from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError + from tradingagents.dataflows.vendors.yahoo import common empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame()) monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: empty) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) - with pytest.raises(VendorRateLimitError, match="unreachable"): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + monkeypatch.setattr(common, "vendor_reachable", lambda url: False) + with pytest.raises(VendorUnavailableError, match="unreachable"): + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) @pytest.mark.unit @@ -216,11 +171,13 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): """A throttled or unreachable chain used to raise RuntimeError('No available vendor'), which ends the run, and never said the vendor was the problem.""" from tradingagents.dataflows import router - from tradingagents.dataflows.errors import VendorRateLimitError + from tradingagents.dataflows.config import set_config + from tradingagents.dataflows.errors import VendorUnavailableError def _down(*a, **k): - raise VendorRateLimitError("Yahoo Finance is unreachable") + raise VendorUnavailableError("Yahoo Finance is unreachable") + set_config({"data_vendors": {"fundamental_data": "yfinance"}}) monkeypatch.setitem(router.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down) out = router.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01") @@ -235,16 +192,16 @@ def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch) delisted symbol either.""" import pandas as pd - from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError - from tradingagents.dataflows.vendors.yahoo import ohlcv + from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError + from tradingagents.dataflows.vendors.yahoo import common monkeypatch.setattr(yahoo_market.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame())) - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) - with pytest.raises(VendorRateLimitError, match="unreachable"): + monkeypatch.setattr(common, "vendor_reachable", lambda url: False) + with pytest.raises(VendorUnavailableError, match="unreachable"): yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") - monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): yahoo_market.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10") @@ -297,7 +254,7 @@ def test_an_unavailable_notice_names_no_date_after_the_run(): coverage_gap([pd.Timestamp(today, tz="UTC")], "2025-01-01", "2025-01-07", "Feed", "news"), withhold_live_profile("2025-01-07", "AAPL"), _yf_insider(_insider_frame(today), "2025-01-07"), - build_instrument_context("EXMP", "stock", {"company_name": "Example"}, curr_date="2025-01-07"), + build_instrument_context("EXMP", "stock", {"company_name": "Example"}, trade_date="2025-01-07"), ] for notice in notices: assert _dates_after(notice, "2025-01-07") == [], notice diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py index 51e2f15fd..f04eec8a1 100644 --- a/tests/test_vendor_errors.py +++ b/tests/test_vendor_errors.py @@ -16,7 +16,7 @@ from tradingagents.dataflows.errors import ( NoMarketDataError, VendorError, VendorNotConfiguredError, - VendorRateLimitError, + VendorUnavailableError, ) from tradingagents.dataflows.vendors.alpha_vantage.common import ( AlphaVantageNotConfiguredError, @@ -28,7 +28,7 @@ from tradingagents.dataflows.vendors.fred import FredNotConfiguredError @pytest.mark.unit class HierarchyTests(unittest.TestCase): def test_all_conditions_derive_from_vendor_error(self): - for cls in (NoMarketDataError, VendorRateLimitError, VendorNotConfiguredError): + for cls in (NoMarketDataError, VendorUnavailableError, VendorNotConfiguredError): self.assertTrue(issubclass(cls, VendorError)) def test_not_configured_is_still_a_value_error(self): @@ -36,7 +36,7 @@ class HierarchyTests(unittest.TestCase): self.assertTrue(issubclass(VendorNotConfiguredError, ValueError)) def test_vendor_named_errors_subclass_the_generic_bases(self): - self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorRateLimitError)) + self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorUnavailableError)) self.assertTrue(issubclass(AlphaVantageNotConfiguredError, VendorNotConfiguredError)) self.assertTrue(issubclass(FredNotConfiguredError, VendorNotConfiguredError)) # ... and therefore still ValueErrors @@ -94,6 +94,26 @@ class RouterHandlesBaseTypesTests(unittest.TestCase): ), self.assertRaises(AlphaVantageNotConfiguredError): router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + def test_an_unavailable_vendor_keeps_absence_from_being_claimed(self): + # The throttled vendor's coverage was never learned, so "no usable data + # from any configured vendor" would be a claim nobody checked. + def throttled(*a, **k): + raise VendorUnavailableError("Yahoo HTTP 429") + + def timed_out(*a, **k): + raise TimeoutError("read timed out") # a vendor that does not type its failures + + def not_covered(*a, **k): + raise NoMarketDataError("AAPL", "AAPL", "not covered") + + for chain in ({"yfinance": throttled, "alpha_vantage": not_covered}, + {"yfinance": not_covered, "alpha_vantage": throttled}, + {"alpha_vantage": timed_out, "yfinance": not_covered}): + set_config({"data_vendors": {"core_stock_apis": ",".join(chain)}}) + with mock.patch.dict(router.VENDOR_METHODS, {"get_stock_data": chain}, clear=False): + out = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") + self.assertTrue(out.startswith("DATA_UNAVAILABLE"), out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vendor_routing.py b/tests/test_vendor_routing.py index 85db0ed3f..3d21759bf 100644 --- a/tests/test_vendor_routing.py +++ b/tests/test_vendor_routing.py @@ -72,13 +72,14 @@ class VendorRoutingTests(unittest.TestCase): self.assertEqual(result, "AV_DATA") def test_primary_error_is_logged_not_masked(self): - # #989: primary errors + fallback no-data -> NO_DATA, but the failure - # must be visible in logs (broken primary not hidden). + # #989: a broken primary is not hidden behind a fallback's verdict. It + # never said whether it has the symbol, so the answer is unavailable, + # and the failure is in the logs. set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}}) with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \ self.assertLogs("tradingagents.dataflows.router", level="WARNING") as cm: result = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10") - self.assertIn("NO_DATA_AVAILABLE", result) + self.assertTrue(result.startswith("DATA_UNAVAILABLE"), result) joined = "\n".join(cm.output) self.assertIn("boom", joined) # the real error surfaced in logs self.assertIn("yfinance", joined) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..212960b87 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,18 @@ +"""The package version has one source, tradingagents.__version__, which the build reads.""" + +import re +from pathlib import Path + +import pytest + +import tradingagents + +PYPROJECT = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + + +@pytest.mark.unit +def test_the_build_reads_the_version_from_the_package(): + assert re.search(r'^dynamic = \["version"\]', PYPROJECT, re.M) + assert 'version = {attr = "tradingagents.__version__"}' in PYPROJECT + assert not re.search(r'^version = "', PYPROJECT, re.M) + assert re.fullmatch(r"\d+\.\d+\.\d+(\.dev\d+|rc\d+)?", tradingagents.__version__) diff --git a/tests/test_yahoo_rate_limit.py b/tests/test_yahoo_rate_limit.py new file mode 100644 index 000000000..29d3b14be --- /dev/null +++ b/tests/test_yahoo_rate_limit.py @@ -0,0 +1,286 @@ +"""A Yahoo rate limit is retried, then reported as a rate limit. + +When the limit outlasts the retries, the agent must hear that the vendor is +throttled. "The symbol may be invalid or delisted" is a claim about the company +that nobody checked, and an exception out of a tool ends the run. +""" + +import pandas as pd +import pytest +import yfinance as yf +from yfinance.data import YfData +from yfinance.exceptions import YFRateLimitError + +from tradingagents.agents.tools import ( + get_global_news, + get_indicators, + get_insider_transactions, + get_news, + get_stock_data, + get_verified_market_snapshot, +) +from tradingagents.dataflows import router +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.errors import VendorUnavailableError +from tradingagents.dataflows.vendors.yahoo import common, fundamentals, ohlcv + +DAY = "2026-09-18" + + +def _rate_limited(*args, **kwargs): + raise YFRateLimitError() + + +@pytest.fixture +def yahoo(monkeypatch, tmp_path): + set_config({"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(common.time, "sleep", lambda seconds: None) + for module in (common, fundamentals): + monkeypatch.setattr(module, "vendor_reachable", lambda url: True) + return monkeypatch + + +@pytest.mark.unit +@pytest.mark.parametrize("tool, args", [ + pytest.param(get_stock_data, ("AAPL", "2026-09-10", DAY), id="stock_data"), + pytest.param(get_indicators, ("AAPL", "rsi", DAY, 5), id="indicators"), + pytest.param(get_verified_market_snapshot, ("AAPL", DAY), id="snapshot"), + pytest.param(get_news, ("AAPL", "2026-09-10", DAY), id="news"), + pytest.param(get_global_news, (DAY, 7, 5), id="global_news"), + pytest.param(get_insider_transactions, ("AAPL",), id="insider"), +]) +def test_a_rate_limit_that_outlasts_the_retries_is_reported_as_one(yahoo, tool, args): + for name in ("history", "get_news"): + yahoo.setattr(yf.Ticker, name, _rate_limited) + yahoo.setattr(yf.Ticker, "insider_transactions", property(_rate_limited)) + yahoo.setattr(yf, "Search", _rate_limited) + + # Dated today: a past run is not served insider trades at all. + out = tool.func(*args, trade_date=get_current_date()) + + assert out.startswith("DATA_UNAVAILABLE"), out + assert "delisted" not in out + + +@pytest.mark.unit +def test_the_indicator_path_retries_a_rate_limit(yahoo): + """The prices behind every indicator were fetched with ``yf.download``, which + returns an empty frame for a 429, so they were never retried.""" + bar = pd.DataFrame({"Open": [1.0], "High": [1.0], "Low": [1.0], "Close": [1.0], + "Volume": [100]}, index=pd.DatetimeIndex([DAY], name="Date")) + answers = [YFRateLimitError(), bar] + + def history(self, **kwargs): + answer = answers.pop(0) + if isinstance(answer, Exception): + raise answer + return answer + + yahoo.setattr(yf.Ticker, "history", history) + + assert ohlcv.load_ohlcv("AAPL", DAY)["Close"].tolist() == [1.0] + + +@pytest.mark.unit +def test_yfinance_raises_the_rate_limit_from_history(yahoo): + yahoo.setattr(YfData, "_make_request", _rate_limited) + + with pytest.raises(VendorUnavailableError, match="rate limited"): + ohlcv.load_ohlcv("AAPL", DAY) + + +@pytest.mark.unit +def test_fundamentals_ask_a_new_ticker_after_a_rate_limit(yahoo): + """A Ticker keeps a failed ``info`` fetch as done, so asking the same one + again reads an empty profile, which looks like a symbol with no data.""" + + class Ticker: + def __init__(self, symbol): + self.fetched = False + + @property + def info(self): + if self.fetched: + return {} + self.fetched = True + raise YFRateLimitError() + + yahoo.setattr(yf, "Ticker", Ticker) + + out = router.route_to_vendor("get_fundamentals", "AAPL", None) + + assert out.startswith("DATA_UNAVAILABLE"), out + + +@pytest.mark.unit +def test_a_price_request_that_raises_is_unavailable_whether_or_not_yahoo_answers(yahoo): + """An unknown symbol comes back from ``Ticker.history`` as an empty frame, so + a request that raised failed in transit, and says nothing about the symbol.""" + + def refused(self, **kwargs): + raise ConnectionError("curl: (7) Failed to connect to query2.finance.yahoo.com") + + yahoo.setattr(yf.Ticker, "history", refused) + for reachable in (False, True): + yahoo.setattr(common, "vendor_reachable", lambda url, _r=reachable: _r) + with pytest.raises(VendorUnavailableError, match="request failed"): + ohlcv.load_ohlcv("AAPL", DAY) + + +@pytest.mark.unit +def test_a_price_request_that_fails_is_unavailable_even_when_yahoo_answers(monkeypatch, tmp_path): + """A timeout says nothing about the symbol; a reachable host does not make it one.""" + def timed_out(**kwargs): + raise TimeoutError("read timed out") + + monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: timed_out(**k)})()) + monkeypatch.setattr(common, "vendor_reachable", lambda url: True) + + with pytest.raises(VendorUnavailableError): + ohlcv.load_ohlcv("NVDA", "2026-09-23") + + +class _Unreachable: + """A Ticker whose every request fails in transit.""" + + def __init__(self, *args, **kwargs): + pass + + def __getattr__(self, name): + raise TimeoutError("read timed out") + + +def _yahoo_calls(): + from tradingagents.dataflows.date_window import get_current_date + from tradingagents.dataflows.vendors.yahoo import market, news + + today = get_current_date() + return [ + pytest.param(lambda: fundamentals.get_fundamentals("AAPL", today), id="fundamentals"), + pytest.param(lambda: fundamentals.get_balance_sheet("AAPL", "quarterly", today), id="statement"), + pytest.param(lambda: fundamentals.get_insider_transactions("AAPL", today), id="insider"), + pytest.param(lambda: fundamentals.get_company_profile("AAPL"), id="profile"), + pytest.param(lambda: news.get_news_yfinance("AAPL", "2026-09-10", DAY), id="news"), + pytest.param(lambda: news.get_global_news_yfinance(DAY, 7, 5), id="global_news"), + pytest.param(lambda: market.get_YFin_data_online("AAPL", "2026-09-10", DAY), id="stock_data"), + pytest.param(lambda: market.get_closes("AAPL", "2026-09-10", DAY), id="closes"), + ] + + +@pytest.mark.unit +@pytest.mark.parametrize("call", _yahoo_calls()) +def test_a_yahoo_request_that_fails_is_unavailable_not_absent(yahoo, call): + """An unknown symbol comes back empty, so a request that raised failed in transit.""" + yahoo.setattr(yf, "Ticker", _Unreachable) + yahoo.setattr(yf, "Search", _Unreachable) + + with pytest.raises(VendorUnavailableError): + call() + + +@pytest.mark.unit +@pytest.mark.parametrize("frame", [ + pytest.param(pd.DataFrame(), id="unknown_symbol"), + pytest.param(pd.DataFrame({"Date": pd.to_datetime(["2026-09-25"]), "Open": [1.0], "High": [1.0], + "Low": [1.0], "Close": [1.0], "Volume": [1]}), id="no_row_by_the_date"), +]) +def test_a_snapshot_with_no_rows_is_reported_not_raised(yahoo, frame): + """An exception out of a tool ends the run; no rows is an answer about the symbol.""" + from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import snapshot + + def load(symbol, as_of_date, **kwargs): + if frame.empty: + raise NoMarketDataError(symbol, symbol, "no price rows") + return frame + + yahoo.setattr(snapshot, "load_ohlcv", load) + + out = get_verified_market_snapshot.func("ZZZZ", DAY, trade_date=DAY) + + assert out.startswith("NO_DATA_AVAILABLE"), out + + +def _yahoo_request_raises(yahoo, exc): + """Every request yfinance makes raises ``exc``, below its own error handling.""" + from yfinance.data import YfData + + def request(self, *args, **kwargs): + raise exc + + yahoo.setattr(YfData, "_make_request", request) + + +@pytest.mark.unit +@pytest.mark.parametrize("call", _yahoo_calls()) +def test_a_request_that_yfinance_would_swallow_is_unavailable(yahoo, call): + """yfinance answers some failed requests with an empty result; an empty + statement from a Yahoo whose host answers a probe read as "no data".""" + _yahoo_request_raises(yahoo, TimeoutError("read timed out")) + + with pytest.raises(VendorUnavailableError): + call() + + +@pytest.mark.unit +@pytest.mark.parametrize("call", [c for c in _yahoo_calls() if c.id in ("fundamentals", "stock_data", "insider")]) +def test_yahoo_answering_not_found_is_no_data(yahoo, call): + """Yahoo answers an unknown symbol with HTTP 404: an answer about the symbol, not an outage.""" + from curl_cffi.requests import Response + from curl_cffi.requests.exceptions import HTTPError + + from tradingagents.dataflows.errors import NoMarketDataError + + not_found = Response() + not_found.status_code = 404 + _yahoo_request_raises(yahoo, HTTPError("HTTP Error 404: ", response=not_found)) + + try: + out = call() + except NoMarketDataError: + return + assert out.startswith("No insider transactions"), out # an empty filing list is not an error + + +@pytest.mark.unit +@pytest.mark.parametrize("debug, reason, absent", [ + pytest.param(" (1d 2026-09-26 -> 2026-09-27)", None, True, id="a_chart_with_no_prices"), + pytest.param(" (1d 1970-01-01 -> 1970-02-01)", "Data doesn't exist for startDate = 18000, endDate = 2696400", + True, id="yahoo_says_the_data_does_not_exist"), + pytest.param(" (1d 2026-09-21 -> 2026-09-25)(Yahoo status_code = 503)", None, False, id="an_http_error"), + pytest.param(" (1d 2026-09-21 -> 2026-09-25)", "Service temporarily unavailable", False, + id="yahoo_reports_a_service_failure"), +]) +def test_only_yahoo_saying_a_window_has_no_prices_is_no_data(yahoo, debug, reason, absent): + """yfinance raises YFPricesMissingError both when Yahoo answered with no prices + and when its answer was an error; only the first is a fact about the symbol.""" + from yfinance.exceptions import YFPricesMissingError + + from tradingagents.dataflows.errors import NoMarketDataError + from tradingagents.dataflows.vendors.yahoo import market + + def no_prices(self, **kwargs): + raise YFPricesMissingError("AAPL", debug, yahoo_reason=reason) + + yahoo.setattr(yf.Ticker, "history", no_prices) + + with pytest.raises(NoMarketDataError if absent else VendorUnavailableError): + market.get_YFin_data_online("AAPL", "2026-09-21", "2026-09-25") + + +@pytest.mark.unit +def test_a_missing_time_zone_is_not_taken_for_an_answer(yahoo): + """yfinance reports a failed time-zone lookup the same way as a symbol without one.""" + from yfinance.exceptions import YFTzMissingError + + from tradingagents.dataflows.vendors.yahoo import market + + def no_tz(self, **kwargs): + raise YFTzMissingError("AAPL") + + yahoo.setattr(yf.Ticker, "history", no_tz) + + with pytest.raises(VendorUnavailableError): + market.get_YFin_data_online("AAPL", "2026-09-21", "2026-09-25") diff --git a/tests/test_yahoo_snapshot.py b/tests/test_yahoo_snapshot.py index 51c852c08..07dbc3d7e 100644 --- a/tests/test_yahoo_snapshot.py +++ b/tests/test_yahoo_snapshot.py @@ -6,6 +6,7 @@ import pandas as pd import pytest import tradingagents.dataflows.vendors.yahoo.snapshot as validator +from tradingagents.dataflows.errors import NoMarketDataError def _sample_ohlcv() -> pd.DataFrame: @@ -47,12 +48,12 @@ class TestVerifiedSnapshot: def test_raises_when_no_rows_on_or_before_date(self, monkeypatch): monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv()) - with pytest.raises(ValueError): + with pytest.raises(NoMarketDataError): 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, fill_gaps=True: pd.DataFrame()) - with pytest.raises(ValueError): + with pytest.raises(NoMarketDataError): validator.build_verified_market_snapshot("COF", "2026-05-13") def test_look_back_window_capped_at_30(self, monkeypatch): diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 6151e5e91..01faa960c 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,3 +1,7 @@ +"""TradingAgents: multi-agent LLM financial trading framework.""" + +__version__ = "0.5.2" + from dotenv import find_dotenv, load_dotenv # Load .env at package import so DEFAULT_CONFIG's env-var overlay and every LLM diff --git a/tradingagents/agents/__init__.py b/tradingagents/agents/__init__.py index 5675c7d2e..169b70910 100644 --- a/tradingagents/agents/__init__.py +++ b/tradingagents/agents/__init__.py @@ -2,7 +2,6 @@ from .analysts.fundamentals_analyst import create_fundamentals_analyst from .analysts.market_analyst import create_market_analyst from .analysts.news_analyst import create_news_analyst from .analysts.sentiment_analyst import create_sentiment_analyst -from .context import create_msg_delete from .managers.portfolio_manager import create_portfolio_manager from .managers.research_manager import create_research_manager from .researchers.bear_researcher import create_bear_researcher @@ -15,7 +14,6 @@ from .trader.trader import create_trader __all__ = [ "AgentState", - "create_msg_delete", "InvestDebateState", "RiskDebateState", "create_bear_researcher", diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index f368a25de..961d58203 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_balance_sheet, @@ -53,14 +54,7 @@ def create_fundamentals_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index b8abf576d..3a95df7e8 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import get_indicators, get_stock_data, get_verified_market_snapshot @@ -73,14 +74,7 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index fc9e0c9c9..df80d114e 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -1,5 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.analysts.turn import take_turn from tradingagents.agents.context import get_instrument_context_from_state, get_language_instruction from tradingagents.agents.tools import ( get_global_news, @@ -25,7 +26,7 @@ def create_news_analyst(llm): instrument_context = get_instrument_context_from_state(state) system_message = ( - f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(start_date, end_date) for news about the {asset_label} under analysis, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" + get_language_instruction() ) @@ -52,13 +53,7 @@ def create_news_analyst(llm): prompt = prompt.partial(current_date=current_date) prompt = prompt.partial(instrument_context=instrument_context) - chain = prompt | llm.bind_tools(TOOLS) - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content + result, report = take_turn(prompt, llm, TOOLS, state["messages"]) return { "messages": [result], diff --git a/tradingagents/agents/analysts/turn.py b/tradingagents/agents/analysts/turn.py new file mode 100644 index 000000000..3b568b22d --- /dev/null +++ b/tradingagents/agents/analysts/turn.py @@ -0,0 +1,42 @@ +"""One turn of a tool-using analyst, and its last turn once its tool rounds are spent.""" + +import json + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +WRAP_UP = ( + "You have used every tool round this report allows. Write your final report " + "now from the tool results above, and say which data you could not retrieve." +) + + +def take_turn(prompt, llm, tools, messages): + """The analyst's reply and its report, which stays empty while it calls tools. + + Told to wrap up, it answers without tools: a model offered tools may call one + whatever it is told. Its earlier calls and their results reach it as text, + since a provider may refuse a history holding tool calls when none are bound. + """ + if messages and isinstance(messages[-1], HumanMessage) and messages[-1].content == WRAP_UP: + # The prompt lists the tools it offers; a model told of tools it cannot + # call may still try, and some providers then answer with nothing. + prompt = prompt.partial(tool_names="none; your tool rounds are spent") + result = (prompt | llm).invoke(_as_text(messages)) + return result, result.content + result = (prompt | llm.bind_tools(tools)).invoke(messages) + return result, "" if result.tool_calls else result.content + + +def _as_text(messages): + """The history with its tool calls and tool results written out as plain messages.""" + written = [] + for message in messages: + if isinstance(message, AIMessage) and message.tool_calls: + said = message.content if isinstance(message.content, str) else "" + calls = "; ".join(f"{c['name']}({json.dumps(c['args'], default=str)})" for c in message.tool_calls) + written.append(AIMessage(content=f"{said}\n[Called {calls}]".strip())) + elif isinstance(message, ToolMessage): + written.append(HumanMessage(content=f"[{message.name or 'Tool'} returned]\n{message.content}")) + else: + written.append(message) + return written diff --git a/tradingagents/agents/context.py b/tradingagents/agents/context.py index 69276fd61..88a01b9d2 100644 --- a/tradingagents/agents/context.py +++ b/tradingagents/agents/context.py @@ -1,14 +1,11 @@ -"""Prompt context shared by the agents: instrument identity, output language, -portfolio, and the message reset between analysts.""" +"""Prompt context shared by the agents: instrument identity, output language and portfolio.""" import functools import logging from collections.abc import Mapping from typing import Any -from langchain_core.messages import HumanMessage, RemoveMessage - -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.date_window import is_historical from tradingagents.dataflows.vendors.yahoo.fundamentals import get_company_profile logger = logging.getLogger(__name__) @@ -27,7 +24,14 @@ def get_language_instruction() -> str: lang = get_config().get("output_language", "English") if lang.strip().lower() == "english": return "" - return f" Write your entire response in {lang}." + # The labelled lines are read by the program, so they keep their English + # label and value: a translated rating line leaves the reader prose to + # search, where a negated rating ("not a Sell") reads as the call (#1435). + return ( + f" Write your entire response in {lang}, except the labelled lines the format" + f" asks for (the \"**Rating**:\" line, \"FINAL TRANSACTION PROPOSAL:\"):" + f" keep their label and value in English, exactly as specified." + ) def opponent_argument_or_opening(text: str, opponent: str) -> str: @@ -54,7 +58,6 @@ def _clean_identity_value(value: Any) -> str | None: return cleaned -@functools.lru_cache(maxsize=256) def resolve_instrument_identity(ticker: str) -> dict: """Resolve deterministic identity metadata (company name, sector, …) for a ticker. @@ -66,18 +69,23 @@ def resolve_instrument_identity(ticker: str) -> dict: Best-effort by design: if yfinance is unavailable, rate-limited, or doesn't recognise the ticker, we return ``{}`` and the caller falls back to - ticker-only context rather than failing before analysis starts. Cached so - the lookup happens at most once per ticker per process. + ticker-only context rather than failing before analysis starts. An answer + is cached for the process; a failed lookup is asked again next time. Identity resolves for the same instrument the price path fetches (``XAUUSD`` -> ``GC=F``, #983). """ try: - info = get_company_profile(ticker) + return _identity(ticker) except Exception as exc: # noqa: BLE001 — fail open, never block the run logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc) return {} + +@functools.lru_cache(maxsize=256) +def _identity(ticker: str) -> dict: + """The vendor's identity fields for ``ticker``; raises if the lookup fails.""" + info = get_company_profile(ticker) identity: dict[str, str] = {} company_name = _clean_identity_value(info.get("longName")) or _clean_identity_value( info.get("shortName") @@ -100,7 +108,7 @@ def build_instrument_context( ticker: str, asset_type: str = "stock", identity: Mapping[str, str] | None = None, - curr_date: str | None = None, + trade_date: str | None = None, ) -> str: """Describe the exact instrument so agents preserve identity and ticker. @@ -110,23 +118,32 @@ def build_instrument_context( than pattern-matching the price chart to a wrong one (#814). That profile carries no historical vintage: it describes the company today. - For a run dated earlier, the context says so, since a company that has since - renamed or been reclassified would otherwise anchor the whole graph to an - identity it did not have on the analysis date. + A run dated earlier gets the current name alone, as a way to tell the + company apart from others rather than as what it was called then; a sector, + industry or exchange it holds today is not given, since it may not have held + on the analysis date. """ is_crypto = asset_type == "crypto" instrument_label = "asset" if is_crypto else "instrument" context = ( f"The {instrument_label} to analyze is `{ticker}`. " - "Use this exact ticker in every tool call, report, and recommendation, " + "The tools serve this instrument; refer to it by this exact ticker in every report and recommendation, " "preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)." ) + identity = identity or {} + name = identity.get("company_name") or identity.get("name") + label = "Name" if is_crypto else "Company" details = [] - if identity: - name = identity.get("company_name") or identity.get("name") + if is_historical(trade_date): if name: - details.append(f"{'Name' if is_crypto else 'Company'}: {name}") + details.append( + f"{label}: {name} (its current name, given only to identify it; " + f"on {trade_date} it may have been named differently)" + ) + else: + if name: + details.append(f"{label}: {name}") sector, industry = identity.get("sector"), identity.get("industry") if sector and industry: details.append(f"Business classification: {sector} / {industry}") @@ -143,13 +160,6 @@ def build_instrument_context( "Do not substitute a different company or ticker unless a tool " "result explicitly disproves this resolved identity." ) - today = get_current_date() - if curr_date and str(curr_date) < today: - context += ( - f" This identity is how the vendor describes the instrument today, " - f"not necessarily on {curr_date}: a name or classification changed " - f"since then would read as the current one." - ) if is_crypto: context += ( @@ -206,33 +216,3 @@ def get_portfolio_context_from_state(state: Mapping[str, Any]) -> str: "holdings or cash, so do not assume a flat book; give direction and " "sizing guidance in terms the caller can apply to their own position." ) - - -def create_msg_delete(): - def delete_messages(state): - """Clear messages and add a context-anchored placeholder. - - The placeholder must not be a bare ``"Continue"``: some - OpenAI-compatible providers interpret that literally as the user task - and produce output about the word "continue" instead of analysing the - instrument (#888). Anchoring it to the resolved instrument context and - date keeps the next analyst on-task even if the provider treats the - placeholder as a standalone request. - """ - messages = state["messages"] - removal_operations = [RemoveMessage(id=m.id) for m in messages] - - instrument_context = get_instrument_context_from_state(state) - trade_date = state.get("trade_date", "the requested date") - placeholder = HumanMessage( - content=( - f"Proceed with your assigned analysis for this workflow. " - f"{instrument_context} The analysis date is {trade_date}." - ) - ) - return {"messages": removal_operations + [placeholder]} - - return delete_messages - - - diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py index 442fbb5d6..0537b5e80 100644 --- a/tradingagents/agents/managers/portfolio_manager.py +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -1,11 +1,11 @@ """Portfolio Manager: synthesises the risk-analyst debate into the final decision. Uses LangChain's ``with_structured_output`` so the LLM produces a typed -``PortfolioDecision`` directly, in a single call. The result is rendered -back to markdown for storage in ``final_trade_decision`` so memory log, -CLI display, and saved reports continue to consume the same shape they do -today. When a provider does not expose structured output, the agent falls -back gracefully to free-text generation. +``PortfolioDecision`` directly, in a single call. Its rating is the run's +``final_rating``, and the decision is rendered to markdown as +``final_trade_decision`` for the memory log, CLI display and saved reports. +When a provider does not expose structured output, the agent falls back to +free-text generation and the rating is read from that text. """ from __future__ import annotations @@ -15,12 +15,9 @@ from tradingagents.agents.context import ( get_language_instruction, get_portfolio_context_from_state, ) +from tradingagents.agents.rating import parse_rating from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision -from tradingagents.agents.structured import ( - NO_EXTERNAL_TOOLS, - bind_structured, - invoke_structured_or_freetext, -) +from tradingagents.agents.structured import NO_EXTERNAL_TOOLS, bind_structured, invoke_structured def create_portfolio_manager(llm): @@ -78,16 +75,17 @@ Write these sections, in this order, starting with the rating on its own line: {NO_EXTERNAL_TOOLS}{get_language_instruction()}""" - final_trade_decision = invoke_structured_or_freetext( - structured_llm, - llm, - prompt, - render_pm_decision, - "Portfolio Manager", - ) + # The typed rating is the decision; the rendered text only carries it. + # Read back from text, a rating the thesis quotes could replace it. + decision = invoke_structured(structured_llm, prompt, "Portfolio Manager") + if decision is not None: + final_trade_decision = render_pm_decision(decision) + final_rating = decision.rating.value + else: + final_trade_decision = llm.invoke(prompt).content + final_rating = parse_rating(final_trade_decision) new_risk_debate_state = { - "judge_decision": final_trade_decision, "history": risk_debate_state["history"], "aggressive_history": risk_debate_state["aggressive_history"], "conservative_history": risk_debate_state["conservative_history"], @@ -102,6 +100,7 @@ Write these sections, in this order, starting with the rating on its own line: return { "risk_debate_state": new_risk_debate_state, "final_trade_decision": final_trade_decision, + "final_rating": final_rating, } return portfolio_manager_node diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py index f1bb2c4fc..3bf288c60 100644 --- a/tradingagents/agents/managers/research_manager.py +++ b/tradingagents/agents/managers/research_manager.py @@ -59,7 +59,6 @@ Write these sections, in this order, starting with the recommendation on its own ) new_investment_debate_state = { - "judge_decision": investment_plan, "history": investment_debate_state.get("history", ""), "bear_history": investment_debate_state.get("bear_history", ""), "bull_history": investment_debate_state.get("bull_history", ""), diff --git a/tradingagents/agents/rating.py b/tradingagents/agents/rating.py index daaa3a492..c82d5cb28 100644 --- a/tradingagents/agents/rating.py +++ b/tradingagents/agents/rating.py @@ -2,8 +2,7 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: - The Research Manager (investment plan recommendation) -- The Portfolio Manager (final position decision) -- The signal processor (rating extracted for downstream consumers) +- The Portfolio Manager (final position decision; its free-text fallback is read here) - The memory log (rating tag stored alongside each decision entry) Centralising it here avoids drift between those call sites. @@ -33,49 +32,54 @@ RATING_REVIEW = "REVIEW" _RATING_SET = {r.lower() for r in RATINGS_5_TIER} # Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown -# bold wrappers and any dash or colon a model writes as the separator. -_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)", +# bold wrappers and any dash or colon a model writes as the separator. "rating" +# must start a word, so "Operating margin: Sell-side" is not a label. +_RATING_LABEL_RE = re.compile(r"(? str | None: - """Extract a 5-tier rating from prose, or ``None`` if none is present. + """Extract a 5-tier rating from its label, or ``None`` if there is none. - Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like - ``Rating:Overweight`` is matched the same as ASCII): - 1. An explicit "Rating: X" label (tolerant of markdown bold). - 2. The first standalone 5-tier rating word found anywhere. + Reads an explicit "Rating: X" label (tolerant of markdown bold) in the + NFKC-normalized text, so fullwidth punctuation like ``Rating:Overweight`` + matches as ASCII does: the first one opening its own line, else the last + one anywhere. """ if not text: return None norm = unicodedata.normalize("NFKC", text) - # The labelled rating, taking the last one written: a decision states its - # rating after discussing the alternatives. Lines presenting the scale - # itself are a legend the model echoed, not a call. - labelled = None + # A decision is asked to open with its rating on its own line, so the first + # such line is the call; later ones may quote someone else's ("Consensus + # rating: Buy"). Without one, the last label anywhere wins: prose states its + # rating after discussing the alternatives. Lines presenting the scale itself + # are a legend the model echoed, not a call. + on_own_line = anywhere = None for line in norm.splitlines(): if _RATING_SCALE_RE.search(line): continue + m = _RATING_LINE_RE.match(line) + if on_own_line is None and m and m.group(1).lower() in _RATING_SET: + on_own_line = m.group(1).capitalize() m = _RATING_LABEL_RE.search(line) if m and m.group(1).lower() in _RATING_SET: - labelled = m.group(1).capitalize() - if labelled: - return labelled - - # No label. A single rating word in the text is the call; several are an - # argument, and picking one of them reports a direction nobody decided -- - # prose that rejects a Buy before concluding Underweight read as Buy. - named = {m.group(1).capitalize() for m in _RATING_WORD_RE.finditer(norm)} - return named.pop() if len(named) == 1 else None + anywhere = m.group(1).capitalize() + # Without a label there is no call to read: a rating word in the prose may be + # one the text argues against ("not a Sell"), and reading it reports a + # direction nobody decided. + return on_own_line or anywhere def parse_rating(text: str, default: str = RATING_REVIEW) -> str: @@ -88,6 +92,15 @@ def parse_rating(text: str, default: str = RATING_REVIEW) -> str: return rating if rating is not None else default +def run_rating(final_state: dict) -> str: + """A finished run's rating: the Portfolio Manager's own, else read from its decision. + + The fallback serves a state without ``final_rating``, such as a run an older + version completed and a checkpoint hands back unchanged. + """ + return final_state.get("final_rating") or parse_rating(final_state.get("final_trade_decision", "")) + + def is_review(signal: str) -> bool: """Whether a signal is the non-tradeable REVIEW sentinel (#1170).""" return signal == RATING_REVIEW diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py index fb89aa42b..be561f2c4 100644 --- a/tradingagents/agents/schemas.py +++ b/tradingagents/agents/schemas.py @@ -18,7 +18,7 @@ so that: from __future__ import annotations -from enum import Enum +from enum import StrEnum from typing import Literal from pydantic import BaseModel, Field, field_validator @@ -63,7 +63,7 @@ def _coerce_optional_float(value): # --------------------------------------------------------------------------- -class PortfolioRating(str, Enum): +class PortfolioRating(StrEnum): """5-tier rating used by the Research Manager and Portfolio Manager.""" BUY = "Buy" @@ -73,7 +73,7 @@ class PortfolioRating(str, Enum): SELL = "Sell" -class TraderAction(str, Enum): +class TraderAction(StrEnum): """3-tier transaction direction used by the Trader. The Trader's job is to translate the Research Manager's investment plan @@ -293,7 +293,7 @@ def render_pm_decision(decision: PortfolioDecision) -> str: # --------------------------------------------------------------------------- -class SentimentBand(str, Enum): +class SentimentBand(StrEnum): """Discrete sentiment direction produced by the Sentiment Analyst. Six tiers keep the signal granular enough to be actionable while remaining diff --git a/tradingagents/agents/state.py b/tradingagents/agents/state.py index 0d6df58cc..5eea76703 100644 --- a/tradingagents/agents/state.py +++ b/tradingagents/agents/state.py @@ -8,70 +8,65 @@ from typing_extensions import TypedDict class InvestDebateState(TypedDict): bull_history: Annotated[ str, "Bullish Conversation history" - ] # Bullish Conversation history + ] bear_history: Annotated[ str, "Bearish Conversation history" - ] # Bullish Conversation history - history: Annotated[str, "Conversation history"] # Conversation history - current_response: Annotated[str, "Latest response"] # Last response - judge_decision: Annotated[str, "Final judge decision"] # Last response - count: Annotated[int, "Length of the current conversation"] # Conversation length + ] + history: Annotated[str, "Conversation history"] + current_response: Annotated[str, "Latest response"] + count: Annotated[int, "Length of the current conversation"] # Risk management team state class RiskDebateState(TypedDict): aggressive_history: Annotated[ str, "Aggressive Agent's Conversation history" - ] # Conversation history + ] conservative_history: Annotated[ str, "Conservative Agent's Conversation history" - ] # Conversation history + ] neutral_history: Annotated[ str, "Neutral Agent's Conversation history" - ] # Conversation history - history: Annotated[str, "Conversation history"] # Conversation history + ] + history: Annotated[str, "Conversation history"] latest_speaker: Annotated[str, "Analyst that spoke last"] current_aggressive_response: Annotated[ str, "Latest response by the aggressive analyst" - ] # Last response + ] current_conservative_response: Annotated[ str, "Latest response by the conservative analyst" - ] # Last response + ] current_neutral_response: Annotated[ str, "Latest response by the neutral analyst" - ] # Last response - judge_decision: Annotated[str, "Judge's decision"] - count: Annotated[int, "Length of the current conversation"] # Conversation length + ] + count: Annotated[int, "Length of the current conversation"] class AgentState(MessagesState): company_of_interest: Annotated[str, "Company that we are interested in trading"] asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"] instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"] - trade_date: Annotated[str, "What date we are trading at"] - - sender: Annotated[str, "Agent that sent this message"] + trade_date: Annotated[str, "The analysis date; data is served as of it"] # research step market_report: Annotated[str, "Report from the Market Analyst"] sentiment_report: Annotated[str, "Report from the Sentiment Analyst"] - news_report: Annotated[ - str, "Report from the News Researcher of current world affairs" - ] - fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"] + news_report: Annotated[str, "Report from the News Analyst on company and world news"] + fundamentals_report: Annotated[str, "Report from the Fundamentals Analyst"] # researcher team discussion step investment_debate_state: Annotated[ InvestDebateState, "Current state of the debate on if to invest or not" ] - investment_plan: Annotated[str, "Plan generated by the Analyst"] + investment_plan: Annotated[str, "Investment plan from the Research Manager"] - trader_investment_plan: Annotated[str, "Plan generated by the Trader"] + trader_investment_plan: Annotated[str, "Transaction proposal from the Trader"] # risk management team discussion step risk_debate_state: Annotated[ RiskDebateState, "Current state of the debate on evaluating risk" ] - final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] + final_trade_decision: Annotated[str, "Final decision from the Portfolio Manager"] + final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"] past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"] diff --git a/tradingagents/agents/structured.py b/tradingagents/agents/structured.py index 8e94132c9..8992e5351 100644 --- a/tradingagents/agents/structured.py +++ b/tradingagents/agents/structured.py @@ -56,6 +56,31 @@ def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None: return None +def invoke_structured(structured_llm: Any | None, prompt: Any, agent_name: str) -> T | None: + """Run the structured call; ``None`` when there is none or it fails. + + ``prompt`` is whatever the underlying LLM accepts (a string for chat + invocations, a list of message dicts for chat models that take that + shape), so a caller can forward the same value to its free-text fallback. + """ + if structured_llm is None: + return None + try: + result = structured_llm.invoke(prompt) + if result is None: + # A thinking model can answer in plain text instead of calling + # the tool, leaving the parser with nothing to return. Treat it + # as a structured miss and fall back, with a clear reason. + raise ValueError("structured output returned no parsed result") + return result + except Exception as exc: + logger.warning( + "%s: structured-output invocation failed (%s); retrying once as free text", + agent_name, exc, + ) + return None + + def invoke_structured_or_freetext( structured_llm: Any | None, plain_llm: Any, @@ -63,27 +88,8 @@ def invoke_structured_or_freetext( render: Callable[[T], str], agent_name: str, ) -> str: - """Run the structured call and render to markdown; fall back to free-text on any failure. - - ``prompt`` is whatever the underlying LLM accepts (a string for chat - invocations, a list of message dicts for chat models that take that - shape). The same value is forwarded to the free-text path so the - fallback sees the same input the structured call did. - """ - if structured_llm is not None: - try: - result = structured_llm.invoke(prompt) - if result is None: - # A thinking model can answer in plain text instead of calling - # the tool, leaving the parser with nothing to return. Treat it - # as a structured miss and fall back, with a clear reason. - raise ValueError("structured output returned no parsed result") - return render(result) - except Exception as exc: - logger.warning( - "%s: structured-output invocation failed (%s); retrying once as free text", - agent_name, exc, - ) - - response = plain_llm.invoke(prompt) - return response.content + """Run the structured call and render to markdown; fall back to free-text on any failure.""" + result = invoke_structured(structured_llm, prompt, agent_name) + if result is not None: + return render(result) + return plain_llm.invoke(prompt).content diff --git a/tradingagents/agents/tools.py b/tradingagents/agents/tools.py index 0ea4667e2..b8b323cba 100644 --- a/tradingagents/agents/tools.py +++ b/tradingagents/agents/tools.py @@ -10,26 +10,26 @@ from langchain_core.tools import tool from langgraph.prebuilt import InjectedState from tradingagents.dataflows.date_window import as_of, as_of_window -from tradingagents.dataflows.router import route_to_vendor +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.router import no_data_available, route_to_vendor, vendor_unavailable from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot @tool def get_stock_data( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], start_date: Annotated[str, "Start date in yyyy-mm-dd format"], end_date: Annotated[str, "End date in yyyy-mm-dd format"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve stock price data (OHLCV) for a given ticker symbol. + Retrieve stock price data (OHLCV) for the instrument under analysis. Uses the configured core_stock_apis vendor. Args: - symbol (str): Ticker symbol of the company, e.g. AAPL, TSM start_date (str): Start date in yyyy-mm-dd format end_date (str): End date in yyyy-mm-dd format Returns: - str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified date range. + str: A formatted dataframe containing the price data for the instrument over the date range. """ start_date, end_date = as_of_window(start_date, end_date, trade_date) return route_to_vendor("get_stock_data", symbol, start_date, end_date) @@ -37,22 +37,21 @@ def get_stock_data( @tool def get_indicators( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], indicator: Annotated[str, "technical indicator to get the analysis and report of"], curr_date: Annotated[str, "The current trading date you are trading on, YYYY-mm-dd"], look_back_days: Annotated[int, "how many days to look back"] = 30, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve a single technical indicator for a given ticker symbol. + Retrieve a single technical indicator for the instrument under analysis. Uses the configured technical_indicators vendor. Args: - symbol (str): Ticker symbol of the company, e.g. AAPL, TSM indicator (str): A single technical indicator name, e.g. 'rsi', 'macd'. Call this tool once per indicator. curr_date (str): The current trading date you are trading on, YYYY-mm-dd look_back_days (int): How many days to look back, default is 30 Returns: - str: A formatted dataframe containing the technical indicators for the specified ticker symbol and indicator. + str: A formatted dataframe containing the technical indicators for the instrument and indicator. """ # LLMs sometimes pass multiple indicators as a comma-separated string; # split and process each individually. @@ -69,7 +68,7 @@ def get_indicators( @tool def get_verified_market_snapshot( - symbol: Annotated[str, "ticker symbol of the company"], + symbol: Annotated[str, InjectedState("company_of_interest")], curr_date: Annotated[str, "the current trading date, YYYY-mm-dd"], look_back_days: Annotated[ int, "number of recent trading rows to include for sanity-checking" @@ -83,20 +82,25 @@ def get_verified_market_snapshot( price levels, Bollinger bands, RSI, MACD, moving averages, support / resistance, or historical comparisons, and treat it as the source of truth. """ - return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) + # An exception out of a tool would end the run. + try: + return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) + except VendorUnavailableError as exc: + return vendor_unavailable("get_verified_market_snapshot", exc) + except NoMarketDataError as exc: + return no_data_available(exc) @tool def get_fundamentals( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve comprehensive fundamental data for a given ticker symbol. + Retrieve comprehensive fundamental data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: str: A formatted report containing comprehensive fundamental data @@ -106,16 +110,15 @@ def get_fundamentals( @tool def get_balance_sheet( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve balance sheet data for a given ticker symbol. + Retrieve balance sheet data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -126,16 +129,15 @@ def get_balance_sheet( @tool def get_cashflow( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve cash flow statement data for a given ticker symbol. + Retrieve cash flow statement data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -146,16 +148,15 @@ def get_cashflow( @tool def get_income_statement( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve income statement data for a given ticker symbol. + Retrieve income statement data for the instrument under analysis. Uses the configured fundamental_data vendor. Args: - ticker (str): Ticker symbol of the company freq (str): Reporting frequency: annual/quarterly (default quarterly) curr_date (str): Current date you are trading at, yyyy-mm-dd Returns: @@ -166,16 +167,15 @@ def get_income_statement( @tool def get_news( - ticker: Annotated[str, "Ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], start_date: Annotated[str, "Start date in yyyy-mm-dd format"], end_date: Annotated[str, "End date in yyyy-mm-dd format"], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ - Retrieve news data for a given ticker symbol. + Retrieve news data for the instrument under analysis. Uses the configured news_data vendor. Args: - ticker (str): Ticker symbol start_date (str): Start date in yyyy-mm-dd format end_date (str): End date in yyyy-mm-dd format Returns: @@ -211,14 +211,12 @@ def get_global_news( @tool def get_insider_transactions( - ticker: Annotated[str, "ticker symbol"], + ticker: Annotated[str, InjectedState("company_of_interest")], trade_date: Annotated[str, InjectedState("trade_date")] = "", ) -> str: """ Retrieve insider transaction information about a company. Uses the configured news_data vendor. - Args: - ticker (str): Ticker symbol of the company Returns: str: A report of insider transaction data """ diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py index 1b85ae404..8993095ec 100644 --- a/tradingagents/agents/trader/trader.py +++ b/tradingagents/agents/trader/trader.py @@ -2,8 +2,6 @@ from __future__ import annotations -import functools - from langchain_core.messages import AIMessage from tradingagents.agents.context import ( @@ -22,7 +20,7 @@ from tradingagents.agents.structured import ( def create_trader(llm): structured_llm = bind_structured(llm, TraderProposal, "Trader") - def trader_node(state, name): + def trader_node(state): company_name = state["company_of_interest"] instrument_context = get_instrument_context_from_state(state) investment_plan = state["investment_plan"] @@ -95,7 +93,6 @@ def create_trader(llm): return { "messages": [AIMessage(content=trader_plan)], "trader_investment_plan": trader_plan, - "sender": name, } - return functools.partial(trader_node, name="Trader") + return trader_node diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index 65c4d9a6a..c4a33cb84 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -2,7 +2,7 @@ One run yields one decision, so it cannot say whether the system decides well. This runs the same machinery over many (ticker, date) cells and reads the -aggregate. The decision log is the results table: every run already records its +aggregate. The memory log is the results table: every run already records its rating and later settles it with realized and alpha return against the instrument's regional benchmark, so there is nothing to record separately. @@ -17,6 +17,7 @@ cell rather than a position carried forward. from __future__ import annotations import logging +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path @@ -24,8 +25,8 @@ from pathlib import Path from tradingagents.agents.rating import RATING_REVIEW from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.symbols import safe_ticker_component -from tradingagents.decision_log import TradingMemoryLog from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.memory import TradingMemoryLog logger = logging.getLogger(__name__) @@ -133,12 +134,14 @@ def run_backtest( portfolio=None, selected_analysts=("market", "social", "news", "fundamentals"), run_id: str | None = None, + progress: Callable[[int, int, str, str], None] | None = None, ) -> BacktestResult: - """Analyze every ticker on every date, into a decision log of this run's own. + """Analyze every ticker on every date, into a memory log of this run's own. The live log stays untouched: a sweep would otherwise flood the context that real runs read back. Cells already in this run's log are skipped, so an - interrupted sweep resumes by being run again. + interrupted sweep resumes by being run again. ``progress(done, total, + ticker, date)`` is called before each cell that runs. """ # run_id becomes a path segment, so it is validated like a ticker: an # absolute or dotted value would otherwise place the run outside results_dir. @@ -152,17 +155,20 @@ def run_backtest( result = BacktestResult(run_id=run_id, log_path=Path(run_config["memory_log_path"])) done = {(e["ticker"], e["date"]) for e in graph.memory_log.load_entries()} - for ticker in tickers: - for date in dates: - if (ticker, date) in done: - result.skipped += 1 - continue - try: - graph.propagate(ticker, date, asset_type, portfolio=portfolio) - result.cells_run += 1 - except Exception as exc: # one unreachable vendor must not end the sweep - logger.warning("Backtest cell %s %s failed: %s", ticker, date, exc) - result.failures.append((ticker, date, str(exc))) + # A ticker or date given twice is one cell, run and settled once. + tickers, dates = list(dict.fromkeys(tickers)), list(dict.fromkeys(dates)) + cells = [(ticker, date) for ticker in tickers for date in dates] + todo = [cell for cell in cells if cell not in done] + result.skipped = len(cells) - len(todo) + for index, (ticker, date) in enumerate(todo, 1): + if progress: + progress(index, len(todo), ticker, date) + try: + graph.propagate(ticker, date, asset_type, portfolio=portfolio) + result.cells_run += 1 + except Exception as exc: # one unreachable vendor must not end the sweep + logger.warning("Backtest cell %s %s failed: %s", ticker, date, exc) + result.failures.append((ticker, date, str(exc))) # Settlement runs at the start of the next run for a ticker, so each ticker's # last cell would stay pending without this pass. @@ -176,13 +182,13 @@ def run_backtest( def summarize(source: BacktestResult | str | Path) -> BacktestSummary: - """Score the settled decisions of a backtest, or of a decision log at a path, by rating.""" + """Score the settled decisions of a backtest, or of a memory log at a path, by rating.""" if isinstance(source, BacktestResult): path = source.log_path # a run whose cells all failed wrote no log: nothing to score elif Path(source).is_file(): path = Path(source) else: - raise FileNotFoundError(f"no decision log at {source}") + raise FileNotFoundError(f"no memory log at {source}") entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries() # A decision with no readable rating has no direction, so it can neither # count for nor against the system; it is reported as unscored instead. diff --git a/tradingagents/dataflows/config.py b/tradingagents/dataflows/config.py index 19a640a43..f7ae14f36 100644 --- a/tradingagents/dataflows/config.py +++ b/tradingagents/dataflows/config.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from contextvars import ContextVar +from contextvars import Context, ContextVar, copy_context from copy import deepcopy import tradingagents.default_config as default_config @@ -51,6 +51,17 @@ def run_config(config: dict): _run_config.reset(token) +def run_config_context(config: dict) -> Context: + """A context serving ``config``, over the defaults, to every read run in it. + + For a run that yields between steps, where a ``run_config`` block would + stay open in the caller's context while it holds the results. + """ + context = copy_context() + context.run(_run_config.set, _merge(deepcopy(default_config.DEFAULT_CONFIG), config)) + return context + + def get_config() -> dict: """Get the configuration of the run in progress, else the process-wide one.""" scoped = _run_config.get() diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index d84e43887..6c30051f7 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -1,22 +1,23 @@ -"""Shared look-ahead-safe date-window filtering for dated content. +"""Point-in-time rules shared by every dated path: data is served as of the run's date. -News, StockTwits, and Reddit all pull recent items that must be trimmed to the -analysis window so a historical/backtest run never sees content published after -its as-of date. Centralizing the rule keeps every source consistent (#1126, -#1220): every timestamp is normalized to UTC, the upper bound is exclusive at -midnight after ``end`` (so an item stamped exactly then can't leak), and an -undated item is kept only when the window reaches the present (a live run), since -in a backtest we can't prove it isn't future. +- ``as_of`` / ``as_of_window`` clamp a date or window the model asks for to the + trade date, so no tool reaches a vendor with a later one. +- ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis + window: timestamps normalized to UTC, the upper bound exclusive at midnight + after ``end``, and an undated item kept only when the window reaches the + present, since a backtest cannot prove it is not from the future (#1126, #1220). +- ``coverage_gap`` reports a window a feed cannot reach as unavailable, not empty. +- ``withhold_live_profile`` withholds present-day snapshots from historical runs. """ from __future__ import annotations -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta def to_utc(dt: datetime) -> datetime: """Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.""" - return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) + return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt.astimezone(UTC) def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool: @@ -27,7 +28,7 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> end = to_utc(end_dt) if pub_dt is not None: return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1) - return end >= datetime.now(timezone.utc) - timedelta(days=1) + return end >= datetime.now(UTC) - timedelta(days=1) def get_current_date() -> str: @@ -35,6 +36,11 @@ def get_current_date() -> str: return date.today().strftime("%Y-%m-%d") +def is_historical(run_date) -> bool: + """Whether a run is dated before today, so live-only data would come from after it.""" + return bool(run_date) and str(run_date) < get_current_date() + + def coverage_gap( dates, start_date: str, end_date: str, source: str, subject: str ) -> str | None: @@ -51,7 +57,7 @@ def coverage_gap( returned newest-first and unbroken in time; a merged or relevance-ranked result passes no dates, leaving only the present as the bound. """ - now = datetime.now(timezone.utc) + now = datetime.now(UTC) oldest = min((to_utc(d) for d in dates if d is not None), default=now) if datetime.strptime(end_date, "%Y-%m-%d").date() > now.date(): reason = "the window extends past today" @@ -95,7 +101,7 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, return f"{_parse(end) - span:%Y-%m-%d}", end -def withhold_live_profile(curr_date: str | None, label: str) -> str | None: +def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: """Notice to serve instead of a live-only company profile, or None to serve it. Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage @@ -105,20 +111,56 @@ def withhold_live_profile(curr_date: str | None, label: str) -> str | None: Every fundamentals vendor withholds on this rule, so switching between them cannot reintroduce the leak. """ - if not curr_date: - return None - today = get_current_date() - if curr_date >= today: + if not is_historical(as_of_date): return None return ( f"# Company Fundamentals for {label}\n" - f"# Point-in-time as of: {curr_date}\n\n" + f"# Point-in-time as of: {as_of_date}\n\n" f"Profile fundamentals are withheld for this date. This vendor serves " f"only present-day values with no historical vintage: market " f"cap, valuation multiples, the 52-week range and TTM income move with " f"today's quote, and even the name, sector and industry reflect today " - f"rather than {curr_date} (companies rename and get reclassified). " - f"Serving them would put post-decision information into a {curr_date} " - f"analysis. Point-in-time fundamentals for {curr_date} are available " - f"from the balance sheet, income statement, and cash flow tools." + f"rather than {as_of_date} (companies rename and get reclassified). " + f"Serving them would put post-decision information into a {as_of_date} " + f"analysis. Statements filed by {as_of_date} are served as filed where " + f"the filing date is known (SEC EDGAR, for US filers)." + ) + + +def withhold_undisclosed_trades(as_of_date: str | None, label: str) -> str | None: + """Notice to serve instead of insider trades with no filing date, or None to serve them. + + A vendor that dates an insider trade by when it happened (Yahoo, Alpha + Vantage) cannot say when it became public: its Form 4 is filed up to two + business days later. A past run is told so rather than served trades that + may not yet have been disclosed. + """ + if not is_historical(as_of_date): + return None + return ( + f"# Insider Transactions for {label}\n" + f"# Point-in-time as of: {as_of_date}\n\n" + f"Insider transactions are withheld for this date. This vendor dates a trade " + f"by when it happened and reports no filing date, so it cannot show which " + f"trades were public on {as_of_date}." + ) + + +def withhold_undated_statements(as_of_date: str | None, label: str, title: str) -> str | None: + """Notice to serve instead of a statement with no filing date, or None to serve it. + + A vendor that dates a statement by the period it covers (Yahoo, Alpha + Vantage) cannot say when its figures became public: a company files weeks + after the period ends, so a run dated in that gap would read figures that + were not yet known. A past run is told so; SEC EDGAR, which dates every + filing, serves US filers as filed. + """ + if not is_historical(as_of_date): + return None + return ( + f"# {title} for {label}\n" + f"# Point-in-time as of: {as_of_date}\n\n" + f"{title} data is withheld for this date. This vendor dates a statement by " + f"the period it covers and reports no filing date, so it cannot show which " + f"figures were public on {as_of_date}. SEC EDGAR serves US filers as filed." ) diff --git a/tradingagents/dataflows/errors.py b/tradingagents/dataflows/errors.py index 2ce6a0c46..3ea1c2a82 100644 --- a/tradingagents/dataflows/errors.py +++ b/tradingagents/dataflows/errors.py @@ -7,7 +7,7 @@ these (or a thin vendor-named subclass) and needs no new ``except`` clause. VendorError ├── NoMarketDataError no usable rows (empty result OR stale data) - ├── VendorRateLimitError transient throttle -> skip to next vendor + ├── VendorUnavailableError throttled or failed request -> skip to next vendor └── VendorNotConfiguredError missing API key/config -> vendor unavailable The number of types is the number of distinct router reactions, not the number @@ -43,8 +43,11 @@ class NoMarketDataError(VendorError): super().__init__(msg) -class VendorRateLimitError(VendorError): - """A vendor throttled the request; the router skips to the next vendor.""" +class VendorUnavailableError(VendorError): + """A vendor could not answer: it throttled or failed the request. + + Says nothing about the instrument; the router skips to the next vendor. + """ class VendorNotConfiguredError(VendorError, ValueError): diff --git a/tradingagents/dataflows/files.py b/tradingagents/dataflows/files.py new file mode 100644 index 000000000..0c7408919 --- /dev/null +++ b/tradingagents/dataflows/files.py @@ -0,0 +1,79 @@ +"""Files written whole, and held by one writer at a time, under concurrent writers.""" + +import errno +import logging +import os +import uuid +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def replace_file(path, write: Callable[[str], None]) -> None: + """Write ``path`` through a uniquely named temp file beside it, then move it into place. + + A reader sees the old file or the new one, never a partial write, and two + writers of the same path (tool calls run concurrently) never share a temp file. + ``write`` receives the temp path and creates the file, so it gets the usual + permissions. The file is a cache: where another reader holds it open (Windows), + the old one stays and the write is skipped. + """ + path = Path(path) + temp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp") + try: + write(str(temp)) + os.replace(temp, path) + except PermissionError as exc: + temp.unlink(missing_ok=True) + logger.warning("Kept the cached %s; it is in use (%s)", path.name, exc) + except BaseException: + temp.unlink(missing_ok=True) + raise + + +@contextmanager +def locked(path) -> Iterator[None]: + """Hold ``path`` for one writer at a time, across threads and processes. + + For a file read, changed and written back: without the lock, two writers + each read the same text and the second write drops the first's change. The + lock is taken on a ``.lock`` file beside ``path``. + """ + with open(f"{path}.lock", "a+b") as handle: + hold = _hold_windows if os.name == "nt" else _hold_posix + with hold(handle): + yield + + +@contextmanager +def _hold_posix(handle) -> Iterator[None]: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +@contextmanager +def _hold_windows(handle) -> Iterator[None]: + import msvcrt + + handle.seek(0) + while True: + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + break + except OSError as exc: + # LK_LOCK gives up after about ten seconds of another writer's hold; + # any other failure is not a wait. + if exc.errno != errno.EDEADLOCK: + raise + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) diff --git a/tradingagents/dataflows/router.py b/tradingagents/dataflows/router.py index 33d7df866..b047e7699 100644 --- a/tradingagents/dataflows/router.py +++ b/tradingagents/dataflows/router.py @@ -4,7 +4,7 @@ from tradingagents.dataflows.config import get_config from tradingagents.dataflows.errors import ( NoMarketDataError, VendorNotConfiguredError, - VendorRateLimitError, + VendorUnavailableError, ) from tradingagents.dataflows.vendors.alpha_vantage import ( get_balance_sheet as get_alpha_vantage_balance_sheet, @@ -86,14 +86,6 @@ TOOLS_CATEGORIES = { } } -VENDOR_LIST = [ - "yfinance", - "sec_edgar", - "fred", - "polymarket", - "alpha_vantage", -] - # Optional enrichment categories. These add macro/event context to the news # analyst but are not core to a decision, so a vendor failure here degrades to a # sentinel instead of aborting the run (a bad LLM-supplied indicator, a missing @@ -181,6 +173,30 @@ def get_vendor(category: str, method: str = None) -> str: return config.get("data_vendors", {}).get(category, "default") +def vendor_unavailable(method: str, error: Exception) -> str: + """What a call returns when every vendor was throttled or unreachable.""" + return ( + f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " + f"({error}). This says nothing about the instrument; report the " + f"data as unavailable and do not estimate or fabricate values." + ) + + +def no_data_available(error: NoMarketDataError) -> str: + """What a call returns when every vendor that answered had no usable data.""" + resolved = "" if error.canonical == error.symbol else f" (resolved to '{error.canonical}')" + # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... + # stale") so the agent sees the specific reason — invalid symbol, no + # coverage, or stale data — not just a generic "unavailable". + reason = f" ({error.detail})" if error.detail else "" + return ( + f"NO_DATA_AVAILABLE: No usable market data for '{error.symbol}'{resolved} from " + f"any configured vendor{reason}. The symbol may be invalid, delisted, " + f"not covered, or the vendor returned stale data. Do not estimate or " + f"fabricate values — report that data is unavailable for this symbol." + ) + + def route_to_vendor(method: str, *args, **kwargs): """Route method calls to appropriate vendor implementation with fallback support.""" category = get_category_for_method(method) @@ -209,7 +225,8 @@ def route_to_vendor(method: str, *args, **kwargs): vendor_chain = all_available_vendors last_no_data: NoMarketDataError | None = None - last_unavailable: VendorRateLimitError | None = None + last_unavailable: VendorUnavailableError | None = None + failed: Exception | None = None # a vendor that raised something untyped first_error: Exception | None = None for vendor in vendor_chain: vendor_impl = VENDOR_METHODS[method][vendor] @@ -217,7 +234,7 @@ def route_to_vendor(method: str, *args, **kwargs): try: return impl_func(*args, **kwargs) - except VendorRateLimitError as e: + except VendorUnavailableError as e: logger.warning("Vendor %r unavailable for %s: %s; trying next vendor.", vendor, method, e) # Kept so an all-unavailable chain can say the vendor was the # problem, rather than reporting nothing about the symbol. @@ -238,9 +255,19 @@ def route_to_vendor(method: str, *args, **kwargs): logger.warning("Vendor %r failed for %s: %s", vendor, method, e) if first_error is None: first_error = e + failed = e continue - # If any vendor reported "no data", the symbol is genuinely unavailable. + # A vendor that throttled or failed the request never said whether it has + # the symbol, so no other vendor's "no data" can speak for the whole chain: + # report the vendors as the problem, not the instrument. It must not end + # the run either. + if last_unavailable is not None: + return vendor_unavailable(method, last_unavailable) + if failed is not None and last_no_data is not None: + return vendor_unavailable(method, failed) + + # Every vendor that answered reported "no data": the symbol is genuinely unavailable. # Return one explicit, instructive sentinel rather than a vendor-specific # empty string, so the agent reports "unavailable" instead of inventing a # value. This takes precedence over incidental fallback errors. @@ -252,33 +279,12 @@ def route_to_vendor(method: str, *args, **kwargs): "Returning NO_DATA for %s, but a vendor errored earlier: %s", method, first_error, ) - sym = last_no_data.symbol - canonical = last_no_data.canonical - resolved = "" if canonical == sym else f" (resolved to '{canonical}')" - # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... - # stale") so the agent sees the specific reason — invalid symbol, no - # coverage, or stale data — not just a generic "unavailable". - reason = f" ({last_no_data.detail})" if last_no_data.detail else "" - return ( - f"NO_DATA_AVAILABLE: No usable market data for '{sym}'{resolved} from " - f"any configured vendor{reason}. The symbol may be invalid, delisted, " - f"not covered, or the vendor returned stale data. Do not estimate or " - f"fabricate values — report that data is unavailable for this symbol." - ) + return no_data_available(last_no_data) # No vendor returned data and none reported clean "no data" — surface the # first real error (e.g. the primary vendor's network failure). Optional # enrichment categories degrade to a sentinel instead, so flavour data can't # abort the run. - # Every vendor was throttled or unreachable: that is a fact about the - # vendors, not about the instrument, and it must not end the run. - if last_unavailable is not None: - return ( - f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now " - f"({last_unavailable}). This says nothing about the instrument; report the " - f"data as unavailable and do not estimate or fabricate values." - ) - if first_error is not None: if category in OPTIONAL_CATEGORIES: logger.warning("Optional %s unavailable for %s: %s", category, method, first_error) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/common.py b/tradingagents/dataflows/vendors/alpha_vantage/common.py index 39ffb1d81..d8e47045f 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/common.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/common.py @@ -5,7 +5,7 @@ from io import StringIO import pandas as pd -from tradingagents.dataflows.errors import VendorNotConfiguredError, VendorRateLimitError +from tradingagents.dataflows.errors import VendorNotConfiguredError, VendorUnavailableError from tradingagents.dataflows.net import get_scrubbed API_BASE_URL = "https://www.alphavantage.co/query" @@ -62,7 +62,7 @@ def format_datetime_for_api(date_input, end_of_day: bool = False) -> str: raise ValueError(f"Date must be string or datetime object, got {type(date_input)}") -class AlphaVantageRateLimitError(VendorRateLimitError): +class AlphaVantageRateLimitError(VendorUnavailableError): """Raised when the Alpha Vantage API rate limit is exceeded.""" pass diff --git a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py index a53698190..8cc90b5ae 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py @@ -1,50 +1,24 @@ -import json - -from tradingagents.dataflows.date_window import withhold_live_profile +from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request -def _filter_reports_by_date(result, curr_date: str): - """Drop annual/quarterly reports dated after curr_date to prevent look-ahead. - - ``_make_api_request`` returns the fundamentals payload as a JSON string, so - parse, filter, and re-serialize. A non-JSON body or an unset ``curr_date`` is - returned unchanged. - """ - if not curr_date or not isinstance(result, str): - return result - try: - payload = json.loads(result) - except json.JSONDecodeError: - return result - if not isinstance(payload, dict): - return result - for key in ("annualReports", "quarterlyReports"): - if isinstance(payload.get(key), list): - payload[key] = [ - r for r in payload[key] - if r.get("fiscalDateEnding", "") <= curr_date - ] - return json.dumps(payload) - - -def get_fundamentals(ticker: str, curr_date: str = None) -> str: +def get_fundamentals(ticker: str, as_of_date: str = None) -> str: """ Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. OVERVIEW serves only present-day values and carries no historical vintage, so - a past ``curr_date`` withholds it rather than leaking post-decision figures - into a backtest (#1300); the statement endpoints below stay point-in-time via - ``_filter_reports_by_date``. + a past ``as_of_date`` withholds it rather than leaking post-decision figures + into a backtest (#1300); so are the statement endpoints below, which carry + no filing date. Args: ticker (str): Ticker symbol of the company - curr_date (str): Analysis date, yyyy-mm-dd + as_of_date (str): Analysis date, yyyy-mm-dd Returns: str: Company overview data including financial ratios and key metrics """ - withheld = withhold_live_profile(curr_date, ticker) + withheld = withhold_live_profile(as_of_date, ticker) if withheld: return withheld @@ -55,20 +29,26 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str: return _make_api_request("OVERVIEW", params) -def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Balance Sheet") + if withheld: + return withheld + return _make_api_request("BALANCE_SHEET", {"symbol": ticker}) -def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("CASH_FLOW", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Cash Flow") + if withheld: + return withheld + return _make_api_request("CASH_FLOW", {"symbol": ticker}) -def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str = None): +def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) - return _filter_reports_by_date(result, curr_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Income Statement") + if withheld: + return withheld + return _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/indicator.py b/tradingagents/dataflows/vendors/alpha_vantage/indicator.py index 20ab45e9b..2408be428 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/indicator.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/indicator.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) def get_indicator( symbol: str, indicator: str, - curr_date: str, + as_of_date: str, look_back_days: int, interval: str = "daily", time_period: int = 14, @@ -21,7 +21,7 @@ def get_indicator( Args: symbol: ticker symbol of the company indicator: technical indicator to get the analysis and report of - curr_date: The current trading date you are trading on, YYYY-mm-dd + as_of_date: The current trading date you are trading on, YYYY-mm-dd look_back_days: how many days to look back interval: Time interval (daily, weekly, monthly) time_period: Number of data points for calculation @@ -72,8 +72,8 @@ def get_indicator( f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}" ) - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - before = curr_date_dt - relativedelta(days=look_back_days) + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + before = as_of_dt - relativedelta(days=look_back_days) # Get the full data for the period instead of making individual calls _, required_series_type = supported_indicators[indicator] @@ -184,7 +184,7 @@ def get_indicator( date_str = values[date_col_idx].strip() date_dt = datetime.strptime(date_str, "%Y-%m-%d") - if before <= date_dt <= curr_date_dt: + if before <= date_dt <= as_of_dt: value = values[value_col_idx].strip() result_data.append((date_dt, value)) except (ValueError, IndexError): @@ -201,7 +201,7 @@ def get_indicator( ind_string = "No data available for the specified date range.\n" result_str = ( - f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {as_of_date}:\n\n" + ind_string + "\n\n" + indicator_descriptions.get(indicator, "No description available.") diff --git a/tradingagents/dataflows/vendors/alpha_vantage/news.py b/tradingagents/dataflows/vendors/alpha_vantage/news.py index 299264da7..3b5b10864 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/news.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/news.py @@ -1,6 +1,6 @@ -import json from tradingagents.dataflows.config import get_config +from tradingagents.dataflows.date_window import withhold_undisclosed_trades from tradingagents.dataflows.vendors.alpha_vantage.common import ( _make_api_request, format_datetime_for_api, @@ -33,13 +33,13 @@ def get_news(ticker, start_date, end_date) -> dict[str, str] | str: return _make_api_request("NEWS_SENTIMENT", params) -def get_global_news(curr_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str: +def get_global_news(as_of_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str: """Returns global market news & sentiment data without ticker-specific filtering. Covers broad market topics like financial markets, economy, and more. Args: - curr_date: Current date in yyyy-mm-dd format. + as_of_date: Current date in yyyy-mm-dd format. look_back_days: Number of days to look back; ``None`` uses ``global_news_lookback_days`` from the active config. limit: Maximum number of articles; ``None`` uses @@ -56,40 +56,34 @@ def get_global_news(curr_date, look_back_days: int | None = None, limit: int | N if limit is None: limit = config["global_news_article_limit"] - curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_dt = curr_dt - timedelta(days=look_back_days) start_date = start_dt.strftime("%Y-%m-%d") params = { "topics": "financial_markets,economy_macro,economy_monetary", "time_from": format_datetime_for_api(start_date), - "time_to": format_datetime_for_api(curr_date, end_of_day=True), + "time_to": format_datetime_for_api(as_of_date, end_of_day=True), "limit": str(limit), } return _make_api_request("NEWS_SENTIMENT", params) -def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[str, str] | str: +def get_insider_transactions(symbol: str, as_of_date: str | None = None) -> dict[str, str] | str: """Returns latest and historical insider transactions by key stakeholders. Covers transactions by founders, executives, board members, etc. Args: symbol: Ticker symbol. Example: "IBM". - curr_date: When given, only transactions on or before it (yyyy-mm-dd). + as_of_date: The analysis date (yyyy-mm-dd); a past one withholds them. Returns: Dictionary containing insider transaction data or JSON string. """ - params = { - "symbol": symbol, - } - - response = _make_api_request("INSIDER_TRANSACTIONS", params) - if not curr_date: - return response - payload = json.loads(response) - payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= curr_date] - return json.dumps(payload) + withheld = withhold_undisclosed_trades(as_of_date, symbol) + if withheld: + return withheld + return _make_api_request("INSIDER_TRANSACTIONS", {"symbol": symbol}) diff --git a/tradingagents/dataflows/vendors/fred.py b/tradingagents/dataflows/vendors/fred.py index 611d8d919..86fbea43b 100644 --- a/tradingagents/dataflows/vendors/fred.py +++ b/tradingagents/dataflows/vendors/fred.py @@ -126,7 +126,7 @@ def _fred_today() -> str: """FRED's current calendar date (US Central) as ``yyyy-mm-dd``. The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after - its own today with a 400, and ``curr_date`` on a live run comes from the + its own today with a 400, and ``as_of_date`` on a live run comes from the caller's local clock, which can already be tomorrow in Chicago. """ return datetime.now(FRED_TZ).strftime("%Y-%m-%d") @@ -155,7 +155,7 @@ def _request(path: str, params: dict) -> dict: def get_macro_data( indicator: str, - curr_date: str, + as_of_date: str, look_back_days: int | None = None, ) -> str: """Fetch a FRED macroeconomic series as a formatted markdown report. @@ -163,9 +163,9 @@ def get_macro_data( Args: indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). - curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window + as_of_date: The as-of date (yyyy-mm-dd). It bounds the observation window AND pins the data vintage: FRED is queried with the realtime bounds - set to ``curr_date`` (clamped to FRED's own today) so a historical + set to ``as_of_date`` (clamped to FRED's own today) so a historical run sees the values that were actually published by that date, not later revisions. Without this, revision-prone series (CPI, GDP, ...) would leak future information into a backtest (#1275). @@ -178,18 +178,18 @@ def get_macro_data( if look_back_days is None: look_back_days = DEFAULT_LOOKBACK_DAYS - end_dt = datetime.strptime(curr_date, "%Y-%m-%d") + end_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") # Pin the data vintage. FRED defaults both realtime bounds to today, serving # the LATEST revision of every observation; a single-day realtime interval # asks for the values known as of the pin instead, on both the metadata and # observations requests (#1275). Clamp to FRED's today: on a live run - # curr_date is the caller's local date, which can be a day ahead of Chicago, + # as_of_date is the caller's local date, which can be a day ahead of Chicago, # and a realtime date in FRED's future 400s -> the routing layer would then - # drop macro data silently. A past curr_date is unaffected, so historical + # drop macro data silently. A past as_of_date is unaffected, so historical # point-in-time behaviour is preserved. - pit = min(curr_date, _fred_today()) + pit = min(as_of_date, _fred_today()) realtime = {"realtime_start": pit, "realtime_end": pit} # Invalid LLM-supplied indicator: return guidance rather than raising, so a @@ -217,7 +217,7 @@ def get_macro_data( { "series_id": series_id, "observation_start": start_date, - "observation_end": curr_date, + "observation_end": as_of_date, "sort_order": "asc", **realtime, }, @@ -235,7 +235,7 @@ def get_macro_data( f"- Units: {units}\n" f"- Frequency: {frequency}" f"{f' ({seasonal})' if seasonal else ''}\n" - f"- Window: {start_date} to {curr_date}\n" + f"- Window: {start_date} to {as_of_date}\n" ) if not points: @@ -254,8 +254,8 @@ def get_macro_data( pct = f" ({delta / base * 100:+.2f}%)" if base != 0 else "" summary = ( f"\n**Latest:** {last_val} ({last_date}) | " - f"**Change over window:** {delta:+.2f}{pct} " - f"from {first_val} ({first_date})\n" + f"**Change from {first_date} to {last_date}:** {delta:+.2f}{pct}, " + f"from {first_val}\n" ) except ValueError: summary = f"\n**Latest:** {last_val} ({last_date})\n" diff --git a/tradingagents/dataflows/vendors/polymarket.py b/tradingagents/dataflows/vendors/polymarket.py index 96a352b0b..69e27e6ff 100644 --- a/tradingagents/dataflows/vendors/polymarket.py +++ b/tradingagents/dataflows/vendors/polymarket.py @@ -11,11 +11,11 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance). """ import json import logging -from datetime import datetime, timezone +from datetime import UTC, datetime import requests -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.dataflows.date_window import is_historical logger = logging.getLogger(__name__) @@ -67,7 +67,7 @@ def _is_forward_looking(market: dict, now: datetime) -> bool: ) -def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str | None = None) -> str: +def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str | None = None) -> str: """Return live prediction-market probabilities for an event topic. Args: @@ -75,7 +75,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str "US election", or a sector/company event. limit: Max markets to return (ranked by traded volume); ``None`` uses DEFAULT_LIMIT. - curr_date: The analysis date. Polymarket serves only live odds, so a + as_of_date: The analysis date. Polymarket serves only live odds, so a date before today withholds them. Returns: @@ -83,11 +83,11 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str each with its implied probability, traded volume, resolution date, and recent (1-week) move. """ - if curr_date and curr_date < get_current_date(): + if is_historical(as_of_date): return ( - f"Prediction-market odds are withheld for {curr_date}. Polymarket serves " + f"Prediction-market odds are withheld for {as_of_date}. Polymarket serves " f"only live odds on open markets, with no historical vintage, so serving " - f"them would put post-decision information into a {curr_date} analysis." + f"them would put post-decision information into a {as_of_date} analysis." ) if limit is None: limit = DEFAULT_LIMIT @@ -101,7 +101,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str f"Proceed without prediction-market signal for '{topic}'." ) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) candidates = [ m for event in data.get("events", []) diff --git a/tradingagents/dataflows/vendors/reddit.py b/tradingagents/dataflows/vendors/reddit.py index c490ebabe..d39a052fd 100644 --- a/tradingagents/dataflows/vendors/reddit.py +++ b/tradingagents/dataflows/vendors/reddit.py @@ -24,7 +24,7 @@ import re import time import xml.etree.ElementTree as ET from collections.abc import Iterable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -51,7 +51,7 @@ def _within_window(posts, start_date, end_date): def _posted_at(post) -> datetime | None: """A post's ``created_utc`` epoch as a UTC datetime, or None when missing.""" ts = post.get("created_utc") - return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None + return datetime.fromtimestamp(ts, tz=UTC) if ts else None def _coverage_dates(posts) -> list: @@ -61,7 +61,7 @@ def _coverage_dates(posts) -> list: themselves do.""" dates = [_posted_at(p) for p in posts] if len(posts) < _FEED_PAGE: - dates.append(datetime.now(timezone.utc) - _SEARCH_LOOKBACK) + dates.append(datetime.now(UTC) - _SEARCH_LOOKBACK) return dates diff --git a/tradingagents/dataflows/vendors/sec_edgar.py b/tradingagents/dataflows/vendors/sec_edgar.py index e096748d4..653cb0a1b 100644 --- a/tradingagents/dataflows/vendors/sec_edgar.py +++ b/tradingagents/dataflows/vendors/sec_edgar.py @@ -5,7 +5,7 @@ statement at the fiscal period end. That is two claims a run should not make: a period that has ended is not public until the company files, weeks later, and a figure that was later restated is not what investors saw at the time. -EDGAR reports every fact with the date it was filed, so a run dated ``curr_date`` +EDGAR reports every fact with the date it was filed, so a run dated ``as_of_date`` serves exactly what was on file by then, restatements included at the vintage that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment restated them to 36.2B. @@ -22,13 +22,14 @@ import logging import os import time from datetime import date, datetime -from importlib import metadata from pathlib import Path import requests +from tradingagents import __version__ from tradingagents.dataflows.config import get_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.files import replace_file logger = logging.getLogger(__name__) @@ -76,6 +77,11 @@ _STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = { # end date, so a match on the end date alone can report half a year as a quarter. _SPANS = {"quarterly": (60, 115), "annual": (300, 400)} +# A 10-Q's cash flows are often filed only year to date. A quarterly table takes +# the quarter where filed, else the span to date, named in the column; it never +# subtracts one filing from another, which would give a figure no filing states. +_YEAR_TO_DATE = ((150, 200, 6), (240, 290, 9)) + # A fiscal year is a period an annual report covers. A 10-Q balance has no span # to reject, and some filers' 10-Qs report twelve-month totals that pass the span # check, so either would read as a fiscal year. The value is still the latest @@ -93,15 +99,9 @@ def _user_agent() -> str: so SEC can reach you about your traffic rather than the project. """ configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip() - return configured or f"TradingAgents/{_version()} (contact@example.com)" + return configured or f"TradingAgents/{__version__} (contact@example.com)" -def _version() -> str: - """The installed package version, so a release identifies itself correctly.""" - try: - return metadata.version("tradingagents") - except metadata.PackageNotFoundError: - return "dev" def _fetch_json(url: str) -> dict: @@ -114,9 +114,9 @@ def _fetch_json(url: str) -> dict: status = getattr(getattr(exc, "response", None), "status_code", None) # Every failure here is "this vendor cannot serve it now", so the router # moves on instead of seeing a transport exception it has no rule for. - raise VendorRateLimitError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc + raise VendorUnavailableError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc except ValueError as exc: - raise VendorRateLimitError("SEC EDGAR returned an unreadable response") from exc + raise VendorUnavailableError("SEC EDGAR returned an unreadable response") from exc def _cached_json(url: str, name: str) -> dict: @@ -128,9 +128,7 @@ def _cached_json(url: str, name: str) -> dict: pass # a truncated file is a miss, not a failure data = _fetch_json(url) path.parent.mkdir(parents=True, exist_ok=True) - temp = path.with_suffix(".tmp") - temp.write_text(json.dumps(data), encoding="utf-8") - os.replace(temp, path) + replace_file(path, lambda temp: Path(temp).write_text(json.dumps(data), encoding="utf-8")) return data @@ -144,48 +142,53 @@ def cik_for(ticker: str) -> str | None: return None -def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int], +def _span_index(fact: dict, spans: tuple[tuple[int, int], ...]) -> int | None: + """Which of ``spans`` a duration fact covers (0 for an instant fact), or None.""" + if "start" not in fact: + return 0 + days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days + return next((i for i, (low, high) in enumerate(spans) if low <= days <= high), None) + + +def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, spans: tuple[tuple[int, int], ...], forms: tuple[str, ...] = ()) -> tuple[dict, str]: - """({period end: value}, unit) for the first tag the filer reports, as known then. + """({(period end, span index): value}, unit) for the tags the filer reports, as known then. A period reported more than once takes its latest filing on or before the date, so an amendment counts from the day it was filed and not before. The unit comes from the filing: most lines are USD, earnings per share are - USD/shares, and scaling those alike would print a real figure as zero. + USD/shares, and scaling those alike would print a real figure as zero. A + duration fact (revenue, cash flow) must cover one of ``spans``; an instant + fact (a balance) has no span and serves any. """ - low, high = span - values: dict[str, float] = {} + values: dict[tuple[str, int], float] = {} chosen_unit = "USD" # Tags are tried in order and a period keeps the first one that reports it: # filers renamed lines over the years, so one tag covers only part of the # history. Values are never added across tags, which would double count. for tag in tags: for unit, unit_values in ((facts.get(tag) or {}).get("units", {})).items(): - latest: dict[str, dict] = {} - covered: set[str] = set() # period ends a filing of ``forms`` reports + latest: dict[tuple[str, int], dict] = {} + covered: set[tuple[str, int]] = set() # periods a filing of ``forms`` reports for fact in unit_values: - if fact["filed"] > curr_date or fact["end"] in values: + index = _span_index(fact, spans) + key = (fact["end"], index) + if fact["filed"] > as_of_date or index is None or key in values: continue - # A duration fact (revenue, cash flow) must cover the span asked - # for. An instant fact (a balance) has no span and serves both. - if "start" in fact: - days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days - if not low <= days <= high: - continue if not forms or fact.get("form", "").startswith(forms): - covered.add(fact["end"]) - seen = latest.get(fact["end"]) + covered.add(key) + seen = latest.get(key) if seen is None or fact["filed"] >= seen["filed"]: - latest[fact["end"]] = fact - latest = {end: fact for end, fact in latest.items() if end in covered} + latest[key] = fact + latest = {key: fact for key, fact in latest.items() if key in covered} if latest: chosen_unit = unit - values.update({end: fact["val"] for end, fact in latest.items()}) + values.update({key: fact["val"] for key, fact in latest.items()}) return dict(sorted(values.items())), chosen_unit -def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> str: - curr_date = curr_date or datetime.now().strftime("%Y-%m-%d") +def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -> str: + as_of_date = as_of_date or datetime.now().strftime("%Y-%m-%d") cik = cik_for(ticker) if cik is None: raise NoMarketDataError(ticker, ticker, "not a US SEC filer") @@ -196,18 +199,29 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts") quarterly = freq.lower() == "quarterly" - span = _SPANS["quarterly" if quarterly else "annual"] + if quarterly: + spans = (_SPANS["quarterly"], *((low, high) for low, high, _ in _YEAR_TO_DATE)) + names = ["", *(f" ({months} months)" for _, _, months in _YEAR_TO_DATE)] + else: + spans, names = (_SPANS["annual"],), [""] forms = () if quarterly else _ANNUAL_FORMS - lines = {label: _as_of(us_gaap, tags, curr_date, span, forms) for label, tags in _STATEMENTS[kind]} - periods = sorted({end for values, _ in lines.values() for end in values}) + lines = {label: _as_of(us_gaap, tags, as_of_date, spans, forms) for label, tags in _STATEMENTS[kind]} + # Each row takes the shortest span it reports for a period, and a column + # holds one span of one period, so a row filed only to date keeps its figure + # beside a row filed by quarter. + chosen = {label: {} for label in lines} + for label, (values, _) in lines.items(): + for end, index in values: + chosen[label][end] = min(index, chosen[label].get(end, index)) + periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()}) if not periods: - raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_date}") + raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}") header = ( f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n" - f"# SEC EDGAR facts filed on or before {curr_date}, at the values filed then\n\n" + f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n" ) - rows = [",".join([""] + periods)] + rows = [",".join([""] + [end + names[index] for end, index in periods])] for label, (values, unit) in lines.items(): # Every row spans the same columns, or a reader lines the table up wrong. if not values: @@ -215,29 +229,29 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> continue name = label if unit == "USD" else f"{label} ({unit})" # Plain numbers: a thousands separator would split the CSV field. - cells = [ - (f"{values[p] / 1e6:.0f}" if unit == "USD" else f"{values[p]:.2f}") - if p in values else "" for p in periods - ] + cells = [] + for end, index in periods: + value = values.get((end, index)) if chosen[label].get(end) == index else None + cells.append("" if value is None else f"{value / 1e6:.0f}" if unit == "USD" else f"{value:.2f}") rows.append(",".join([name] + cells)) return header + "\n".join(rows) + "\n" -def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Balance sheet as filed on or before ``curr_date``.""" - return _statement("balance_sheet", ticker, freq, curr_date, "Balance Sheet") +def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Balance sheet as filed on or before ``as_of_date``.""" + return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet") -def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Income statement as filed on or before ``curr_date``. +def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Income statement as filed on or before ``as_of_date``. A fourth quarter is never derived: filers report it only inside the annual figure, and subtracting three separately filed quarters would invent a number with no filing date behind it. """ - return _statement("income_statement", ticker, freq, curr_date, "Income Statement") + return _statement("income_statement", ticker, freq, as_of_date, "Income Statement") -def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: - """Cash flow statement as filed on or before ``curr_date``.""" - return _statement("cashflow", ticker, freq, curr_date, "Cash Flow Statement") +def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str: + """Cash flow statement as filed on or before ``as_of_date``.""" + return _statement("cashflow", ticker, freq, as_of_date, "Cash Flow Statement") diff --git a/tradingagents/dataflows/vendors/yahoo/common.py b/tradingagents/dataflows/vendors/yahoo/common.py new file mode 100644 index 000000000..7ab0afec2 --- /dev/null +++ b/tradingagents/dataflows/vendors/yahoo/common.py @@ -0,0 +1,76 @@ +"""What every Yahoo request shares: its retry, and how a failure or an empty answer is reported.""" + +import logging +import time + +import yfinance as yf +from yfinance.exceptions import YFPricesMissingError, YFRateLimitError + +from tradingagents.dataflows.errors import NoMarketDataError, VendorUnavailableError +from tradingagents.dataflows.net import vendor_reachable + +logger = logging.getLogger(__name__) + +YAHOO_HOST = "https://query2.finance.yahoo.com" + + +def raise_for_empty(symbol: str, canonical: str, what: str) -> None: + """Report an empty Yahoo answer as an absence, or as an outage if it is one. + + An empty answer from a Yahoo that cannot be reached is not an answer about + the symbol, so the host is probed before "this symbol has no {what}" is said. + """ + if not vendor_reachable(YAHOO_HOST): + raise VendorUnavailableError(f"Yahoo Finance is unreachable; no {what} was retrieved") + raise NoMarketDataError(symbol, canonical, f"no {what}") + + +# yfinance answers some failed requests with an empty result, which would read as +# "no data" for a symbol nobody checked; raised, the failure is reported as one. +yf.config.debug.hide_exceptions = False + + +def _answered_empty(exc: Exception) -> bool: + """Whether Yahoo answered that it has nothing: no such symbol (HTTP 404), or a + price window with no prices in it. + + yfinance raises YFPricesMissingError for that answer and also for an answer + that was an error (an error status, or Yahoo describing a failure), so only + a chart with no prices, or Yahoo saying the data does not exist, counts. + A missing time zone is not an answer: yfinance reports a failed lookup the same way. + """ + if isinstance(exc, YFPricesMissingError): + reason = exc.yahoo_reason + return "status_code" not in (exc.debug_info or "") and ( + reason is None or reason.startswith("Data doesn't exist")) + return getattr(getattr(exc, "response", None), "status_code", None) == 404 + + +def yf_retry(func, max_retries=3, base_delay=2.0): + """Execute a yfinance call with exponential backoff on rate limits. + + yfinance raises YFRateLimitError on HTTP 429 responses but does not + retry them internally, so this wrapper retries them. A rate limit that + outlasts the retries, or any other exception, is raised as + VendorUnavailableError: it failed in transit and says nothing about the + symbol. Yahoo answering that it has nothing for the symbol returns None, + an empty answer. ``func`` should build its own Ticker and make the request + itself: a Ticker keeps a failed ``info`` fetch as done, so asking the same + one again reads an empty profile. + """ + for attempt in range(max_retries + 1): + try: + return func() + except YFRateLimitError as exc: + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") + time.sleep(delay) + else: + raise VendorUnavailableError( + f"Yahoo Finance rate limited after {max_retries} retries: {exc}" + ) from exc + except Exception as exc: + if _answered_empty(exc): + return None + raise VendorUnavailableError(f"Yahoo Finance request failed: {type(exc).__name__}") from exc diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 72974f633..37000728b 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -1,13 +1,16 @@ from typing import Annotated -import pandas as pd import yfinance as yf -from tradingagents.dataflows.date_window import withhold_live_profile -from tradingagents.dataflows.errors import NoMarketDataError, VendorError, VendorRateLimitError +from tradingagents.dataflows.date_window import ( + withhold_live_profile, + withhold_undated_statements, + withhold_undisclosed_trades, +) +from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import ( +from tradingagents.dataflows.vendors.yahoo.common import ( YAHOO_HOST, raise_for_empty, yf_retry, @@ -16,198 +19,136 @@ from tradingagents.dataflows.vendors.yahoo.ohlcv import ( def get_fundamentals( ticker: Annotated[str, "ticker symbol of the company"], - curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None ): """Get company fundamentals overview from yfinance. ``Ticker.info`` is a present-day snapshot with no historical vintage, so a - past ``curr_date`` withholds it through the shared point-in-time guard + past ``as_of_date`` withholds it through the shared point-in-time guard (``date_window.withhold_live_profile``, #1300). """ canonical = normalize_symbol(ticker) # Guard before the request: the response would only be discarded, and the # answer does not depend on it. - withheld = withhold_live_profile(curr_date, canonical) + withheld = withhold_live_profile(as_of_date, canonical) if withheld: return withheld - try: - ticker_obj = yf.Ticker(canonical) - info = yf_retry(lambda: ticker_obj.info) + info = yf_retry(lambda: yf.Ticker(canonical).info) + if not info: + raise_for_empty(ticker, canonical, "fundamentals") - if not info: - raise_for_empty(ticker, canonical, "fundamentals") + # Yahoo gives these two in percent (dividendYield 0.41 is 0.41%; debtToEquity + # 78.4 is 78.4%, a ratio of 0.78) but the margins and returns as fractions, + # so each carries its unit. + dividend_yield, debt_to_equity = info.get("dividendYield"), info.get("debtToEquity") + fields = [ + ("Name", info.get("longName")), + ("Sector", info.get("sector")), + ("Industry", info.get("industry")), + ("Market Cap", info.get("marketCap")), + ("PE Ratio (TTM)", info.get("trailingPE")), + ("Forward PE", info.get("forwardPE")), + ("PEG Ratio", info.get("pegRatio")), + ("Price to Book", info.get("priceToBook")), + ("EPS (TTM)", info.get("trailingEps")), + ("Forward EPS", info.get("forwardEps")), + ("Dividend Yield", None if dividend_yield is None else f"{dividend_yield}%"), + ("Beta", info.get("beta")), + ("52 Week High", info.get("fiftyTwoWeekHigh")), + ("52 Week Low", info.get("fiftyTwoWeekLow")), + ("50 Day Average", info.get("fiftyDayAverage")), + ("200 Day Average", info.get("twoHundredDayAverage")), + ("Revenue (TTM)", info.get("totalRevenue")), + ("Gross Profit", info.get("grossProfits")), + ("EBITDA", info.get("ebitda")), + ("Net Income", info.get("netIncomeToCommon")), + ("Profit Margin", info.get("profitMargins")), + ("Operating Margin", info.get("operatingMargins")), + ("Return on Equity", info.get("returnOnEquity")), + ("Return on Assets", info.get("returnOnAssets")), + ("Debt to Equity", None if debt_to_equity is None + else f"{debt_to_equity}% ({debt_to_equity / 100:.2f}x)"), + ("Current Ratio", info.get("currentRatio")), + ("Book Value", info.get("bookValue")), + ("Free Cash Flow", info.get("freeCashflow")), + ] - fields = [ - ("Name", info.get("longName")), - ("Sector", info.get("sector")), - ("Industry", info.get("industry")), - ("Market Cap", info.get("marketCap")), - ("PE Ratio (TTM)", info.get("trailingPE")), - ("Forward PE", info.get("forwardPE")), - ("PEG Ratio", info.get("pegRatio")), - ("Price to Book", info.get("priceToBook")), - ("EPS (TTM)", info.get("trailingEps")), - ("Forward EPS", info.get("forwardEps")), - ("Dividend Yield", info.get("dividendYield")), - ("Beta", info.get("beta")), - ("52 Week High", info.get("fiftyTwoWeekHigh")), - ("52 Week Low", info.get("fiftyTwoWeekLow")), - ("50 Day Average", info.get("fiftyDayAverage")), - ("200 Day Average", info.get("twoHundredDayAverage")), - ("Revenue (TTM)", info.get("totalRevenue")), - ("Gross Profit", info.get("grossProfits")), - ("EBITDA", info.get("ebitda")), - ("Net Income", info.get("netIncomeToCommon")), - ("Profit Margin", info.get("profitMargins")), - ("Operating Margin", info.get("operatingMargins")), - ("Return on Equity", info.get("returnOnEquity")), - ("Return on Assets", info.get("returnOnAssets")), - ("Debt to Equity", info.get("debtToEquity")), - ("Current Ratio", info.get("currentRatio")), - ("Book Value", info.get("bookValue")), - ("Free Cash Flow", info.get("freeCashflow")), - ] + lines = [f"{label}: {v}" for label, v in fields if v is not None] - lines = [f"{label}: {v}" for label, v in fields if v is not None] + # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for + # unknown symbols, so `info` is truthy but every field is empty. Treat + # "no usable fields" as no data rather than emitting a bare header the + # agent might fabricate around. + if not lines: + raise_for_empty(ticker, canonical, "fundamental fields") - # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for - # unknown symbols, so `info` is truthy but every field is empty. Treat - # "no usable fields" as no data rather than emitting a bare header the - # agent might fabricate around. - if not lines: - raise NoMarketDataError(ticker, canonical, "no fundamental fields returned") - - header = f"# Company Fundamentals for {canonical}\n\n" - - return header + "\n".join(lines) - - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e + return f"# Company Fundamentals for {canonical}\n\n" + "\n".join(lines) -# This vendor dates a statement by the period it covers, not by the day it was -# filed, and carries no filing date to do better. A company files weeks after its -# period ends, so a run dated in that gap can be served figures that were not yet -# public. Say so rather than implying the stricter guarantee (SEC EDGAR, which -# does carry filing dates, serves US filers as filed). -_PERIOD_END_VINTAGE = ( - "# Periods are cut at the fiscal period end; this vendor does not report " - "filing dates, so the most recent period may not have been published yet.\n\n" -) - - -def _statement(ticker, freq, curr_date, title, quarterly_attr, annual_attr) -> str: - """One financial statement as CSV, cut at ``curr_date`` by period end.""" +def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> str: + """One financial statement as CSV, for a run dated today.""" canonical = normalize_symbol(ticker) + withheld = withhold_undated_statements(as_of_date, canonical, title) + if withheld: + return withheld what = title.lower() - try: - ticker_obj = yf.Ticker(canonical) - attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr - data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), curr_date) - if data.empty: - raise_for_empty(ticker, canonical, f"{what} data") - return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"{what} unavailable: {e}") from e + attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr + data = yf_retry(lambda: getattr(yf.Ticker(canonical), attr)) + if data is None or data.empty: + raise_for_empty(ticker, canonical, f"{what} data") + return f"# {title} data for {canonical} ({freq})\n" + data.to_csv() def get_balance_sheet( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get balance sheet data from yfinance.""" - return _statement(ticker, freq, curr_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet") + return _statement(ticker, freq, as_of_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet") def get_cashflow( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get cash flow data from yfinance.""" - return _statement(ticker, freq, curr_date, "Cash Flow", "quarterly_cashflow", "cashflow") + return _statement(ticker, freq, as_of_date, "Cash Flow", "quarterly_cashflow", "cashflow") def get_income_statement( ticker: Annotated[str, "ticker symbol of the company"], freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", - curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None + as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None ): """Get income statement data from yfinance.""" - return _statement(ticker, freq, curr_date, "Income Statement", "quarterly_income_stmt", "income_stmt") - - -# Rows are dated by the transaction, which is when the insider traded, not when -# the market learned of it: a Form 4 is filed up to two business days later and -# this vendor reports no filing date, so the most recent rows may not have been -# public on the analysis date. -_TRANSACTION_DATE_VINTAGE = ( - "# Rows are dated by transaction date. A trade becomes public when its Form 4 " - "is filed, up to two business days later, so the newest rows may not have been " - "known on this date.\n\n" -) + return _statement(ticker, freq, as_of_date, "Income Statement", "quarterly_income_stmt", "income_stmt") def get_insider_transactions( ticker: Annotated[str, "ticker symbol of the company"], - curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, + as_of_date: Annotated[str | None, "analysis date, yyyy-mm-dd"] = None, ): - """Get insider transactions data from yfinance.""" + """Get insider transactions data from yfinance, for a run dated today.""" canonical = normalize_symbol(ticker) - try: - ticker_obj = yf.Ticker(canonical) - data = yf_retry(lambda: ticker_obj.insider_transactions) + withheld = withhold_undisclosed_trades(as_of_date, canonical) + if withheld: + return withheld + data = yf_retry(lambda: yf.Ticker(canonical).insider_transactions) - # Empty is normal here (many valid symbols have no insider filings), - # so report it plainly rather than treating the symbol as invalid. - if data is None or data.empty: - if not vendor_reachable(YAHOO_HOST): - raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") - return f"No insider transactions reported for symbol '{canonical}'" + # Empty is normal here (many valid symbols have no insider filings), + # so report it plainly rather than treating the symbol as invalid. + if data is None or data.empty: + if not vendor_reachable(YAHOO_HOST): + raise VendorUnavailableError("Yahoo Finance is unreachable; insider filings were not retrieved") + return f"No insider transactions reported for symbol '{canonical}'" - if curr_date: - traded = data["Start Date"] - kept = data[traded <= pd.Timestamp(curr_date)] - if kept.empty: - return ( - f"" - ) - data = kept - - return f"# Insider Transactions data for {canonical}\n" + _TRANSACTION_DATE_VINTAGE + data.to_csv() - - except VendorError: - raise - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e + return f"# Insider Transactions data for {canonical}\n" + data.to_csv() def get_company_profile(ticker: str) -> dict: """Yahoo's current profile for ``ticker``: name, sector, industry and the like.""" - canonical = normalize_symbol(ticker) - try: - return yf_retry(lambda: yf.Ticker(canonical).info) or {} - except Exception as e: - raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e - - -def filter_financials_by_date(data: pd.DataFrame, curr_date: str) -> pd.DataFrame: - """Drop financial statement columns (fiscal period timestamps) after curr_date. - - yfinance financial statements use fiscal period end dates as columns. - Columns after curr_date represent future data and are removed to - prevent look-ahead bias. - """ - if not curr_date or data.empty: - return data - cutoff = pd.Timestamp(curr_date) - mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff - return data.loc[:, mask] + return yf_retry(lambda: yf.Ticker(normalize_symbol(ticker)).info) or {} diff --git a/tradingagents/dataflows/vendors/yahoo/market.py b/tradingagents/dataflows/vendors/yahoo/market.py index 5e4d5a9c2..b099caabf 100644 --- a/tradingagents/dataflows/vendors/yahoo/market.py +++ b/tradingagents/dataflows/vendors/yahoo/market.py @@ -9,12 +9,8 @@ from stockstats import wrap from tradingagents.dataflows.errors import NoMarketDataError, VendorError from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import ( - _assert_ohlcv_not_stale, - load_ohlcv, - raise_for_empty, - yf_retry, -) +from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry +from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv logger = logging.getLogger(__name__) @@ -30,18 +26,17 @@ def get_YFin_data_online( # Resolve broker/forex symbols to Yahoo's convention (XAUUSD+ -> GC=F). canonical = normalize_symbol(symbol) - ticker = yf.Ticker(canonical) # yfinance treats ``end`` as EXCLUSIVE, so it would drop the requested # end_date row (and the current day when end_date is today). Request one day # past end_date so the requested range is actually inclusive (#986/#987). end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d") - data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive)) + data = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_inclusive)) # Empty result means the symbol is unknown/delisted. Raise a typed error # instead of returning prose: the routing layer turns it into a single # unambiguous "no data" signal so the agent never fabricates a price. - if data.empty: + if data is None or data.empty: raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}") # Remove timezone info from index for cleaner output @@ -73,7 +68,7 @@ def get_YFin_data_online( def get_stock_stats_indicators_window( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], - curr_date: Annotated[ + as_of_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], look_back_days: Annotated[int, "how many days to look back"], @@ -157,16 +152,16 @@ def get_stock_stats_indicators_window( f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" ) - end_date = curr_date - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - before = curr_date_dt - relativedelta(days=look_back_days) + end_date = as_of_date + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + before = as_of_dt - relativedelta(days=look_back_days) # Optimized: Get stock data once and calculate indicators for all dates try: - indicator_data = _get_stock_stats_bulk(symbol, indicator, curr_date) + indicator_data = _get_stock_stats_bulk(symbol, indicator, as_of_date) # Generate the date range we need - current_dt = curr_date_dt + current_dt = as_of_dt date_values = [] while current_dt >= before: @@ -191,13 +186,13 @@ def get_stock_stats_indicators_window( logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e) # Fallback to original implementation if bulk method fails ind_string = "" - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - while curr_date_dt >= before: + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + while as_of_dt >= before: indicator_value = get_stockstats_indicator( - symbol, indicator, curr_date_dt.strftime("%Y-%m-%d") + symbol, indicator, as_of_dt.strftime("%Y-%m-%d") ) - ind_string += f"{curr_date_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" - curr_date_dt = curr_date_dt - relativedelta(days=1) + ind_string += f"{as_of_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" + as_of_dt = as_of_dt - relativedelta(days=1) result_str = ( f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n" @@ -212,7 +207,7 @@ def get_stock_stats_indicators_window( def _get_stock_stats_bulk( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to calculate"], - curr_date: Annotated[str, "current date for reference"] + as_of_date: Annotated[str, "current date for reference"] ) -> dict: """ Optimized bulk calculation of stock stats indicators. @@ -221,7 +216,7 @@ def _get_stock_stats_bulk( """ from stockstats import wrap - data = load_ohlcv(symbol, curr_date) + data = load_ohlcv(symbol, as_of_date) df = wrap(data) df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") @@ -243,19 +238,19 @@ def _get_stock_stats_bulk( def get_stockstats_indicator( symbol: Annotated[str, "ticker symbol of the company"], indicator: Annotated[str, "technical indicator to get the analysis and report of"], - curr_date: Annotated[ + as_of_date: Annotated[ str, "The current trading date you are trading on, YYYY-mm-dd" ], ) -> str: - curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") - curr_date = curr_date_dt.strftime("%Y-%m-%d") + as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d") + as_of_date = as_of_dt.strftime("%Y-%m-%d") try: indicator_value = get_stock_stats( symbol, indicator, - curr_date, + as_of_date, ) except VendorError: raise # Unknown/delisted symbol — let the router emit the sentinel @@ -264,7 +259,7 @@ def get_stockstats_indicator( # reads as no value that day rather than a read that failed. Raise so the # router can try the next vendor or report the series unavailable. raise NoMarketDataError( - symbol, symbol, f"{indicator} could not be read for {curr_date}: {e}" + symbol, symbol, f"{indicator} could not be read for {as_of_date}: {e}" ) from e return str(indicator_value) @@ -273,11 +268,8 @@ def get_stockstats_indicator( def get_closes(symbol: str, start_date: str, end_date: str) -> pd.Series: """Daily closes from ``start_date`` up to, not including, ``end_date``.""" canonical = normalize_symbol(symbol) - try: - history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) - except Exception as e: - raise NoMarketDataError(symbol, canonical, f"prices unavailable: {e}") from e - return history["Close"] if "Close" in history else pd.Series(dtype=float) + history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) + return history["Close"] if history is not None and "Close" in history else pd.Series(dtype=float) def get_stock_stats( @@ -285,17 +277,17 @@ def get_stock_stats( indicator: Annotated[ str, "quantitative indicators based off of the stock data for the company" ], - curr_date: Annotated[ + as_of_date: Annotated[ str, "curr date for retrieving stock price data, YYYY-mm-dd" ], ): - data = load_ohlcv(symbol, curr_date) + data = load_ohlcv(symbol, as_of_date) df = wrap(data) df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") - curr_date_str = pd.to_datetime(curr_date).strftime("%Y-%m-%d") + as_of_str = pd.to_datetime(as_of_date).strftime("%Y-%m-%d") df[indicator] # trigger stockstats to calculate the indicator - matching_rows = df[df["Date"].str.startswith(curr_date_str)] + matching_rows = df[df["Date"].str.startswith(as_of_str)] if not matching_rows.empty: indicator_value = matching_rows[indicator].values[0] diff --git a/tradingagents/dataflows/vendors/yahoo/news.py b/tradingagents/dataflows/vendors/yahoo/news.py index 9f0498b9a..1671daa8b 100644 --- a/tradingagents/dataflows/vendors/yahoo/news.py +++ b/tradingagents/dataflows/vendors/yahoo/news.py @@ -1,16 +1,15 @@ """yfinance-based news data fetching functions.""" import contextlib -from datetime import datetime, timezone +from datetime import UTC, datetime import yfinance as yf from dateutil.relativedelta import relativedelta from tradingagents.dataflows.config import get_config from tradingagents.dataflows.date_window import coverage_gap, in_window -from tradingagents.dataflows.errors import NoMarketDataError from tradingagents.dataflows.symbols import normalize_symbol -from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry +from tradingagents.dataflows.vendors.yahoo.common import yf_retry def _extract_article_data(article: dict) -> dict: @@ -48,7 +47,7 @@ def _extract_article_data(article: dict) -> dict: # Epoch seconds are UTC; parse them as UTC-aware so filtering does # not shift with the host timezone (#1126). with contextlib.suppress(ValueError, OSError, TypeError): - pub_date = datetime.fromtimestamp(ts, tz=timezone.utc) + pub_date = datetime.fromtimestamp(ts, tz=UTC) return { "title": article.get("title", "No title"), "summary": article.get("summary", ""), @@ -80,46 +79,42 @@ def get_news_yfinance( # returns no news. Keep the user's ticker in the report header. canonical = normalize_symbol(ticker) resolved = "" if canonical == ticker else f" (resolved to {canonical})" - try: - stock = yf.Ticker(canonical) - news = yf_retry(lambda: stock.get_news(count=article_limit)) or [] + news = yf_retry(lambda: yf.Ticker(canonical).get_news(count=article_limit)) or [] - start_dt = datetime.strptime(start_date, "%Y-%m-%d") - end_dt = datetime.strptime(end_date, "%Y-%m-%d") + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") - news_str = "" - filtered_count = 0 + news_str = "" + filtered_count = 0 - for article in news: - data = _extract_article_data(article) + for article in news: + data = _extract_article_data(article) - # Keep only articles within the requested window (look-ahead safe). - if not in_window(data["pub_date"], start_dt, end_dt): - continue + # Keep only articles within the requested window (look-ahead safe). + if not in_window(data["pub_date"], start_dt, end_dt): + continue - news_str += f"### {data['title']} (source: {data['publisher']})\n" - if data["summary"]: - news_str += f"{data['summary']}\n" - if data["link"]: - news_str += f"Link: {data['link']}\n" - news_str += "\n" - filtered_count += 1 + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" + filtered_count += 1 - if filtered_count == 0: - gap = coverage_gap( - (_extract_article_data(a)["pub_date"] for a in news), - start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}", - ) - return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}" + if filtered_count == 0: + gap = coverage_gap( + (_extract_article_data(a)["pub_date"] for a in news), + start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}", + ) + return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}" - return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" + return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" - except Exception as e: - raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e def get_global_news_yfinance( - curr_date: str, + as_of_date: str, look_back_days: int | None = None, limit: int | None = None, ) -> str: @@ -127,7 +122,7 @@ def get_global_news_yfinance( Retrieve global/macro economic news using yfinance Search. Args: - curr_date: Current date in yyyy-mm-dd format + as_of_date: Current date in yyyy-mm-dd format look_back_days: Number of days to look back. ``None`` falls back to ``global_news_lookback_days`` from the active config. limit: Maximum number of articles to return. ``None`` falls back to @@ -143,54 +138,50 @@ def get_global_news_yfinance( limit = config["global_news_article_limit"] search_queries = config["global_news_queries"] - curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d") start_dt = curr_dt - relativedelta(days=look_back_days) start_date = start_dt.strftime("%Y-%m-%d") in_window_news = [] seen_titles = set() - try: - for query in search_queries: - search = yf_retry(lambda q=query: yf.Search( - query=q, - news_count=limit, - enable_fuzzy_query=True, - )) + for query in search_queries: + found = yf_retry(lambda q=query: yf.Search( + query=q, + news_count=limit, + enable_fuzzy_query=True, + ).news) - for article in search.news or []: - # Window first: the limit counts what the run may read, so an - # out-of-window article must not spend the budget or cut the - # remaining searches short (#1356). Flat articles are filtered - # on the same rule, so none can leak future news (#1007). - data = _extract_article_data(article) - if not in_window(data["pub_date"], start_dt, curr_dt): - continue - if data["title"] and data["title"] not in seen_titles: - seen_titles.add(data["title"]) - in_window_news.append(data) + for article in found or []: + # Window first: the limit counts what the run may read, so an + # out-of-window article must not spend the budget or cut the + # remaining searches short (#1356). Flat articles are filtered + # on the same rule, so none can leak future news (#1007). + data = _extract_article_data(article) + if not in_window(data["pub_date"], start_dt, curr_dt): + continue + if data["title"] and data["title"] not in seen_titles: + seen_titles.add(data["title"]) + in_window_news.append(data) - if len(in_window_news) >= limit: - break + if len(in_window_news) >= limit: + break - news_str = "" - for data in in_window_news[:limit]: - news_str += f"### {data['title']} (source: {data['publisher']})\n" - if data["summary"]: - news_str += f"{data['summary']}\n" - if data["link"]: - news_str += f"Link: {data['link']}\n" - news_str += "\n" + news_str = "" + for data in in_window_news[:limit]: + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" - # Nothing fell inside the window -> say so rather than return an - # empty-bodied report (#993). - if not news_str: - # Results merge several fuzzy searches, so their timestamps prove no - # continuous coverage; judge the window against the present only. - gap = coverage_gap((), start_date, curr_date, "Yahoo Finance global news", "market news") - return gap or f"No global news found between {start_date} and {curr_date}" + # Nothing fell inside the window -> say so rather than return an + # empty-bodied report (#993). + if not news_str: + # Results merge several fuzzy searches, so their timestamps prove no + # continuous coverage; judge the window against the present only. + gap = coverage_gap((), start_date, as_of_date, "Yahoo Finance global news", "market news") + return gap or f"No global news found between {start_date} and {as_of_date}" - return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" - - except Exception as e: - raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e + return f"## Global Market News, from {start_date} to {as_of_date}:\n\n{news_str}" diff --git a/tradingagents/dataflows/vendors/yahoo/ohlcv.py b/tradingagents/dataflows/vendors/yahoo/ohlcv.py index 6325d6172..beb612f22 100644 --- a/tradingagents/dataflows/vendors/yahoo/ohlcv.py +++ b/tradingagents/dataflows/vendors/yahoo/ohlcv.py @@ -1,20 +1,17 @@ import logging import os -import time import pandas as pd import yfinance as yf -from yfinance.exceptions import YFRateLimitError from tradingagents.dataflows.config import get_config -from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError -from tradingagents.dataflows.net import vendor_reachable +from tradingagents.dataflows.errors import NoMarketDataError +from tradingagents.dataflows.files import replace_file from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component +from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry logger = logging.getLogger(__name__) -YAHOO_HOST = "https://query2.finance.yahoo.com" - # A vendor's latest OHLCV row this many calendar days before the requested date # is treated as stale. Generous enough to span long holiday weekends, tight # enough to catch the year-old frames yfinance occasionally returns (#1021). @@ -27,36 +24,6 @@ MAX_OHLCV_STALE_DAYS = 10 OHLCV_CACHE_TTL_SECONDS = 900 -def raise_for_empty(symbol: str, canonical: str, what: str) -> None: - """Report an empty Yahoo result as an absence, or as an outage if it is one. - - yfinance returns an empty frame for a failed request rather than raising, so - without this a Yahoo outage reads as "this symbol has no {what}". - """ - if not vendor_reachable(YAHOO_HOST): - raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved") - raise NoMarketDataError(symbol, canonical, f"no {what}") - - -def yf_retry(func, max_retries=3, base_delay=2.0): - """Execute a yfinance call with exponential backoff on rate limits. - - yfinance raises YFRateLimitError on HTTP 429 responses but does not - retry them internally. This wrapper adds retry logic specifically - for rate limits. Other exceptions propagate immediately. - """ - for attempt in range(max_retries + 1): - try: - return func() - except YFRateLimitError: - if attempt < max_retries: - delay = base_delay * (2 ** attempt) - logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") - time.sleep(delay) - else: - raise - - def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: """Normalize the date column to ``Date``. @@ -87,7 +54,7 @@ def _local_midnight(value) -> pd.Timestamp: def _normalize_dates(dates) -> pd.Series: """Parse to naive, midnight-normalized dates so tz-aware or intraday - timestamps compare correctly against the naive ``curr_date`` cutoff (#1201). + timestamps compare correctly against the naive ``as_of_date`` cutoff (#1201). Normalized per element: 5 years of yfinance bars span daylight-saving changes (and cache CSVs round-trip the offsets as strings), so the series can @@ -141,13 +108,13 @@ def _coerce_ohlcv_dates(data: pd.DataFrame) -> pd.Series: def _assert_ohlcv_not_stale( data: pd.DataFrame, - curr_date: str, + as_of_date: str, symbol: str, canonical: str | None = None, *, max_stale_days: int = MAX_OHLCV_STALE_DAYS, ) -> None: - """Reject OHLCV whose latest row is far older than curr_date. + """Reject OHLCV whose latest row is far older than as_of_date. Raises NoMarketDataError (with a stale-specific detail) so the router treats it like any other "no usable data from this vendor" — try the next vendor, @@ -158,7 +125,7 @@ def _assert_ohlcv_not_stale( """ if data is None or data.empty: return - requested = pd.to_datetime(curr_date, errors="coerce") + requested = pd.to_datetime(as_of_date, errors="coerce") if pd.isna(requested): return requested = requested.normalize() @@ -176,7 +143,7 @@ def _assert_ohlcv_not_stale( ) -def _cache_is_fresh(data_file, curr_date_dt, now) -> bool: +def _cache_is_fresh(data_file, as_of_dt, now) -> bool: """Whether the symbol's cached download can serve this request. The file holds the download made on the day it was written, so it serves @@ -188,14 +155,14 @@ def _cache_is_fresh(data_file, curr_date_dt, now) -> bool: written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file)) if written.date() != now.date(): return False - return curr_date_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS + return as_of_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS -def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFrame: +def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataFrame: """Fetch OHLCV data with caching, filtered to prevent look-ahead bias. Downloads 5 years of data up to today and caches per symbol. On - subsequent calls the cache is reused. Rows after curr_date are + subsequent calls the cache is reused. Rows after as_of_date are filtered out so backtests never see future prices. ``fill_gaps`` carries prices forward over gaps so indicators compute on a @@ -209,15 +176,15 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr safe_symbol = safe_ticker_component(canonical) config = get_config() - curr_date_dt = pd.to_datetime(curr_date).normalize() + as_of_dt = pd.to_datetime(as_of_date).normalize() # One cache file per symbol, holding the latest 5y-to-today download. now = pd.Timestamp.today() start_date = now - pd.DateOffset(years=5) start_str = start_date.strftime("%Y-%m-%d") # yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included - # when curr_date is the current day (#986). Look-ahead is still prevented by - # the curr_date filter below. + # when as_of_date is the current day (#986). Look-ahead is still prevented by + # the as_of_date filter below. end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d") os.makedirs(config["data_cache_dir"], exist_ok=True) @@ -235,30 +202,32 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr if ( not cached.empty and "Close" in cached.columns - and _cache_is_fresh(data_file, curr_date_dt, now) + and _cache_is_fresh(data_file, as_of_dt, now) ): data = cached if data is None: - downloaded = yf_retry(lambda: yf.download( - canonical, + # yf.download catches every error, a rate limit included, and returns + # an empty frame. Ticker.history raises the rate limit, so it is retried. + downloaded = yf_retry(lambda: yf.Ticker(canonical).history( start=start_str, end=end_str, - multi_level_index=False, - progress=False, auto_adjust=True, + actions=False, )) + if downloaded is None: + raise_for_empty(symbol, canonical, "price rows") downloaded = _ensure_date_column(downloaded.reset_index()) # Only cache real data — never persist an empty frame. if downloaded.empty or "Close" not in downloaded.columns: raise_for_empty(symbol, canonical, "price rows") - downloaded.to_csv(data_file, index=False, encoding="utf-8") + replace_file(data_file, lambda temp: downloaded.to_csv(temp, index=False, encoding="utf-8")) data = downloaded data = _clean_dataframe(data) - # Filter to curr_date to prevent look-ahead bias in backtesting. - data = data[data["Date"] <= curr_date_dt] + # Filter to as_of_date to prevent look-ahead bias in backtesting. + data = data[data["Date"] <= as_of_dt] # A closeless newest bar is an unsettled session, not a symbol without data. # _fill_price_gaps below drops it, here and mid-series alike, so the frame @@ -281,9 +250,9 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr # a filled cell is the previous session's price under this session's date. data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy() - # Reject a stale frame (latest row far older than curr_date) rather than + # Reject a stale frame (latest row far older than as_of_date) rather than # feeding year-old prices into indicators (#1021). - _assert_ohlcv_not_stale(data, curr_date, symbol, canonical) + _assert_ohlcv_not_stale(data, as_of_date, symbol, canonical) return data diff --git a/tradingagents/dataflows/vendors/yahoo/snapshot.py b/tradingagents/dataflows/vendors/yahoo/snapshot.py index 6c490ff71..3bec5ebc5 100644 --- a/tradingagents/dataflows/vendors/yahoo/snapshot.py +++ b/tradingagents/dataflows/vendors/yahoo/snapshot.py @@ -15,6 +15,8 @@ from collections.abc import Iterable import pandas as pd from stockstats import wrap +from tradingagents.dataflows.errors import NoMarketDataError +from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.vendors.yahoo.ohlcv import load_ohlcv # A fixed, common indicator set so the snapshot is the same shape every run. @@ -25,8 +27,8 @@ DEFAULT_SNAPSHOT_INDICATORS: tuple[str, ...] = ( ) -def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: - """OHLCV on or before curr_date, date-sorted. Raises if nothing usable. +def _verified_rows(symbol: str, as_of_date: str) -> pd.DataFrame: + """OHLCV on or before as_of_date, date-sorted. Raises NoMarketDataError if nothing usable. ``load_ohlcv`` already normalizes the Date column and filters out look-ahead rows, but we re-apply the cutoff defensively — this is a @@ -34,16 +36,16 @@ def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: """ # As reported: this snapshot is quoted by the agents as exact prices, so a # gap-filled cell would put the previous session's number under this date. - data = load_ohlcv(symbol, curr_date, fill_gaps=False) + data = load_ohlcv(symbol, as_of_date, fill_gaps=False) if data is None or data.empty: - raise ValueError(f"No OHLCV data available for {symbol}.") + raise NoMarketDataError(symbol, normalize_symbol(symbol), "no price rows") df = data.copy() df["Date"] = pd.to_datetime(df["Date"], errors="coerce") df = df.dropna(subset=["Date"]) - df = df[df["Date"] <= pd.to_datetime(curr_date)].sort_values("Date") + df = df[df["Date"] <= pd.to_datetime(as_of_date)].sort_values("Date") if df.empty: - raise ValueError(f"No OHLCV rows on or before {curr_date} for {symbol}.") + raise NoMarketDataError(symbol, normalize_symbol(symbol), f"no price rows on or before {as_of_date}") return df @@ -63,7 +65,7 @@ def _fmt(value) -> str: def build_verified_market_snapshot( symbol: str, - curr_date: str, + as_of_date: str, look_back_days: int = 30, indicators: Iterable[str] | None = None, ) -> str: @@ -71,7 +73,7 @@ def build_verified_market_snapshot( # `df` keeps the original capitalized OHLCV columns (Open/High/Low/Close/ # Volume); stockstats `wrap()` lowercases columns and adds indicator # columns, so read raw prices from `df` and indicators from `stock_df`. - df = _verified_rows(symbol, curr_date) + df = _verified_rows(symbol, as_of_date) stock_df = wrap(df.copy()) selected = tuple(indicators or DEFAULT_SNAPSHOT_INDICATORS) @@ -91,7 +93,7 @@ def build_verified_market_snapshot( lines = [ f"## Verified market data snapshot for {symbol.upper()}", "", - f"- Requested analysis date: {curr_date}", + f"- Requested analysis date: {as_of_date}", f"- Latest trading row used: {latest_date}", "- Rows after the requested analysis date are excluded before verification.", "", diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 3bfaf3db9..49ecf7875 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -15,6 +15,7 @@ _ENV_OVERRIDES = { "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", "TRADINGAGENTS_MAX_RISK_ROUNDS": "max_risk_discuss_rounds", + "TRADINGAGENTS_MAX_TOOL_ROUNDS": "max_tool_rounds", "TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled", "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", "TRADINGAGENTS_TEMPERATURE": "temperature", @@ -69,105 +70,129 @@ def _apply_env_overrides(config: dict) -> dict: return config -DEFAULT_CONFIG = _apply_env_overrides({ - "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR") or os.path.join(_TRADINGAGENTS_HOME, "logs"), - "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR") or os.path.join(_TRADINGAGENTS_HOME, "cache"), - "memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH") or os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md"), - # Optional cap on the number of resolved memory log entries. When set, - # the oldest resolved entries are pruned once this limit is exceeded. - # Pending entries are never pruned. None disables rotation entirely. - "memory_log_max_entries": None, - # LLM settings - "llm_provider": "openai", - "deep_think_llm": "gpt-6-sol", - "quick_think_llm": "gpt-6-luna", - # When None, each provider's client falls back to its own default endpoint - # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). - # The CLI overrides this per provider when the user picks one. Keeping a - # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously - # being forwarded to Gemini, producing malformed request URLs). - "backend_url": None, - # Provider-specific thinking configuration - "google_thinking_level": None, # "high", "minimal", etc. - "openai_reasoning_effort": None, # "medium", "high", "low" - "anthropic_effort": None, # "high", "medium", "low" - # Sampling temperature, forwarded to every provider when set. None leaves - # each provider at its own default. Lower values reduce run-to-run - # variation on models that honor it; reasoning models largely ignore it - # and no setting makes LLM output bit-identical across runs (see README). - "temperature": None, - # SDK retry budget forwarded to every provider chat client. None leaves each - # provider/SDK at its own default (usually 2). Raise it to ride out bursty - # 429 throttling on rate-limited deployments instead of aborting a run (#1091). - "llm_max_retries": None, - # Cap on output tokens forwarded to every provider chat client. None leaves - # each provider at its own default. Set it to bound a model that emits - # unbounded reasoning/output and hangs or trips a gateway idle timeout - # (e.g. some deepseek-v4-flash deployments, #1204). - "max_tokens": None, - # Checkpoint/resume: when True, LangGraph saves state after each node - # so a crashed run can resume from the last successful step. - "checkpoint_enabled": False, - # Output language for analyst reports and final decision - # Internal agent debate stays in English for reasoning quality - "output_language": "English", - # Debate and discussion settings - "max_debate_rounds": 1, - "max_risk_discuss_rounds": 1, - "max_recur_limit": 100, - # News / data fetching parameters - # Increase for longer lookback strategies or to broaden macro coverage; - # decrease to reduce token usage in agent prompts. - "news_article_limit": 20, # max articles per ticker (ticker-news) - "global_news_article_limit": 10, # max articles for global/macro news - "global_news_lookback_days": 7, # macro news lookback window - # Search queries used by get_global_news for macro headlines. Extend or - # replace to broaden geographic / sector coverage. - "global_news_queries": [ - "Federal Reserve interest rates inflation", - "S&P 500 earnings GDP economic outlook", - "geopolitical risk trade war sanctions", - "ECB Bank of England BOJ central bank policy", - "oil commodities supply chain energy", - ], - # Data vendor configuration - # Category-level configuration (default for all tools in category). - # The configured value is the exact vendor chain — requests are NOT silently - # routed to vendors you didn't choose. For ordered fallback, list several, - # e.g. "yfinance,alpha_vantage". "default" uses all available vendors. - "data_vendors": { - "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance - "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance - "news_data": "yfinance", # Options: alpha_vantage, yfinance - "macro_data": "fred", # Options: fred (needs FRED_API_KEY) - "prediction_markets": "polymarket", # Options: polymarket (keyless) - }, - # Tool-level configuration (takes precedence over category-level) - "tool_vendors": { - # Example: "get_stock_data": "alpha_vantage", # Override category default - }, - # Benchmark for alpha calculation in the reflection layer. - # ``benchmark_ticker`` (when set) overrides the suffix map for all - # tickers; leave it None to use ``benchmark_map`` for auto-detection - # based on the ticker's exchange suffix. SPY remains the US default - # so the reflection label keeps reading "Alpha vs SPY" for US tickers - # while non-US tickers get their regional index automatically. - # Trading days after the analysis date over which a decision's outcome is - # measured, for reflection and for the backtest figures. - "holding_period_days": 5, - "benchmark_ticker": None, - "benchmark_map": { - ".NS": "^NSEI", # NSE India (Nifty 50) - ".BO": "^BSESN", # BSE India (Sensex) - ".T": "^N225", # Tokyo (Nikkei 225) - ".HK": "^HSI", # Hong Kong (Hang Seng) - ".L": "^FTSE", # London (FTSE 100) - ".TO": "^GSPTSE", # Toronto (TSX Composite) - ".AX": "^AXJO", # Australia (ASX 200) - ".SS": "000001.SS", # Shanghai (SSE Composite) - ".SZ": "399001.SZ", # Shenzhen (SZSE Component) - ".SA": "^BVSP", # B3 Brazil (Ibovespa) - "": "SPY", # default for US-listed tickers (no suffix) - }, -}) +def build_default_config() -> dict: + """The built-in defaults with the TRADINGAGENTS_* environment folded in. + + Read when the package is imported, as DEFAULT_CONFIG; call it again to see + the environment as it is now. + """ + return _apply_env_overrides({ + "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR") or os.path.join(_TRADINGAGENTS_HOME, "logs"), + "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR") or os.path.join(_TRADINGAGENTS_HOME, "cache"), + "memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH") or os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md"), + # Optional cap on the number of resolved memory log entries. When set, + # the oldest resolved entries are pruned once this limit is exceeded. + # Pending entries are never pruned. None disables rotation entirely. + "memory_log_max_entries": None, + # LLM settings + "llm_provider": "openai", + "deep_think_llm": "gpt-6-sol", + "quick_think_llm": "gpt-6-luna", + # When None, each provider's client falls back to its own default endpoint + # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). + # The CLI overrides this per provider when the user picks one. Keeping a + # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously + # being forwarded to Gemini, producing malformed request URLs). + "backend_url": None, + # Provider-specific thinking configuration + "google_thinking_level": None, # "high", "minimal", etc. + "openai_reasoning_effort": None, # "medium", "high", "low" + "anthropic_effort": None, # "high", "medium", "low" + # Sampling temperature, forwarded to every provider when set. None leaves + # each provider at its own default. Lower values reduce run-to-run + # variation on models that honor it; reasoning models largely ignore it + # and no setting makes LLM output bit-identical across runs (see README). + "temperature": None, + # SDK retry budget forwarded to every provider chat client. None leaves each + # provider/SDK at its own default (usually 2). Raise it to ride out bursty + # 429 throttling on rate-limited deployments instead of aborting a run (#1091). + "llm_max_retries": None, + # Cap on output tokens forwarded to every provider chat client. None leaves + # each provider at its own default. Set it to bound a model that emits + # unbounded reasoning/output and hangs or trips a gateway idle timeout + # (e.g. some deepseek-v4-flash deployments, #1204). + "max_tokens": None, + # Checkpoint/resume: when True, LangGraph saves state after each node + # so a crashed run can resume from the last successful step. + "checkpoint_enabled": False, + # Output language for analyst reports and final decision + # Internal agent debate stays in English for reasoning quality + "output_language": "English", + # Debate and discussion settings + "max_debate_rounds": 1, + "max_risk_discuss_rounds": 1, + "max_recur_limit": 100, + # Rounds of tool calls an analyst may make before it is asked for its report. + "max_tool_rounds": 20, + # News / data fetching parameters + # Increase for longer lookback strategies or to broaden macro coverage; + # decrease to reduce token usage in agent prompts. + "news_article_limit": 20, # max articles per ticker (ticker-news) + "global_news_article_limit": 10, # max articles for global/macro news + "global_news_lookback_days": 7, # macro news lookback window + # Search queries used by get_global_news for macro headlines. Extend or + # replace to broaden geographic / sector coverage. + "global_news_queries": [ + "Federal Reserve interest rates inflation", + "S&P 500 earnings GDP economic outlook", + "geopolitical risk trade war sanctions", + "ECB Bank of England BOJ central bank policy", + "oil commodities supply chain energy", + ], + # Data vendor configuration + # Category-level configuration (default for all tools in category). + # The configured value is the exact vendor chain — requests are NOT silently + # routed to vendors you didn't choose. For ordered fallback, list several, + # e.g. "yfinance,alpha_vantage". "default" uses all available vendors. + "data_vendors": { + "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance + "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance + # Statements come from SEC EDGAR as filed (US filers), then Yahoo; the + # overview and insider tools, which SEC EDGAR does not serve, from Yahoo. + "fundamental_data": "sec_edgar,yfinance", # Options: sec_edgar, alpha_vantage, yfinance + "news_data": "yfinance", # Options: alpha_vantage, yfinance + "macro_data": "fred", # Options: fred (needs FRED_API_KEY) + "prediction_markets": "polymarket", # Options: polymarket (keyless) + }, + # Tool-level configuration (takes precedence over category-level) + "tool_vendors": { + # Example: "get_stock_data": "alpha_vantage", # Override category default + }, + # Benchmark for alpha calculation in the reflection layer. + # ``benchmark_ticker`` (when set) overrides the suffix map for all + # tickers; leave it None to use ``benchmark_map`` for auto-detection + # based on the ticker's exchange suffix. SPY remains the US default + # so the reflection label keeps reading "Alpha vs SPY" for US tickers + # while non-US tickers get their regional index automatically. + # Trading days after the analysis date over which a decision's outcome is + # measured, for reflection and for the backtest figures. + "holding_period_days": 5, + "benchmark_ticker": None, + "benchmark_map": { + ".NS": "^NSEI", # NSE India (Nifty 50) + ".BO": "^BSESN", # BSE India (Sensex) + ".T": "^N225", # Tokyo (Nikkei 225) + ".TW": "^TWII", # Taiwan (TAIEX) + ".TWO": "^TWII", # Taipei OTC (TPEx has no Yahoo index; TAIEX) + ".KS": "^KS11", # Korea (KOSPI) + ".KQ": "^KQ11", # Korea (KOSDAQ) + ".HK": "^HSI", # Hong Kong (Hang Seng) + ".SI": "^STI", # Singapore (Straits Times) + ".L": "^FTSE", # London (FTSE 100) + ".DE": "^GDAXI", # Germany (DAX) + ".PA": "^FCHI", # Paris (CAC 40) + ".AS": "^AEX", # Amsterdam (AEX) + ".SW": "^SSMI", # Switzerland (SMI) + ".MI": "FTSEMIB.MI", # Milan (FTSE MIB) + ".TO": "^GSPTSE", # Toronto (TSX Composite) + ".AX": "^AXJO", # Australia (ASX 200) + ".SS": "000001.SS", # Shanghai (SSE Composite) + ".SZ": "399001.SZ", # Shenzhen (SZSE Component) + ".SA": "^BVSP", # B3 Brazil (Ibovespa) + "": "SPY", # default for US-listed tickers (no suffix) + }, + + }) + + +DEFAULT_CONFIG = build_default_config() diff --git a/tradingagents/graph/__init__.py b/tradingagents/graph/__init__.py index 54f3037fc..e3715701b 100644 --- a/tradingagents/graph/__init__.py +++ b/tradingagents/graph/__init__.py @@ -1,6 +1,5 @@ from .conditional_logic import ConditionalLogic from .propagation import Propagator -from .reflection import Reflector from .setup import GraphSetup from .trading_graph import TradingAgentsGraph @@ -9,5 +8,4 @@ __all__ = [ "ConditionalLogic", "GraphSetup", "Propagator", - "Reflector", ] diff --git a/tradingagents/graph/analyst_execution.py b/tradingagents/graph/analyst_execution.py index 741685c50..3a4d0f70b 100644 --- a/tradingagents/graph/analyst_execution.py +++ b/tradingagents/graph/analyst_execution.py @@ -8,15 +8,9 @@ from tradingagents.agents.analysts import fundamentals_analyst, market_analyst, class AnalystNodeSpec: key: str agent_node: str - clear_node: str report_key: str tools: tuple = () - @property - def tool_node(self) -> str | None: - """The node that runs this analyst's tool calls; None when it has no tools.""" - return f"tools_{self.key}" if self.tools else None - @dataclass(frozen=True) class AnalystExecutionPlan: @@ -27,7 +21,6 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { "market": AnalystNodeSpec( key="market", agent_node="Market Analyst", - clear_node="Msg Clear Market", report_key="market_report", tools=market_analyst.TOOLS, ), @@ -36,20 +29,17 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { # sources before calling the model, so it has no tools. key="social", agent_node="Sentiment Analyst", - clear_node="Msg Clear Sentiment", report_key="sentiment_report", ), "news": AnalystNodeSpec( key="news", agent_node="News Analyst", - clear_node="Msg Clear News", report_key="news_report", tools=news_analyst.TOOLS, ), "fundamentals": AnalystNodeSpec( key="fundamentals", agent_node="Fundamentals Analyst", - clear_node="Msg Clear Fundamentals", report_key="fundamentals_report", tools=fundamentals_analyst.TOOLS, ), diff --git a/tradingagents/graph/conditional_logic.py b/tradingagents/graph/conditional_logic.py index 9d41160a0..2c6caca2c 100644 --- a/tradingagents/graph/conditional_logic.py +++ b/tradingagents/graph/conditional_logic.py @@ -14,7 +14,7 @@ class ConditionalLogic: if ( state["investment_debate_state"]["count"] >= 2 * self.max_debate_rounds - ): # 3 rounds of back-and-forth between 2 agents + ): # max_debate_rounds turns each for bull and bear return "Research Manager" if state["investment_debate_state"]["current_response"].startswith("Bull"): return "Bear Researcher" @@ -24,7 +24,7 @@ class ConditionalLogic: """Determine if risk analysis should continue.""" if ( state["risk_debate_state"]["count"] >= 3 * self.max_risk_discuss_rounds - ): # 3 rounds of back-and-forth between 3 agents + ): # max_risk_discuss_rounds turns each for the three risk analysts return "Portfolio Manager" if state["risk_debate_state"]["latest_speaker"].startswith("Aggressive"): return "Conservative Analyst" diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index 775ddc3ae..733cfc00a 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -41,7 +41,6 @@ class Propagator: "bear_history": "", "history": "", "current_response": "", - "judge_decision": "", "count": 0, } ), @@ -55,7 +54,6 @@ class Propagator: "current_aggressive_response": "", "current_conservative_response": "", "current_neutral_response": "", - "judge_decision": "", "count": 0, } ), diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index df4577fa0..46027e346 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -1,5 +1,8 @@ -from typing import Any +import logging +from collections import Counter +from typing import Any, TypedDict +from langchain_core.messages import HumanMessage from langgraph.graph import END, START, StateGraph from langgraph.prebuilt import ToolNode @@ -10,7 +13,6 @@ from tradingagents.agents import ( create_conservative_debator, create_fundamentals_analyst, create_market_analyst, - create_msg_delete, create_neutral_debator, create_news_analyst, create_portfolio_manager, @@ -18,11 +20,14 @@ from tradingagents.agents import ( create_sentiment_analyst, create_trader, ) +from tradingagents.agents.analysts.turn import WRAP_UP from tradingagents.agents.state import AgentState from .analyst_execution import build_analyst_execution_plan from .conditional_logic import ConditionalLogic +logger = logging.getLogger(__name__) + # Every target a shared conditional router can return. Each edge driven by the # router maps all of them, so a fall-through return (e.g. under prompt/i18n/ # refactor drift in the speaker labels) can never hit a missing path_map entry @@ -40,11 +45,49 @@ RISK_ANALYSIS_PATH_MAP = { } -def _tools_or_clear(spec): - """Route an analyst's turn: run its tool calls, or finish its report.""" - def route(state) -> str: - return spec.tool_node if state["messages"][-1].tool_calls else spec.clear_node - return route +def _tools_or_done(state) -> str: + """Route an analyst's turn: run its tool calls, or finish with its report.""" + return "tools" if state["messages"][-1].tool_calls else END + + +def _analyst_graph(spec, agent, max_tool_rounds: int): + """One analyst as a graph of its own: the model and its tools, on a private message history. + + It returns only its report, so analysts running side by side never write the + same key, and its tool calls never reach the other analysts' messages. After + ``max_tool_rounds`` rounds of tool calls it is told to write its report, and + that turn ends it whatever it answers, so a model that keeps calling tools + cannot run the graph into its recursion limit (#1420). + """ + output = TypedDict(f"{spec.key.capitalize()}Report", {spec.report_key: str}) + graph = StateGraph(AgentState, output_schema=output) + graph.add_node("agent", agent) + graph.add_edge(START, "agent") + if not spec.tools: + graph.add_edge("agent", END) + return graph.compile() + + def calls(messages): + return [call["name"] for m in messages for call in (getattr(m, "tool_calls", None) or [])] + + def rounds(messages) -> int: + return sum(1 for m in messages if getattr(m, "tool_calls", None)) + + def more_or_wrap_up(state) -> str: + return "wrap_up" if rounds(state["messages"]) >= max_tool_rounds else "agent" + + def wrap_up(state): + repeated = ", ".join(f"{name} x{n}" for name, n in Counter(calls(state["messages"])).most_common()) + logger.warning("%s used its %d tool rounds (%s); asking for its report", + spec.agent_node, max_tool_rounds, repeated) + return agent({**state, "messages": [*state["messages"], HumanMessage(WRAP_UP)]}) + + graph.add_node("tools", ToolNode(list(spec.tools))) + graph.add_node("wrap_up", wrap_up) + graph.add_conditional_edges("agent", _tools_or_done, ["tools", END]) + graph.add_conditional_edges("tools", more_or_wrap_up, ["agent", "wrap_up"]) + graph.add_edge("wrap_up", END) + return graph.compile() class GraphSetup: @@ -55,11 +98,13 @@ class GraphSetup: quick_thinking_llm: Any, deep_thinking_llm: Any, conditional_logic: ConditionalLogic, + max_tool_rounds: int, ): """Initialize with required components.""" self.quick_thinking_llm = quick_thinking_llm self.deep_thinking_llm = deep_thinking_llm self.conditional_logic = conditional_logic + self.max_tool_rounds = max_tool_rounds def setup_graph( self, selected_analysts=("market", "social", "news", "fundamentals") @@ -95,10 +140,8 @@ class GraphSetup: workflow = StateGraph(AgentState) for spec in plan.specs: - workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) - workflow.add_node(spec.clear_node, create_msg_delete()) - if spec.tools: - workflow.add_node(spec.tool_node, ToolNode(list(spec.tools))) + workflow.add_node(spec.agent_node, + _analyst_graph(spec, analyst_factories[spec.key](), self.max_tool_rounds)) workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bear Researcher", bear_researcher_node) @@ -109,20 +152,12 @@ class GraphSetup: workflow.add_node("Conservative Analyst", conservative_analyst) workflow.add_node("Portfolio Manager", portfolio_manager_node) - workflow.add_edge(START, plan.specs[0].agent_node) - - for i, spec in enumerate(plan.specs): - if spec.tools: - workflow.add_conditional_edges( - spec.agent_node, _tools_or_clear(spec), [spec.tool_node, spec.clear_node] - ) - workflow.add_edge(spec.tool_node, spec.agent_node) - else: - workflow.add_edge(spec.agent_node, spec.clear_node) - - # The last analyst hands over to the research debate. - following = plan.specs[i + 1].agent_node if i < len(plan.specs) - 1 else "Bull Researcher" - workflow.add_edge(spec.clear_node, following) + # The analysts work at the same time; the research debate starts once + # every one of them has filed its report. + analysts = [spec.agent_node for spec in plan.specs] + for node in analysts: + workflow.add_edge(START, node) + workflow.add_edge(analysts, "Bull Researcher") # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). for debate_node in ("Bull Researcher", "Bear Researcher"): diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8169decc4..180ab65b5 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -1,3 +1,4 @@ +import hashlib import json import logging import os @@ -6,21 +7,21 @@ from datetime import datetime from pathlib import Path from typing import Any +import tradingagents from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity -from tradingagents.agents.rating import parse_rating -from tradingagents.dataflows.config import run_config, set_config -from tradingagents.dataflows.date_window import get_current_date +from tradingagents.agents.rating import run_rating +from tradingagents.dataflows.config import run_config, run_config_context, set_config +from tradingagents.dataflows.date_window import get_current_date, is_historical from tradingagents.dataflows.symbols import safe_ticker_component -from tradingagents.decision_log import TradingMemoryLog from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import build_llm_kwargs, create_llm_client +from tradingagents.memory import TradingMemoryLog, settlement +from tradingagents.memory.reflection import Reflector from tradingagents.reporting import write_report_tree -from . import settlement from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id from .conditional_logic import ConditionalLogic from .propagation import Propagator -from .reflection import Reflector from .setup import GraphSetup logger = logging.getLogger(__name__) @@ -40,6 +41,13 @@ def _validate_trade_date(trade_date) -> str: return value +# Config keys that do not change what a run writes: where it keeps its files, +# whether it checkpoints, and how often it retries a provider. +_NOT_IN_SIGNATURE = frozenset({ + "results_dir", "data_cache_dir", "memory_log_path", "checkpoint_enabled", "llm_max_retries", +}) + + class TradingAgentsGraph: """Main class that orchestrates the trading agents framework.""" @@ -94,14 +102,23 @@ class TradingAgentsGraph: max_debate_rounds=self.config["max_debate_rounds"], max_risk_discuss_rounds=self.config["max_risk_discuss_rounds"], ) + # An analyst takes two graph steps per tool round, plus its first turn + # and its wrap-up; a limit past the recursion limit would end the run + # there instead. + max_tool_rounds, max_recur_limit = self.config["max_tool_rounds"], self.config["max_recur_limit"] + if 2 * max_tool_rounds + 2 >= max_recur_limit: + raise ValueError( + f"max_tool_rounds={max_tool_rounds} needs max_recur_limit above {2 * max_tool_rounds + 2}" + ) self.graph_setup = GraphSetup( self.quick_thinking_llm, self.deep_thinking_llm, self.conditional_logic, + max_tool_rounds, ) self.propagator = Propagator( - max_recur_limit=self.config.get("max_recur_limit", 100), + max_recur_limit=max_recur_limit, ) self.reflector = Reflector(self.quick_thinking_llm) @@ -115,7 +132,7 @@ class TradingAgentsGraph: self._resuming = False def resolve_instrument_context(self, ticker: str, asset_type: str = "stock", - curr_date: str | None = None) -> str: + trade_date: str | None = None) -> str: """Resolve ticker identity once and return the full instrument context. Deterministic yfinance lookup (cached, fail-open) injected into a @@ -125,7 +142,7 @@ class TradingAgentsGraph: graph regardless of entry point. """ identity = resolve_instrument_identity(ticker) - return build_instrument_context(ticker, asset_type, identity, curr_date) + return build_instrument_context(ticker, asset_type, identity, trade_date) def _memory_as_of(self, trade_date) -> str | None: """Point-in-time cutoff for past-context lessons (#1251). @@ -135,16 +152,20 @@ class TradingAgentsGraph: None, disabling the filter so live behavior and pre-migration entries (which have no stored resolution date) are unaffected. """ - td = str(trade_date) - return td if td < datetime.now().strftime("%Y-%m-%d") else None + return str(trade_date) if is_historical(trade_date) else None def _run_signature(self, asset_type: str, portfolio=None) -> str: - """Graph-shape inputs that must invalidate a checkpoint if changed. + """Run inputs that must invalidate a checkpoint if changed. Keyed into the checkpoint thread ID so a resume under a different analyst selection, debate/risk depth, or asset mode starts fresh instead of - silently continuing the previous graph (#1089). + silently continuing the previous graph (#1089). The rest of the config + counts too (provider, models, endpoint, language, vendors, limits): a + resume must not carry reports that other settings produced. Only where + the run keeps its files and how it retries are left out. """ + settings = {k: v for k, v in self.config.items() if k not in _NOT_IN_SIGNATURE} + digest = hashlib.sha256(json.dumps(settings, sort_keys=True, default=str).encode()).hexdigest()[:12] return "|".join([ "analysts=" + ",".join(self.selected_analysts), f"debate={self.config['max_debate_rounds']}", @@ -152,6 +173,10 @@ class TradingAgentsGraph: f"asset={asset_type}", # None, an empty book and a changed book are three different runs. f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}", + # The layout itself: a checkpoint saved when analysts ran one after + # another has pending nodes this graph no longer has. + "analysts=parallel", + f"settings={digest}", ]) def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): @@ -242,6 +267,26 @@ class TradingAgentsGraph: self._run_signature(asset_type, portfolio), ) + def run_settings(self) -> dict: + """What produces this graph's runs, for the saved report and state log. + + An allowlist: endpoints (a backend_url can carry credentials), keys and + local paths are never recorded. + """ + cfg = self.config + return { + "version": tradingagents.__version__, + "llm_provider": cfg.get("llm_provider"), + "deep_think_llm": cfg.get("deep_think_llm"), + "quick_think_llm": cfg.get("quick_think_llm"), + "analysts": list(self.selected_analysts), + "max_debate_rounds": cfg.get("max_debate_rounds"), + "max_risk_discuss_rounds": cfg.get("max_risk_discuss_rounds"), + "output_language": cfg.get("output_language"), + "data_vendors": dict(cfg.get("data_vendors") or {}), + "tool_vendors": dict(cfg.get("tool_vendors") or {}), + } + def save_reports(self, final_state, ticker, save_path=None) -> Path: """Write the markdown report tree for a completed run, like the CLI does. @@ -249,13 +294,13 @@ class TradingAgentsGraph: an explicit ``save_path`` or let it default under ``results_dir``. """ if save_path is None: - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - save_path = ( - Path(self.config["results_dir"]) - / "reports" - / f"{safe_ticker_component(ticker)}_{stamp}" - ) - return write_report_tree(final_state, ticker, save_path) + save_path = self.default_report_path(ticker) + return write_report_tree(final_state, ticker, save_path, settings=self.run_settings()) + + def default_report_path(self, ticker) -> Path: + """Where a run's reports go unless told otherwise: under results_dir, stamped now.""" + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return Path(self.config["results_dir"]) / "reports" / f"{safe_ticker_component(ticker)}_{stamp}" def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): """Build a run's initial state; propagate() and the CLI both start here. @@ -263,7 +308,7 @@ class TradingAgentsGraph: Settles this ticker's pending decisions first, then injects the lessons known by the trade date for the Portfolio Manager (#1251) and the resolved instrument identity for every agent (#814). An entry point that - assembled the state itself would skip the decision log. + assembled the state itself would skip the memory log. """ self.settle_pending(company_name) return self.propagator.create_initial_state( @@ -289,13 +334,17 @@ class TradingAgentsGraph: settlement.settle_pending(company_name, self.memory_log, self.reflector, self.config) def record_decision(self, company_name, trade_date, final_state): - """Log a finished run's decision for reflection on the next same-ticker run.""" + """Record a finished run: its state log, and its decision in the memory log + for reflection on the next same-ticker run. propagate() and the CLI both end here.""" + self._log_state(trade_date, final_state) decision = final_state.get("final_trade_decision") if not decision: - logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) + logger.warning("No final decision for %s on %s; nothing added to the memory log", + company_name, trade_date) return self.memory_log.store_decision( - ticker=company_name, trade_date=trade_date, final_trade_decision=decision + ticker=company_name, trade_date=trade_date, final_trade_decision=decision, + rating=run_rating(final_state), ) def _run_graph(self, company_name, trade_date, asset_type: str = "stock", @@ -312,36 +361,56 @@ class TradingAgentsGraph: # None resumes an existing checkpoint; init_agent_state starts fresh (#1249). graph_input = self.checkpoint_input(init_agent_state) if self.debug: - trace = [] - last_printed = None - for chunk in self.graph.stream(graph_input, **args): - if chunk["messages"]: - msg = chunk["messages"][-1] - # Nodes after the trader don't append to messages, so the - # same trailing message repeats across chunks. Print it only - # when it changes (#1027); the trace/state merge is unchanged. - signature = (type(msg).__name__, getattr(msg, "content", None)) - if signature != last_printed: + # A state repeats the messages before it, so each prints once (#1027). + final_state, printed = {}, set() + for messages, state in self.stream_run(graph_input, **args): + for msg in messages: + key = getattr(msg, "id", None) or (type(msg).__name__, getattr(msg, "content", None)) + if key not in printed: + printed.add(key) msg.pretty_print() - last_printed = signature - trace.append(chunk) - # Streamed chunks are per-node deltas. Merge them so the returned - # state matches what graph.invoke() yields in the non-debug path. - final_state = {} - for chunk in trace: - final_state.update(chunk) + if state is not None: + final_state.update(state) else: final_state = self.graph.invoke(graph_input, **args) - # Log state to disk. - self._log_state(trade_date, final_state) - self.record_decision(company_name, trade_date, final_state) # Clear checkpoint on successful completion to avoid stale state. self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio) - return final_state, self.process_signal(final_state["final_trade_decision"]) + return final_state, run_rating(final_state) + + def stream_run(self, graph_input, **args): + """Stream a run as ``(messages, state)`` pairs. + + ``messages`` are the agents' messages, the analysts' included. ``state`` + is the run's state after a top-level step; for a step inside an analyst's + graph it is that analyst's report once filed, else None. + + Each analyst works in a graph of its own, and the run's state takes the + analysts' reports only when the slowest has finished, so their messages + and reports come from their own finished steps ("tasks") as they happen. + """ + args = {**args, "stream_mode": ["values", "tasks"]} + # The graph's own config serves every tool call, as in propagate(), even + # when the process-wide config has changed since. Each step runs in the + # run's context, so the caller keeps its own between steps. + context = run_config_context(self.config) + stream = context.run(self.graph.stream, graph_input, subgraphs=True, **args) + try: + while (step := context.run(next, stream, None)) is not None: + namespace, mode, chunk = step + if namespace: + result = chunk.get("result") if mode == "tasks" else None + if isinstance(result, dict): + report = {k: v for k, v in result.items() if k != "messages" and v} + if result.get("messages") or report: + yield result.get("messages", []), report or None + elif mode == "values": + yield chunk.get("messages", []), chunk + finally: + context.run(stream.close) def _log_state(self, trade_date, final_state): """Write a run's final state to JSON under the run's own ticker.""" @@ -359,20 +428,18 @@ class TradingAgentsGraph: "current_response": final_state["investment_debate_state"][ "current_response" ], - "judge_decision": final_state["investment_debate_state"][ - "judge_decision" - ], }, - "trader_investment_decision": final_state["trader_investment_plan"], + "trader_investment_plan": final_state["trader_investment_plan"], "risk_debate_state": { "aggressive_history": final_state["risk_debate_state"]["aggressive_history"], "conservative_history": final_state["risk_debate_state"]["conservative_history"], "neutral_history": final_state["risk_debate_state"]["neutral_history"], "history": final_state["risk_debate_state"]["history"], - "judge_decision": final_state["risk_debate_state"]["judge_decision"], }, "investment_plan": final_state["investment_plan"], "final_trade_decision": final_state["final_trade_decision"], + "final_rating": run_rating(final_state), + "run_settings": self.run_settings(), } # A ticker that would escape the results directory is rejected. @@ -384,7 +451,3 @@ class TradingAgentsGraph: with open(log_path, "w", encoding="utf-8") as f: # Reports can be in any language and this file is read by a person. json.dump(entry, f, indent=4, ensure_ascii=False) - - def process_signal(self, full_signal): - """The decision's 5-tier rating, or REVIEW when it has none.""" - return parse_rating(full_signal) diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py index a2df7100a..e693bb5df 100644 --- a/tradingagents/llm_clients/capabilities.py +++ b/tradingagents/llm_clients/capabilities.py @@ -94,6 +94,7 @@ _DEFAULT = ModelCapabilities( _BY_ID: dict[str, ModelCapabilities] = { "deepseek-chat": _DEEPSEEK_CHAT, "deepseek-reasoner": _DEEPSEEK_THINKING, + "deepseek-flash": _DEEPSEEK_THINKING, "deepseek-v4-flash": _DEEPSEEK_THINKING, "deepseek-v4-pro": _DEEPSEEK_THINKING, # MiniMax — full official model lineup per @@ -108,10 +109,14 @@ _BY_ID: dict[str, ModelCapabilities] = { } # Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` -# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. +# / ``deepseek-flash-*`` or ``MiniMax-M3*`` variants inherit the thinking-mode +# quirks automatically. ``deepseek-flash`` is the unversioned alias the model +# picker offers for V4.1 Flash; it serves thinking mode and so rejects +# ``tool_choice`` exactly like the versioned ``deepseek-v4-flash`` ID does. _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), + (re.compile(r"^deepseek-flash"), _DEEPSEEK_THINKING), (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), ] diff --git a/tradingagents/memory/__init__.py b/tradingagents/memory/__init__.py new file mode 100644 index 000000000..04c764230 --- /dev/null +++ b/tradingagents/memory/__init__.py @@ -0,0 +1,10 @@ +"""The memory log: each decision recorded as made, settled against the market, and reflected on. + +``log`` keeps the entries, ``settlement`` measures a decision's return once its +holding window has traded, and ``reflection`` turns that outcome into a lesson +the next run of the same ticker reads. +""" + +from tradingagents.memory.log import TradingMemoryLog + +__all__ = ["TradingMemoryLog"] diff --git a/tradingagents/decision_log.py b/tradingagents/memory/log.py similarity index 66% rename from tradingagents/decision_log.py rename to tradingagents/memory/log.py index ca5afb47f..dc1622ccf 100644 --- a/tradingagents/decision_log.py +++ b/tradingagents/memory/log.py @@ -1,9 +1,10 @@ -"""Append-only markdown decision log for TradingAgents.""" +"""The memory log: an append-only markdown record of each decision and, once settled, its outcome.""" import re from pathlib import Path from tradingagents.agents.rating import parse_rating +from tradingagents.dataflows.files import locked class TradingMemoryLog: @@ -25,33 +26,39 @@ class TradingMemoryLog: # Optional cap on resolved entries. None disables rotation. self._max_entries = cfg.get("memory_log_max_entries") - # --- Write path (Phase A) --- + # --- Write: a run records its decision --- def store_decision( self, ticker: str, trade_date: str, final_trade_decision: str, + rating: str | None = None, ) -> None: - """Append pending entry at end of propagate(). No LLM call.""" + """Append pending entry at end of propagate(). No LLM call. + + ``rating`` is the decision's own rating when the caller has it; without + one it is read from the decision text. + """ if not self._log_path: return - # Idempotency guard: fast raw-text scan instead of full parse. Any entry - # for this ticker and date blocks another, pending or settled: a re-run - # after the outcome landed would otherwise count the same decision twice - # in past context and in every aggregate over the log. - if self._log_path.exists(): - raw = self._log_path.read_text(encoding="utf-8") - for line in raw.splitlines(): - if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): - return - rating = parse_rating(final_trade_decision) - tag = f"[{trade_date} | {ticker} | {rating} | pending]" - entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" - with open(self._log_path, "a", encoding="utf-8") as f: - f.write(entry) + with locked(self._log_path): + # Idempotency guard: fast raw-text scan instead of full parse. Any entry + # for this ticker and date blocks another, pending or settled: a re-run + # after the outcome landed would otherwise count the same decision twice + # in past context and in every aggregate over the log. + if self._log_path.exists(): + raw = self._log_path.read_text(encoding="utf-8") + for line in raw.splitlines(): + if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): + return + rating = rating or parse_rating(final_trade_decision) + tag = f"[{trade_date} | {ticker} | {rating} | pending]" + entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" + with open(self._log_path, "a", encoding="utf-8") as f: + f.write(entry) - # --- Read path (Phase A) --- + # --- Read --- def load_entries(self) -> list[dict]: """Parse all entries from log. Returns list of dicts.""" @@ -67,7 +74,7 @@ class TradingMemoryLog: return entries def get_pending_entries(self) -> list[dict]: - """Return entries with outcome:pending (for Phase B).""" + """Return entries with outcome:pending, for settlement.""" return [e for e in self.load_entries() if e.get("pending")] def get_past_context( @@ -109,7 +116,7 @@ class TradingMemoryLog: parts.extend(self._format_reflection_only(e) for e in cross) return "\n\n".join(parts) - # --- Update path (Phase B) --- + # --- Settle: record a decision's outcome and reflection --- def update_with_outcome( self, @@ -130,51 +137,51 @@ class TradingMemoryLog: """ if not self._log_path or not self._log_path.exists(): return + with locked(self._log_path): + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) - text = self._log_path.read_text(encoding="utf-8") - blocks = text.split(self._SEPARATOR) + pending_prefix = f"[{trade_date} | {ticker} |" + raw_pct = f"{raw_return:+.1%}" + alpha_pct = f"{alpha_return:+.1%}" - pending_prefix = f"[{trade_date} | {ticker} |" - raw_pct = f"{raw_return:+.1%}" - alpha_pct = f"{alpha_return:+.1%}" + updated = False + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue - updated = False - new_blocks = [] - for block in blocks: - stripped = block.strip() - if not stripped: - new_blocks.append(block) - continue + lines = stripped.splitlines() + tag_line = lines[0].strip() - lines = stripped.splitlines() - tag_line = lines[0].strip() + if ( + not updated + and tag_line.startswith(pending_prefix) + and tag_line.endswith("| pending]") + ): + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}" + ) + updated = True + else: + new_blocks.append(block) - if ( - not updated - and tag_line.startswith(pending_prefix) - and tag_line.endswith("| pending]") - ): - fields = [f.strip() for f in tag_line[1:-1].split("|")] - rating = fields[2] - new_tag = self._resolved_tag( - trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date - ) - rest = "\n".join(lines[1:]) - new_blocks.append( - f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}" - ) - updated = True - else: - new_blocks.append(block) + if not updated: + return - if not updated: - return - - new_blocks = self._apply_rotation(new_blocks) - new_text = self._SEPARATOR.join(new_blocks) - tmp_path = self._log_path.with_suffix(".tmp") - tmp_path.write_text(new_text, encoding="utf-8") - tmp_path.replace(self._log_path) + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) def batch_update_with_outcomes(self, updates: list[dict]) -> None: """Apply multiple outcome updates in a single read + atomic write. @@ -184,50 +191,50 @@ class TradingMemoryLog: """ if not self._log_path or not self._log_path.exists() or not updates: return + with locked(self._log_path): + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) - text = self._log_path.read_text(encoding="utf-8") - blocks = text.split(self._SEPARATOR) + update_map = {(u["trade_date"], u["ticker"]): u for u in updates} - update_map = {(u["trade_date"], u["ticker"]): u for u in updates} + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue - new_blocks = [] - for block in blocks: - stripped = block.strip() - if not stripped: - new_blocks.append(block) - continue + lines = stripped.splitlines() + tag_line = lines[0].strip() - lines = stripped.splitlines() - tag_line = lines[0].strip() + matched = False + for (trade_date, ticker), upd in list(update_map.items()): + pending_prefix = f"[{trade_date} | {ticker} |" + if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"): + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + raw_pct = f"{upd['raw_return']:+.1%}" + alpha_pct = f"{upd['alpha_return']:+.1%}" + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, + upd["holding_days"], upd.get("resolution_date"), + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}" + ) + del update_map[(trade_date, ticker)] + matched = True + break - matched = False - for (trade_date, ticker), upd in list(update_map.items()): - pending_prefix = f"[{trade_date} | {ticker} |" - if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"): - fields = [f.strip() for f in tag_line[1:-1].split("|")] - rating = fields[2] - raw_pct = f"{upd['raw_return']:+.1%}" - alpha_pct = f"{upd['alpha_return']:+.1%}" - new_tag = self._resolved_tag( - trade_date, ticker, rating, raw_pct, alpha_pct, - upd["holding_days"], upd.get("resolution_date"), - ) - rest = "\n".join(lines[1:]) - new_blocks.append( - f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}" - ) - del update_map[(trade_date, ticker)] - matched = True - break + if not matched: + new_blocks.append(block) - if not matched: - new_blocks.append(block) - - new_blocks = self._apply_rotation(new_blocks) - new_text = self._SEPARATOR.join(new_blocks) - tmp_path = self._log_path.with_suffix(".tmp") - tmp_path.write_text(new_text, encoding="utf-8") - tmp_path.replace(self._log_path) + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) # --- Helpers --- diff --git a/tradingagents/graph/reflection.py b/tradingagents/memory/reflection.py similarity index 90% rename from tradingagents/graph/reflection.py rename to tradingagents/memory/reflection.py index 198333ffe..ba8bf5f31 100644 --- a/tradingagents/graph/reflection.py +++ b/tradingagents/memory/reflection.py @@ -1,3 +1,5 @@ +"""Reflection: a settled decision's outcome turned into a short lesson for later runs.""" + from typing import Any @@ -9,7 +11,7 @@ class Reflector: self.quick_thinking_llm = quick_thinking_llm def _system_prompt(self, holding_days: int) -> str: - """Concise prompt for reflect_on_final_decision (Phase B log entries). + """Concise prompt for reflect_on_final_decision (settled memory log entries). Produces 2-4 sentences of plain prose, compact enough to be re-injected into future agent prompts without bloating the context window. The @@ -27,7 +29,7 @@ class Reflector: "and say so plainly if the window is too short to judge the thesis.\n" "2. Which part of the investment thesis this window supports or undercuts.\n" "3. One concrete lesson to apply to the next similar analysis.\n\n" - "Be specific and terse. Your output will be stored verbatim in a decision log " + "Be specific and terse. Your output will be stored verbatim in a memory log " "and re-read by future analysts, so every word must earn its place." ) @@ -41,7 +43,7 @@ class Reflector: ) -> str: """Single reflection call on the final trade decision with outcome context. - Used by Phase B deferred reflection. The final_trade_decision already + Used when a pending decision is settled. The final_trade_decision already synthesises all analyst insights, so no separate market context is needed. ``benchmark_name`` is the label used for the alpha line (e.g. ``"SPY"`` for US tickers, ``"^N225"`` for ``.T`` listings); defaults to SPY for diff --git a/tradingagents/graph/settlement.py b/tradingagents/memory/settlement.py similarity index 69% rename from tradingagents/graph/settlement.py rename to tradingagents/memory/settlement.py index 794b987ee..de14c9b11 100644 --- a/tradingagents/graph/settlement.py +++ b/tradingagents/memory/settlement.py @@ -1,5 +1,5 @@ """Settling past decisions: once a decision's holding window has traded, score -it against its benchmark and record a reflection on it in the decision log.""" +it against its benchmark and record a reflection on it in the memory log.""" import logging from datetime import datetime, timedelta @@ -16,10 +16,9 @@ def resolve_benchmark(ticker: str, config: dict) -> str: ``config["benchmark_ticker"]`` overrides everything when set; otherwise the suffix map matches the ticker's exchange suffix (e.g. ``.T`` for Tokyo). US-listed tickers without a dotted suffix fall through to the - empty-suffix entry (SPY by default). Unrecognised suffixes (including - US tickers with dots like ``BRK.B``) also fall back to the empty-suffix - entry, which is the right default because the alpha calculation works - in USD. + empty-suffix entry (SPY by default). Unrecognised suffixes, including + US tickers with dots like ``BRK.B``, also take the empty-suffix entry. + Returns are compared as percentages, each in its own currency. """ explicit = config.get("benchmark_ticker") @@ -35,6 +34,20 @@ def resolve_benchmark(ticker: str, config: dict) -> str: return benchmark_map.get("", "SPY") +def _by_day(closes): + """Closes keyed by the calendar day of their bar, missing closes dropped. + + Yahoo stamps a daily bar at midnight in its market's time zone (UTC for a + coin, New York for SPY), so two series meet on their dates, not their + instants. A missing or non-positive close is no price at all, not a bar to + score on. + """ + closes = closes[closes > 0] + if getattr(closes.index, "tz", None) is not None: + closes = closes.tz_localize(None) + return closes + + def fetch_returns( ticker: str, trade_date: str, holding_days: int = 5, benchmark: str = "SPY", @@ -57,21 +70,31 @@ def fetch_returns( end_str = end.strftime("%Y-%m-%d") # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984). - stock = get_closes(ticker, trade_date, end_str) - bench = get_closes(benchmark, trade_date, end_str) + stock = _by_day(get_closes(ticker, trade_date, end_str)) + # From a week earlier, so the benchmark has a close on or before entry. + bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d") + bench = _by_day(get_closes(benchmark, bench_start, end_str)) - # Require the full holding window in both series. A rerun before it - # has traded leaves the entry pending to retry next run, rather than - # settling on a premature partial return (#1169). - if len(stock) <= holding_days or len(bench) <= holding_days: + # Require the full holding window to have traded. A rerun before it has + # leaves the entry pending to retry next run, rather than settling on a + # premature partial return (#1169). + if len(stock) <= holding_days: + return None, None, None, None + entry, exit_ = stock.index[0], stock.index[holding_days] + # The window is the stock's own sessions; the benchmark is valued at its + # last close by each end, so both returns span the same dates even when + # the calendars differ (a coin trades at weekends, an index does not). + # It must have traded through the exit, or its close there may yet move. + if bench.empty or bench.index[0] > entry or bench.index[-1] < exit_: return None, None, None, None raw = float((stock.iloc[holding_days] - stock.iloc[0]) / stock.iloc[0]) - bench_ret = float((bench.iloc[holding_days] - bench.iloc[0]) / bench.iloc[0]) + bench_entry, bench_exit = bench.asof(entry), bench.asof(exit_) + bench_ret = float((bench_exit - bench_entry) / bench_entry) alpha = raw - bench_ret - # The date of the last price bar used is when this outcome became - # known — the point-in-time cutoff for injecting the lesson (#1251). - resolution_date = stock.index[holding_days].strftime("%Y-%m-%d") + # Every close used is known by the exit: the point-in-time cutoff for + # injecting the lesson (#1251). + resolution_date = exit_.strftime("%Y-%m-%d") return raw, alpha, holding_days, resolution_date except Exception as e: logger.warning( diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index f89d6a04d..214e1df20 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -10,8 +10,31 @@ from datetime import datetime from pathlib import Path -def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: - """Save a completed run's reports to ``save_path``; return the complete-report path.""" +def _header(ticker: str, final_state: dict, settings: dict | None) -> str: + """The report's title and what produced it: analysis date, version, models, analysts, vendors.""" + lines = [f"# Trading Analysis Report: {ticker}", ""] + if final_state.get("trade_date"): + lines.append(f"- Analysis date: {final_state['trade_date']}") + lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + if settings: + s = settings.get + lines.append(f"- TradingAgents {s('version', '?')}: {s('llm_provider', '?')}, " + f"deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}") + lines.append(f"- Analysts: {', '.join(s('analysts') or [])}; " + f"research debate rounds {s('max_debate_rounds', '?')}, " + f"risk debate rounds {s('max_risk_discuss_rounds', '?')}") + vendors = {**(s("data_vendors") or {}), **(s("tool_vendors") or {})} + if vendors: + lines.append("- Data vendors: " + ", ".join(f"{k} {v}" for k, v in vendors.items())) + return "\n".join(lines) + "\n\n" + + +def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict | None = None) -> Path: + """Save a completed run's reports to ``save_path``; return the complete-report path. + + ``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run + to the report's header. + """ save_path = Path(save_path) save_path.mkdir(parents=True, exist_ok=True) sections = [] @@ -52,10 +75,10 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: research_dir.mkdir(exist_ok=True) (research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8") research_parts.append(("Bear Researcher", debate["bear_history"])) - if debate.get("judge_decision"): + if final_state.get("investment_plan"): research_dir.mkdir(exist_ok=True) - (research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8") - research_parts.append(("Research Manager", debate["judge_decision"])) + (research_dir / "manager.md").write_text(final_state["investment_plan"], encoding="utf-8") + research_parts.append(("Research Manager", final_state["investment_plan"])) if research_parts: content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) sections.append(f"## II. Research Team Decision\n\n{content}") @@ -88,14 +111,15 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts) sections.append(f"## IV. Risk Management Team Decision\n\n{content}") - # 5. Portfolio Manager - if risk.get("judge_decision"): - portfolio_dir = save_path / "5_portfolio" - portfolio_dir.mkdir(exist_ok=True) - (portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8") - sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}") + # 5. Portfolio Manager + if final_state.get("final_trade_decision"): + portfolio_dir = save_path / "5_portfolio" + portfolio_dir.mkdir(exist_ok=True) + (portfolio_dir / "decision.md").write_text(final_state["final_trade_decision"], encoding="utf-8") + sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{final_state['final_trade_decision']}") # Write consolidated report - header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" - (save_path / "complete_report.md").write_text(header + "\n\n".join(sections), encoding="utf-8") + (save_path / "complete_report.md").write_text( + _header(ticker, final_state, settings) + "\n\n".join(sections), encoding="utf-8" + ) return save_path / "complete_report.md"