TradingAgents v0.5.2 release

This commit is contained in:
Yijia Xiao
2026-09-28 22:59:16 -07:00
committed by GitHub
118 changed files with 3526 additions and 1766 deletions
+4
View File
@@ -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=
+8 -8
View File
@@ -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).
+56 -3
View File
@@ -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
+2 -2
View File
@@ -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
+33 -19
View File
@@ -32,15 +32,16 @@
## News
<!-- news:start -->
- [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).
<details>
<summary>Earlier news</summary>
- [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.
<p align="center">
<img src="assets/analyst.png" width="100%" style="display: inline-block; margin: 0 2%;">
</p>
@@ -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 <name>`, 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
+2 -1
View File
@@ -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()
+18 -36
View File
@@ -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
+32 -6
View File
@@ -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:
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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
+43
View File
@@ -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]"
)
+57 -37
View File
@@ -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)
+74 -39
View File
@@ -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]")
+2 -2
View File
@@ -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
-2
View File
@@ -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
+18 -15
View File
@@ -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]
+47 -2
View File
@@ -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 = (
+1 -19
View File
@@ -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 = (
+2 -2
View File
@@ -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):
+3 -5
View File
@@ -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):
+33 -3
View File
@@ -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"]
+128
View File
@@ -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
+18
View File
@@ -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).
+32
View File
@@ -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__":
+47 -1
View File
@@ -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
+39 -17
View File
@@ -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"
+187
View File
@@ -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)
@@ -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"))
+4 -10
View File
@@ -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 ---------------------------------
+3 -3
View File
@@ -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")))
+4 -2
View File
@@ -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)
+43 -49
View File
@@ -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
+12 -10
View File
@@ -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")
+9 -6
View File
@@ -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})
+39
View File
@@ -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
+65
View File
@@ -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()
+126 -6
View File
@@ -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)
+9
View File
@@ -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)
+11 -51
View File
@@ -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()
+8 -9
View File
@@ -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
+8 -9
View File
@@ -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
+124 -12
View File
@@ -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
+1 -1
View File
@@ -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):
+7 -7
View File
@@ -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
+12 -12
View File
@@ -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
+9 -8
View File
@@ -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())
+4 -2
View File
@@ -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)
+4 -3
View File
@@ -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)
+8 -59
View File
@@ -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
+3 -5
View File
@@ -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
+4 -5
View File
@@ -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
+2 -2
View File
@@ -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)
+55 -14
View File
@@ -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"
+64 -6
View File
@@ -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_<stamp>/...
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__
+49 -21
View File
@@ -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", ""]
-17
View File
@@ -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"
+9 -9
View File
@@ -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)
+78
View File
@@ -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
+1 -1
View File
@@ -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"])
-1
View File
@@ -292,7 +292,6 @@ def _make_rm_state():
"bull_history": "Bull says...",
"bear_history": "Bear says...",
"current_response": "",
"judge_decision": "",
"count": 1,
},
}
+39 -2
View File
@@ -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")
+2 -2
View File
@@ -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")
+4 -8
View File
@@ -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
+29 -7
View File
@@ -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
+36 -79
View File
@@ -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
+23 -3
View File
@@ -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()
+4 -3
View File
@@ -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)
+18
View File
@@ -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__)
+286
View File
@@ -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")
+3 -2
View File
@@ -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):
+4
View File
@@ -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
-2
View File
@@ -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",
@@ -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],
@@ -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],
@@ -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],
+42
View File
@@ -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
+35 -55
View File
@@ -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
@@ -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
@@ -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", ""),
+41 -28
View File
@@ -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"(?<![a-z])rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
re.IGNORECASE)
# The same label opening its own line ("**Rating**: X", "## Final Rating - X",
# "Our rating: X"): the shape the Portfolio Manager is asked to write its
# decision in. Only emphasis and heading marks may precede it, so a list item,
# table row or blockquote quoting someone else's rating is not one.
_RATING_LINE_RE = re.compile(
r"[\s*_#]*(?:\w+\s+)?rating[^\w:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
re.IGNORECASE,
)
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match).
_RATING_WORD_RE = re.compile(
r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE
)
def extract_rating(text: str) -> 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
+4 -4
View File
@@ -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
+20 -25
View File
@@ -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"]
+30 -24
View File
@@ -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
+27 -29
View File
@@ -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
"""
+2 -5
View File
@@ -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
+23 -17
View File
@@ -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.
+12 -1
View File
@@ -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()
+64 -22
View File
@@ -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."
)
+6 -3
View File
@@ -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):
+79
View File
@@ -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)
+40 -34
View File
@@ -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)
+2 -2
View File
@@ -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
+22 -42
View File
@@ -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})
+6 -6
View File
@@ -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.")
+11 -17
View File
@@ -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})
+12 -12
View File
@@ -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"
+8 -8
View File
@@ -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", [])
+3 -3
View File
@@ -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
+69 -55
View File
@@ -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")
+76
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More