mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fe2252244 | ||
|
|
76a93d615a | ||
|
|
c039e39056 | ||
|
|
4acdc16513 | ||
|
|
d5ba41bac3 | ||
|
|
10cc070fa3 | ||
|
|
ed6eae44b6 | ||
|
|
aef4af90e3 | ||
|
|
bbcd6661af | ||
|
|
f8042efdde | ||
|
|
d0881f081e | ||
|
|
8bde10cf44 | ||
|
|
9683194793 | ||
|
|
04b691804c | ||
|
|
c3bb991974 | ||
|
|
f0a1cf6290 | ||
|
|
de7e43fc4a | ||
|
|
13b35e8aeb | ||
|
|
008ac655a9 | ||
|
|
2ddfe4ceb5 | ||
|
|
85d9137437 | ||
|
|
2ca59cc795 | ||
|
|
63989515c9 | ||
|
|
486dec1710 | ||
|
|
8d30fee06b | ||
|
|
3244a568ed | ||
|
|
b6dad747e9 | ||
|
|
f881c4a111 | ||
|
|
8ac4371387 | ||
|
|
60dcf64723 | ||
|
|
8d64416436 | ||
|
|
c4152d6946 | ||
|
|
375af054b7 | ||
|
|
d8eceb6571 | ||
|
|
63738c8f2c | ||
|
|
7bf2117491 | ||
|
|
8721b92f65 | ||
|
|
9794a90734 | ||
|
|
6436d1ff30 | ||
|
|
dffff22951 | ||
|
|
ef52d4452b | ||
|
|
2942655f70 | ||
|
|
fadc698e20 | ||
|
|
29e331a9af | ||
|
|
99313bfdbc | ||
|
|
d04693a208 | ||
|
|
4a9f196e92 | ||
|
|
2c1ba388d6 | ||
|
|
b9666f6e09 | ||
|
|
34899bd320 | ||
|
|
b20c8e60a4 | ||
|
|
241638da68 | ||
|
|
2e38b47dca | ||
|
|
673abb3c68 | ||
|
|
9b4c741d33 | ||
|
|
62d3479217 | ||
|
|
be952b8ecc | ||
|
|
821848bb82 | ||
|
|
d6ca23aee5 | ||
|
|
ef383df8f4 | ||
|
|
d58b838081 | ||
|
|
ffd5d9a180 | ||
|
|
16f7fd613c | ||
|
|
260c899c72 | ||
|
|
94113c8d11 | ||
|
|
7cc478ad07 | ||
|
|
1c44dd1ffc | ||
|
|
96111aa368 | ||
|
|
9dee508c44 | ||
|
|
5a26ae17a1 | ||
|
|
a4acd8a174 | ||
|
|
2322dd9baa | ||
|
|
70b58c21dc | ||
|
|
2448d0a125 |
56
.env.example
56
.env.example
@@ -16,57 +16,49 @@ MOONSHOT_API_KEY=
|
|||||||
GROQ_API_KEY=
|
GROQ_API_KEY=
|
||||||
NVIDIA_API_KEY=
|
NVIDIA_API_KEY=
|
||||||
|
|
||||||
# FRED (Federal Reserve macro data: rates, inflation, labor, growth). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
# SEC EDGAR (US company filings, point-in-time). No key; a contact address SEC can reach you at.
|
||||||
|
#SEC_EDGAR_USER_AGENT=Your Name your@email.com
|
||||||
|
|
||||||
|
# FRED (Federal Reserve macro data). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
||||||
#FRED_API_KEY=
|
#FRED_API_KEY=
|
||||||
|
|
||||||
# Optional: a custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp,
|
# Custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp). Local servers need no key.
|
||||||
# relay). Select provider "openai_compatible" and set the base URL; the key is
|
|
||||||
# optional (local servers need none).
|
|
||||||
#OPENAI_COMPATIBLE_API_KEY=
|
#OPENAI_COMPATIBLE_API_KEY=
|
||||||
|
|
||||||
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
|
# AWS Bedrock (pip install ".[bedrock]"). Bearer token, or the AWS credential chain; set the region either way.
|
||||||
# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS
|
|
||||||
# credential chain (env keys / ~/.aws/credentials / IAM role / AWS_PROFILE). Set
|
|
||||||
# the region either way; a bearer token takes precedence when both are present.
|
|
||||||
#AWS_BEARER_TOKEN_BEDROCK=
|
#AWS_BEARER_TOKEN_BEDROCK=
|
||||||
#AWS_DEFAULT_REGION=us-west-2
|
#AWS_DEFAULT_REGION=us-west-2
|
||||||
#AWS_PROFILE=
|
#AWS_PROFILE=
|
||||||
|
|
||||||
# Optional: point at a remote Ollama server. When unset, defaults to
|
# Remote Ollama server. Unset uses http://localhost:11434/v1.
|
||||||
# the local instance at http://localhost:11434/v1. Convention follows
|
|
||||||
# the broader Ollama ecosystem; both the CLI dropdown and programmatic
|
|
||||||
# client pick this up.
|
|
||||||
#OLLAMA_BASE_URL=http://your-ollama-host:11434/v1
|
#OLLAMA_BASE_URL=http://your-ollama-host:11434/v1
|
||||||
|
|
||||||
# Optional: override DEFAULT_CONFIG without editing code.
|
# Override these DEFAULT_CONFIG keys. Provider, models, language and round counts also skip their CLI prompt.
|
||||||
# Any TRADINGAGENTS_* variable below, when set, replaces the matching key
|
|
||||||
# in tradingagents/default_config.py. Values are coerced to the type of
|
|
||||||
# the existing default (bool / int / str), so "true"/"3" work as expected.
|
|
||||||
# In the CLI, setting the LLM provider / models / backend URL / language
|
|
||||||
# also skips the matching interactive selection step (useful for
|
|
||||||
# OpenAI-compatible endpoints like opencode or LM Studio, and unattended runs).
|
|
||||||
#TRADINGAGENTS_LLM_PROVIDER=openai
|
#TRADINGAGENTS_LLM_PROVIDER=openai
|
||||||
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
|
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.6
|
||||||
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
|
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.6-luna
|
||||||
#TRADINGAGENTS_LLM_BACKEND_URL=
|
#TRADINGAGENTS_LLM_BACKEND_URL=
|
||||||
#TRADINGAGENTS_OUTPUT_LANGUAGE=English
|
#TRADINGAGENTS_OUTPUT_LANGUAGE=English
|
||||||
#TRADINGAGENTS_MAX_DEBATE_ROUNDS=1
|
#TRADINGAGENTS_MAX_DEBATE_ROUNDS=1
|
||||||
#TRADINGAGENTS_MAX_RISK_ROUNDS=1
|
#TRADINGAGENTS_MAX_RISK_ROUNDS=1
|
||||||
#TRADINGAGENTS_CHECKPOINT_ENABLED=false
|
#TRADINGAGENTS_CHECKPOINT_ENABLED=false
|
||||||
# Sampling temperature (lower = less run-to-run variation on models that
|
|
||||||
# honor it). Unset leaves each provider at its default. See the README
|
# Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index.
|
||||||
# "Reproducibility" note — no setting makes LLM output fully deterministic.
|
#TRADINGAGENTS_RESULTS_DIR=
|
||||||
|
#TRADINGAGENTS_CACHE_DIR=
|
||||||
|
#TRADINGAGENTS_MEMORY_LOG_PATH=
|
||||||
|
#TRADINGAGENTS_BENCHMARK_TICKER=SPY
|
||||||
|
|
||||||
|
# Lower temperature means less run-to-run variation on models that honor it.
|
||||||
#TRADINGAGENTS_TEMPERATURE=0.0
|
#TRADINGAGENTS_TEMPERATURE=0.0
|
||||||
# LLM SDK retry budget forwarded to every provider. Unset leaves each SDK at its
|
|
||||||
# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling
|
# Retry budget for every LLM SDK. Raise it to ride out 429 throttling.
|
||||||
# on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run.
|
|
||||||
#TRADINGAGENTS_LLM_MAX_RETRIES=6
|
#TRADINGAGENTS_LLM_MAX_RETRIES=6
|
||||||
# Cap on output tokens forwarded to every provider (Gemini's max_output_tokens
|
|
||||||
# too). Unset leaves each provider at its default. Set it to bound a model that
|
# Cap output tokens to bound a model that runs long and trips a timeout.
|
||||||
# emits unbounded reasoning/output and hangs or trips a gateway idle timeout.
|
|
||||||
#TRADINGAGENTS_MAX_TOKENS=8192
|
#TRADINGAGENTS_MAX_TOKENS=8192
|
||||||
# Provider-specific reasoning/thinking depth (optional; unset = provider
|
|
||||||
# default). Setting one also skips the matching interactive prompt.
|
# Reasoning depth per provider; setting one skips its prompt.
|
||||||
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
|
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
|
||||||
#TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high
|
#TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high
|
||||||
#TRADINGAGENTS_ANTHROPIC_EFFORT=high
|
#TRADINGAGENTS_ANTHROPIC_EFFORT=high
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -57,5 +57,5 @@ jobs:
|
|||||||
run: pip install "ruff>=0.15"
|
run: pip install "ruff>=0.15"
|
||||||
- name: Lint the repository
|
- name: Lint the repository
|
||||||
# The repo is fully clean under the strict select, so we lint everything
|
# The repo is fully clean under the strict select, so we lint everything
|
||||||
# (results/ and worklog/ are excluded via pyproject extend-exclude).
|
# (generated results/ is excluded via pyproject extend-exclude).
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|||||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -6,6 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
Breaking changes within the 0.x line are called out explicitly.
|
Breaking changes within the 0.x line are called out explicitly.
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-09-18
|
||||||
|
|
||||||
|
Point-in-time integrity across every dated path, decisions that are recorded as
|
||||||
|
they were made, backtesting over a grid of tickers and dates, the caller's
|
||||||
|
portfolio as run input, and SEC EDGAR fundamentals served as filed.
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
|
||||||
|
- **Fundamentals as filed.** SEC EDGAR serves US company statements as they stood on the run's date: a period that has ended but has not been filed is not served, and a figure restated later still reads as first reported. Keyless, opt-in via the vendor chain.
|
||||||
|
- **Backtesting.** `run_backtest` runs the pipeline over a ticker and date grid into its own decision log, and `summarize` scores the settled cells; `tradingagents backtest` does the same from the CLI.
|
||||||
|
- **Portfolio context.** `propagate(..., portfolio=...)` and `--portfolio` let the trader, risk and portfolio agents size against real holdings. A run without one is never treated as a flat book.
|
||||||
|
- **Decisions are recorded as made.** An unreadable decision is flagged for review everywhere instead of becoming a tradeable Hold, and a rating argued against is no longer read as the call.
|
||||||
|
|
||||||
|
### Point-in-time and honest attribution
|
||||||
|
|
||||||
|
- Dated tools take the run's date from graph state, so an omitted or later date cannot reach a vendor. (#1331, #1319, #1118)
|
||||||
|
- Insider filings and prediction-market odds are bounded by the run date; insider rows state that a trade becomes public when its Form 4 is filed.
|
||||||
|
- A feed that never observed a window reports it as unavailable rather than as an absence, across news, Reddit and StockTwits.
|
||||||
|
- The resolved company identity says when it describes today rather than the run date.
|
||||||
|
- The verification snapshot quotes the prices the vendor reported, never a gap-filled value.
|
||||||
|
- A vendor failure is a vendor failure: yfinance raises instead of returning its errors as text, an outage is not reported as a company with no data, and a chain where every vendor is unavailable says so instead of ending the run.
|
||||||
|
- The macro vintage pin is clamped to the vendor's own clock, so a run dated today cannot ask for a vintage it does not have.
|
||||||
|
- A historical run is not served a present-day company profile by either fundamentals vendor. (#1300)
|
||||||
|
|
||||||
|
### Decisions and evaluation
|
||||||
|
|
||||||
|
- The labelled rating decides, whatever separates it, and prose naming several ratings is reviewed rather than guessed.
|
||||||
|
- Decision prompts state the shape of their answer, so a provider without structured output still returns a readable decision.
|
||||||
|
- A report that was not produced says so, instead of appearing as an empty section.
|
||||||
|
- Backtest scoring reads the direction each rating claimed: a Sell that fell is a hit, and Hold reports no hit rate.
|
||||||
|
- The outcome window is configurable (`holding_period_days`), and reflection states the window it judges.
|
||||||
|
- A settled decision is not logged twice, and a failed reflection no longer stops the next run. (#645)
|
||||||
|
- The trader states entry and stop levels as prices, so a percentage no longer fails the whole proposal. (#1288)
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
- `tradingagents backtest`, with `--run-id` to continue an interrupted sweep. (#1234)
|
||||||
|
- The previous run's selections come back as prompt defaults. (#1236, #920)
|
||||||
|
- A run with no readable rating says so; the live view no longer scrolls; messages that read like Python values are shown. (#649, #784)
|
||||||
|
- The state log keeps non-ASCII readable. (#1081)
|
||||||
|
|
||||||
|
### Data sources
|
||||||
|
|
||||||
|
- SEC EDGAR fundamentals vendor (US filers, keyless).
|
||||||
|
- Hong Kong and Shanghai tickers resolve to the symbols Yahoo serves. (#1342, #957, #1260)
|
||||||
|
- Reddit is fetched as one combined request per run. (#1286)
|
||||||
|
- One OHLCV cache file per symbol. (#1330)
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
- Current lineups for every provider: GPT-6 Astra and the GPT-5.6 family, Gemini 3.8 Flash, Claude Opus 5 and Fable 5.1, Grok 4.6, DeepSeek Flash, Qwen 3.8, GLM-5.3, MiniMax M3, Kimi K3 and the current Mistral snapshots.
|
||||||
|
- Every provider accepts a model ID the picker does not list.
|
||||||
|
- GLM traffic goes to the platform its key belongs to, and Ollama structured output no longer sends a tool_choice it rejects. (#1062)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The memory log records `REVIEW` for a decision with no readable rating, where it previously recorded `Hold`.
|
||||||
|
- Optional fields the model did not provide are named as such rather than omitted.
|
||||||
|
- Removed dependencies nothing imports: backtrader, redis, setuptools, langchain-experimental, parsel, tqdm. (#1353, #1070)
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who reported these or sent a fix:
|
||||||
|
|
||||||
|
[@akashkpfreelancer](https://github.com/akashkpfreelancer), [@angziii](https://github.com/angziii), [@anupamme](https://github.com/anupamme), [@AyushKar2005](https://github.com/AyushKar2005), [@bulkypanda](https://github.com/bulkypanda), [@CadeYu](https://github.com/CadeYu), [@chiang21fcb](https://github.com/chiang21fcb), [@dajiaohuang](https://github.com/dajiaohuang), [@dewrama](https://github.com/dewrama), [@DogInfantry](https://github.com/DogInfantry), [@emitov](https://github.com/emitov), [@farukerdem34](https://github.com/farukerdem34), [@flydragon2018](https://github.com/flydragon2018), [@fusshell](https://github.com/fusshell), [@Ganesh1729-ui](https://github.com/Ganesh1729-ui), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@hamzabudeir](https://github.com/hamzabudeir), [@ihsieh31](https://github.com/ihsieh31), [@jaylew20250206](https://github.com/jaylew20250206), [@kaushik-yadav](https://github.com/kaushik-yadav), [@kbnnf](https://github.com/kbnnf), [@kevinkda](https://github.com/kevinkda), [@LudwigJMarx](https://github.com/LudwigJMarx), [@lx7720](https://github.com/lx7720), [@malandrindev](https://github.com/malandrindev), [@mhd325ic-hash](https://github.com/mhd325ic-hash), [@minhdn90](https://github.com/minhdn90), [@miznan](https://github.com/miznan), [@mmssix](https://github.com/mmssix), [@mrbob-git](https://github.com/mrbob-git), [@newnewself](https://github.com/newnewself), [@prithvirajrh](https://github.com/prithvirajrh), [@PyriteResearch](https://github.com/PyriteResearch), [@Rajatendu1](https://github.com/Rajatendu1), [@Recnelis0](https://github.com/Recnelis0), [@Rodvask](https://github.com/Rodvask), [@samhoooo](https://github.com/samhoooo), [@sheiun-xu](https://github.com/sheiun-xu), [@shivsin25](https://github.com/shivsin25), [@SmileShaun](https://github.com/SmileShaun), [@SonnyRajagopalan](https://github.com/SonnyRajagopalan), [@taro0915](https://github.com/taro0915), [@wupengbo125](https://github.com/wupengbo125), [@wxggzz](https://github.com/wxggzz), [@Yixiang-Wu](https://github.com/Yixiang-Wu), [@ZahirBodrike](https://github.com/ZahirBodrike), [@ZHUYAWEI](https://github.com/ZHUYAWEI), [@zkwang616](https://github.com/zkwang616).
|
||||||
|
|
||||||
## [0.4.0] — 2026-08-31
|
## [0.4.0] — 2026-08-31
|
||||||
|
|
||||||
Look-ahead and point-in-time fixes across the data and memory layers, clearer
|
Look-ahead and point-in-time fixes across the data and memory layers, clearer
|
||||||
|
|||||||
104
README.md
104
README.md
@@ -30,8 +30,13 @@
|
|||||||
# TradingAgents: Multi-Agents LLM Financial Trading Framework
|
# TradingAgents: Multi-Agents LLM Financial Trading Framework
|
||||||
|
|
||||||
## News
|
## News
|
||||||
- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. See [CHANGELOG.md](CHANGELOG.md) for the full list.
|
- [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider. See [CHANGELOG.md](CHANGELOG.md) for the full list.
|
||||||
|
- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models.
|
||||||
- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support.
|
- [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.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Earlier releases</summary>
|
||||||
|
|
||||||
- [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-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate.
|
||||||
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening.
|
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening.
|
||||||
- [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix.
|
- [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix.
|
||||||
@@ -40,6 +45,8 @@
|
|||||||
- [2026-02] **TradingAgents v0.2.0** released with multi-provider LLM support (GPT-5.x, Gemini 3.x, Claude 4.x, Grok 4.x) and improved system architecture.
|
- [2026-02] **TradingAgents v0.2.0** released with multi-provider LLM support (GPT-5.x, Gemini 3.x, Claude 4.x, Grok 4.x) and improved system architecture.
|
||||||
- [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon.
|
- [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
|
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
|
||||||
@@ -110,7 +117,13 @@ conda create -n tradingagents python=3.12
|
|||||||
conda activate tradingagents
|
conda activate tradingagents
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the package and its dependencies:
|
Or with [uv](https://docs.astral.sh/uv/):
|
||||||
|
```bash
|
||||||
|
uv venv --python 3.12
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the package and its dependencies (`uv pip install .` with uv):
|
||||||
```bash
|
```bash
|
||||||
pip install .
|
pip install .
|
||||||
```
|
```
|
||||||
@@ -123,6 +136,8 @@ cp .env.example .env # add your API keys
|
|||||||
docker compose run --rm tradingagents
|
docker compose run --rm tradingagents
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After updating the repository, rebuild the image with `docker compose build`.
|
||||||
|
|
||||||
For local models with Ollama:
|
For local models with Ollama:
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile ollama run --rm tradingagents-ollama
|
docker compose --profile ollama run --rm tradingagents-ollama
|
||||||
@@ -145,6 +160,11 @@ export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn)
|
|||||||
export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io)
|
export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io)
|
||||||
export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com)
|
export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com)
|
||||||
export OPENROUTER_API_KEY=... # OpenRouter
|
export OPENROUTER_API_KEY=... # OpenRouter
|
||||||
|
export MISTRAL_API_KEY=... # Mistral
|
||||||
|
export MOONSHOT_API_KEY=... # Kimi (Moonshot)
|
||||||
|
export GROQ_API_KEY=... # Groq
|
||||||
|
export NVIDIA_API_KEY=... # NVIDIA NIM
|
||||||
|
export FRED_API_KEY=... # FRED macro data (free, optional)
|
||||||
export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
|
export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,7 +188,7 @@ Launch the interactive CLI:
|
|||||||
tradingagents # installed command
|
tradingagents # installed command
|
||||||
python -m cli.main # alternative: run directly from source
|
python -m cli.main # alternative: run directly from source
|
||||||
```
|
```
|
||||||
You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more.
|
You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely.
|
||||||
|
|
||||||
### Markets and tickers
|
### Markets and tickers
|
||||||
|
|
||||||
@@ -211,7 +231,7 @@ from tradingagents.default_config import DEFAULT_CONFIG
|
|||||||
ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
|
ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy())
|
||||||
|
|
||||||
# forward propagate
|
# forward propagate
|
||||||
_, decision = ta.propagate("NVDA", "2026-01-15")
|
_, decision = ta.propagate("NVDA", "2026-09-01")
|
||||||
print(decision)
|
print(decision)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,40 +248,98 @@ config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks
|
|||||||
config["max_debate_rounds"] = 2
|
config["max_debate_rounds"] = 2
|
||||||
|
|
||||||
ta = TradingAgentsGraph(debug=True, config=config)
|
ta = TradingAgentsGraph(debug=True, config=config)
|
||||||
_, decision = ta.propagate("NVDA", "2026-01-15")
|
_, decision = ta.propagate("NVDA", "2026-09-01")
|
||||||
print(decision)
|
print(decision)
|
||||||
```
|
```
|
||||||
|
|
||||||
See `tradingagents/default_config.py` for all configuration options.
|
See `tradingagents/default_config.py` for all configuration options.
|
||||||
|
|
||||||
|
### Fundamentals as filed
|
||||||
|
|
||||||
|
US company statements can come from SEC EDGAR, which records the date every figure was filed. A run dated in the past then reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B.
|
||||||
|
|
||||||
|
EDGAR needs no account or API key. Add the vendor to the chain:
|
||||||
|
|
||||||
|
```python
|
||||||
|
config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance"
|
||||||
|
```
|
||||||
|
|
||||||
|
SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SEC_EDGAR_USER_AGENT="Your Name your@email.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
It covers companies that file with the SEC, including foreign companies listed in the US. Anything else, such as Hong Kong or A-share listings, falls through to the next vendor in the chain. EDGAR's machine-readable filings begin in 2009, and a fourth quarter is reported as unavailable rather than derived, because filers publish it only inside the annual figure.
|
||||||
|
|
||||||
|
### Current holdings
|
||||||
|
|
||||||
|
By default the agents do not know what you hold, so their guidance is written for a reader who applies it to their own position. Pass a portfolio to have the trader, the risk analysts and the portfolio manager work against your actual book.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tradingagents.portfolio import PortfolioContext
|
||||||
|
|
||||||
|
portfolio = PortfolioContext.model_validate({
|
||||||
|
"cash": 25000.0,
|
||||||
|
"currency": "USD",
|
||||||
|
"positions": [{"ticker": "NVDA", "quantity": 120, "average_price": 150.0}],
|
||||||
|
})
|
||||||
|
_, decision = ta.propagate("NVDA", "2026-09-01", portfolio=portfolio)
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI takes the same content as a JSON file: `tradingagents --portfolio my_book.json`.
|
||||||
|
|
||||||
|
An empty `positions` list means a flat book, which is different from passing nothing. A run without a portfolio is never treated as flat.
|
||||||
|
|
||||||
## Persistence and Recovery
|
## Persistence and Recovery
|
||||||
|
|
||||||
TradingAgents persists two kinds of state across runs.
|
TradingAgents persists two kinds of state across runs.
|
||||||
|
|
||||||
### Decision log
|
### Decision log
|
||||||
|
|
||||||
The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw and alpha vs SPY), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't.
|
The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't.
|
||||||
|
|
||||||
Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
|
Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
|
||||||
|
|
||||||
### Checkpoint resume
|
### Checkpoint resume
|
||||||
|
|
||||||
Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. On a resume run you will see `Resuming from step N for <TICKER> on <date>` in the logs; on a new run you will see `Starting fresh`. Checkpoints are cleared automatically on successful completion.
|
Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. The run view says whether it resumed a saved run or started fresh. Checkpoints are cleared automatically on successful completion.
|
||||||
|
|
||||||
Per-ticker SQLite databases live at `~/.tradingagents/cache/checkpoints/<TICKER>.db` (override the base with `TRADINGAGENTS_CACHE_DIR`). Use `--clear-checkpoints` to reset all of them before a run.
|
Per-ticker SQLite databases live at `~/.tradingagents/cache/checkpoints/<TICKER>.db` (override the base with `TRADINGAGENTS_CACHE_DIR`). Use `--clear-checkpoints` to reset all of them before a run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tradingagents analyze --checkpoint # enable for this run
|
tradingagents --checkpoint # enable for this run
|
||||||
tradingagents analyze --clear-checkpoints # reset before running
|
tradingagents --clear-checkpoints # reset before running
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
config = DEFAULT_CONFIG.copy()
|
config = DEFAULT_CONFIG.copy()
|
||||||
config["checkpoint_enabled"] = True
|
config["checkpoint_enabled"] = True
|
||||||
ta = TradingAgentsGraph(config=config)
|
ta = TradingAgentsGraph(config=config)
|
||||||
_, decision = ta.propagate("NVDA", "2026-01-15")
|
_, decision = ta.propagate("NVDA", "2026-09-01")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Evaluating decisions over time
|
||||||
|
|
||||||
|
One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a decision log of its own, and scores the decisions whose holding window has since traded.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tradingagents.backtest import iter_grid, run_backtest, summarize
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
|
||||||
|
dates = iter_grid("2026-06-01", "2026-08-01", every_n_days=7)
|
||||||
|
result = run_backtest(["NVDA", "AAPL"], dates, config, selected_analysts=["market", "news"])
|
||||||
|
print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render())
|
||||||
|
```
|
||||||
|
|
||||||
|
From the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7
|
||||||
|
```
|
||||||
|
|
||||||
|
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own decision log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped.
|
||||||
|
|
||||||
## Reproducibility
|
## Reproducibility
|
||||||
|
|
||||||
TradingAgents is LLM-driven, so two runs of the same ticker and date can differ. This is expected for a research tool built on language models, not a defect. The variation comes from a few distinct sources, and it helps to separate them.
|
TradingAgents is LLM-driven, so two runs of the same ticker and date can differ. This is expected for a research tool built on language models, not a defect. The variation comes from a few distinct sources, and it helps to separate them.
|
||||||
@@ -270,14 +348,14 @@ Language model sampling is non-deterministic. Even at a fixed temperature, provi
|
|||||||
|
|
||||||
Live data moves. News, StockTwits, and Reddit return different content as time passes, so a run today sees different inputs than a run last week even for the same historical trade date. Pin the analysis date to hold the price and indicator window fixed, but the social and news sources still reflect "now".
|
Live data moves. News, StockTwits, and Reddit return different content as time passes, so a run today sees different inputs than a run last week even for the same historical trade date. Pin the analysis date to hold the price and indicator window fixed, but the social and news sources still reflect "now".
|
||||||
|
|
||||||
To reduce variation you can lower the sampling temperature. Set `temperature` in your config (or `TRADINGAGENTS_TEMPERATURE` in `.env`); lower values make models that honor it more repeatable. The current curated models are reasoning-first and largely ignore temperature, so for tighter reproducibility use a non-reasoning model, which you can set explicitly via the Custom model ID option.
|
To reduce variation you can lower the sampling temperature. Set `temperature` in your config (or `TRADINGAGENTS_TEMPERATURE` in `.env`); lower values make models that honor it more repeatable. The current curated models are reasoning-first and largely ignore temperature, so for tighter reproducibility name a non-reasoning model in your config, or in `TRADINGAGENTS_DEEP_THINK_LLM` and `TRADINGAGENTS_QUICK_THINK_LLM`. Any model ID your provider serves is accepted, whether or not the picker lists it.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
config = DEFAULT_CONFIG.copy()
|
config = DEFAULT_CONFIG.copy()
|
||||||
config["llm_provider"] = "openai"
|
config["llm_provider"] = "openai"
|
||||||
config["temperature"] = 0.0
|
config["temperature"] = 0.0
|
||||||
# Reasoning models ignore temperature. For tighter reproducibility, set a
|
# Reasoning models ignore temperature. For tighter reproducibility, name a
|
||||||
# non-reasoning deep/quick model explicitly (e.g. via the Custom model ID option).
|
# non-reasoning model in deep_think_llm / quick_think_llm.
|
||||||
```
|
```
|
||||||
|
|
||||||
What does not vary anymore: the analyzed company identity is resolved deterministically from the ticker before any agent runs, and the market analyst grounds exact price and indicator claims in a verified data snapshot. Earlier reports of "different companies" or fabricated price levels across runs are addressed by these two mechanisms.
|
What does not vary anymore: the analyzed company identity is resolved deterministically from the ticker before any agent runs, and the market analyst grounds exact price and indicator claims in a verified data snapshot. Earlier reports of "different companies" or fabricated price levels across runs are addressed by these two mechanisms.
|
||||||
|
|||||||
236
cli/main.py
236
cli/main.py
@@ -20,6 +20,7 @@ from rich.table import Table
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from cli.announcements import display_announcements, fetch_announcements
|
from cli.announcements import display_announcements, fetch_announcements
|
||||||
|
from cli.prefs import load_last_run, sanitize, save_last_run
|
||||||
from cli.stats_handler import StatsCallbackHandler
|
from cli.stats_handler import StatsCallbackHandler
|
||||||
from cli.utils import (
|
from cli.utils import (
|
||||||
ask_anthropic_effort,
|
ask_anthropic_effort,
|
||||||
@@ -41,6 +42,9 @@ from cli.utils import (
|
|||||||
select_research_depth,
|
select_research_depth,
|
||||||
select_shallow_thinking_agent,
|
select_shallow_thinking_agent,
|
||||||
)
|
)
|
||||||
|
from tradingagents.agents.utils.rating import is_review
|
||||||
|
from tradingagents.backtest import iter_grid, run_backtest, summarize
|
||||||
|
from tradingagents.dataflows.utils import safe_ticker_component
|
||||||
from tradingagents.default_config import DEFAULT_CONFIG
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
from tradingagents.graph.analyst_execution import (
|
from tradingagents.graph.analyst_execution import (
|
||||||
AnalystWallTimeTracker,
|
AnalystWallTimeTracker,
|
||||||
@@ -49,6 +53,7 @@ from tradingagents.graph.analyst_execution import (
|
|||||||
sync_analyst_tracker_from_chunk,
|
sync_analyst_tracker_from_chunk,
|
||||||
)
|
)
|
||||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
from tradingagents.portfolio import load_portfolio
|
||||||
from tradingagents.reporting import write_report_tree
|
from tradingagents.reporting import write_report_tree
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -493,7 +498,14 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non
|
|||||||
|
|
||||||
|
|
||||||
def get_user_selections():
|
def get_user_selections():
|
||||||
"""Get all user selections before starting the analysis display."""
|
"""Ask for the run's settings, offering the previous run's answers."""
|
||||||
|
selections = _prompt_selections(load_last_run())
|
||||||
|
save_last_run(selections)
|
||||||
|
return selections
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_selections(prefs):
|
||||||
|
"""Walk the selection steps. ``prefs`` prefills, the environment skips."""
|
||||||
# Display ASCII art welcome message
|
# Display ASCII art welcome message
|
||||||
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
|
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
|
||||||
welcome_ascii = f.read()
|
welcome_ascii = f.read()
|
||||||
@@ -586,7 +598,7 @@ def get_user_selections():
|
|||||||
"Select the language for analyst reports and final decision"
|
"Select the language for analyst reports and final decision"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
output_language = ask_output_language()
|
output_language = ask_output_language(prefs.get("output_language"))
|
||||||
|
|
||||||
# Step 4: Select analysts
|
# Step 4: Select analysts
|
||||||
console.print(
|
console.print(
|
||||||
@@ -594,7 +606,8 @@ def get_user_selections():
|
|||||||
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
|
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
selected_analysts = select_analysts(asset_type)
|
prefs = sanitize(prefs, asset_type.value)
|
||||||
|
selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
|
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
|
||||||
)
|
)
|
||||||
@@ -619,7 +632,7 @@ def get_user_selections():
|
|||||||
"Step 5: Research Depth", "Select your research depth level"
|
"Step 5: Research Depth", "Select your research depth level"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
selected_research_depth = select_research_depth()
|
selected_research_depth = select_research_depth(prefs.get("research_depth"))
|
||||||
|
|
||||||
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
|
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
|
||||||
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
|
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
|
||||||
@@ -641,7 +654,7 @@ def get_user_selections():
|
|||||||
"Step 6: LLM Provider", "Select your LLM provider"
|
"Step 6: LLM Provider", "Select your LLM provider"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
selected_llm_provider, backend_url = select_llm_provider()
|
selected_llm_provider, backend_url = select_llm_provider(prefs.get("llm_provider"))
|
||||||
|
|
||||||
# Providers with regional endpoints prompt for the region as a secondary
|
# Providers with regional endpoints prompt for the region as a secondary
|
||||||
# step so the main dropdown stays clean (mainland China and international
|
# step so the main dropdown stays clean (mainland China and international
|
||||||
@@ -662,7 +675,9 @@ def get_user_selections():
|
|||||||
# The generic OpenAI-compatible endpoint has no default; ask for it if
|
# The generic OpenAI-compatible endpoint has no default; ask for it if
|
||||||
# neither the menu nor the environment supplied one.
|
# neither the menu nor the environment supplied one.
|
||||||
if selected_llm_provider == "openai_compatible" and not backend_url:
|
if selected_llm_provider == "openai_compatible" and not backend_url:
|
||||||
backend_url = prompt_openai_compatible_url()
|
remembered_url = (prefs.get("backend_url")
|
||||||
|
if prefs.get("llm_provider") == selected_llm_provider else None)
|
||||||
|
backend_url = prompt_openai_compatible_url(remembered_url)
|
||||||
|
|
||||||
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
|
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
|
||||||
# before model selection so it's obvious where we're connecting.
|
# before model selection so it's obvious where we're connecting.
|
||||||
@@ -688,8 +703,13 @@ def get_user_selections():
|
|||||||
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
|
remembered = prefs if prefs.get("llm_provider") == selected_llm_provider else {}
|
||||||
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
|
selected_shallow_thinker = select_shallow_thinking_agent(
|
||||||
|
selected_llm_provider, remembered.get("quick_think_llm")
|
||||||
|
)
|
||||||
|
selected_deep_thinker = select_deep_thinking_agent(
|
||||||
|
selected_llm_provider, remembered.get("deep_think_llm")
|
||||||
|
)
|
||||||
|
|
||||||
# Step 8: Provider-specific reasoning/thinking configuration. Each knob is
|
# Step 8: Provider-specific reasoning/thinking configuration. Each knob is
|
||||||
# settable via its TRADINGAGENTS_* env var; when that var is set (or the
|
# settable via its TRADINGAGENTS_* env var; when that var is set (or the
|
||||||
@@ -732,8 +752,8 @@ def get_user_selections():
|
|||||||
"research_depth": selected_research_depth,
|
"research_depth": selected_research_depth,
|
||||||
"llm_provider": selected_llm_provider.lower(),
|
"llm_provider": selected_llm_provider.lower(),
|
||||||
"backend_url": backend_url,
|
"backend_url": backend_url,
|
||||||
"shallow_thinker": selected_shallow_thinker,
|
"quick_think_llm": selected_shallow_thinker,
|
||||||
"deep_thinker": selected_deep_thinker,
|
"deep_think_llm": selected_deep_thinker,
|
||||||
"google_thinking_level": thinking_level,
|
"google_thinking_level": thinking_level,
|
||||||
"openai_reasoning_effort": reasoning_effort,
|
"openai_reasoning_effort": reasoning_effort,
|
||||||
"anthropic_effort": anthropic_effort,
|
"anthropic_effort": anthropic_effort,
|
||||||
@@ -900,21 +920,16 @@ def extract_content_string(content):
|
|||||||
"""Extract string content from various message formats.
|
"""Extract string content from various message formats.
|
||||||
Returns None if no meaningful text content is found.
|
Returns None if no meaningful text content is found.
|
||||||
"""
|
"""
|
||||||
import ast
|
|
||||||
|
|
||||||
def is_empty(val):
|
def is_empty(val):
|
||||||
"""Check if value is empty using Python's truthiness."""
|
"""Whether a value carries nothing to show.
|
||||||
if val is None or val == '':
|
|
||||||
return True
|
Text is judged by whether anything was written, not by what it would
|
||||||
|
mean as Python: a report saying "0" or "None" is a message the run
|
||||||
|
produced, and reading it as a falsy literal dropped it from the display.
|
||||||
|
"""
|
||||||
if isinstance(val, str):
|
if isinstance(val, str):
|
||||||
s = val.strip()
|
return not val.strip()
|
||||||
if not s:
|
return val is None or not bool(val)
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return not bool(ast.literal_eval(s))
|
|
||||||
except (ValueError, SyntaxError):
|
|
||||||
return False # Can't parse = real text
|
|
||||||
return not bool(val)
|
|
||||||
|
|
||||||
if is_empty(content):
|
if is_empty(content):
|
||||||
return None
|
return None
|
||||||
@@ -971,6 +986,29 @@ def format_tool_args(args, max_length=80) -> str:
|
|||||||
return result[:max_length - 3] + "..."
|
return result[:max_length - 3] + "..."
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _run_directory(config: dict, ticker: str, trade_date: str) -> Path:
|
||||||
|
"""Where this run writes, with the ticker validated as a path component.
|
||||||
|
|
||||||
|
Every other path that interpolates a ticker checks it first; a value of
|
||||||
|
".." here would place the run outside the results directory.
|
||||||
|
"""
|
||||||
|
return Path(config["results_dir"]) / safe_ticker_component(ticker) / trade_date
|
||||||
|
|
||||||
|
|
||||||
|
def _announce_checkpoint_state(graph, ticker: str, trade_date: str) -> None:
|
||||||
|
"""Say whether this run resumed a saved one, where the user can see it.
|
||||||
|
|
||||||
|
The graph logs this, but nothing in the CLI configures logging and the live
|
||||||
|
view owns the screen, so a resume was invisible.
|
||||||
|
"""
|
||||||
|
if getattr(graph, "_resuming", False):
|
||||||
|
message_buffer.add_message(
|
||||||
|
"System", f"Resuming the saved run for {ticker} on {trade_date}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message_buffer.add_message("System", f"Starting fresh for {ticker} on {trade_date}")
|
||||||
|
|
||||||
|
|
||||||
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
|
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
|
||||||
"""Assemble the run config from interactive selections, honoring env precedence.
|
"""Assemble the run config from interactive selections, honoring env precedence.
|
||||||
|
|
||||||
@@ -981,12 +1019,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
|
|||||||
# Research depth sets both round counts, but an explicit env override
|
# Research depth sets both round counts, but an explicit env override
|
||||||
# (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the
|
# (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the
|
||||||
# interactive selection — leave the env-applied value in place (#977).
|
# interactive selection — leave the env-applied value in place (#977).
|
||||||
if not os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS"):
|
for env_var, key in (("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "max_debate_rounds"),
|
||||||
config["max_debate_rounds"] = selections["research_depth"]
|
("TRADINGAGENTS_MAX_RISK_ROUNDS", "max_risk_discuss_rounds")):
|
||||||
if not os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS"):
|
if os.environ.get(env_var):
|
||||||
config["max_risk_discuss_rounds"] = selections["research_depth"]
|
# The depth prompt still appeared (it is skipped only when both are
|
||||||
config["quick_think_llm"] = selections["shallow_thinker"]
|
# set), so say which half of the answer the environment overrode.
|
||||||
config["deep_think_llm"] = selections["deep_thinker"]
|
console.print(
|
||||||
|
f"[green]✓ {key} from environment:[/green] {config[key]} "
|
||||||
|
f"(set by {env_var}, so the research depth you chose does not apply to it)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
config[key] = selections["research_depth"]
|
||||||
|
config["quick_think_llm"] = selections["quick_think_llm"]
|
||||||
|
config["deep_think_llm"] = selections["deep_think_llm"]
|
||||||
config["backend_url"] = selections["backend_url"]
|
config["backend_url"] = selections["backend_url"]
|
||||||
config["llm_provider"] = selections["llm_provider"].lower()
|
config["llm_provider"] = selections["llm_provider"].lower()
|
||||||
# Provider-specific thinking configuration
|
# Provider-specific thinking configuration
|
||||||
@@ -1001,7 +1046,7 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def run_analysis(checkpoint: bool | None = None):
|
def run_analysis(checkpoint: bool | None = None, portfolio=None):
|
||||||
# First get all user selections
|
# First get all user selections
|
||||||
selections = get_user_selections()
|
selections = get_user_selections()
|
||||||
|
|
||||||
@@ -1031,7 +1076,7 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Create result directory
|
# Create result directory
|
||||||
results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"]
|
results_dir = _run_directory(config, selections["ticker"], selections["analysis_date"])
|
||||||
results_dir.mkdir(parents=True, exist_ok=True)
|
results_dir.mkdir(parents=True, exist_ok=True)
|
||||||
report_dir = results_dir / "reports"
|
report_dir = results_dir / "reports"
|
||||||
report_dir.mkdir(parents=True, exist_ok=True)
|
report_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1081,7 +1126,9 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
# Now start the display layout
|
# Now start the display layout
|
||||||
layout = create_layout()
|
layout = create_layout()
|
||||||
|
|
||||||
with Live(layout, refresh_per_second=4):
|
# The alternate screen keeps a layout taller than the window from redrawing
|
||||||
|
# by scrolling; the final report prints after this block, on the normal screen.
|
||||||
|
with Live(layout, refresh_per_second=4, screen=True):
|
||||||
# Initial display
|
# Initial display
|
||||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||||
|
|
||||||
@@ -1110,18 +1157,10 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
)
|
)
|
||||||
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
|
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
|
||||||
|
|
||||||
# Initialize state and get graph args with callbacks.
|
# The same initial state propagate() builds: settled decision log, past
|
||||||
# Resolve the instrument identity once here so all agents anchor to
|
# context and resolved instrument identity.
|
||||||
# the real company (#814); the CLI builds state directly rather than
|
init_agent_state = graph.create_run_state(
|
||||||
# going through propagate(), so this must happen on the CLI path too.
|
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
|
||||||
instrument_context = graph.resolve_instrument_context(
|
|
||||||
selections["ticker"], selections["asset_type"]
|
|
||||||
)
|
|
||||||
init_agent_state = graph.propagator.create_initial_state(
|
|
||||||
selections["ticker"],
|
|
||||||
selections["analysis_date"],
|
|
||||||
asset_type=selections["asset_type"],
|
|
||||||
instrument_context=instrument_context,
|
|
||||||
)
|
)
|
||||||
# Pass callbacks to graph config for tool execution tracking
|
# Pass callbacks to graph config for tool execution tracking
|
||||||
# (LLM tracking is handled separately via LLM constructor)
|
# (LLM tracking is handled separately via LLM constructor)
|
||||||
@@ -1131,7 +1170,7 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
# actually saves and resumes on the CLI path (#1249); a no-op when
|
# actually saves and resumes on the CLI path (#1249); a no-op when
|
||||||
# checkpointing is disabled. Torn down in the finally below.
|
# checkpointing is disabled. Torn down in the finally below.
|
||||||
checkpoint_tid = graph.begin_checkpoint(
|
checkpoint_tid = graph.begin_checkpoint(
|
||||||
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
|
||||||
)
|
)
|
||||||
if checkpoint_tid is not None:
|
if checkpoint_tid is not None:
|
||||||
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
|
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
|
||||||
@@ -1243,21 +1282,23 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
|
|
||||||
trace.append(chunk)
|
trace.append(chunk)
|
||||||
|
|
||||||
# Clean run: drop this run's checkpoint so a later run starts fresh.
|
|
||||||
# A mid-stream failure skips this, keeping the checkpoint for resume.
|
|
||||||
graph.clear_checkpoint_on_success(
|
|
||||||
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
# Always restore the plain uncheckpointed graph, even on failure.
|
|
||||||
graph.end_checkpoint()
|
|
||||||
|
|
||||||
# Streamed chunks are per-node deltas, not full state. Merge them
|
# Streamed chunks are per-node deltas, not full state. Merge them
|
||||||
# so every report field populated across the run is present.
|
# so every report field populated across the run is present.
|
||||||
final_state = {}
|
final_state = {}
|
||||||
for chunk in trace:
|
for chunk in trace:
|
||||||
final_state.update(chunk)
|
final_state.update(chunk)
|
||||||
|
|
||||||
|
# Clean run: log the decision, then drop this run's checkpoint so a
|
||||||
|
# later run starts fresh. A mid-stream failure skips both, keeping
|
||||||
|
# the checkpoint for resume.
|
||||||
|
graph.record_decision(selections["ticker"], selections["analysis_date"], final_state)
|
||||||
|
graph.clear_checkpoint_on_success(
|
||||||
|
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Always restore the plain uncheckpointed graph, even on failure.
|
||||||
|
graph.end_checkpoint()
|
||||||
|
|
||||||
# Update all agent statuses to completed
|
# Update all agent statuses to completed
|
||||||
for agent in message_buffer.agent_status:
|
for agent in message_buffer.agent_status:
|
||||||
message_buffer.update_agent_status(agent, "completed")
|
message_buffer.update_agent_status(agent, "completed")
|
||||||
@@ -1276,13 +1317,26 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
|
|
||||||
# Post-analysis prompts (outside Live context for clean interaction)
|
# Post-analysis prompts (outside Live context for clean interaction)
|
||||||
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n")
|
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n")
|
||||||
|
|
||||||
|
# A decision nobody can read is not a position. Say so here rather than
|
||||||
|
# leaving the run to look like a normal result.
|
||||||
|
if is_review(graph.process_signal(final_state.get("final_trade_decision", ""))):
|
||||||
|
console.print(
|
||||||
|
"[yellow]No rating could be read from the final decision, so this run "
|
||||||
|
"is recorded for review rather than as a position. Re-run, or read the "
|
||||||
|
"decision text below and judge it yourself.[/yellow]\n"
|
||||||
|
)
|
||||||
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
|
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
|
||||||
|
|
||||||
# Prompt to save report
|
# Prompt to save report
|
||||||
save_choice = typer.prompt("Save report?", default="Y").strip().upper()
|
save_choice = typer.prompt("Save report?", default="Y").strip().upper()
|
||||||
if save_choice in ("Y", "YES", ""):
|
if save_choice in ("Y", "YES", ""):
|
||||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}"
|
# Under results_dir, not the working directory: in Docker the working
|
||||||
|
# directory is inside the container and the report goes with it, while
|
||||||
|
# results_dir is the mounted volume the rest of the run already writes to.
|
||||||
|
default_path = (Path(config["results_dir"]) / "reports"
|
||||||
|
/ f"{safe_ticker_component(selections['ticker'])}_{timestamp}")
|
||||||
save_path_str = typer.prompt(
|
save_path_str = typer.prompt(
|
||||||
"Save path (press Enter for default)",
|
"Save path (press Enter for default)",
|
||||||
default=str(default_path)
|
default=str(default_path)
|
||||||
@@ -1301,8 +1355,9 @@ def run_analysis(checkpoint: bool | None = None):
|
|||||||
display_complete_report(final_state)
|
display_complete_report(final_state)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.callback(invoke_without_command=True)
|
||||||
def analyze(
|
def analyze(
|
||||||
|
ctx: typer.Context,
|
||||||
checkpoint: bool | None = typer.Option(
|
checkpoint: bool | None = typer.Option(
|
||||||
None,
|
None,
|
||||||
"--checkpoint/--no-checkpoint",
|
"--checkpoint/--no-checkpoint",
|
||||||
@@ -1314,13 +1369,31 @@ def analyze(
|
|||||||
"--clear-checkpoints",
|
"--clear-checkpoints",
|
||||||
help="Delete all saved checkpoints before running (force fresh start).",
|
help="Delete all saved checkpoints before running (force fresh start).",
|
||||||
),
|
),
|
||||||
|
portfolio: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--portfolio",
|
||||||
|
help="JSON file with current holdings and cash, so the trader, risk and "
|
||||||
|
"portfolio agents size against your actual position.",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
|
"""Run an analysis. This is what a bare `tradingagents` does."""
|
||||||
|
if ctx.invoked_subcommand is not None:
|
||||||
|
return
|
||||||
if clear_checkpoints:
|
if clear_checkpoints:
|
||||||
from tradingagents.graph.checkpointer import clear_all_checkpoints
|
from tradingagents.graph.checkpointer import clear_all_checkpoints
|
||||||
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
|
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
|
||||||
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
|
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
|
||||||
|
portfolio_context = None
|
||||||
|
if portfolio:
|
||||||
|
from tradingagents.portfolio import load_portfolio
|
||||||
try:
|
try:
|
||||||
run_analysis(checkpoint=checkpoint)
|
portfolio_context = load_portfolio(portfolio)
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
raise typer.Exit(code=1) from None
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context)
|
||||||
except _NO_CONSOLE_ERRORS:
|
except _NO_CONSOLE_ERRORS:
|
||||||
# A terminal with no console buffer cannot host the interactive prompts.
|
# A terminal with no console buffer cannot host the interactive prompts.
|
||||||
# Emit one actionable line on stderr instead of a prompt_toolkit
|
# Emit one actionable line on stderr instead of a prompt_toolkit
|
||||||
@@ -1334,5 +1407,54 @@ def analyze(
|
|||||||
raise typer.Exit(code=1) from None
|
raise typer.Exit(code=1) from None
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def backtest(
|
||||||
|
tickers: str = typer.Argument(..., help="Comma-separated tickers, e.g. NVDA,AAPL"),
|
||||||
|
start: str = typer.Option(..., "--start", help="First analysis date, YYYY-MM-DD"),
|
||||||
|
end: str = typer.Option(..., "--end", help="Last analysis date, YYYY-MM-DD"),
|
||||||
|
every: int = typer.Option(7, "--every", help="Days between analysis dates"),
|
||||||
|
analysts: str = typer.Option(
|
||||||
|
None, "--analysts", help="Comma-separated analysts to run; omit for all four"
|
||||||
|
),
|
||||||
|
asset_type: str = typer.Option("stock", "--asset-type", help="stock or crypto"),
|
||||||
|
portfolio: str = typer.Option(
|
||||||
|
None, "--portfolio", help="JSON file with holdings and cash, held constant across the grid"
|
||||||
|
),
|
||||||
|
run_id: str = typer.Option(
|
||||||
|
None, "--run-id", help="Continue an earlier sweep: its cells are skipped and its log reused"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Score past decisions over a grid of tickers and dates."""
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
|
||||||
|
try:
|
||||||
|
dates = iter_grid(start, end, every)
|
||||||
|
book = load_portfolio(portfolio) if portfolio else None
|
||||||
|
except ValueError as exc:
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
raise typer.Exit(code=1) from None
|
||||||
|
|
||||||
|
names = [t.strip() for t in tickers.split(",") if t.strip()]
|
||||||
|
if not names:
|
||||||
|
console.print("[red]No ticker to analyze; pass them comma-separated, e.g. NVDA,AAPL[/red]")
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
kwargs = {"asset_type": asset_type, "portfolio": book, "run_id": run_id}
|
||||||
|
if analysts:
|
||||||
|
kwargs["selected_analysts"] = [a.strip().lower() for a in analysts.split(",") if a.strip()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_backtest(names, dates, DEFAULT_CONFIG, **kwargs)
|
||||||
|
except Exception as exc: # a missing key or an unknown analyst is a setup error
|
||||||
|
console.print(f"[red]{exc}[/red]")
|
||||||
|
raise typer.Exit(code=1) from None
|
||||||
|
console.print(summarize(TradingMemoryLog({"memory_log_path": str(result.log_path)})).render())
|
||||||
|
console.print(f"\nRan {result.cells_run} cells, skipped {result.skipped}. Log: {result.log_path}")
|
||||||
|
for ticker, date, reason in result.failures:
|
||||||
|
console.print(f"[yellow]failed:[/yellow] {ticker} {date}: {reason}")
|
||||||
|
for ticker, reason in result.settlement_failures:
|
||||||
|
console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
91
cli/prefs.py
Normal file
91
cli/prefs.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""What the last run chose, offered back as the next run's defaults.
|
||||||
|
|
||||||
|
The interactive flow asks the same questions every time, and only some of them
|
||||||
|
have an environment variable to skip them (the analyst set has none). Remembered
|
||||||
|
answers prefill the prompts so Enter accepts them; they never skip a step, so a
|
||||||
|
run always starts on choices the user has seen.
|
||||||
|
|
||||||
|
Only answers that are stable between runs are kept. The ticker and the analysis
|
||||||
|
date are not: they change every run, and a remembered date would quietly offer a
|
||||||
|
stale one.
|
||||||
|
|
||||||
|
Every value is checked against the current choices on the way out, because
|
||||||
|
models and providers are added and retired between versions. A remembered model
|
||||||
|
that is no longer offered is dropped rather than shown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cli.models import AnalystType, AssetType
|
||||||
|
from cli.utils import _llm_provider_table, filter_analysts_for_asset_type
|
||||||
|
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||||
|
|
||||||
|
_PREFS_PATH = Path(os.path.expanduser("~")) / ".tradingagents" / "cli_prefs.json"
|
||||||
|
|
||||||
|
REMEMBERED = (
|
||||||
|
"output_language", "analysts", "research_depth", "llm_provider",
|
||||||
|
"quick_think_llm", "deep_think_llm", "backend_url",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_last_run() -> dict:
|
||||||
|
"""The previous run's answers, or an empty dict when there is nothing usable.
|
||||||
|
|
||||||
|
Convenience state: an unreadable or corrupt file means no defaults, never an
|
||||||
|
error in the user's way.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(_PREFS_PATH.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_last_run(selections: dict) -> None:
|
||||||
|
"""Record the answers worth offering next time; failure is never fatal."""
|
||||||
|
kept = {k: v for k, v in selections.items() if k in REMEMBERED and v not in (None, "", [])}
|
||||||
|
kept["analysts"] = [getattr(a, "value", a) for a in kept.get("analysts", [])] or None
|
||||||
|
kept = {k: v for k, v in kept.items() if v is not None}
|
||||||
|
try:
|
||||||
|
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp = _PREFS_PATH.with_suffix(".tmp")
|
||||||
|
temp.write_text(json.dumps(kept, indent=2), encoding="utf-8")
|
||||||
|
os.replace(temp, _PREFS_PATH) # a concurrent run reads one file or the other
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(prefs: dict, asset_type) -> dict:
|
||||||
|
"""Keep only the remembered answers that are still choosable now."""
|
||||||
|
kept: dict = {}
|
||||||
|
if isinstance(prefs.get("output_language"), str):
|
||||||
|
kept["output_language"] = prefs["output_language"]
|
||||||
|
if prefs.get("research_depth") in (1, 3, 5):
|
||||||
|
kept["research_depth"] = prefs["research_depth"]
|
||||||
|
|
||||||
|
known = {a.value for a in AnalystType}
|
||||||
|
analysts = [a for a in prefs.get("analysts") or [] if a in known]
|
||||||
|
allowed = filter_analysts_for_asset_type([AnalystType(a) for a in analysts], AssetType(asset_type))
|
||||||
|
if allowed:
|
||||||
|
kept["analysts"] = [a.value for a in allowed]
|
||||||
|
|
||||||
|
provider = prefs.get("llm_provider")
|
||||||
|
# Region-specific providers (qwen-cn) are picked in a second prompt, so the
|
||||||
|
# base key is what the provider menu matches.
|
||||||
|
base = (provider or "").split("-cn")[0]
|
||||||
|
if base and base in {key for _, key, _ in _llm_provider_table()}:
|
||||||
|
kept["llm_provider"] = provider
|
||||||
|
if isinstance(prefs.get("backend_url"), str) and prefs["backend_url"]:
|
||||||
|
kept["backend_url"] = prefs["backend_url"]
|
||||||
|
for field, mode in (("quick_think_llm", "quick"), ("deep_think_llm", "deep")):
|
||||||
|
try:
|
||||||
|
offered = {model for _, model in get_model_options(base, mode)}
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
if prefs.get(field) in offered:
|
||||||
|
kept[field] = prefs[field]
|
||||||
|
return kept
|
||||||
68
cli/utils.py
68
cli/utils.py
@@ -132,8 +132,16 @@ def get_analysis_date() -> str:
|
|||||||
return date.strip()
|
return date.strip()
|
||||||
|
|
||||||
|
|
||||||
def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType]:
|
def _matching_choice(options, default):
|
||||||
"""Select analysts using an interactive checkbox."""
|
"""The option value equal to ``default``, or None to leave the menu as is."""
|
||||||
|
return next((value for _, value in options if value == default), None)
|
||||||
|
|
||||||
|
|
||||||
|
def select_analysts(asset_type: AssetType = AssetType.STOCK, default=None) -> list[AnalystType]:
|
||||||
|
"""Select analysts using an interactive checkbox.
|
||||||
|
|
||||||
|
``default`` pre-checks the previous run's analysts; the prompt still shows.
|
||||||
|
"""
|
||||||
available_analysts = filter_analysts_for_asset_type(
|
available_analysts = filter_analysts_for_asset_type(
|
||||||
[value for _, value in ANALYST_ORDER],
|
[value for _, value in ANALYST_ORDER],
|
||||||
asset_type,
|
asset_type,
|
||||||
@@ -141,7 +149,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType
|
|||||||
choices = questionary.checkbox(
|
choices = questionary.checkbox(
|
||||||
"Select Your [Analysts Team]:",
|
"Select Your [Analysts Team]:",
|
||||||
choices=[
|
choices=[
|
||||||
questionary.Choice(display, value=value)
|
questionary.Choice(display, value=value, checked=value.value in (default or []))
|
||||||
for display, value in ANALYST_ORDER
|
for display, value in ANALYST_ORDER
|
||||||
if value in available_analysts
|
if value in available_analysts
|
||||||
],
|
],
|
||||||
@@ -164,7 +172,7 @@ def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType
|
|||||||
return choices
|
return choices
|
||||||
|
|
||||||
|
|
||||||
def select_research_depth() -> int:
|
def select_research_depth(default=None) -> int:
|
||||||
"""Select research depth using an interactive selection."""
|
"""Select research depth using an interactive selection."""
|
||||||
|
|
||||||
# Define research depth options with their corresponding values
|
# Define research depth options with their corresponding values
|
||||||
@@ -179,6 +187,7 @@ def select_research_depth() -> int:
|
|||||||
choices=[
|
choices=[
|
||||||
questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS
|
questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS
|
||||||
],
|
],
|
||||||
|
default=_matching_choice(DEPTH_OPTIONS, default),
|
||||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||||
style=questionary.Style(
|
style=questionary.Style(
|
||||||
[
|
[
|
||||||
@@ -289,7 +298,7 @@ def _prompt_custom_model_id() -> str:
|
|||||||
return _require_text("Enter model ID:", "Please enter a model ID.")
|
return _require_text("Enter model ID:", "Please enter a model ID.")
|
||||||
|
|
||||||
|
|
||||||
def _select_model(provider: str, mode: str) -> str:
|
def _select_model(provider: str, mode: str, default=None) -> str:
|
||||||
"""Select a model for the given provider and mode (quick/deep)."""
|
"""Select a model for the given provider and mode (quick/deep)."""
|
||||||
if provider.lower() == "openrouter":
|
if provider.lower() == "openrouter":
|
||||||
return select_openrouter_model(mode)
|
return select_openrouter_model(mode)
|
||||||
@@ -306,6 +315,7 @@ def _select_model(provider: str, mode: str) -> str:
|
|||||||
questionary.Choice(display, value=value)
|
questionary.Choice(display, value=value)
|
||||||
for display, value in get_model_options(provider, mode)
|
for display, value in get_model_options(provider, mode)
|
||||||
],
|
],
|
||||||
|
default=_matching_choice(get_model_options(provider, mode), default),
|
||||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||||
style=questionary.Style(
|
style=questionary.Style(
|
||||||
[
|
[
|
||||||
@@ -326,14 +336,14 @@ def _select_model(provider: str, mode: str) -> str:
|
|||||||
return choice
|
return choice
|
||||||
|
|
||||||
|
|
||||||
def select_shallow_thinking_agent(provider) -> str:
|
def select_shallow_thinking_agent(provider, default=None) -> str:
|
||||||
"""Select shallow thinking llm engine using an interactive selection."""
|
"""Select shallow thinking llm engine using an interactive selection."""
|
||||||
return _select_model(provider, "quick")
|
return _select_model(provider, "quick", default)
|
||||||
|
|
||||||
|
|
||||||
def select_deep_thinking_agent(provider) -> str:
|
def select_deep_thinking_agent(provider, default=None) -> str:
|
||||||
"""Select deep thinking llm engine using an interactive selection."""
|
"""Select deep thinking llm engine using an interactive selection."""
|
||||||
return _select_model(provider, "deep")
|
return _select_model(provider, "deep", default)
|
||||||
|
|
||||||
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
||||||
"""(display_name, provider_key, base_url) for every supported provider.
|
"""(display_name, provider_key, base_url) for every supported provider.
|
||||||
@@ -352,7 +362,9 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
|||||||
("xAI", "xai", "https://api.x.ai/v1"),
|
("xAI", "xai", "https://api.x.ai/v1"),
|
||||||
("DeepSeek", "deepseek", "https://api.deepseek.com"),
|
("DeepSeek", "deepseek", "https://api.deepseek.com"),
|
||||||
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
||||||
("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"),
|
# Z.AI international, the platform ZHIPU_API_KEY belongs to; the CN
|
||||||
|
# platform is the separate glm-cn key, chosen in the region prompt.
|
||||||
|
("GLM", "glm", "https://api.z.ai/api/paas/v4/"),
|
||||||
("MiniMax", "minimax", "https://api.minimax.io/v1"),
|
("MiniMax", "minimax", "https://api.minimax.io/v1"),
|
||||||
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
|
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
|
||||||
("Mistral", "mistral", "https://api.mistral.ai/v1"),
|
("Mistral", "mistral", "https://api.mistral.ai/v1"),
|
||||||
@@ -388,11 +400,12 @@ def resolve_backend_url(
|
|||||||
return env_url or menu_url or provider_default_url(provider)
|
return env_url or menu_url or provider_default_url(provider)
|
||||||
|
|
||||||
|
|
||||||
def prompt_openai_compatible_url() -> str:
|
def prompt_openai_compatible_url(default=None) -> str:
|
||||||
"""Prompt for a custom OpenAI-compatible endpoint base URL."""
|
"""Prompt for a custom OpenAI-compatible endpoint base URL."""
|
||||||
url = questionary.text(
|
url = questionary.text(
|
||||||
"Enter the OpenAI-compatible base URL "
|
"Enter the OpenAI-compatible base URL "
|
||||||
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
|
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
|
||||||
|
default=default or "",
|
||||||
validate=lambda x: x.strip().startswith(("http://", "https://"))
|
validate=lambda x: x.strip().startswith(("http://", "https://"))
|
||||||
or "Enter a URL starting with http:// or https://",
|
or "Enter a URL starting with http:// or https://",
|
||||||
).ask()
|
).ask()
|
||||||
@@ -402,9 +415,15 @@ def prompt_openai_compatible_url() -> str:
|
|||||||
return url.strip()
|
return url.strip()
|
||||||
|
|
||||||
|
|
||||||
def select_llm_provider() -> tuple[str, str | None]:
|
def select_llm_provider(default=None) -> tuple[str, str | None]:
|
||||||
"""Select the LLM provider and its API endpoint."""
|
"""Select the LLM provider and its API endpoint."""
|
||||||
PROVIDERS = _llm_provider_table()
|
PROVIDERS = _llm_provider_table()
|
||||||
|
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu
|
||||||
|
# lists the base provider.
|
||||||
|
base = (default or "").split("-cn")[0]
|
||||||
|
preselected = next(
|
||||||
|
((key, url) for _, key, url in PROVIDERS if key == base), None
|
||||||
|
)
|
||||||
|
|
||||||
choice = questionary.select(
|
choice = questionary.select(
|
||||||
"Select your LLM Provider:",
|
"Select your LLM Provider:",
|
||||||
@@ -412,6 +431,7 @@ def select_llm_provider() -> tuple[str, str | None]:
|
|||||||
questionary.Choice(display, value=(provider_key, url))
|
questionary.Choice(display, value=(provider_key, url))
|
||||||
for display, provider_key, url in PROVIDERS
|
for display, provider_key, url in PROVIDERS
|
||||||
],
|
],
|
||||||
|
default=preselected,
|
||||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||||
style=questionary.Style(
|
style=questionary.Style(
|
||||||
[
|
[
|
||||||
@@ -643,18 +663,24 @@ def ensure_api_key(provider: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
|
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
|
||||||
Path(env_path).touch(exist_ok=True)
|
# The file holds credentials, so make it owner-only before writing: create
|
||||||
|
# it 0600 when absent, and tighten an existing one (set_key keeps the mode).
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
os.close(os.open(env_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
||||||
|
os.chmod(env_path, 0o600)
|
||||||
set_key(env_path, env_var, key)
|
set_key(env_path, env_var, key)
|
||||||
os.environ[env_var] = key
|
os.environ[env_var] = key
|
||||||
console.print(f"[green]Saved {env_var} to {env_path}[/green]")
|
console.print(f"[green]Saved {env_var} to {env_path}[/green]")
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def ask_output_language() -> str:
|
def ask_output_language(default=None) -> str:
|
||||||
"""Ask for report output language."""
|
"""Ask for report output language.
|
||||||
choice = questionary.select(
|
|
||||||
"Select Output Language:",
|
``default`` is offered only when it is one of the listed languages: a custom
|
||||||
choices=[
|
one entered last time is free text, which the menu cannot preselect.
|
||||||
|
"""
|
||||||
|
choices = [
|
||||||
questionary.Choice("English (default)", "English"),
|
questionary.Choice("English (default)", "English"),
|
||||||
questionary.Choice("Chinese (中文)", "Chinese"),
|
questionary.Choice("Chinese (中文)", "Chinese"),
|
||||||
questionary.Choice("Japanese (日本語)", "Japanese"),
|
questionary.Choice("Japanese (日本語)", "Japanese"),
|
||||||
@@ -667,7 +693,11 @@ def ask_output_language() -> str:
|
|||||||
questionary.Choice("Arabic (العربية)", "Arabic"),
|
questionary.Choice("Arabic (العربية)", "Arabic"),
|
||||||
questionary.Choice("Russian (Русский)", "Russian"),
|
questionary.Choice("Russian (Русский)", "Russian"),
|
||||||
questionary.Choice("Custom language", "custom"),
|
questionary.Choice("Custom language", "custom"),
|
||||||
],
|
]
|
||||||
|
choice = questionary.select(
|
||||||
|
"Select Output Language:",
|
||||||
|
choices=choices,
|
||||||
|
default=_matching_choice([(c.title, c.value) for c in choices], default),
|
||||||
style=questionary.Style([
|
style=questionary.Style([
|
||||||
("selected", "fg:yellow noinherit"),
|
("selected", "fg:yellow noinherit"),
|
||||||
("highlighted", "fg:yellow noinherit"),
|
("highlighted", "fg:yellow noinherit"),
|
||||||
|
|||||||
3
main.py
3
main.py
@@ -12,8 +12,7 @@ config = DEFAULT_CONFIG.copy()
|
|||||||
ta = TradingAgentsGraph(debug=True, config=config)
|
ta = TradingAgentsGraph(debug=True, config=config)
|
||||||
|
|
||||||
# forward propagate
|
# forward propagate
|
||||||
_, decision = ta.propagate("NVDA", "2024-05-10")
|
_, decision = ta.propagate("NVDA", "2026-09-01")
|
||||||
print(decision)
|
print(decision)
|
||||||
|
|
||||||
# Memorize mistakes and reflect
|
# Memorize mistakes and reflect
|
||||||
# ta.reflect_and_remember(1000) # parameter is the position returns
|
|
||||||
|
|||||||
@@ -4,31 +4,25 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tradingagents"
|
name = "tradingagents"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
|
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"langchain-core>=0.3.81",
|
"langchain-core>=0.3.81",
|
||||||
"backtrader>=1.9.78.123",
|
|
||||||
"langchain-anthropic>=0.3.15",
|
"langchain-anthropic>=0.3.15",
|
||||||
"langchain-experimental>=0.3.4",
|
|
||||||
"langchain-google-genai>=4.0.0",
|
"langchain-google-genai>=4.0.0",
|
||||||
"langchain-openai>=0.3.23",
|
"langchain-openai>=0.3.23",
|
||||||
"langgraph>=0.4.8",
|
"langgraph>=0.4.8",
|
||||||
"langgraph-checkpoint-sqlite>=2.0.0",
|
"langgraph-checkpoint-sqlite>=2.0.0",
|
||||||
"pandas>=2.3.0",
|
"pandas>=2.3.0",
|
||||||
"parsel>=1.10.0",
|
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pytz>=2025.2",
|
"pytz>=2025.2",
|
||||||
"questionary>=2.1.0",
|
"questionary>=2.1.0",
|
||||||
"redis>=6.2.0",
|
|
||||||
"requests>=2.32.4",
|
"requests>=2.32.4",
|
||||||
"rich>=14.0.0",
|
"rich>=14.0.0",
|
||||||
"typer>=0.21.0",
|
"typer>=0.21.0",
|
||||||
"setuptools>=80.9.0",
|
|
||||||
"stockstats>=0.6.5",
|
"stockstats>=0.6.5",
|
||||||
"tqdm>=4.67.1",
|
|
||||||
"typing-extensions>=4.14.0",
|
"typing-extensions>=4.14.0",
|
||||||
"yfinance>=1.4.1",
|
"yfinance>=1.4.1",
|
||||||
]
|
]
|
||||||
@@ -69,7 +63,7 @@ filterwarnings = [
|
|||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
extend-exclude = ["results", "worklog"]
|
extend-exclude = ["results"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
|
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
"""End-to-end smoke for structured-output agents against a real LLM provider.
|
|
||||||
|
|
||||||
Runs the three decision-making agents (Research Manager, Trader, Portfolio
|
|
||||||
Manager) directly with their structured-output bindings and prints the
|
|
||||||
typed Pydantic instance + the rendered markdown for each. Use this to
|
|
||||||
verify a provider's native structured-output mode (json_schema for
|
|
||||||
OpenAI / xAI / DeepSeek / Qwen / GLM, response_schema for Gemini, tool-use
|
|
||||||
for Anthropic) returns clean instances on the schemas we ship.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
OPENAI_API_KEY=... python scripts/smoke_structured_output.py openai
|
|
||||||
GOOGLE_API_KEY=... python scripts/smoke_structured_output.py google
|
|
||||||
ANTHROPIC_API_KEY=... python scripts/smoke_structured_output.py anthropic
|
|
||||||
DEEPSEEK_API_KEY=... python scripts/smoke_structured_output.py deepseek
|
|
||||||
|
|
||||||
The script does NOT call propagate(), to keep the surface tight and the
|
|
||||||
cost low — it exercises only the three structured-output calls we just
|
|
||||||
added, plus the heuristic SignalProcessor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
|
||||||
from tradingagents.agents.managers.research_manager import create_research_manager
|
|
||||||
from tradingagents.agents.trader.trader import create_trader
|
|
||||||
from tradingagents.graph.signal_processing import SignalProcessor
|
|
||||||
from tradingagents.llm_clients import create_llm_client
|
|
||||||
|
|
||||||
PROVIDER_DEFAULTS = {
|
|
||||||
"openai": ("gpt-5.4-mini", None),
|
|
||||||
"google": ("gemini-3.5-flash", None),
|
|
||||||
"anthropic": ("claude-sonnet-4-6", None),
|
|
||||||
"deepseek": ("deepseek-v4-flash", None),
|
|
||||||
"qwen": ("qwen3.7-plus", None),
|
|
||||||
"glm": ("glm-5", None),
|
|
||||||
"xai": ("grok-4.3", None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Minimal but realistic state for the three agents.
|
|
||||||
DEBATE_HISTORY = """
|
|
||||||
Bull Analyst: NVDA's data-center revenue grew 60% YoY last quarter, driven by
|
|
||||||
Blackwell ramp; sovereign AI deals with multiple governments add a $40B+
|
|
||||||
multi-year tailwind. Margins remain above peer average.
|
|
||||||
|
|
||||||
Bear Analyst: Concentration risk is real — top three customers are >40% of
|
|
||||||
revenue. Any pause in hyperscaler capex would compress the multiple. China
|
|
||||||
export restrictions still cap a meaningful portion of demand.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _make_rm_state():
|
|
||||||
return {
|
|
||||||
"company_of_interest": "NVDA",
|
|
||||||
"investment_debate_state": {
|
|
||||||
"history": DEBATE_HISTORY,
|
|
||||||
"bull_history": "Bull Analyst: NVDA's data-center revenue grew 60% YoY...",
|
|
||||||
"bear_history": "Bear Analyst: Concentration risk is real...",
|
|
||||||
"current_response": "",
|
|
||||||
"judge_decision": "",
|
|
||||||
"count": 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_trader_state(investment_plan: str):
|
|
||||||
return {
|
|
||||||
"company_of_interest": "NVDA",
|
|
||||||
"investment_plan": investment_plan,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_pm_state(investment_plan: str, trader_plan: str):
|
|
||||||
return {
|
|
||||||
"company_of_interest": "NVDA",
|
|
||||||
"past_context": "",
|
|
||||||
"risk_debate_state": {
|
|
||||||
"history": "Aggressive: lean in. Conservative: trim. Neutral: balanced sizing.",
|
|
||||||
"aggressive_history": "Aggressive: ...",
|
|
||||||
"conservative_history": "Conservative: ...",
|
|
||||||
"neutral_history": "Neutral: ...",
|
|
||||||
"judge_decision": "",
|
|
||||||
"current_aggressive_response": "",
|
|
||||||
"current_conservative_response": "",
|
|
||||||
"current_neutral_response": "",
|
|
||||||
"count": 1,
|
|
||||||
},
|
|
||||||
"market_report": "Market report.",
|
|
||||||
"sentiment_report": "Sentiment report.",
|
|
||||||
"news_report": "News report.",
|
|
||||||
"fundamentals_report": "Fundamentals report.",
|
|
||||||
"investment_plan": investment_plan,
|
|
||||||
"trader_investment_plan": trader_plan,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _print_section(title: str, content: str) -> None:
|
|
||||||
bar = "=" * 70
|
|
||||||
print(f"\n{bar}\n{title}\n{bar}\n{content}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("provider", choices=list(PROVIDER_DEFAULTS.keys()))
|
|
||||||
parser.add_argument("--deep-model", default=None, help="Override deep_think_llm")
|
|
||||||
parser.add_argument("--quick-model", default=None, help="Override quick_think_llm")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
default_model, _ = PROVIDER_DEFAULTS[args.provider]
|
|
||||||
deep_model = args.deep_model or default_model
|
|
||||||
quick_model = args.quick_model or default_model
|
|
||||||
|
|
||||||
print(f"Provider: {args.provider}")
|
|
||||||
print(f"Deep model: {deep_model}")
|
|
||||||
print(f"Quick model: {quick_model}")
|
|
||||||
|
|
||||||
# Build the LLM clients via the framework's factory.
|
|
||||||
deep_client = create_llm_client(provider=args.provider, model=deep_model)
|
|
||||||
quick_client = create_llm_client(provider=args.provider, model=quick_model)
|
|
||||||
deep_llm = deep_client.get_llm()
|
|
||||||
quick_llm = quick_client.get_llm()
|
|
||||||
|
|
||||||
# 1) Research Manager
|
|
||||||
rm = create_research_manager(deep_llm)
|
|
||||||
rm_result = rm(_make_rm_state())
|
|
||||||
investment_plan = rm_result["investment_plan"]
|
|
||||||
_print_section("[1] Research Manager — investment_plan", investment_plan)
|
|
||||||
|
|
||||||
# 2) Trader (consumes RM's plan)
|
|
||||||
trader = create_trader(quick_llm)
|
|
||||||
trader_result = trader(_make_trader_state(investment_plan))
|
|
||||||
trader_plan = trader_result["trader_investment_plan"]
|
|
||||||
_print_section("[2] Trader — trader_investment_plan", trader_plan)
|
|
||||||
|
|
||||||
# 3) Portfolio Manager (consumes both)
|
|
||||||
pm = create_portfolio_manager(deep_llm)
|
|
||||||
pm_result = pm(_make_pm_state(investment_plan, trader_plan))
|
|
||||||
final_decision = pm_result["final_trade_decision"]
|
|
||||||
_print_section("[3] Portfolio Manager — final_trade_decision", final_decision)
|
|
||||||
|
|
||||||
# 4) SignalProcessor extracts the rating with zero LLM calls.
|
|
||||||
sp = SignalProcessor()
|
|
||||||
rating = sp.process_signal(final_decision)
|
|
||||||
_print_section("[4] SignalProcessor → rating", rating)
|
|
||||||
|
|
||||||
# 5) Lightweight checks: each rendered output should carry the expected
|
|
||||||
# section headers so downstream consumers (memory log, CLI display,
|
|
||||||
# saved reports) keep working.
|
|
||||||
checks = [
|
|
||||||
("Research Manager", investment_plan, ["**Recommendation**:"]),
|
|
||||||
("Trader", trader_plan, ["**Action**:", "FINAL TRANSACTION PROPOSAL:"]),
|
|
||||||
("Portfolio Manager", final_decision, ["**Rating**:", "**Executive Summary**:", "**Investment Thesis**:"]),
|
|
||||||
]
|
|
||||||
print("\n" + "=" * 70 + "\nStructure checks\n" + "=" * 70)
|
|
||||||
failures = 0
|
|
||||||
for name, text, required in checks:
|
|
||||||
for marker in required:
|
|
||||||
ok = marker in text
|
|
||||||
print(f" {'PASS' if ok else 'FAIL'} {name}: contains {marker!r}")
|
|
||||||
failures += int(not ok)
|
|
||||||
|
|
||||||
print()
|
|
||||||
if failures:
|
|
||||||
print(f"Smoke FAILED: {failures} structure check(s) missing.")
|
|
||||||
return 1
|
|
||||||
print("Smoke PASSED: structured output → rendered markdown chain works for", args.provider)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
14
test.py
14
test.py
@@ -1,14 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from tradingagents.dataflows.y_finance import (
|
|
||||||
get_stock_stats_indicators_window,
|
|
||||||
)
|
|
||||||
|
|
||||||
print("Testing optimized implementation with 30-day lookback:")
|
|
||||||
start_time = time.time()
|
|
||||||
result = get_stock_stats_indicators_window("AAPL", "macd", "2024-11-01", 30)
|
|
||||||
end_time = time.time()
|
|
||||||
|
|
||||||
print(f"Execution time: {end_time - start_time:.2f} seconds")
|
|
||||||
print(f"Result length: {len(result)} characters")
|
|
||||||
print(result)
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
||||||
responses mislabeled as rate limits and silently treated as transient), and
|
responses mislabeled as rate limits and silently treated as transient), and
|
||||||
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
|
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
|
||||||
string, not a dict).
|
string, not a dict), and the date trim that keeps post-end_date bars out of a
|
||||||
|
historical run.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -11,9 +12,13 @@ import pytest
|
|||||||
|
|
||||||
import tradingagents.dataflows.alpha_vantage_common as av
|
import tradingagents.dataflows.alpha_vantage_common as av
|
||||||
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
||||||
|
import tradingagents.dataflows.alpha_vantage_stock as avs
|
||||||
|
import tradingagents.dataflows.utils as utils
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
def __init__(self, text):
|
def __init__(self, text):
|
||||||
self.text = text
|
self.text = text
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ def _patched_get(body, capture=None):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_request_passes_timeout(monkeypatch):
|
def test_request_passes_timeout(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
monkeypatch.setattr(av.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured))
|
monkeypatch.setattr(utils.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured))
|
||||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||||
assert captured.get("timeout") == av.REQUEST_TIMEOUT # #990
|
assert captured.get("timeout") == av.REQUEST_TIMEOUT # #990
|
||||||
|
|
||||||
@@ -40,7 +45,7 @@ def test_request_passes_timeout(monkeypatch):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_rate_limit_detected(monkeypatch):
|
def test_rate_limit_detected(monkeypatch):
|
||||||
body = '{"Information": "Our standard API rate limit is 25 requests per day. ... your API key ..."}'
|
body = '{"Information": "Our standard API rate limit is 25 requests per day. ... your API key ..."}'
|
||||||
monkeypatch.setattr(av.requests, "get", _patched_get(body))
|
monkeypatch.setattr(utils.requests, "get", _patched_get(body))
|
||||||
with pytest.raises(av.AlphaVantageRateLimitError):
|
with pytest.raises(av.AlphaVantageRateLimitError):
|
||||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||||
|
|
||||||
@@ -51,11 +56,11 @@ def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch):
|
|||||||
# (transient) rate limit, but surface as a real configuration error (#991).
|
# (transient) rate limit, but surface as a real configuration error (#991).
|
||||||
body = ('{"Information": "the parameter apikey is invalid or missing. '
|
body = ('{"Information": "the parameter apikey is invalid or missing. '
|
||||||
'Please claim your free API key on (https://www.alphavantage.co/support/#api-key)."}')
|
'Please claim your free API key on (https://www.alphavantage.co/support/#api-key)."}')
|
||||||
monkeypatch.setattr(av.requests, "get", _patched_get(body))
|
monkeypatch.setattr(utils.requests, "get", _patched_get(body))
|
||||||
with pytest.raises(av.AlphaVantageNotConfiguredError):
|
with pytest.raises(av.AlphaVantageNotConfiguredError):
|
||||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||||
with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct
|
with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct
|
||||||
monkeypatch.setattr(av.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
|
monkeypatch.setattr(utils.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
|
||||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||||
|
|
||||||
|
|
||||||
@@ -94,3 +99,115 @@ def test_fundamentals_no_curr_date_passes_through(monkeypatch):
|
|||||||
def test_fundamentals_non_json_body_unchanged(monkeypatch):
|
def test_fundamentals_non_json_body_unchanged(monkeypatch):
|
||||||
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
|
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
|
||||||
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "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)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DAILY_CSV = (
|
||||||
|
"timestamp,open,high,low,close,volume\n"
|
||||||
|
"2024-05-13,1,1,1,1,10\n" # after end_date -> must never be served
|
||||||
|
"2024-05-10,1,1,1,1,10\n"
|
||||||
|
"2024-05-09,1,1,1,1,10\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stock_data_is_trimmed_to_the_requested_window(monkeypatch):
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: _DAILY_CSV)
|
||||||
|
out = avs.get_stock("IBM", "2024-05-09", "2024-05-10")
|
||||||
|
assert "2024-05-10" in out and "2024-05-09" in out
|
||||||
|
assert "2024-05-13" not in out, "bar after end_date leaked into the window"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_unparseable_body_is_never_served_untrimmed(monkeypatch):
|
||||||
|
"""The trim used to swallow the failure and return the whole body, putting
|
||||||
|
bars after end_date into a backtest. It must raise instead."""
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request",
|
||||||
|
lambda *a, **k: "timestamp,close\nnot-a-date,1\n")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
avs.get_stock("IBM", "2024-05-09", "2024-05-10")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_empty_body_still_passes_through(monkeypatch):
|
||||||
|
monkeypatch.setattr(avs, "_make_api_request", lambda *a, **k: "")
|
||||||
|
assert avs.get_stock("IBM", "2024-05-09", "2024-05-10") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_error_message_carries_no_key(monkeypatch):
|
||||||
|
# Alpha Vantage also sends its key in the URL (#1324).
|
||||||
|
import requests
|
||||||
|
key = "AVKEY1234567890XYZ"
|
||||||
|
monkeypatch.setenv("ALPHA_VANTAGE_API_KEY", key)
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise requests.Timeout(f"Read timed out. url: https://www.alphavantage.co/query?apikey={key}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(utils.requests, "get", boom)
|
||||||
|
with pytest.raises(requests.Timeout) as caught:
|
||||||
|
av._make_api_request("OVERVIEW", {"symbol": "IBM"})
|
||||||
|
assert key not in str(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_global_news_omitted_optionals_use_the_configured_defaults(monkeypatch):
|
||||||
|
"""The tool passes None for an omitted look_back_days or limit (#1326)."""
|
||||||
|
from tradingagents.dataflows import alpha_vantage_news
|
||||||
|
|
||||||
|
monkeypatch.setattr(alpha_vantage_news, "get_config",
|
||||||
|
lambda: {"global_news_lookback_days": 3, "global_news_article_limit": 9})
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(alpha_vantage_news, "_make_api_request", lambda fn, params: seen.update(params) or "{}")
|
||||||
|
|
||||||
|
alpha_vantage_news.get_global_news("2026-08-14", None, None)
|
||||||
|
|
||||||
|
assert seen["time_from"].startswith("20260811") and seen["limit"] == "9"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_news_window_includes_the_analysis_day(monkeypatch):
|
||||||
|
"""time_to was midnight at the start of the end date, so everything
|
||||||
|
published during the analysis day, the most decision-relevant day, was
|
||||||
|
excluded. The yfinance path includes it."""
|
||||||
|
from tradingagents.dataflows import alpha_vantage_news
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(alpha_vantage_news, "_make_api_request",
|
||||||
|
lambda fn, params: seen.update(params) or "{}")
|
||||||
|
|
||||||
|
alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14")
|
||||||
|
|
||||||
|
assert seen["time_from"] == "20260310T0000"
|
||||||
|
assert seen["time_to"] == "20260314T2359"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("indicator", ["vwma", "mfi"])
|
||||||
|
def test_an_indicator_this_vendor_lacks_lets_the_next_one_serve_it(indicator):
|
||||||
|
"""Returning prose counts as success to the router, so the chain stops at a
|
||||||
|
vendor that cannot compute the indicator while the next one can."""
|
||||||
|
from tradingagents.dataflows import alpha_vantage_indicator
|
||||||
|
from tradingagents.dataflows.errors import VendorError
|
||||||
|
|
||||||
|
with pytest.raises(VendorError):
|
||||||
|
alpha_vantage_indicator.get_indicator("AAPL", indicator, "2026-05-08", 30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ticker_news_asks_for_only_as_many_articles_as_configured(monkeypatch):
|
||||||
|
"""The endpoint returns 50 articles with per-article sentiment arrays by
|
||||||
|
default, and the whole payload went into the prompt."""
|
||||||
|
from tradingagents.dataflows import alpha_vantage_news
|
||||||
|
|
||||||
|
monkeypatch.setattr(alpha_vantage_news, "get_config", lambda: {"news_article_limit": 8})
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(alpha_vantage_news, "_make_api_request",
|
||||||
|
lambda fn, params: seen.update(params) or "{}")
|
||||||
|
|
||||||
|
alpha_vantage_news.get_news("AAPL", "2026-03-10", "2026-03-14")
|
||||||
|
|
||||||
|
assert seen["limit"] == "8"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TestEffortGate:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"model",
|
"model",
|
||||||
# Claude 5 family uses single-number version IDs; all are effort-capable.
|
# Claude 5 family uses single-number version IDs; all are effort-capable.
|
||||||
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5"],
|
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5", "claude-opus-5", "claude-fable-5-1"],
|
||||||
)
|
)
|
||||||
def test_claude_5_family_receives_effort(self, monkeypatch, model):
|
def test_claude_5_family_receives_effort(self, monkeypatch, model):
|
||||||
captured = _capture_kwargs(monkeypatch)
|
captured = _capture_kwargs(monkeypatch)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -146,3 +147,47 @@ def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_uti
|
|||||||
assert "OPENAI_API_KEY" in content and "sk-existing" in content
|
assert "OPENAI_API_KEY" in content and "sk-existing" in content
|
||||||
assert "OTHER=value" in content
|
assert "OTHER=value" in content
|
||||||
assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content
|
assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_key(cli_utils, monkeypatch, tmp_path, key="sk-typed-in"):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
monkeypatch.setattr(cli_utils, "find_dotenv", lambda **k: "")
|
||||||
|
with patch.object(cli_utils, "questionary") as mock_q:
|
||||||
|
mock_q.password.return_value.ask.return_value = key
|
||||||
|
cli_utils.ensure_api_key("openai")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
|
||||||
|
def test_saved_key_file_is_owner_only(monkeypatch, cli_utils, tmp_path):
|
||||||
|
# The prompt writes a real credential; the file must not be readable by
|
||||||
|
# other local users whatever the umask is.
|
||||||
|
old = os.umask(0o002)
|
||||||
|
try:
|
||||||
|
_prompt_key(cli_utils, monkeypatch, tmp_path)
|
||||||
|
finally:
|
||||||
|
os.umask(old)
|
||||||
|
env = tmp_path / ".env"
|
||||||
|
assert "sk-typed-in" in env.read_text()
|
||||||
|
assert stat.S_IMODE(env.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
|
||||||
|
def test_existing_key_file_is_tightened_before_writing(monkeypatch, cli_utils, tmp_path):
|
||||||
|
env = tmp_path / ".env"
|
||||||
|
env.write_text("OTHER=1\n")
|
||||||
|
os.chmod(env, 0o664)
|
||||||
|
_prompt_key(cli_utils, monkeypatch, tmp_path)
|
||||||
|
assert stat.S_IMODE(env.stat().st_mode) == 0o600
|
||||||
|
assert "OTHER=1" in env.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes")
|
||||||
|
def test_read_only_key_file_is_still_updated(monkeypatch, cli_utils, tmp_path):
|
||||||
|
env = tmp_path / ".env"
|
||||||
|
env.write_text("OTHER=1\n")
|
||||||
|
os.chmod(env, 0o400)
|
||||||
|
_prompt_key(cli_utils, monkeypatch, tmp_path)
|
||||||
|
assert "sk-typed-in" in env.read_text()
|
||||||
|
assert stat.S_IMODE(env.stat().st_mode) == 0o600
|
||||||
|
|||||||
248
tests/test_backtest.py
Normal file
248
tests/test_backtest.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Backtesting: many single-shot decisions, scored by the decision log.
|
||||||
|
|
||||||
|
A run already records its rating and later settles it with realized and alpha
|
||||||
|
return against the regional benchmark. A backtest is that machinery over a grid
|
||||||
|
of tickers and dates, aggregated. It evaluates decision quality; it does not
|
||||||
|
simulate a portfolio, so there is no execution, no fees and no equity curve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.backtest import iter_grid, run_backtest, summarize
|
||||||
|
|
||||||
|
DECISION = "Rating: Buy\n\nbuy it"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_grid_spacing_and_canonical_dates():
|
||||||
|
assert iter_grid("2026-01-05", "2026-01-20", every_n_days=7) == ["2026-01-05", "2026-01-12", "2026-01-19"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_grid_stops_at_today(monkeypatch):
|
||||||
|
import tradingagents.backtest as bt
|
||||||
|
|
||||||
|
monkeypatch.setattr(bt, "get_current_date", lambda: "2026-01-10")
|
||||||
|
assert iter_grid("2026-01-05", "2026-02-20", every_n_days=5) == ["2026-01-05", "2026-01-10"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_grid_rejects_a_non_canonical_date():
|
||||||
|
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||||
|
iter_grid("2026-1-5", "2026-01-20")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGraph:
|
||||||
|
"""Stands in for TradingAgentsGraph, writing to the log the harness gave it."""
|
||||||
|
|
||||||
|
instances: list = []
|
||||||
|
fail_on: set = set()
|
||||||
|
|
||||||
|
def __init__(self, selected_analysts=None, config=None, **kw):
|
||||||
|
self.analysts = list(selected_analysts) if selected_analysts else None
|
||||||
|
self.config = config
|
||||||
|
self.memory_log = TradingMemoryLog(config)
|
||||||
|
self.calls = []
|
||||||
|
self.settled = []
|
||||||
|
_FakeGraph.instances.append(self)
|
||||||
|
|
||||||
|
def propagate(self, ticker, trade_date, asset_type="stock", portfolio=None):
|
||||||
|
self.calls.append((ticker, trade_date))
|
||||||
|
if (ticker, trade_date) in _FakeGraph.fail_on:
|
||||||
|
raise RuntimeError("vendor exploded")
|
||||||
|
self.memory_log.store_decision(ticker, trade_date, DECISION)
|
||||||
|
return {"final_trade_decision": DECISION}, "Buy"
|
||||||
|
|
||||||
|
def settle_pending(self, ticker):
|
||||||
|
self.settled.append(ticker)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fake_graph(monkeypatch, tmp_path):
|
||||||
|
import tradingagents.backtest as bt
|
||||||
|
|
||||||
|
_FakeGraph.instances = []
|
||||||
|
_FakeGraph.fail_on = set()
|
||||||
|
monkeypatch.setattr(bt, "TradingAgentsGraph", _FakeGraph)
|
||||||
|
return _FakeGraph
|
||||||
|
|
||||||
|
|
||||||
|
def _config(tmp_path):
|
||||||
|
return {"results_dir": str(tmp_path / "results"),
|
||||||
|
"memory_log_path": str(tmp_path / "live_trading_memory.md")}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_live_decision_log_is_never_written(tmp_path):
|
||||||
|
config = _config(tmp_path)
|
||||||
|
result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config)
|
||||||
|
|
||||||
|
assert not (tmp_path / "live_trading_memory.md").exists()
|
||||||
|
assert result.log_path.exists() and result.cells_run == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_cell_already_in_the_log_is_not_run_again(tmp_path):
|
||||||
|
config = _config(tmp_path)
|
||||||
|
first = run_backtest(["NVDA"], ["2026-01-05"], config)
|
||||||
|
|
||||||
|
again = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config, run_id=first.run_id)
|
||||||
|
|
||||||
|
assert again.cells_run == 1 and again.skipped == 1
|
||||||
|
assert _FakeGraph.instances[-1].calls == [("NVDA", "2026-01-12")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_every_ticker_is_settled_after_the_grid(tmp_path):
|
||||||
|
"""Settlement runs at the start of the next same-ticker run, so the last
|
||||||
|
date of each ticker would stay pending without an explicit pass."""
|
||||||
|
run_backtest(["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path))
|
||||||
|
assert sorted(_FakeGraph.instances[-1].settled) == ["AAPL", "NVDA"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_failed_cell_does_not_abort_the_sweep(tmp_path):
|
||||||
|
_FakeGraph.fail_on = {("NVDA", "2026-01-05")}
|
||||||
|
result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], _config(tmp_path))
|
||||||
|
|
||||||
|
assert result.cells_run == 1
|
||||||
|
assert result.failures == [("NVDA", "2026-01-05", "vendor exploded")]
|
||||||
|
|
||||||
|
|
||||||
|
# --- reading the result ------------------------------------------------------
|
||||||
|
|
||||||
|
def _log_with(tmp_path, rows):
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
for ticker, date, decision, outcome in rows:
|
||||||
|
log.store_decision(ticker, date, decision)
|
||||||
|
if outcome is not None:
|
||||||
|
log.update_with_outcome(ticker, date, outcome[0], outcome[1], 5, "note", "2026-02-01")
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_summary_scores_resolved_cells_and_keeps_pending_out_of_the_average(tmp_path):
|
||||||
|
log = _log_with(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "Rating: Buy\n\nx", (0.10, 0.04)),
|
||||||
|
("NVDA", "2026-01-12", "Rating: Buy\n\nx", (-0.02, -0.02)),
|
||||||
|
("AAPL", "2026-01-05", "Rating: Sell\n\nx", None),
|
||||||
|
])
|
||||||
|
|
||||||
|
summary = summarize(log)
|
||||||
|
|
||||||
|
assert summary.resolved == 2 and summary.pending == 1
|
||||||
|
buys = summary.by_rating["Buy"]
|
||||||
|
assert buys.count == 2 and buys.hit_rate == 0.5 and round(buys.mean_alpha, 4) == 0.01
|
||||||
|
assert "Sell" not in summary.by_rating # unsettled: nothing to score yet
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_summary_states_what_it_cannot_prove(tmp_path):
|
||||||
|
text = summarize(_log_with(tmp_path, [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))])).render()
|
||||||
|
assert "not archived" in text
|
||||||
|
assert "one" in text.lower() and "sampl" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path):
|
||||||
|
"""A backtest of a two-analyst setup must not silently run four."""
|
||||||
|
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), selected_analysts=["market", "news"])
|
||||||
|
assert _FakeGraph.instances[-1].analysts == ["market", "news"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_run_id_cannot_escape_the_results_directory(tmp_path):
|
||||||
|
"""run_id becomes a path segment, so it is validated like a ticker is."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="../../escaped")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path), run_id="/etc/cron.d/x")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monkeypatch):
|
||||||
|
"""Settlement reflects with an LLM, so it can fail; the sweep still returns
|
||||||
|
its result and every other ticker still gets settled."""
|
||||||
|
settled = []
|
||||||
|
|
||||||
|
def _settle(self, ticker):
|
||||||
|
if ticker == "NVDA":
|
||||||
|
raise RuntimeError("reflector timed out")
|
||||||
|
settled.append(ticker)
|
||||||
|
|
||||||
|
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
|
||||||
|
result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path))
|
||||||
|
|
||||||
|
assert result.cells_run == 2
|
||||||
|
assert settled == ["AAPL"]
|
||||||
|
assert result.settlement_failures == [("NVDA", "reflector timed out")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
|
||||||
|
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
|
||||||
|
assert "Pending" not in summarize(_log_with(tmp_path, settled)).render()
|
||||||
|
assert "Pending" in summarize(_log_with(tmp_path, settled + [("AAPL", "2026-01-05", DECISION, None)])).render()
|
||||||
|
|
||||||
|
|
||||||
|
# --- scoring reads the direction the rating claimed ---------------------------
|
||||||
|
|
||||||
|
def _scored(tmp_path, rows):
|
||||||
|
log = _log_with(tmp_path, rows)
|
||||||
|
return summarize(log).by_rating
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bearish_call_that_fell_counts_as_right(tmp_path):
|
||||||
|
"""Alpha below the benchmark is the outcome a Sell predicted; scoring it as
|
||||||
|
a miss reported the system as wrong exactly when it was right."""
|
||||||
|
scores = _scored(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (-0.08, -0.05)),
|
||||||
|
("AAPL", "2026-01-05", "**Rating**: Underweight\n\nx", (-0.03, -0.02)),
|
||||||
|
])
|
||||||
|
assert scores["Sell"].hit_rate == 1.0
|
||||||
|
assert scores["Underweight"].hit_rate == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bearish_call_that_rose_counts_as_wrong(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Sell\n\nx", (0.08, 0.05))])
|
||||||
|
assert scores["Sell"].hit_rate == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_bullish_call_is_scored_the_same_way_as_before(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.10, 0.04)),
|
||||||
|
("AAPL", "2026-01-05", "**Rating**: Buy\n\nx", (-0.02, -0.02)),
|
||||||
|
])
|
||||||
|
assert scores["Buy"].hit_rate == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_hold_claims_no_direction_so_it_gets_no_hit_rate(tmp_path):
|
||||||
|
scores = _scored(tmp_path, [("NVDA", "2026-01-05", "**Rating**: Hold\n\nx", (0.01, 0.005))])
|
||||||
|
assert scores["Hold"].hit_rate is None
|
||||||
|
assert scores["Hold"].mean_alpha == 0.005
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_report_names_the_window_the_scores_cover(tmp_path):
|
||||||
|
text = summarize(_log_with(tmp_path, [
|
||||||
|
("NVDA", "2026-01-05", "**Rating**: Buy\n\nx", (0.1, 0.05))])).render()
|
||||||
|
assert "5" in text and "day" in text.lower()
|
||||||
|
assert "Hold" not in text or "no direction" in text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_window_reported_is_the_one_the_outcomes_used(tmp_path):
|
||||||
|
"""The log records the window each outcome was measured over; the summary
|
||||||
|
must not claim a different one."""
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
|
||||||
|
log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 21, "note", "2026-02-01")
|
||||||
|
|
||||||
|
assert "21 trading days" in summarize(log).render()
|
||||||
@@ -153,3 +153,20 @@ def test_cli_style_usage_saves_then_resumes():
|
|||||||
|
|
||||||
# Cleared on success -> a later run starts fresh.
|
# Cleared on success -> a later run starts fresh.
|
||||||
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None
|
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_clearing_removes_the_database_sidecars(tmp_path):
|
||||||
|
"""SQLite writes -wal and -shm next to the database; leaving them behind
|
||||||
|
means a cleared checkpoint still has committed state on disk."""
|
||||||
|
from tradingagents.graph.checkpointer import clear_all_checkpoints
|
||||||
|
|
||||||
|
cp = tmp_path / "checkpoints"
|
||||||
|
cp.mkdir(parents=True)
|
||||||
|
for suffix in (".db", ".db-wal", ".db-shm"):
|
||||||
|
(cp / f"NVDA{suffix}").write_text("x")
|
||||||
|
|
||||||
|
cleared = clear_all_checkpoints(str(tmp_path))
|
||||||
|
|
||||||
|
assert cleared == 1
|
||||||
|
assert list(cp.iterdir()) == []
|
||||||
|
|||||||
153
tests/test_cli_commands.py
Normal file
153
tests/test_cli_commands.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""The CLI keeps running an analysis with no arguments, and gains `backtest`.
|
||||||
|
|
||||||
|
Every documented invocation is bare (`tradingagents --checkpoint`), so analysis
|
||||||
|
has to stay the default action while a second command exists alongside it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def runner(monkeypatch):
|
||||||
|
monkeypatch.setattr(m, "run_analysis", lambda **kw: calls.append(("analysis", kw)))
|
||||||
|
calls.clear()
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_no_arguments_still_runs_an_analysis(runner):
|
||||||
|
assert runner.invoke(m.app, []).exit_code == 0
|
||||||
|
assert calls == [("analysis", {"checkpoint": None, "portfolio": None})]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_options_still_parse_without_a_subcommand(runner):
|
||||||
|
assert runner.invoke(m.app, ["--checkpoint"]).exit_code == 0
|
||||||
|
assert calls[0][1]["checkpoint"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_backtest_does_not_also_run_an_analysis(runner, monkeypatch, tmp_path):
|
||||||
|
swept = []
|
||||||
|
monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append((a, kw)) or _Result(tmp_path))
|
||||||
|
monkeypatch.setattr(m, "summarize", lambda log: _Summary())
|
||||||
|
|
||||||
|
result = runner.invoke(m.app, ["backtest", "NVDA,AAPL", "--start", "2026-06-01",
|
||||||
|
"--end", "2026-06-15", "--every", "7"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert calls == [] # the interactive analysis must not run
|
||||||
|
(tickers, dates, _config), kwargs = swept[0]
|
||||||
|
assert tickers == ["NVDA", "AAPL"]
|
||||||
|
assert dates == ["2026-06-01", "2026-06-08", "2026-06-15"]
|
||||||
|
assert "scored" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_backtest_reports_a_bad_date_instead_of_a_traceback(runner):
|
||||||
|
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "June", "--end", "2026-06-15"])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "YYYY-MM-DD" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_help_lists_the_backtest_command(runner):
|
||||||
|
assert "backtest" in runner.invoke(m.app, ["--help"]).output
|
||||||
|
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def __init__(self, tmp_path):
|
||||||
|
self.run_id = "20260916_000000"
|
||||||
|
self.log_path = tmp_path / "trading_memory.md"
|
||||||
|
self.cells_run = 2
|
||||||
|
self.skipped = 0
|
||||||
|
self.failures = []
|
||||||
|
self.settlement_failures = []
|
||||||
|
|
||||||
|
|
||||||
|
class _Summary:
|
||||||
|
def render(self):
|
||||||
|
return "scored 2 cells"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_every_command_is_registered_when_run_as_a_module():
|
||||||
|
"""README documents `python -m cli.main`, which executes the file top to
|
||||||
|
bottom, so a command defined after the __main__ block would not exist."""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
out = subprocess.run([sys.executable, "-m", "cli.main", "backtest", "--help"],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
assert out.returncode == 0, out.stderr[-400:]
|
||||||
|
# Where the terminal takes colour, help styles each option and splits
|
||||||
|
# "--start" across escape sequences, so read the text without them.
|
||||||
|
plain = re.sub(r"\x1b\[[0-9;]*m", "", out.stdout)
|
||||||
|
assert "--start" in plain
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_cli_says_whether_a_run_resumed(monkeypatch):
|
||||||
|
"""The README promises the user can tell a resumed run from a fresh one.
|
||||||
|
The graph logs it, but nothing configures logging, so it was never shown."""
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
monkeypatch.setattr(m.message_buffer, "add_message",
|
||||||
|
lambda kind, text: messages.append(text), raising=False)
|
||||||
|
|
||||||
|
m._announce_checkpoint_state(type("G", (), {"_resuming": True})(), "NVDA", "2026-01-10")
|
||||||
|
m._announce_checkpoint_state(type("G", (), {"_resuming": False})(), "NVDA", "2026-01-10")
|
||||||
|
|
||||||
|
assert any("resum" in text.lower() for text in messages)
|
||||||
|
assert any("fresh" in text.lower() for text in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_backtest_can_continue_an_interrupted_sweep(runner, monkeypatch, tmp_path):
|
||||||
|
"""Resuming is what makes a long sweep practical, and the Python API has it."""
|
||||||
|
swept = []
|
||||||
|
monkeypatch.setattr(m, "run_backtest", lambda *a, **kw: swept.append(kw) or _Result(tmp_path))
|
||||||
|
monkeypatch.setattr(m, "summarize", lambda log: _Summary())
|
||||||
|
|
||||||
|
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01",
|
||||||
|
"--end", "2026-06-08", "--run-id", "20260617_120000"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert swept[0]["run_id"] == "20260617_120000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("args, expected", [
|
||||||
|
(["backtest", "NVDA", "--start", "2026-08-01", "--end", "2026-06-08"], "before"),
|
||||||
|
(["backtest", ",,", "--start", "2026-06-01", "--end", "2026-06-08"], "ticker"),
|
||||||
|
])
|
||||||
|
def test_backtest_rejects_input_that_would_sweep_nothing(runner, args, expected):
|
||||||
|
"""An inverted range or an empty ticker list reported a clean zero-cell run,
|
||||||
|
which reads as 'nothing to find' rather than 'you asked for nothing'."""
|
||||||
|
result = runner.invoke(m.app, args)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert expected in result.output.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_backtest_reports_a_setup_failure_in_one_line(runner, monkeypatch):
|
||||||
|
"""A missing key or a bad analyst name produced a raw traceback."""
|
||||||
|
def _explode(*a, **kw):
|
||||||
|
raise ValueError("API key for provider 'openai' is not set")
|
||||||
|
|
||||||
|
monkeypatch.setattr(m, "run_backtest", _explode)
|
||||||
|
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-08"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "API key" in result.output
|
||||||
|
assert "Traceback" not in result.output
|
||||||
@@ -14,8 +14,8 @@ import cli.main as m
|
|||||||
# Minimal selections dict shaped like get_user_selections()'s return value.
|
# Minimal selections dict shaped like get_user_selections()'s return value.
|
||||||
SELECTIONS = {
|
SELECTIONS = {
|
||||||
"research_depth": 5,
|
"research_depth": 5,
|
||||||
"shallow_thinker": "gpt-5.4-mini",
|
"quick_think_llm": "gpt-5.4-mini",
|
||||||
"deep_thinker": "gpt-5.5",
|
"deep_think_llm": "gpt-5.5",
|
||||||
"backend_url": None,
|
"backend_url": None,
|
||||||
"llm_provider": "openai",
|
"llm_provider": "openai",
|
||||||
"google_thinking_level": None,
|
"google_thinking_level": None,
|
||||||
@@ -67,3 +67,41 @@ def test_checkpoint_flag_overrides_env(flag):
|
|||||||
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
||||||
cfg = m._build_run_config(SELECTIONS, checkpoint=flag)
|
cfg = m._build_run_config(SELECTIONS, checkpoint=flag)
|
||||||
assert cfg["checkpoint_enabled"] is flag
|
assert cfg["checkpoint_enabled"] is flag
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_glm_resolves_to_the_endpoint_its_key_belongs_to():
|
||||||
|
"""The provider table, the client registry and the key mapping must name the
|
||||||
|
same platform: glm is Z.AI international (ZHIPU_API_KEY) and glm-cn is
|
||||||
|
BigModel China. A mismatch sends the key to the other platform and every
|
||||||
|
call fails auth."""
|
||||||
|
from cli.utils import resolve_backend_url
|
||||||
|
from tradingagents.llm_clients.api_key_env import get_api_key_env
|
||||||
|
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
|
||||||
|
|
||||||
|
assert resolve_backend_url("glm", None, None) == OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url
|
||||||
|
assert get_api_key_env("glm") == "ZHIPU_API_KEY"
|
||||||
|
assert "z.ai" in OPENAI_COMPATIBLE_PROVIDERS["glm"].base_url
|
||||||
|
assert "bigmodel.cn" in OPENAI_COMPATIBLE_PROVIDERS["glm-cn"].base_url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_half_set_round_count_says_which_value_won(capsys, monkeypatch):
|
||||||
|
"""With only one of the two round-count variables set, the depth prompt is
|
||||||
|
still shown but half the answer is discarded; the user was never told."""
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "1")
|
||||||
|
monkeypatch.delenv("TRADINGAGENTS_MAX_RISK_ROUNDS", raising=False)
|
||||||
|
printed = []
|
||||||
|
monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(str(a[0]) if a else ""))
|
||||||
|
|
||||||
|
config = m._build_run_config({
|
||||||
|
"ticker": "NVDA", "analysis_date": "2026-09-01", "asset_type": "stock",
|
||||||
|
"analysts": [], "research_depth": 5, "llm_provider": "openai",
|
||||||
|
"quick_think_llm": "gpt-5.6-luna", "deep_think_llm": "gpt-5.6",
|
||||||
|
"backend_url": None, "output_language": "English",
|
||||||
|
}, None)
|
||||||
|
|
||||||
|
assert config["max_risk_discuss_rounds"] == 5
|
||||||
|
assert any("TRADINGAGENTS_MAX_DEBATE_ROUNDS" in line for line in printed), printed
|
||||||
|
|||||||
167
tests/test_cli_decision_log.py
Normal file
167
tests/test_cli_decision_log.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""The CLI must use the decision log the same way propagate() does.
|
||||||
|
|
||||||
|
The CLI streams the graph itself instead of calling propagate(), so memory steps
|
||||||
|
that lived only in propagate() never ran on the primary entry point: pending
|
||||||
|
decisions were not settled, the Portfolio Manager got no past context, and the
|
||||||
|
finished decision was not recorded. Both paths now build their initial state and
|
||||||
|
record their decision through the same graph methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_graph(tmp_path):
|
||||||
|
"""A graph without __init__ (no LLM clients), wired to a temp log."""
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
graph.config = {"memory_log_path": str(tmp_path / "trading_memory.md")}
|
||||||
|
graph.memory_log = TradingMemoryLog(graph.config)
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_create_run_state_settles_pending_and_carries_context(tmp_path, monkeypatch):
|
||||||
|
from tradingagents.graph.propagation import Propagator
|
||||||
|
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
graph.propagator = Propagator()
|
||||||
|
settled = []
|
||||||
|
monkeypatch.setattr(graph, "_resolve_pending_entries", settled.append, raising=False)
|
||||||
|
monkeypatch.setattr(graph, "resolve_instrument_context", lambda t, a="stock", d=None: f"id:{t}", raising=False)
|
||||||
|
monkeypatch.setattr(graph, "_memory_as_of", lambda d: d, raising=False)
|
||||||
|
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nold call")
|
||||||
|
graph.memory_log.update_with_outcome("NVDA", "2026-01-05", 0.01, 0.005, 5, "great trade", "2026-01-12")
|
||||||
|
|
||||||
|
state = graph.create_run_state("NVDA", "2026-02-01")
|
||||||
|
|
||||||
|
assert settled == ["NVDA"]
|
||||||
|
assert "great trade" in state["past_context"]
|
||||||
|
assert state["instrument_context"] == "id:NVDA"
|
||||||
|
assert state["company_of_interest"] == "NVDA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_record_decision_appends_a_pending_entry(tmp_path):
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
graph.record_decision("NVDA", "2026-01-10", {"final_trade_decision": "Rating: Buy\n\nBuy NVDA."})
|
||||||
|
entries = graph.memory_log.load_entries()
|
||||||
|
assert [(e["ticker"], e["pending"], e["rating"]) for e in entries] == [("NVDA", True, "Buy")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_record_decision_skips_a_run_without_a_decision(tmp_path):
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
graph.record_decision("NVDA", "2026-01-10", {})
|
||||||
|
assert graph.memory_log.load_entries() == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- the CLI path ----------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakeGraph:
|
||||||
|
"""Records the lifecycle calls run_analysis makes."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.graph = self
|
||||||
|
self.propagator = self
|
||||||
|
|
||||||
|
def create_run_state(self, ticker, trade_date, asset_type="stock", portfolio=None):
|
||||||
|
self.calls.append(("create_run_state", ticker, trade_date))
|
||||||
|
return {"messages": [], "company_of_interest": ticker}
|
||||||
|
|
||||||
|
def process_signal(self, text):
|
||||||
|
from tradingagents.graph.signal_processing import SignalProcessor
|
||||||
|
return SignalProcessor.process_signal(None, text)
|
||||||
|
|
||||||
|
def record_decision(self, ticker, trade_date, final_state):
|
||||||
|
self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision")))
|
||||||
|
|
||||||
|
def get_graph_args(self, callbacks=None):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def begin_checkpoint(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def checkpoint_input(self, state):
|
||||||
|
return state
|
||||||
|
|
||||||
|
def clear_checkpoint_on_success(self, *a, **k):
|
||||||
|
self.calls.append(("clear_checkpoint",))
|
||||||
|
|
||||||
|
def end_checkpoint(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stream(self, graph_input, **kwargs):
|
||||||
|
yield {"messages": [], "market_report": "M"}
|
||||||
|
yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."}
|
||||||
|
|
||||||
|
|
||||||
|
class _NullLive:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBuffer:
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
self.tool_calls = []
|
||||||
|
self.report_sections = {}
|
||||||
|
self.agent_status = {}
|
||||||
|
self.selected_analysts = []
|
||||||
|
self._processed_message_ids = set()
|
||||||
|
|
||||||
|
def init_for_analysis(self, selected_analysts):
|
||||||
|
self.selected_analysts = [a.lower() for a in selected_analysts]
|
||||||
|
|
||||||
|
def add_message(self, kind, content):
|
||||||
|
self.messages.append((0.0, kind, content))
|
||||||
|
|
||||||
|
def add_tool_call(self, name, args):
|
||||||
|
self.tool_calls.append((0.0, name, args))
|
||||||
|
|
||||||
|
def update_report_section(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_agent_status(self, agent, status):
|
||||||
|
self.agent_status[agent] = status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch):
|
||||||
|
import cli.main as m
|
||||||
|
from cli.models import AnalystType
|
||||||
|
|
||||||
|
fake = _FakeGraph()
|
||||||
|
monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake)
|
||||||
|
monkeypatch.setattr(m, "message_buffer", _FakeBuffer())
|
||||||
|
monkeypatch.setattr(m, "create_layout", lambda: None)
|
||||||
|
monkeypatch.setattr(m, "update_display", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(m, "Live", _NullLive)
|
||||||
|
monkeypatch.setattr(m, "get_user_selections", lambda: {
|
||||||
|
"ticker": "NVDA", "analysis_date": "2026-01-10",
|
||||||
|
"analysts": [AnalystType.MARKET], "asset_type": "stock",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(m, "_build_run_config", lambda selections, checkpoint: {
|
||||||
|
"data_cache_dir": str(tmp_path / "cache"), "results_dir": str(tmp_path / "results"),
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N")
|
||||||
|
|
||||||
|
m.run_analysis()
|
||||||
|
|
||||||
|
assert fake.calls == [
|
||||||
|
("create_run_state", "NVDA", "2026-01-10"),
|
||||||
|
# The decision is recorded from the merged stream, before the checkpoint
|
||||||
|
# is cleared, matching propagate().
|
||||||
|
("record_decision", "NVDA", "2026-01-10", "Rating: Buy\n\nBuy NVDA."),
|
||||||
|
("clear_checkpoint",),
|
||||||
|
]
|
||||||
77
tests/test_cli_display.py
Normal file
77
tests/test_cli_display.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""What the live display shows, and what the run log keeps.
|
||||||
|
|
||||||
|
The display drops a message it judges empty, and the state log is written for a
|
||||||
|
person to read afterwards. Both got that wrong in ways that hide real content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cli.main import extract_content_string
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("text", ["0", "False", "None", "[]", "{}", "0.0"])
|
||||||
|
def test_a_message_that_reads_like_a_python_value_is_still_text(text):
|
||||||
|
"""These were parsed as Python and judged empty, so the message vanished."""
|
||||||
|
assert extract_content_string(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("value, expected", [
|
||||||
|
(" Hold ", "Hold"),
|
||||||
|
("", None),
|
||||||
|
(" ", None),
|
||||||
|
(None, None),
|
||||||
|
([], None),
|
||||||
|
({}, None),
|
||||||
|
({"text": "from a dict"}, "from a dict"),
|
||||||
|
([{"type": "text", "text": "part one"}, {"type": "text", "text": "part two"}], "part one part two"),
|
||||||
|
])
|
||||||
|
def test_the_other_shapes_are_unchanged(value, expected):
|
||||||
|
assert extract_content_string(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_state_log_keeps_non_ascii_readable(tmp_path):
|
||||||
|
"""Reports can be in any language; the log is read by a person."""
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
graph.config = {"results_dir": str(tmp_path)}
|
||||||
|
graph.ticker = "600519.SS"
|
||||||
|
graph.log_states_dict = {}
|
||||||
|
|
||||||
|
graph._log_state("2026-09-01", {
|
||||||
|
"company_of_interest": "600519.SS", "trade_date": "2026-09-01",
|
||||||
|
"market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻",
|
||||||
|
"fundamentals_report": "基本面", "investment_plan": "计划",
|
||||||
|
"trader_investment_plan": "交易计划", "final_trade_decision": "评级: 买入",
|
||||||
|
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
|
||||||
|
"current_response": "", "judge_decision": "", "count": 0},
|
||||||
|
"risk_debate_state": {"aggressive_history": "", "conservative_history": "",
|
||||||
|
"neutral_history": "", "history": "", "judge_decision": "",
|
||||||
|
"latest_speaker": "", "current_aggressive_response": "",
|
||||||
|
"current_conservative_response": "", "current_neutral_response": "",
|
||||||
|
"count": 0},
|
||||||
|
})
|
||||||
|
|
||||||
|
written = next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8")
|
||||||
|
assert "买入" in written
|
||||||
|
assert "\\u" not in written
|
||||||
|
assert json.loads(written) # still valid JSON
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_live_display_does_not_scroll_the_terminal():
|
||||||
|
"""A layout taller than the window makes rich redraw by scrolling, which
|
||||||
|
reads as flicker; the alternate screen holds it in place (#784). The final
|
||||||
|
report prints after the live view ends, so nothing is lost when it closes."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
assert "screen=True" in inspect.getsource(m.run_analysis)
|
||||||
@@ -77,8 +77,8 @@ class TestCliSkipsPromptsFromEnv(unittest.TestCase):
|
|||||||
# The env values flow into the returned selections.
|
# The env values flow into the returned selections.
|
||||||
self.assertEqual(sel["llm_provider"], "openai")
|
self.assertEqual(sel["llm_provider"], "openai")
|
||||||
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
|
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
|
||||||
self.assertEqual(sel["shallow_thinker"], "deepseek-v4-pro")
|
self.assertEqual(sel["quick_think_llm"], "deepseek-v4-pro")
|
||||||
self.assertEqual(sel["deep_thinker"], "kimi-k2.5")
|
self.assertEqual(sel["deep_think_llm"], "kimi-k2.5")
|
||||||
self.assertEqual(sel["output_language"], "Japanese")
|
self.assertEqual(sel["output_language"], "Japanese")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
169
tests/test_cli_prefs.py
Normal file
169
tests/test_cli_prefs.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""The CLI remembers what you chose last time and offers it back.
|
||||||
|
|
||||||
|
Prefill only: every prompt still appears, so a run never starts on a choice the
|
||||||
|
user did not see. Environment variables keep skipping their step outright and
|
||||||
|
win over anything remembered. Remembered values are validated against the
|
||||||
|
current choices each time, since models and providers come and go between
|
||||||
|
versions and a stale one must not be offered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cli.models import AnalystType
|
||||||
|
from cli.prefs import load_last_run, sanitize, save_last_run
|
||||||
|
|
||||||
|
SAVED = {
|
||||||
|
"output_language": "English",
|
||||||
|
"analysts": ["market", "fundamentals"],
|
||||||
|
"research_depth": 3,
|
||||||
|
"llm_provider": "openai",
|
||||||
|
"quick_think_llm": "gpt-5.6-mini",
|
||||||
|
"deep_think_llm": "gpt-5.6",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _home(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_round_trip():
|
||||||
|
save_last_run(SAVED)
|
||||||
|
assert load_last_run() == SAVED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_missing_file_is_not_an_error():
|
||||||
|
assert load_last_run() == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_corrupt_file_degrades_to_no_memory(_home):
|
||||||
|
(_home / "cli_prefs.json").write_text("{not json")
|
||||||
|
assert load_last_run() == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_half_written_file_cannot_be_observed(_home):
|
||||||
|
"""Two runs finishing together must never leave a torn file behind."""
|
||||||
|
save_last_run(SAVED)
|
||||||
|
save_last_run({**SAVED, "research_depth": 5})
|
||||||
|
assert load_last_run()["research_depth"] == 5
|
||||||
|
assert list((_home).glob("*.tmp*")) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- validation against the current choices ---------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_model_that_no_longer_exists_is_dropped():
|
||||||
|
# gpt-5.4 is still accepted by config, but is no longer in the picker's list.
|
||||||
|
kept = sanitize({**SAVED, "quick_think_llm": "gpt-5.4"}, "stock")
|
||||||
|
assert "quick_think_llm" not in kept
|
||||||
|
assert kept["deep_think_llm"] == "gpt-5.6" # the valid sibling survives
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_an_unknown_provider_drops_itself_and_its_models():
|
||||||
|
kept = sanitize({**SAVED, "llm_provider": "no-such-provider"}, "stock")
|
||||||
|
assert "llm_provider" not in kept
|
||||||
|
assert "quick_think_llm" not in kept and "deep_think_llm" not in kept
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_analysts_are_narrowed_to_the_asset_type():
|
||||||
|
kept = sanitize(SAVED, "crypto")
|
||||||
|
assert AnalystType.FUNDAMENTALS.value not in kept["analysts"]
|
||||||
|
assert AnalystType.MARKET.value in kept["analysts"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_junk_values_are_dropped_rather_than_offered():
|
||||||
|
kept = sanitize({"research_depth": 99, "analysts": ["astrology"], "output_language": 5}, "stock")
|
||||||
|
assert kept == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_region_specific_provider_survives():
|
||||||
|
kept = sanitize({**SAVED, "llm_provider": "qwen-cn", "quick_think_llm": None}, "stock")
|
||||||
|
assert kept["llm_provider"] == "qwen-cn"
|
||||||
|
|
||||||
|
|
||||||
|
# --- wiring ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _answer_every_prompt(monkeypatch):
|
||||||
|
"""Drive the real selection flow, answering each prompt with a fixed value."""
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
monkeypatch.setattr(m, "fetch_announcements", lambda: [])
|
||||||
|
monkeypatch.setattr(m, "display_announcements", lambda *a: None)
|
||||||
|
monkeypatch.setattr(m, "get_ticker", lambda: "NVDA")
|
||||||
|
monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01")
|
||||||
|
monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English")
|
||||||
|
monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET])
|
||||||
|
monkeypatch.setattr(m, "select_research_depth", lambda default=None: 3)
|
||||||
|
monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai", None))
|
||||||
|
monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "gpt-5.6-mini")
|
||||||
|
monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "gpt-5.6")
|
||||||
|
monkeypatch.setattr(m, "ask_openai_reasoning_effort", lambda: "medium")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_selections_are_remembered_after_a_run(monkeypatch):
|
||||||
|
"""Drives the real flow: a stubbed selections dict would hide a key mismatch."""
|
||||||
|
m = _answer_every_prompt(monkeypatch)
|
||||||
|
|
||||||
|
m.get_user_selections()
|
||||||
|
|
||||||
|
remembered = load_last_run()
|
||||||
|
assert remembered["analysts"] == ["market"]
|
||||||
|
assert remembered["quick_think_llm"] == "gpt-5.6-mini"
|
||||||
|
assert remembered["deep_think_llm"] == "gpt-5.6"
|
||||||
|
assert remembered["llm_provider"] == "openai"
|
||||||
|
assert "ticker" not in remembered # changes every run; never remembered
|
||||||
|
assert "analysis_date" not in remembered # a stale date must not be offered
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_custom_language_is_remembered_without_breaking_the_next_run():
|
||||||
|
"""A free-text answer is not one of the menu's choices, and questionary
|
||||||
|
rejects a default it cannot find, so offering it back would crash startup."""
|
||||||
|
from cli.utils import ask_output_language
|
||||||
|
|
||||||
|
save_last_run({"output_language": "Turkish"})
|
||||||
|
with mock.patch("cli.utils.questionary.select") as select:
|
||||||
|
select.return_value.ask.return_value = "English"
|
||||||
|
ask_output_language(load_last_run()["output_language"])
|
||||||
|
assert select.call_args.kwargs["default"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_remembered_endpoint_is_offered_back(monkeypatch):
|
||||||
|
"""Users of a local or custom endpoint retyped the URL every run: it was
|
||||||
|
remembered and validated, then never read."""
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
save_last_run({"llm_provider": "openai_compatible", "backend_url": "http://localhost:1234/v1"})
|
||||||
|
offered = {}
|
||||||
|
monkeypatch.setattr(m, "select_llm_provider", lambda default=None: ("openai_compatible", None))
|
||||||
|
monkeypatch.setattr(m, "prompt_openai_compatible_url",
|
||||||
|
lambda default=None: offered.setdefault("default", default) or "http://x/v1")
|
||||||
|
monkeypatch.setattr(m, "fetch_announcements", lambda: [])
|
||||||
|
monkeypatch.setattr(m, "display_announcements", lambda *a: None)
|
||||||
|
monkeypatch.setattr(m, "get_ticker", lambda: "NVDA")
|
||||||
|
monkeypatch.setattr(m, "get_analysis_date", lambda: "2026-09-01")
|
||||||
|
monkeypatch.setattr(m, "ask_output_language", lambda default=None: "English")
|
||||||
|
monkeypatch.setattr(m, "select_analysts", lambda asset_type, default=None: [AnalystType.MARKET])
|
||||||
|
monkeypatch.setattr(m, "select_research_depth", lambda default=None: 1)
|
||||||
|
monkeypatch.setattr(m, "select_shallow_thinking_agent", lambda p, default=None: "local-model")
|
||||||
|
monkeypatch.setattr(m, "select_deep_thinking_agent", lambda p, default=None: "local-model")
|
||||||
|
|
||||||
|
m.get_user_selections()
|
||||||
|
|
||||||
|
assert offered["default"] == "http://localhost:1234/v1"
|
||||||
@@ -60,3 +60,16 @@ def test_cli_normalize_delegates_to_data_layer():
|
|||||||
# CLI must produce the same canonical symbol the data path will price.
|
# CLI must produce the same canonical symbol the data path will price.
|
||||||
for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"):
|
for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"):
|
||||||
assert normalize_ticker_symbol(raw) == normalize_symbol(raw)
|
assert normalize_ticker_symbol(raw) == normalize_symbol(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_run_directory_cannot_escape_the_results_directory(tmp_path, monkeypatch):
|
||||||
|
"""Every other path that interpolates a ticker validates it first; the CLI's
|
||||||
|
own results tree did not, so a ticker of '..' wrote a level up."""
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
m._run_directory({"results_dir": str(tmp_path)}, "..", "2026-09-01")
|
||||||
|
|
||||||
|
ok = m._run_directory({"results_dir": str(tmp_path)}, "NVDA", "2026-09-01")
|
||||||
|
assert str(ok).startswith(str(tmp_path))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import unittest
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
import tradingagents.dataflows.config as config_module
|
import tradingagents.dataflows.config as config_module
|
||||||
import tradingagents.default_config as default_config
|
import tradingagents.default_config as default_config
|
||||||
@@ -151,22 +152,46 @@ class FredFormattingTests(unittest.TestCase):
|
|||||||
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back
|
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back
|
||||||
|
|
||||||
def test_requests_pin_the_data_vintage(self):
|
def test_requests_pin_the_data_vintage(self):
|
||||||
# #1275: both the metadata and observations requests must set
|
# #1275: both the metadata and observations requests must pin the vintage
|
||||||
# realtime_start=realtime_end=curr_date, or FRED serves the latest
|
# to curr_date (clamped to FRED's today), or FRED serves the latest
|
||||||
# revision and revision-prone series leak future information.
|
# revision and revision-prone series leak future information. A past
|
||||||
|
# curr_date sits below FRED's today, so it pins through unchanged.
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def _capture(path, params):
|
def _capture(path, params):
|
||||||
captured[path] = params
|
captured[path] = params
|
||||||
return _META if path == "series" else _OBS
|
return _META if path == "series" else _OBS
|
||||||
|
|
||||||
with mock.patch.object(fred, "_request", side_effect=_capture):
|
with mock.patch.object(fred, "_fred_today", return_value="2026-01-01"), \
|
||||||
|
mock.patch.object(fred, "_request", side_effect=_capture):
|
||||||
fred.get_macro_data("cpi", "2025-09-30", 90)
|
fred.get_macro_data("cpi", "2025-09-30", 90)
|
||||||
|
|
||||||
for path in ("series", "series/observations"):
|
for path in ("series", "series/observations"):
|
||||||
self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path)
|
self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path)
|
||||||
self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path)
|
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,
|
||||||
|
# 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.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _capture(path, params):
|
||||||
|
captured[path] = params
|
||||||
|
return _META if path == "series" else _OBS
|
||||||
|
|
||||||
|
with mock.patch.object(fred, "_fred_today", return_value="2026-08-31"), \
|
||||||
|
mock.patch.object(fred, "_request", side_effect=_capture):
|
||||||
|
fred.get_macro_data("cpi", "2026-09-01", 90) # local a day ahead of Chicago
|
||||||
|
|
||||||
|
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
|
||||||
|
self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class FredRoutingTests(unittest.TestCase):
|
class FredRoutingTests(unittest.TestCase):
|
||||||
@@ -209,3 +234,53 @@ class FredRoutingTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
_KEY = "abcdef0123456789abcdef0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestKeyKeptOutOfErrors:
|
||||||
|
"""The key travels as a query parameter, and requests quotes the full URL in
|
||||||
|
its error messages, so any log or traceback would carry it (#1324)."""
|
||||||
|
|
||||||
|
def _raises(self, side_effect):
|
||||||
|
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
|
||||||
|
mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=side_effect), \
|
||||||
|
pytest.raises(requests.RequestException) as caught:
|
||||||
|
fred._request("series", {"series_id": "DGS10"})
|
||||||
|
return caught.value
|
||||||
|
|
||||||
|
def test_http_error_message_carries_no_key(self):
|
||||||
|
response = mock.Mock(status_code=502)
|
||||||
|
response.raise_for_status.side_effect = requests.HTTPError(
|
||||||
|
f"502 Server Error for url: https://api.stlouisfed.org/fred/series?api_key={_KEY}",
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
|
||||||
|
mock.patch("tradingagents.dataflows.utils.requests.get", return_value=response), \
|
||||||
|
pytest.raises(requests.HTTPError) as caught:
|
||||||
|
fred._request("series", {"series_id": "DGS10"})
|
||||||
|
exc = caught.value
|
||||||
|
assert _KEY not in str(exc) and _KEY not in repr(exc)
|
||||||
|
# The response and request carry the full URL, so they are not attached.
|
||||||
|
assert exc.response is None and exc.request is None
|
||||||
|
assert exc.__cause__ is None and exc.__context__ is None # no chain holds the key
|
||||||
|
|
||||||
|
def test_connection_error_before_any_response_carries_no_key(self):
|
||||||
|
exc = self._raises(requests.ConnectionError(
|
||||||
|
f"Max retries exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}"))
|
||||||
|
assert isinstance(exc, requests.ConnectionError)
|
||||||
|
assert _KEY not in str(exc) and exc.__context__ is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_error_without_the_key_in_its_message_still_drops_the_request():
|
||||||
|
# Some timeout messages omit the URL, but the attached request still has it.
|
||||||
|
import requests as rq
|
||||||
|
req = rq.Request("GET", f"https://api.stlouisfed.org/fred/series?api_key={_KEY}").prepare()
|
||||||
|
with mock.patch.dict("os.environ", {"FRED_API_KEY": _KEY}), \
|
||||||
|
mock.patch("tradingagents.dataflows.utils.requests.get", side_effect=rq.Timeout("Read timed out.", request=req)), \
|
||||||
|
pytest.raises(rq.Timeout) as caught:
|
||||||
|
fred._request("series", {"series_id": "DGS10"})
|
||||||
|
assert caught.value.request is None
|
||||||
|
|||||||
130
tests/test_fundamentals_lookahead.py
Normal file
130
tests/test_fundamentals_lookahead.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""Historical fundamentals must not leak a live company profile (#1300).
|
||||||
|
|
||||||
|
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
|
||||||
|
``OVERVIEW``) serve only present-day values: market cap, valuation multiples,
|
||||||
|
the 52-week range and TTM income all move with today's quote, and even name,
|
||||||
|
sector and industry shift when a company renames or is reclassified. None of it
|
||||||
|
carries a historical vintage, so emitting it into a run dated in the past puts
|
||||||
|
post-decision information into the analyst's context, in the same family as the
|
||||||
|
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
|
||||||
|
is unchanged. All API access is mocked.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import alpha_vantage_fundamentals as av, date_window, y_finance
|
||||||
|
|
||||||
|
_TODAY = "2026-09-07"
|
||||||
|
_PAST = "2024-05-10"
|
||||||
|
|
||||||
|
# A profile payload mixing stable-looking identity fields with market-dependent ones.
|
||||||
|
_INFO = {
|
||||||
|
"longName": "Apple Inc.",
|
||||||
|
"sector": "Technology",
|
||||||
|
"industry": "Consumer Electronics",
|
||||||
|
"marketCap": 3_500_000_000_000,
|
||||||
|
"trailingPE": 34.2,
|
||||||
|
"fiftyTwoWeekHigh": 260.1,
|
||||||
|
"totalRevenue": 391_000_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Values that must never reach a historical run.
|
||||||
|
_LEAKY = ("3500000000000", "34.2", "260.1", "391000000000",
|
||||||
|
"Apple Inc.", "Technology", "Consumer Electronics")
|
||||||
|
|
||||||
|
|
||||||
|
def _yf(curr_date, info=_INFO, today=_TODAY):
|
||||||
|
with mock.patch.object(date_window, "get_current_date", return_value=today), \
|
||||||
|
mock.patch.object(y_finance, "yf_retry", lambda fn: info), \
|
||||||
|
mock.patch.object(y_finance.yf, "Ticker"):
|
||||||
|
return y_finance.get_fundamentals("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _av(curr_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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestYFinanceHistoricalRun:
|
||||||
|
def test_no_profile_value_survives(self):
|
||||||
|
out = _yf(_PAST)
|
||||||
|
for leaked in _LEAKY:
|
||||||
|
assert leaked not in out, f"leaked live-profile value {leaked!r}"
|
||||||
|
|
||||||
|
def test_states_the_as_of_date_and_explains_itself(self):
|
||||||
|
# The analyst must be told why the figures are absent, so it does not
|
||||||
|
# read the gap as a real signal or fabricate around it.
|
||||||
|
out = _yf(_PAST)
|
||||||
|
assert f"Point-in-time as of: {_PAST}" in out
|
||||||
|
assert "withheld" in out
|
||||||
|
assert _PAST in out and _TODAY in out
|
||||||
|
|
||||||
|
def test_no_wall_clock_retrieval_stamp(self):
|
||||||
|
# The old header stamped datetime.now(), which is what surfaced the leak.
|
||||||
|
assert "Data retrieved on:" not in _yf(_PAST)
|
||||||
|
|
||||||
|
def test_the_request_is_not_even_made(self):
|
||||||
|
# The response would only be discarded; skipping it also avoids burning
|
||||||
|
# vendor quota on a call whose result cannot be used.
|
||||||
|
with mock.patch.object(date_window, "get_current_date", return_value=_TODAY), \
|
||||||
|
mock.patch.object(y_finance.yf, "Ticker") as tk:
|
||||||
|
y_finance.get_fundamentals("AAPL", _PAST)
|
||||||
|
tk.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAlphaVantageHistoricalRun:
|
||||||
|
"""The same rule must hold for the other fundamentals vendor, or switching
|
||||||
|
data_vendors["fundamental_data"] would silently reintroduce the leak."""
|
||||||
|
|
||||||
|
def test_overview_is_withheld(self):
|
||||||
|
out, _ = _av(_PAST)
|
||||||
|
assert "3500000000000" not in out
|
||||||
|
assert "withheld" in out
|
||||||
|
assert f"Point-in-time as of: {_PAST}" in out
|
||||||
|
|
||||||
|
def test_the_api_call_is_not_made(self):
|
||||||
|
_, req = _av(_PAST)
|
||||||
|
req.assert_not_called()
|
||||||
|
|
||||||
|
def test_live_run_still_calls_the_api(self):
|
||||||
|
out, req = _av(_TODAY)
|
||||||
|
req.assert_called_once()
|
||||||
|
assert "3500000000000" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLiveRunUnchanged:
|
||||||
|
def test_yfinance_current_date_returns_the_full_profile(self):
|
||||||
|
out = _yf(_TODAY)
|
||||||
|
for value in _LEAKY:
|
||||||
|
assert value in out
|
||||||
|
assert "Data retrieved on:" in out
|
||||||
|
assert "withheld" not in out
|
||||||
|
|
||||||
|
def test_yfinance_absent_curr_date_returns_the_full_profile(self):
|
||||||
|
out = _yf(None)
|
||||||
|
assert "Market Cap: 3500000000000" in out
|
||||||
|
assert "withheld" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNoUsableFieldsStillRaises:
|
||||||
|
def test_stub_payload_raises_no_market_data(self):
|
||||||
|
# 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.symbol_utils import NoMarketDataError
|
||||||
|
|
||||||
|
with pytest.raises(NoMarketDataError):
|
||||||
|
_yf(_TODAY, info={"trailingPegRatio": None})
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Gemini thinking_level forwarding (Gemini 3.x).
|
"""Gemini thinking_level forwarding (Gemini 3.x).
|
||||||
|
|
||||||
The catalog is Gemini 3.x only, which takes the string ``thinking_level``
|
The catalog is Gemini 3.x only, which takes the string ``thinking_level``
|
||||||
directly. Pro accepts low/high; Flash also accepts minimal/medium — an
|
directly. Pro, Gemini 3.8+ and the -latest aliases reject "minimal" with a 400,
|
||||||
unsupported "minimal" on Pro is mapped to "low".
|
so it is mapped to "low" there; numbered Flash models before 3.8 accept it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -35,6 +35,22 @@ def test_pro_remaps_minimal_to_low():
|
|||||||
assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal"
|
assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_38_remaps_minimal_to_low():
|
||||||
|
kw = _captured_kwargs("gemini-3.8-flash", thinking_level="minimal")
|
||||||
|
assert kw["thinking_level"] == "low" # 3.8 Flash 400s on "minimal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flash_38_keeps_supported_levels():
|
||||||
|
kw = _captured_kwargs("gemini-3.8-flash", thinking_level="high")
|
||||||
|
assert kw["thinking_level"] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("alias", ["gemini-flash-latest", "gemini-pro-latest"])
|
||||||
|
def test_latest_alias_remaps_minimal_to_low(alias):
|
||||||
|
# Aliases move between generations; gemini-flash-latest 400s on "minimal".
|
||||||
|
assert _captured_kwargs(alias, thinking_level="minimal")["thinking_level"] == "low"
|
||||||
|
|
||||||
|
|
||||||
def test_pro_keeps_high():
|
def test_pro_keeps_high():
|
||||||
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high")
|
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high")
|
||||||
assert kw["thinking_level"] == "high"
|
assert kw["thinking_level"] == "high"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TestVerifiedSnapshot:
|
|||||||
pd.DataFrame({"Date": [pd.Timestamp("2026-06-01")], "Open": [999.0],
|
pd.DataFrame({"Date": [pd.Timestamp("2026-06-01")], "Open": [999.0],
|
||||||
"High": [999.0], "Low": [999.0], "Close": [999.0], "Volume": [999]}),
|
"High": [999.0], "Low": [999.0], "Close": [999.0], "Volume": [999]}),
|
||||||
], ignore_index=True)
|
], ignore_index=True)
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: data)
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: data)
|
||||||
|
|
||||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-13")
|
snap = validator.build_verified_market_snapshot("COF", "2026-05-13")
|
||||||
assert "Verified market data snapshot for COF" in snap
|
assert "Verified market data snapshot for COF" in snap
|
||||||
@@ -39,24 +39,24 @@ class TestVerifiedSnapshot:
|
|||||||
assert "boll_lb" in snap # indicators present
|
assert "boll_lb" in snap # indicators present
|
||||||
|
|
||||||
def test_uses_previous_trading_day_when_date_is_weekend(self, monkeypatch):
|
def test_uses_previous_trading_day_when_date_is_weekend(self, monkeypatch):
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv())
|
||||||
# 2026-05-16 is a Saturday; latest row should be Fri 2026-05-15
|
# 2026-05-16 is a Saturday; latest row should be Fri 2026-05-15
|
||||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-16")
|
snap = validator.build_verified_market_snapshot("COF", "2026-05-16")
|
||||||
assert "Latest trading row used: 2026-05-15" in snap
|
assert "Latest trading row used: 2026-05-15" in snap
|
||||||
assert "Recent verified closes" in snap
|
assert "Recent verified closes" in snap
|
||||||
|
|
||||||
def test_raises_when_no_rows_on_or_before_date(self, monkeypatch):
|
def test_raises_when_no_rows_on_or_before_date(self, monkeypatch):
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv())
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validator.build_verified_market_snapshot("COF", "2020-01-01")
|
validator.build_verified_market_snapshot("COF", "2020-01-01")
|
||||||
|
|
||||||
def test_raises_on_empty_data(self, monkeypatch):
|
def test_raises_on_empty_data(self, monkeypatch):
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: pd.DataFrame())
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: pd.DataFrame())
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validator.build_verified_market_snapshot("COF", "2026-05-13")
|
validator.build_verified_market_snapshot("COF", "2026-05-13")
|
||||||
|
|
||||||
def test_look_back_window_capped_at_30(self, monkeypatch):
|
def test_look_back_window_capped_at_30(self, monkeypatch):
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv())
|
||||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-20", look_back_days=999)
|
snap = validator.build_verified_market_snapshot("COF", "2026-05-20", look_back_days=999)
|
||||||
# last-N closes table has at most 30 data rows
|
# last-N closes table has at most 30 data rows
|
||||||
close_rows = [ln for ln in snap.splitlines() if ln.startswith("| 2026-")]
|
close_rows = [ln for ln in snap.splitlines() if ln.startswith("| 2026-")]
|
||||||
@@ -69,7 +69,7 @@ class TestTool:
|
|||||||
from tradingagents.agents.utils.market_data_validation_tools import (
|
from tradingagents.agents.utils.market_data_validation_tools import (
|
||||||
get_verified_market_snapshot,
|
get_verified_market_snapshot,
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: _sample_ohlcv())
|
||||||
out = get_verified_market_snapshot.invoke(
|
out = get_verified_market_snapshot.invoke(
|
||||||
{"symbol": "COF", "curr_date": "2026-05-20"}
|
{"symbol": "COF", "curr_date": "2026-05-20"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -136,6 +136,23 @@ class TestTradingMemoryLogCore:
|
|||||||
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
|
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
|
||||||
assert len(log.load_entries()) == 1
|
assert len(log.load_entries()) == 1
|
||||||
|
|
||||||
|
def test_store_decision_idempotent_after_the_entry_resolves(self, tmp_path):
|
||||||
|
"""A settled entry still blocks a duplicate.
|
||||||
|
|
||||||
|
The guard matched only pending entries, so re-running a ticker and date
|
||||||
|
whose outcome had already been settled appended a second entry: the same
|
||||||
|
decision counted twice in past context and in any aggregate over the log.
|
||||||
|
"""
|
||||||
|
log = make_log(tmp_path)
|
||||||
|
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
|
||||||
|
log.update_with_outcome("NVDA", "2026-01-10", 0.05, 0.02, 5, "worked", "2026-01-17")
|
||||||
|
|
||||||
|
log.store_decision("NVDA", "2026-01-10", DECISION_BUY)
|
||||||
|
|
||||||
|
entries = log.load_entries()
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0]["pending"] is False # the settled record is kept, not replaced
|
||||||
|
|
||||||
def test_batch_update_resolves_multiple_entries(self, tmp_path):
|
def test_batch_update_resolves_multiple_entries(self, tmp_path):
|
||||||
"""batch_update_with_outcomes resolves multiple pending entries in one write."""
|
"""batch_update_with_outcomes resolves multiple pending entries in one write."""
|
||||||
log = make_log(tmp_path)
|
log = make_log(tmp_path)
|
||||||
@@ -176,10 +193,14 @@ class TestTradingMemoryLogCore:
|
|||||||
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
|
log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT)
|
||||||
assert log.load_entries()[0]["rating"] == "Overweight"
|
assert log.load_entries()[0]["rating"] == "Overweight"
|
||||||
|
|
||||||
def test_rating_fallback_hold(self, tmp_path):
|
def test_an_unreadable_decision_is_tagged_for_review(self, tmp_path):
|
||||||
|
"""Not a Hold: a fabricated rating is quoted back to the next run as a
|
||||||
|
call that was never made, and counted in the backtest figures."""
|
||||||
|
from tradingagents.agents.utils.rating import RATING_REVIEW
|
||||||
|
|
||||||
log = make_log(tmp_path)
|
log = make_log(tmp_path)
|
||||||
log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING)
|
log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING)
|
||||||
assert log.load_entries()[0]["rating"] == "Hold"
|
assert log.load_entries()[0]["rating"] == RATING_REVIEW
|
||||||
|
|
||||||
def test_rating_priority_over_prose(self, tmp_path):
|
def test_rating_priority_over_prose(self, tmp_path):
|
||||||
"""'Rating: X' label wins even when an opposing rating word appears earlier in prose."""
|
"""'Rating: X' label wins even when an opposing rating word appears earlier in prose."""
|
||||||
@@ -589,6 +610,13 @@ class TestDeferredReflection:
|
|||||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI"
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI"
|
||||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE"
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE"
|
||||||
|
|
||||||
|
def test_explicit_benchmark_is_resolved_like_any_other_symbol(self):
|
||||||
|
"""A configured benchmark takes the same alias mapping as the ticker, or
|
||||||
|
the return lookup finds nothing and the decision never settles."""
|
||||||
|
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||||
|
mock_graph.config = {"benchmark_ticker": "SPX500", "benchmark_map": {"": "SPY"}}
|
||||||
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "^GSPC"
|
||||||
|
|
||||||
def test_resolve_benchmark_china_a_shares(self):
|
def test_resolve_benchmark_china_a_shares(self):
|
||||||
"""A-share tickers route to their exchange composite (uses the real
|
"""A-share tickers route to their exchange composite (uses the real
|
||||||
default benchmark_map, since A-share support relies on it)."""
|
default benchmark_map, since A-share support relies on it)."""
|
||||||
@@ -598,6 +626,8 @@ class TestDeferredReflection:
|
|||||||
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
|
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
|
||||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS"
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS"
|
||||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ"
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ"
|
||||||
|
# .SH is the exchange's own suffix; Yahoo spells Shanghai .SS (#1260)
|
||||||
|
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SH") == "000001.SS"
|
||||||
|
|
||||||
def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
|
def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
|
||||||
"""US tickers (no dotted suffix) take the empty-suffix entry."""
|
"""US tickers (no dotted suffix) take the empty-suffix entry."""
|
||||||
@@ -665,6 +695,7 @@ class TestDeferredReflection:
|
|||||||
log = make_log(tmp_path)
|
log = make_log(tmp_path)
|
||||||
log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
|
log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
|
||||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||||
|
mock_graph.config = {}
|
||||||
mock_graph.memory_log = log
|
mock_graph.memory_log = log
|
||||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
||||||
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
|
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
|
||||||
@@ -678,6 +709,7 @@ class TestDeferredReflection:
|
|||||||
mock_reflector = MagicMock()
|
mock_reflector = MagicMock()
|
||||||
mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed."
|
mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed."
|
||||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||||
|
mock_graph.config = {}
|
||||||
mock_graph.memory_log = log
|
mock_graph.memory_log = log
|
||||||
mock_graph.reflector = mock_reflector
|
mock_graph.reflector = mock_reflector
|
||||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
||||||
@@ -697,6 +729,7 @@ class TestDeferredReflection:
|
|||||||
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
|
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
|
||||||
mock_reflector = MagicMock()
|
mock_reflector = MagicMock()
|
||||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||||
|
mock_graph.config = {}
|
||||||
mock_graph.memory_log = log
|
mock_graph.memory_log = log
|
||||||
mock_graph.reflector = mock_reflector
|
mock_graph.reflector = mock_reflector
|
||||||
mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None))
|
mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None))
|
||||||
@@ -903,8 +936,107 @@ class TestLegacyRemoval:
|
|||||||
mock_graph._run_graph = functools.partial(
|
mock_graph._run_graph = functools.partial(
|
||||||
TradingAgentsGraph._run_graph, mock_graph
|
TradingAgentsGraph._run_graph, mock_graph
|
||||||
)
|
)
|
||||||
|
mock_graph.record_decision = functools.partial(
|
||||||
|
TradingAgentsGraph.record_decision, mock_graph
|
||||||
|
)
|
||||||
TradingAgentsGraph.propagate(mock_graph, "NVDA", "2026-01-10")
|
TradingAgentsGraph.propagate(mock_graph, "NVDA", "2026-01-10")
|
||||||
entries = mock_graph.memory_log.load_entries()
|
entries = mock_graph.memory_log.load_entries()
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0]["ticker"] == "NVDA"
|
assert entries[0]["ticker"] == "NVDA"
|
||||||
assert entries[0]["pending"] is True
|
assert entries[0]["pending"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch):
|
||||||
|
"""Settling past decisions happens on the way into a new run, and reflection
|
||||||
|
calls an LLM. A transient failure there must not stop the new analysis."""
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
graph.config = {"memory_log_path": str(tmp_path / "m.md")}
|
||||||
|
graph.memory_log = TradingMemoryLog(graph.config)
|
||||||
|
graph.memory_log.store_decision("NVDA", "2026-01-05", "Rating: Buy\n\nx")
|
||||||
|
graph.memory_log.store_decision("NVDA", "2026-01-12", "Rating: Sell\n\ny")
|
||||||
|
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False)
|
||||||
|
monkeypatch.setattr(graph, "_fetch_returns",
|
||||||
|
lambda t, d, holding_days=5, benchmark=None: (0.01, 0.005, holding_days, "2026-01-19"), raising=False)
|
||||||
|
|
||||||
|
class _Reflector:
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def reflect_on_final_decision(self, **kw):
|
||||||
|
_Reflector.calls += 1
|
||||||
|
if _Reflector.calls == 1:
|
||||||
|
raise RuntimeError("provider timed out")
|
||||||
|
return "second one worked"
|
||||||
|
|
||||||
|
graph.reflector = _Reflector()
|
||||||
|
|
||||||
|
graph._resolve_pending_entries("NVDA") # must not raise
|
||||||
|
|
||||||
|
entries = graph.memory_log.load_entries()
|
||||||
|
assert [e["pending"] for e in entries] == [True, False] # the failed one waits for next time
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_holding_window_is_configurable(tmp_path, monkeypatch):
|
||||||
|
"""A decision written for months should not be graded at a week without the
|
||||||
|
operator choosing that window."""
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21}
|
||||||
|
graph.memory_log = TradingMemoryLog(graph.config)
|
||||||
|
graph.memory_log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
|
||||||
|
monkeypatch.setattr(graph, "_resolve_benchmark", lambda t: "SPY", raising=False)
|
||||||
|
asked = {}
|
||||||
|
|
||||||
|
def _returns(ticker, date, holding_days=5, benchmark=None):
|
||||||
|
asked["holding_days"] = holding_days
|
||||||
|
return 0.05, 0.02, holding_days, "2026-02-02"
|
||||||
|
|
||||||
|
monkeypatch.setattr(graph, "_fetch_returns", _returns, raising=False)
|
||||||
|
graph.reflector = type("R", (), {"reflect_on_final_decision": lambda self, **kw: "lesson"})()
|
||||||
|
|
||||||
|
graph._resolve_pending_entries("NVDA")
|
||||||
|
|
||||||
|
assert asked["holding_days"] == 21
|
||||||
|
assert graph.memory_log.load_entries()[0]["holding"] == "21d"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_reflection_states_the_window_it_judges():
|
||||||
|
"""Judging a months-long thesis on a week's alpha, without saying so, turns
|
||||||
|
a scope mismatch into a lesson that the call was wrong."""
|
||||||
|
from tradingagents.graph.reflection import Reflector
|
||||||
|
|
||||||
|
prompt = Reflector(None)._system_prompt(holding_days=5)
|
||||||
|
assert "5" in prompt and "trading day" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_longer_window_asks_for_enough_price_history(monkeypatch):
|
||||||
|
"""Trading days are not calendar days: a 21-day window needs about a month
|
||||||
|
of bars, and asking for 28 days left every outcome unsettled."""
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
asked = {}
|
||||||
|
|
||||||
|
class _Ticker:
|
||||||
|
def __init__(self, symbol):
|
||||||
|
self.symbol = symbol
|
||||||
|
|
||||||
|
def history(self, start, end):
|
||||||
|
asked["start"], asked["end"] = start, end
|
||||||
|
import pandas as pd
|
||||||
|
days = pd.bdate_range(start, end)
|
||||||
|
return pd.DataFrame({"Close": range(len(days))}, index=days)
|
||||||
|
|
||||||
|
monkeypatch.setattr("tradingagents.graph.trading_graph.yf.Ticker", _Ticker)
|
||||||
|
|
||||||
|
raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY")
|
||||||
|
|
||||||
|
assert days == 21 and resolved is not None, (raw, alpha, days, resolved)
|
||||||
|
|||||||
@@ -53,3 +53,48 @@ class ModelValidationTests(unittest.TestCase):
|
|||||||
client.get_llm()
|
client.get_llm()
|
||||||
|
|
||||||
self.assertEqual(caught, [])
|
self.assertEqual(caught, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_ids_stay_valid_without_being_offered():
|
||||||
|
from tradingagents.llm_clients.model_catalog import LEGACY_MODELS, MODEL_OPTIONS
|
||||||
|
from tradingagents.llm_clients.validators import validate_model
|
||||||
|
|
||||||
|
for provider, ids in LEGACY_MODELS.items():
|
||||||
|
offered = {v for opts in MODEL_OPTIONS[provider].values() for _, v in opts}
|
||||||
|
for model in ids:
|
||||||
|
assert validate_model(provider, model), model
|
||||||
|
assert model not in offered, f"{model} is legacy but still in the picker"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_an_explicit_alias_of_a_listed_model_is_known():
|
||||||
|
"""gpt-5.6 is served under its own name and as gpt-5.6-sol; naming the
|
||||||
|
explicit one should not warn that the model is unknown."""
|
||||||
|
from tradingagents.llm_clients.validators import validate_model
|
||||||
|
|
||||||
|
assert validate_model("openai", "gpt-5.6-sol")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google", "xai"])
|
||||||
|
@pytest.mark.parametrize("mode", ["quick", "deep"])
|
||||||
|
def test_every_provider_lets_you_name_your_own_model(provider, mode):
|
||||||
|
"""The docs tell users to name any model their provider serves; the picker
|
||||||
|
has to offer that too, or a new model is unreachable until we ship a list."""
|
||||||
|
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||||
|
|
||||||
|
assert "custom" in [value for _, value in get_model_options(provider, mode)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("provider, model", [
|
||||||
|
("xai", "grok-4.20-0309-reasoning"),
|
||||||
|
("deepseek", "deepseek-v4-flash"),
|
||||||
|
("qwen", "qwen3.7-max"),
|
||||||
|
])
|
||||||
|
def test_a_retired_model_id_still_runs_without_a_warning(provider, model):
|
||||||
|
"""A config written against an earlier release keeps working: the provider
|
||||||
|
still serves these, they are just no longer offered in the picker."""
|
||||||
|
from tradingagents.llm_clients.validators import validate_model
|
||||||
|
|
||||||
|
assert validate_model(provider, model)
|
||||||
|
|||||||
@@ -101,5 +101,159 @@ def test_global_news_empty_after_filter_is_informative(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||||
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
||||||
assert "No global news found" in out
|
|
||||||
assert "###" not in out # no empty article body
|
assert "###" not in out # no empty article body
|
||||||
|
# Only a later article came back, so the feed does not reach this window.
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ticker_with(articles, monkeypatch):
|
||||||
|
class FakeTicker:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_news(self, count=20):
|
||||||
|
return articles
|
||||||
|
|
||||||
|
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ticker_news_window_before_feed_coverage_is_unavailable(monkeypatch):
|
||||||
|
# Yahoo serves only recent articles: a historical window gets none of them,
|
||||||
|
# which must read as "cannot answer", not "no news happened".
|
||||||
|
recent = [{"title": "RECENT", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2026-09-10")}]
|
||||||
|
_ticker_with(recent, monkeypatch)
|
||||||
|
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
|
||||||
|
assert "RECENT" not in out
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
assert "2026-09-10" in out # says how far back the feed actually reaches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ticker_news_covered_but_empty_window_is_a_real_absence(monkeypatch):
|
||||||
|
articles = [{"title": "RECENT", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2026-09-10")},
|
||||||
|
{"title": "OLDER", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2026-07-01")}]
|
||||||
|
_ticker_with(articles, monkeypatch)
|
||||||
|
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
|
||||||
|
assert "No news found" in out
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("dates, expect_gap", [
|
||||||
|
([], True), # empty feed: covers at most now
|
||||||
|
([None], True), # undated only: same
|
||||||
|
([datetime(2026, 5, 20, tzinfo=timezone.utc)], True), # all after the window
|
||||||
|
([datetime(2026, 5, 4, tzinfo=timezone.utc)], True), # starts mid-window: partial
|
||||||
|
([datetime(2026, 5, 1, 18, tzinfo=timezone.utc)], False), # reaches the first day
|
||||||
|
([datetime(2026, 5, 20, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 4, 1, tzinfo=timezone.utc)], False), # coverage reaches back
|
||||||
|
])
|
||||||
|
def test_coverage_gap_boundaries(dates, expect_gap):
|
||||||
|
from tradingagents.dataflows.date_window import coverage_gap
|
||||||
|
|
||||||
|
out = coverage_gap(dates, "2026-05-01", "2026-05-08", "Feed", "items")
|
||||||
|
assert (out is not None) is expect_gap
|
||||||
|
if expect_gap:
|
||||||
|
assert "unavailable for 2026-05-01..2026-05-08" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ticker_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch):
|
||||||
|
_ticker_with([], monkeypatch)
|
||||||
|
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ticker_news_null_feed_is_handled(monkeypatch):
|
||||||
|
# Yahoo can return None instead of a list; that is unavailability, not an error.
|
||||||
|
_ticker_with(None, monkeypatch)
|
||||||
|
out = ynews.get_news_yfinance("AAPL", "2026-08-07", "2026-08-14")
|
||||||
|
assert "unavailable" in out and "Error" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_global_news_empty_feed_for_a_past_window_is_unavailable(monkeypatch):
|
||||||
|
class FakeSearch:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.news = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||||
|
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_global_news_does_not_infer_coverage_from_a_stale_search_hit(monkeypatch):
|
||||||
|
# Global news merges fuzzy searches; one old hit before the window says
|
||||||
|
# nothing about the days in between, so the window stays unavailable.
|
||||||
|
stale = {"title": "STALE", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-01-01")}
|
||||||
|
fresh = {"title": "FRESH", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-06-01")}
|
||||||
|
|
||||||
|
class FakeSearch:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.news = [fresh, stale]
|
||||||
|
|
||||||
|
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||||
|
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
||||||
|
assert "unavailable" in out and "No global news found" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_coverage_gap_future_window_is_unavailable():
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import coverage_gap
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
out = coverage_gap([], str(today), str(today + timedelta(days=3)), "Feed", "items")
|
||||||
|
assert out is not None and "past today" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_out_of_window_articles_do_not_consume_the_article_budget(monkeypatch):
|
||||||
|
"""The limit counts articles the run may see, not candidates fetched (#1356).
|
||||||
|
|
||||||
|
Out-of-window items were counted first, so they filled the budget, stopped
|
||||||
|
the remaining searches, and the in-window news was reported as absent.
|
||||||
|
"""
|
||||||
|
stale = [{"title": f"OLD {i}", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2025-01-01")} for i in range(2)]
|
||||||
|
wanted = {"title": "IN WINDOW", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2025-05-08")}
|
||||||
|
pages = [stale, [wanted]]
|
||||||
|
|
||||||
|
class FakeSearch:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.news = pages.pop(0) if pages else []
|
||||||
|
|
||||||
|
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||||
|
monkeypatch.setattr(ynews, "get_config", lambda: {
|
||||||
|
"global_news_lookback_days": 7, "global_news_article_limit": 2,
|
||||||
|
"global_news_queries": ["markets", "economy"],
|
||||||
|
})
|
||||||
|
|
||||||
|
out = ynews.get_global_news_yfinance("2025-05-09")
|
||||||
|
|
||||||
|
assert "IN WINDOW" in out
|
||||||
|
assert "OLD 0" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_article_limit_still_caps_what_is_returned(monkeypatch):
|
||||||
|
articles = [{"title": f"NEWS {i}", "publisher": "P", "link": "l",
|
||||||
|
"providerPublishTime": _epoch("2025-05-08")} for i in range(5)]
|
||||||
|
|
||||||
|
class FakeSearch:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
self.news = articles
|
||||||
|
|
||||||
|
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||||
|
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=3)
|
||||||
|
|
||||||
|
assert out.count("### ") == 3
|
||||||
|
|||||||
@@ -1,80 +1,72 @@
|
|||||||
"""Same-day OHLCV cache must not serve a stale snapshot all day (#1150).
|
"""The OHLCV cache: one file per symbol, fresh only on the day it was written.
|
||||||
|
|
||||||
The cache file is keyed per day, so a run started before the day's bar was final
|
A current-day request also refetches past a TTL, so a run started before the
|
||||||
would be reused by every later run, feeding a stale close into technical
|
day's bar was final is not served that snapshot all day (#1150). Keying the file
|
||||||
analysis. Two cases matter for a current-day request: the bar may be missing, or
|
by symbol rather than by day keeps the cache from growing a file per symbol per
|
||||||
present but still in progress (Yahoo publishes a partial daily candle intraday).
|
day (#1330).
|
||||||
Refresh is bounded by a TTL so repeated runs cannot hammer the vendor.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import tradingagents.dataflows.stockstats_utils as su
|
import tradingagents.dataflows.stockstats_utils as su
|
||||||
|
|
||||||
TODAY = pd.Timestamp("2026-07-18")
|
NOW = pd.Timestamp("2026-07-18 12:00")
|
||||||
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
|
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
|
||||||
|
|
||||||
|
|
||||||
def _write(tmp_path, name="cache.csv", age_seconds=0.0, last_date="2026-07-17"):
|
def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026-07-17"):
|
||||||
f = tmp_path / name
|
f = tmp_path / name
|
||||||
pd.DataFrame({"Date": [last_date], "Close": [1.0]}).to_csv(f, index=False)
|
pd.DataFrame({"Date": [last_date], "Close": [100.0]}).to_csv(f, index=False)
|
||||||
if age_seconds:
|
written = NOW.timestamp() - age_seconds
|
||||||
old = time.time() - age_seconds
|
os.utime(f, (written, written))
|
||||||
os.utime(f, (old, old))
|
return f
|
||||||
return str(f)
|
|
||||||
|
|
||||||
|
def _load(tmp_path, monkeypatch, curr_date, download):
|
||||||
|
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
|
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: NOW))
|
||||||
|
monkeypatch.setattr(su.yf, "download", download)
|
||||||
|
return su.load_ohlcv("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_download(*a, **k):
|
||||||
|
raise AssertionError("fresh cache must not refetch")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_current_day_cache_past_ttl_is_refreshed(tmp_path):
|
def test_current_day_cache_past_ttl_is_not_fresh(tmp_path):
|
||||||
# Bar missing (rows stop at yesterday) and file older than the TTL -> refetch.
|
# Today's bar missing or still in progress: row inspection can't tell, so the TTL governs.
|
||||||
assert su._needs_same_day_refresh(_write(tmp_path, age_seconds=STALE), TODAY, TODAY) is True
|
assert su._cache_is_fresh(_write(tmp_path, age_seconds=STALE), NOW.normalize(), NOW) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_partial_current_day_bar_is_still_refreshed(tmp_path):
|
|
||||||
# Today's row is present but may be an in-progress candle whose Close is not
|
|
||||||
# the closing price. Row inspection can't distinguish it, so the TTL governs.
|
|
||||||
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
|
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
|
||||||
assert su._needs_same_day_refresh(f, TODAY, TODAY) is True
|
assert su._cache_is_fresh(f, NOW.normalize(), NOW) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_recent_cache_is_not_refetched(tmp_path):
|
def test_recent_cache_is_fresh(tmp_path):
|
||||||
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
|
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
|
||||||
assert su._needs_same_day_refresh(_write(tmp_path), TODAY, TODAY) is False
|
assert su._cache_is_fresh(_write(tmp_path), NOW.normalize(), NOW) is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_historical_request_always_uses_cache(tmp_path):
|
def test_historical_request_uses_todays_cache_past_the_ttl(tmp_path):
|
||||||
# Past dates are immutable: never refetch, however old the file is.
|
|
||||||
past = pd.Timestamp("2026-05-01")
|
|
||||||
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
|
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
|
||||||
assert su._needs_same_day_refresh(f, past, TODAY) is False
|
assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_download_from_an_earlier_day_is_not_fresh(tmp_path):
|
||||||
|
f = _write(tmp_path, age_seconds=13 * 3600) # yesterday 23:00
|
||||||
|
assert su._cache_is_fresh(f, pd.Timestamp("2026-05-01"), NOW) is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
|
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
|
||||||
"""End-to-end: the helper is actually wired into load_ohlcv's cache branch.
|
"""End-to-end: the freshness check is wired into load_ohlcv's cache branch."""
|
||||||
|
_write(tmp_path, age_seconds=STALE)
|
||||||
Without this, the unit tests above would still pass if the helper were never
|
|
||||||
called from the real code path.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
|
||||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
|
|
||||||
|
|
||||||
# Pre-seed the cache file load_ohlcv will look for, aged past the TTL.
|
|
||||||
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
|
||||||
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
|
||||||
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
|
|
||||||
pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False)
|
|
||||||
old = time.time() - STALE
|
|
||||||
os.utime(cache_file, (old, old))
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def _fake_download(*a, **k):
|
def _fake_download(*a, **k):
|
||||||
@@ -83,27 +75,31 @@ def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
|
|||||||
{"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]}
|
{"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]}
|
||||||
).set_index("Date")
|
).set_index("Date")
|
||||||
|
|
||||||
monkeypatch.setattr(su.yf, "download", _fake_download)
|
out = _load(tmp_path, monkeypatch, "2026-07-18", _fake_download)
|
||||||
|
|
||||||
out = su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
|
|
||||||
|
|
||||||
assert calls, "stale same-day cache must trigger a refetch"
|
assert calls, "stale same-day cache must trigger a refetch"
|
||||||
assert 222.0 in out["Close"].values, "refreshed close must reach the caller"
|
assert 222.0 in out["Close"].values, "refreshed close must reach the caller"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
|
def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
|
||||||
# Mirror image: a fresh cache must NOT trigger a download.
|
_write(tmp_path, last_date="2026-07-18")
|
||||||
|
_load(tmp_path, monkeypatch, "2026-07-18", _fail_download)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch):
|
||||||
|
"""A later day's download replaces the symbol's file instead of adding one (#1330)."""
|
||||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
|
frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]})
|
||||||
|
downloads = []
|
||||||
|
monkeypatch.setattr(su.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date"))
|
||||||
|
|
||||||
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"):
|
||||||
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
now = pd.Timestamp(day)
|
||||||
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
|
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda now=now: now))
|
||||||
pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False)
|
su.load_ohlcv("AAPL", "2026-07-17")
|
||||||
|
written = list(tmp_path.glob("AAPL-*.csv"))
|
||||||
|
os.utime(written[0], (now.timestamp(), now.timestamp()))
|
||||||
|
|
||||||
def _fail_download(*a, **k):
|
assert len(downloads) == 3, "each new day refetches"
|
||||||
raise AssertionError("fresh cache must not refetch")
|
assert [p.name for p in tmp_path.iterdir()] == ["AAPL-YFin-data.csv"]
|
||||||
|
|
||||||
monkeypatch.setattr(su.yf, "download", _fail_download)
|
|
||||||
su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
|
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ yfinance can return the newest in-range bar with a NaN close (an unsettled or
|
|||||||
glitched session). The old path parsed dates without normalizing timezone and
|
glitched session). The old path parsed dates without normalizing timezone and
|
||||||
dropped every NaN-close row before applying the curr_date cutoff, so the latest
|
dropped every NaN-close row before applying the curr_date cutoff, so the latest
|
||||||
bar disappeared and the previous trading day looked like the latest. Now dates
|
bar disappeared and the previous trading day looked like the latest. Now dates
|
||||||
are normalized, and a latest in-range bar with no close raises rather than
|
are normalized before the cutoff, so the frame ends at the last settled bar
|
||||||
silently falling back.
|
instead of carrying a fabricated close.
|
||||||
|
|
||||||
|
Refusing the whole frame instead (the first attempt at #1201) reported a
|
||||||
|
tradable symbol as invalid or delisted (#1289), so only a range with no close
|
||||||
|
anywhere counts as no data and the staleness check judges the rest.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -85,26 +91,53 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date):
|
|||||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
today = pd.Timestamp(curr_date)
|
today = pd.Timestamp(curr_date)
|
||||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
|
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
|
||||||
start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
cache_file = tmp_path / "AAPL-YFin-data.csv"
|
||||||
end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
cache_file.write_text(frame.to_csv(index=False))
|
||||||
(tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False))
|
os.utime(cache_file, (today.timestamp(), today.timestamp()))
|
||||||
|
|
||||||
def _fail_download(*a, **k):
|
def _fail_download(*a, **k):
|
||||||
raise AssertionError("should use the seeded cache, not download")
|
raise AssertionError("should use the seeded cache, not download")
|
||||||
monkeypatch.setattr(su.yf, "download", _fail_download)
|
monkeypatch.setattr(su.yf, "download", _fail_download)
|
||||||
monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None)
|
|
||||||
return su.load_ohlcv("AAPL", curr_date)
|
return su.load_ohlcv("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
|
def test_unsettled_latest_bar_is_served_as_the_last_settled_bar(monkeypatch, tmp_path):
|
||||||
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
|
# Newest bar (the curr_date) has no close: serve the last settled bar rather
|
||||||
|
# than reporting the whole symbol as unavailable (#1289).
|
||||||
frame = pd.DataFrame({
|
frame = pd.DataFrame({
|
||||||
"Date": ["2026-05-07", "2026-05-08"],
|
"Date": ["2026-05-07", "2026-05-08"],
|
||||||
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
})
|
})
|
||||||
with pytest.raises(NoMarketDataError, match="no closing price"):
|
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-07")
|
||||||
|
assert out["Close"].iloc[-1] == 100.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_no_settled_bar_at_all_is_still_no_data(monkeypatch, tmp_path):
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-07", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [float("nan"), float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
with pytest.raises(NoMarketDataError, match="no bar in range has a closing price"):
|
||||||
|
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_serving_the_last_settled_bar_does_not_bypass_the_staleness_check(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
# Falling back must not resurrect a long-dead series: once the closeless
|
||||||
|
# tail is gone, the remaining bar is judged on its age like any other.
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-01-05", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
with pytest.raises(NoMarketDataError, match="stale"):
|
||||||
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
@@ -134,3 +167,34 @@ def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path):
|
|||||||
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
assert out["Close"].iloc[-1] == 101.5
|
assert out["Close"].iloc[-1] == 101.5
|
||||||
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")
|
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, tmp_path):
|
||||||
|
"""Gap filling exists so indicators compute on a continuous series. The
|
||||||
|
verification snapshot is the one place a number must be what the vendor
|
||||||
|
reported, or the module built to stop invented prices supplies them."""
|
||||||
|
from tradingagents.dataflows import market_data_validator as mdv, stockstats_utils as su
|
||||||
|
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
|
||||||
|
"Open": [100.0, 104.5, ""], # the latest bar has not settled
|
||||||
|
"High": [101.0, 105.5, ""],
|
||||||
|
"Low": [99.0, 103.5, ""],
|
||||||
|
"Close": [100.5, 105.0, 106.0],
|
||||||
|
"Volume": [1000000, 1000000, ""],
|
||||||
|
})
|
||||||
|
today = pd.Timestamp("2026-05-08 12:00")
|
||||||
|
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
|
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
|
||||||
|
cache = tmp_path / "AAPL-YFin-data.csv"
|
||||||
|
cache.write_text(frame.to_csv(index=False))
|
||||||
|
os.utime(cache, (today.timestamp(), today.timestamp()))
|
||||||
|
monkeypatch.setattr(su.yf, "download", lambda *a, **k: (_ for _ in ()).throw(
|
||||||
|
AssertionError("should read the seeded cache")))
|
||||||
|
|
||||||
|
out = mdv.build_verified_market_snapshot("AAPL", "2026-05-08", 3)
|
||||||
|
|
||||||
|
row = out.split("Latest verified OHLCV row")[1].split("###")[0]
|
||||||
|
assert "104.50" not in row and "105.50" not in row # the previous session's numbers
|
||||||
|
assert "106.00" in row # the close the vendor did report
|
||||||
|
|||||||
@@ -3,9 +3,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# Rich colorizes console output and highlights numbers and URLs, which splits
|
||||||
|
# asserted substrings with escape codes ("port \x1b[1;33m11434"). Whether it
|
||||||
|
# does so depends on the ambient terminal, so strip the codes to keep these
|
||||||
|
# assertions independent of where the suite runs.
|
||||||
|
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||||
|
|
||||||
|
|
||||||
|
def _console_out(capsys) -> str:
|
||||||
|
return _ANSI.sub("", capsys.readouterr().out)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
def _resync_reloaded_modules():
|
def _resync_reloaded_modules():
|
||||||
@@ -122,7 +133,7 @@ def test_confirm_endpoint_shows_default(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
|
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "http://localhost:11434/v1" in out
|
assert "http://localhost:11434/v1" in out
|
||||||
assert "OLLAMA_BASE_URL" not in out # not from env
|
assert "OLLAMA_BASE_URL" not in out # not from env
|
||||||
assert "Note" not in out # no warnings for the canonical default
|
assert "Note" not in out # no warnings for the canonical default
|
||||||
@@ -133,7 +144,7 @@ def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
|
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "http://remote-host:11434/v1" in out
|
assert "http://remote-host:11434/v1" in out
|
||||||
assert "OLLAMA_BASE_URL" in out
|
assert "OLLAMA_BASE_URL" in out
|
||||||
|
|
||||||
@@ -144,7 +155,7 @@ def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("0.0.0.128")
|
cli_utils.confirm_ollama_endpoint("0.0.0.128")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "missing a scheme" in out
|
assert "missing a scheme" in out
|
||||||
assert "http://<host>:11434/v1" in out
|
assert "http://<host>:11434/v1" in out
|
||||||
|
|
||||||
@@ -155,7 +166,7 @@ def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
|
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "port 11434" in out
|
assert "port 11434" in out
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +176,7 @@ def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys):
|
|||||||
import cli.utils as cli_utils
|
import cli.utils as cli_utils
|
||||||
importlib.reload(cli_utils)
|
importlib.reload(cli_utils)
|
||||||
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
|
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
|
||||||
out = capsys.readouterr().out
|
out = _console_out(capsys)
|
||||||
assert "Note" not in out # localhost is fine without explicit port
|
assert "Note" not in out # localhost is fine without explicit port
|
||||||
|
|
||||||
|
|
||||||
@@ -186,3 +197,28 @@ def test_ollama_offers_custom_model_id():
|
|||||||
assert "custom" in values, f"Ollama {mode!r} missing 'custom' option: {entries}"
|
assert "custom" in values, f"Ollama {mode!r} missing 'custom' option: {entries}"
|
||||||
# Custom option is last so it doesn't push the curated defaults off-screen
|
# Custom option is last so it doesn't push the curated defaults off-screen
|
||||||
assert values[-1] == "custom", f"'custom' should be last entry: {values}"
|
assert values[-1] == "custom", f"'custom' should be last entry: {values}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_structured_output_suppresses_object_tool_choice(monkeypatch):
|
||||||
|
"""Ollama rejects the object-form tool_choice like other local servers
|
||||||
|
(#1062), and a local model ID has no capability entry saying otherwise, so
|
||||||
|
it takes the same client as the generic local endpoint."""
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tradingagents.llm_clients import create_llm_client
|
||||||
|
|
||||||
|
class Schema(BaseModel):
|
||||||
|
x: int
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ChatOpenAI,
|
||||||
|
"with_structured_output",
|
||||||
|
lambda self, schema, method=None, **kw: captured.update({"method": method, **kw}) or "BOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
create_llm_client(provider="ollama", model="qwen3:30b").get_llm().with_structured_output(Schema)
|
||||||
|
|
||||||
|
assert captured["tool_choice"] is None
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ from tradingagents.llm_clients.openai_client import (
|
|||||||
"model,expected",
|
"model,expected",
|
||||||
[
|
[
|
||||||
("gpt-5.5", True), ("gpt-5.4", True), ("gpt-5.4-mini", True),
|
("gpt-5.5", True), ("gpt-5.4", True), ("gpt-5.4-mini", True),
|
||||||
("gpt-5.5-pro", True), ("o1", True), ("o3-mini", True),
|
("gpt-5.5-pro", True), ("gpt-6-astra", True), ("o1", True), ("o3-mini", True),
|
||||||
("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False),
|
("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False),
|
||||||
("gpt-3.5-turbo", False),
|
("gpt-3.5-turbo", False), ("gpt-10", True),
|
||||||
|
("gpt-5foo", False), ("gpt-60x", False), ("o3rd-party", False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_supports_reasoning_effort(model, expected):
|
def test_supports_reasoning_effort(model, expected):
|
||||||
|
|||||||
240
tests/test_portfolio_context.py
Normal file
240
tests/test_portfolio_context.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""Portfolio context: what the caller holds, threaded into the decision agents.
|
||||||
|
|
||||||
|
Decisions were made with no knowledge of the current book, so "add to a full
|
||||||
|
position" and "open a new one" read alike. The context is optional and carries
|
||||||
|
three distinct states: a position, a flat book, and no context at all. Nothing
|
||||||
|
may present the third as the second. The research team stays blind so the bull
|
||||||
|
and bear cases are not anchored by the caller's position.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.agent_utils import get_portfolio_context_from_state
|
||||||
|
from tradingagents.portfolio import PortfolioContext, load_portfolio
|
||||||
|
|
||||||
|
HOLDING = {
|
||||||
|
"cash": 25000.0,
|
||||||
|
"currency": "USD",
|
||||||
|
"positions": [
|
||||||
|
{"ticker": "AAPL", "quantity": 120, "average_price": 150.0},
|
||||||
|
{"ticker": "MSFT", "quantity": 10},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_position_in_the_analyzed_instrument_leads_the_render():
|
||||||
|
text = PortfolioContext.model_validate(HOLDING).render("AAPL")
|
||||||
|
assert "120" in text and "150" in text
|
||||||
|
assert "MSFT" in text and "25,000" in text and "USD" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_flat_book_says_no_position_rather_than_omitting_it():
|
||||||
|
text = PortfolioContext.model_validate({"cash": 1000.0, "positions": []}).render("AAPL")
|
||||||
|
assert "No current position in AAPL" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_ticker_held_under_another_spelling_is_matched():
|
||||||
|
text = PortfolioContext.model_validate({"positions": [{"ticker": "aapl", "quantity": 5}]}).render("AAPL")
|
||||||
|
assert "No current position" not in text and "5" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_absent_context_is_reported_as_not_provided():
|
||||||
|
notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"})
|
||||||
|
assert "not provided" in notice.lower()
|
||||||
|
assert "no position" not in notice.lower() # missing must not read as flat
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_rendered_context_reaches_the_agents_from_state():
|
||||||
|
block = get_portfolio_context_from_state({"portfolio_context": "Portfolio: flat", "company_of_interest": "AAPL"})
|
||||||
|
assert block == "Portfolio: flat"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_load_rejects_a_malformed_file_with_a_clear_error(tmp_path):
|
||||||
|
bad = tmp_path / "p.json"
|
||||||
|
bad.write_text(json.dumps({"positions": [{"quantity": 5}]}))
|
||||||
|
with pytest.raises(ValueError, match="portfolio"):
|
||||||
|
load_portfolio(bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_load_reads_a_valid_file(tmp_path):
|
||||||
|
good = tmp_path / "p.json"
|
||||||
|
good.write_text(json.dumps(HOLDING))
|
||||||
|
assert load_portfolio(good).positions[0].ticker == "AAPL"
|
||||||
|
|
||||||
|
|
||||||
|
# --- threading through the graph --------------------------------------------
|
||||||
|
|
||||||
|
def _bare_graph(tmp_path):
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.graph.propagation import Propagator
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
graph = object.__new__(TradingAgentsGraph)
|
||||||
|
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1,
|
||||||
|
"max_risk_discuss_rounds": 1}
|
||||||
|
graph.memory_log = TradingMemoryLog(graph.config)
|
||||||
|
graph.propagator = Propagator()
|
||||||
|
graph.selected_analysts = ["market"]
|
||||||
|
graph._resolve_pending_entries = lambda t: None
|
||||||
|
graph.resolve_instrument_context = lambda t, a="stock", d=None: ""
|
||||||
|
graph._memory_as_of = lambda d: None
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_create_run_state_renders_the_portfolio_once(tmp_path):
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
state = graph.create_run_state("AAPL", "2026-08-14", portfolio=PortfolioContext.model_validate(HOLDING))
|
||||||
|
assert "120" in state["portfolio_context"]
|
||||||
|
assert graph.create_run_state("AAPL", "2026-08-14")["portfolio_context"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_checkpoint_signature_changes_with_the_portfolio(tmp_path):
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
none = graph._run_signature("stock")
|
||||||
|
flat = graph._run_signature("stock", PortfolioContext())
|
||||||
|
held = graph._run_signature("stock", PortfolioContext.model_validate(HOLDING))
|
||||||
|
assert len({none, flat, held}) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("module, factory", [
|
||||||
|
("tradingagents.agents.trader.trader", "create_trader"),
|
||||||
|
("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager"),
|
||||||
|
("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"),
|
||||||
|
("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"),
|
||||||
|
("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"),
|
||||||
|
])
|
||||||
|
def test_decision_agents_see_the_portfolio(module, factory, monkeypatch):
|
||||||
|
"""The prompt each decision agent sends carries the portfolio block."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class _LLM:
|
||||||
|
def invoke(self, prompt, *a, **k):
|
||||||
|
seen.append(prompt if isinstance(prompt, str) else json.dumps(str(prompt)))
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
return AIMessage("Rating: Hold\n\nnothing to do")
|
||||||
|
|
||||||
|
def with_structured_output(self, *a, **k):
|
||||||
|
raise NotImplementedError # force the free-text path
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"company_of_interest": "AAPL", "trade_date": "2026-08-14", "asset_type": "stock",
|
||||||
|
"instrument_context": "", "market_report": "M", "sentiment_report": "S",
|
||||||
|
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
|
||||||
|
"trader_investment_plan": "T", "past_context": "",
|
||||||
|
"portfolio_context": "PORTFOLIO_BLOCK_MARKER",
|
||||||
|
"investment_debate_state": {"history": "", "judge_decision": "", "count": 0},
|
||||||
|
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
|
||||||
|
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
|
||||||
|
"current_aggressive_response": "", "current_conservative_response": "",
|
||||||
|
"current_neutral_response": "", "judge_decision": ""},
|
||||||
|
}
|
||||||
|
node = getattr(mod, factory)(_LLM())
|
||||||
|
node(state)
|
||||||
|
assert any("PORTFOLIO_BLOCK_MARKER" in p for p in seen), f"{factory} prompt lacks the portfolio block"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_research_team_stays_blind_to_the_portfolio():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from tradingagents.agents.researchers import bear_researcher, bull_researcher
|
||||||
|
for mod in (bull_researcher, bear_researcher):
|
||||||
|
assert "portfolio_context" not in inspect.getsource(mod)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch):
|
||||||
|
"""The clear must key on the same portfolio the run was checkpointed under.
|
||||||
|
|
||||||
|
Keyed on a different one it deletes nothing, and the next identical call
|
||||||
|
resumes the finished thread and returns the old decision without running.
|
||||||
|
"""
|
||||||
|
import tradingagents.graph.trading_graph as tg
|
||||||
|
|
||||||
|
graph = _bare_graph(tmp_path)
|
||||||
|
graph.config.update({"checkpoint_enabled": True, "data_cache_dir": str(tmp_path),
|
||||||
|
"results_dir": str(tmp_path)})
|
||||||
|
graph.debug = False
|
||||||
|
graph._resuming = False
|
||||||
|
graph.propagator.get_graph_args = lambda callbacks=None: {}
|
||||||
|
graph.process_signal = lambda d: "Hold"
|
||||||
|
graph._log_state = lambda *a, **k: None
|
||||||
|
graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})()
|
||||||
|
book = PortfolioContext.model_validate(HOLDING)
|
||||||
|
|
||||||
|
written = graph._run_signature("stock", book) # what begin_checkpoint keys on
|
||||||
|
cleared = []
|
||||||
|
monkeypatch.setattr(tg, "clear_checkpoint", lambda d, t, dt, signature: cleared.append(signature))
|
||||||
|
|
||||||
|
graph._run_graph("AAPL", "2026-08-14", "stock", checkpoint_thread_id=None, portfolio=book)
|
||||||
|
|
||||||
|
assert cleared == [written]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_research_layer_sizes_against_a_standard_allocation():
|
||||||
|
"""The research team is blind to the book, so its plan cannot promise
|
||||||
|
position-relative sizing: it sizes against a standard allocation instead."""
|
||||||
|
from tradingagents.agents.schemas import ResearchPlan
|
||||||
|
|
||||||
|
description = ResearchPlan.model_fields["strategic_actions"].description
|
||||||
|
assert "standard allocation" in description
|
||||||
|
assert "does not see the caller's holdings" in description
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_partial_portfolio_states_only_what_it_was_given():
|
||||||
|
"""Cash omitted is not cash zero; the line is absent rather than invented."""
|
||||||
|
text = PortfolioContext.model_validate({"positions": [{"ticker": "AAPL", "quantity": 5}]}).render("AAPL")
|
||||||
|
assert "Cash" not in text
|
||||||
|
assert "5" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cli_rejects_an_unusable_portfolio_file_before_running(tmp_path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
bad = tmp_path / "bad.json"
|
||||||
|
bad.write_text('{"positions": [{"quantity": 5}]}')
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k))
|
||||||
|
|
||||||
|
result = CliRunner().invoke(m.app, ["--portfolio", str(bad)])
|
||||||
|
|
||||||
|
assert result.exit_code == 1 and ran == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cli_passes_a_valid_portfolio_into_the_run(tmp_path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
good = tmp_path / "good.json"
|
||||||
|
good.write_text(json.dumps(HOLDING))
|
||||||
|
ran = []
|
||||||
|
monkeypatch.setattr(m, "run_analysis", lambda **k: ran.append(k))
|
||||||
|
|
||||||
|
result = CliRunner().invoke(m.app, ["--portfolio", str(good)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert ran[0]["portfolio"].position_in("AAPL").quantity == 120
|
||||||
87
tests/test_prompt_integrity.py
Normal file
87
tests/test_prompt_integrity.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""What the agents are actually told.
|
||||||
|
|
||||||
|
Three problems the audit found: one analyst's brief reached the model as a Python
|
||||||
|
tuple, every analyst was asked for a trade call that nothing reads, and a report
|
||||||
|
that was never produced was presented as an empty labelled section, which invites
|
||||||
|
the next agent to fill it in from nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ANALYSTS = ["market_analyst", "sentiment_analyst", "news_analyst", "fundamentals_analyst"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("name", ANALYSTS)
|
||||||
|
def test_an_analyst_brief_is_text_not_a_python_object(name):
|
||||||
|
"""A trailing comma made one brief a tuple, so the model was handed its repr
|
||||||
|
(quotes, parens and all) instead of the instruction."""
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
mod = importlib.import_module(f"tradingagents.agents.analysts.{name}")
|
||||||
|
tree = ast.parse(inspect.getsource(mod))
|
||||||
|
briefs = [node.value for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Assign)
|
||||||
|
and getattr(node.targets[0], "id", "") == "system_message"]
|
||||||
|
assert briefs, f"{name} has no system_message"
|
||||||
|
for brief in briefs:
|
||||||
|
assert not isinstance(brief, ast.Tuple), "the brief is a tuple, not text"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("name", ANALYSTS)
|
||||||
|
def test_an_analyst_is_not_asked_for_a_trade_call_nothing_reads(name):
|
||||||
|
"""The stop signal is never consumed, and asking for it makes an analyst
|
||||||
|
open with a direction that then travels as evidence."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
mod = importlib.import_module(f"tradingagents.agents.analysts.{name}")
|
||||||
|
assert "FINAL TRANSACTION PROPOSAL" not in inspect.getsource(mod)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("module, factory", [
|
||||||
|
("tradingagents.agents.researchers.bull_researcher", "create_bull_researcher"),
|
||||||
|
("tradingagents.agents.researchers.bear_researcher", "create_bear_researcher"),
|
||||||
|
("tradingagents.agents.risk_mgmt.aggressive_debator", "create_aggressive_debator"),
|
||||||
|
("tradingagents.agents.risk_mgmt.conservative_debator", "create_conservative_debator"),
|
||||||
|
("tradingagents.agents.risk_mgmt.neutral_debator", "create_neutral_debator"),
|
||||||
|
])
|
||||||
|
def test_a_report_that_was_never_produced_says_so(module, factory):
|
||||||
|
"""`--analysts market` leaves three reports empty; presenting them as blank
|
||||||
|
sections invites the model to invent the contents."""
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class _LLM:
|
||||||
|
def invoke(self, prompt, *a, **k):
|
||||||
|
seen.append(prompt if isinstance(prompt, str) else str(prompt))
|
||||||
|
return AIMessage("argument")
|
||||||
|
|
||||||
|
def with_structured_output(self, *a, **k):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock",
|
||||||
|
"instrument_context": "", "portfolio_context": "", "past_context": "",
|
||||||
|
"market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "",
|
||||||
|
"fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T",
|
||||||
|
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
|
||||||
|
"current_response": "", "judge_decision": "", "count": 0},
|
||||||
|
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
|
||||||
|
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
|
||||||
|
"current_aggressive_response": "", "current_conservative_response": "",
|
||||||
|
"current_neutral_response": "", "judge_decision": ""},
|
||||||
|
}
|
||||||
|
getattr(mod, factory)(_LLM())(state)
|
||||||
|
|
||||||
|
prompt = " ".join(seen)
|
||||||
|
assert "not part of this run" in prompt or "not available" in prompt, prompt[:400]
|
||||||
|
assert "RSI 61" in prompt # the report that does exist is still passed through
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from tradingagents.llm_clients.openai_client import (
|
from tradingagents.llm_clients.openai_client import (
|
||||||
OPENAI_COMPATIBLE_PROVIDERS,
|
OPENAI_COMPATIBLE_PROVIDERS,
|
||||||
DeepSeekChatOpenAI,
|
DeepSeekChatOpenAI,
|
||||||
|
LocalCompatibleChatOpenAI,
|
||||||
MinimaxChatOpenAI,
|
MinimaxChatOpenAI,
|
||||||
NormalizedChatOpenAI,
|
NormalizedChatOpenAI,
|
||||||
is_openai_compatible,
|
is_openai_compatible,
|
||||||
@@ -39,7 +40,7 @@ def test_registry_membership():
|
|||||||
("kimi", "https://api.moonshot.ai/v1", NormalizedChatOpenAI, False),
|
("kimi", "https://api.moonshot.ai/v1", NormalizedChatOpenAI, False),
|
||||||
("groq", "https://api.groq.com/openai/v1", NormalizedChatOpenAI, False),
|
("groq", "https://api.groq.com/openai/v1", NormalizedChatOpenAI, False),
|
||||||
("nvidia", "https://integrate.api.nvidia.com/v1", NormalizedChatOpenAI, False),
|
("nvidia", "https://integrate.api.nvidia.com/v1", NormalizedChatOpenAI, False),
|
||||||
("ollama", "http://localhost:11434/v1", NormalizedChatOpenAI, False),
|
("ollama", "http://localhost:11434/v1", LocalCompatibleChatOpenAI, False),
|
||||||
])
|
])
|
||||||
def test_registry_spec(provider, base_url, chat_class, responses):
|
def test_registry_spec(provider, base_url, chat_class, responses):
|
||||||
spec = OPENAI_COMPATIBLE_PROVIDERS[provider]
|
spec = OPENAI_COMPATIBLE_PROVIDERS[provider]
|
||||||
|
|||||||
211
tests/test_rating_integrity.py
Normal file
211
tests/test_rating_integrity.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""A decision is recorded as the call that was made, or as needing review.
|
||||||
|
|
||||||
|
Two readers used to disagree about the same text: the signal said REVIEW while
|
||||||
|
the memory log wrote a fabricated Hold. Worse, prose that argued against a Buy
|
||||||
|
before concluding Underweight was read as Buy, because the parser took the first
|
||||||
|
rating word anywhere in the document. A wrong direction is worse than no
|
||||||
|
direction, so an unclear decision is REVIEW everywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating, parse_rating
|
||||||
|
|
||||||
|
INVERTED = ("The aggressive analyst pushed hard for a Buy on the AI backlog, but the "
|
||||||
|
"conservative case on margin compression carried the debate. "
|
||||||
|
"Final rating — Underweight. Trim to half weight over the next two weeks.")
|
||||||
|
REFUSAL = "I'm sorry, I can't provide a rating for this security."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("separator", [":", "-", "—", "–", ":", ": **"])
|
||||||
|
def test_the_labelled_rating_wins_whatever_separates_it(separator):
|
||||||
|
text = f"Buy arguments were raised and rejected.\n\nRating{separator}Underweight\n\nTrim."
|
||||||
|
assert extract_rating(text) == "Underweight"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_rating_argued_against_is_not_read_as_the_decision():
|
||||||
|
assert extract_rating(INVERTED) == "Underweight"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_prose_naming_several_ratings_without_a_label_needs_review():
|
||||||
|
"""Nothing in the text says which one is the call, so guessing risks
|
||||||
|
reporting the opposite of the decision."""
|
||||||
|
text = "The bull wants Buy, the bear wants Sell, and the committee was split."
|
||||||
|
assert extract_rating(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_prose_naming_one_rating_is_taken_as_the_call():
|
||||||
|
assert extract_rating("On balance we stay Underweight until margins recover.") == "Underweight"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_refusal_has_no_rating_and_is_not_defaulted():
|
||||||
|
assert extract_rating(REFUSAL) is None
|
||||||
|
assert parse_rating(REFUSAL) == RATING_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating():
|
||||||
|
"""A free-text answer that echoes the rating scale was read as the first
|
||||||
|
tier listed in it."""
|
||||||
|
text = ("**Rating Scale**: Buy, Overweight, Hold, Underweight, Sell.\n\n"
|
||||||
|
"**Rating**: Sell\n\nExit the position.")
|
||||||
|
assert extract_rating(text) == "Sell"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the readers agree ------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path):
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
log.store_decision("NVDA", "2026-01-05", REFUSAL)
|
||||||
|
|
||||||
|
entry = log.load_entries()[0]
|
||||||
|
assert entry["rating"] == RATING_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path):
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.graph.signal_processing import SignalProcessor
|
||||||
|
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."):
|
||||||
|
log.store_decision("NVDA", f"2026-01-0{len(log.load_entries()) + 1}", text)
|
||||||
|
|
||||||
|
signals = [SignalProcessor.process_signal(None, text)
|
||||||
|
for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate.")]
|
||||||
|
assert [e["rating"] for e in log.load_entries()] == signals
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path):
|
||||||
|
"""REVIEW has no direction, so it cannot count for or against the system."""
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.backtest import summarize
|
||||||
|
|
||||||
|
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
|
||||||
|
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
|
||||||
|
log.update_with_outcome("NVDA", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01")
|
||||||
|
log.store_decision("AAPL", "2026-01-05", REFUSAL)
|
||||||
|
log.update_with_outcome("AAPL", "2026-01-05", 0.1, 0.04, 5, "note", "2026-02-01")
|
||||||
|
|
||||||
|
summary = summarize(log)
|
||||||
|
assert set(summary.by_rating) == {"Buy"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path, capsys):
|
||||||
|
"""The CLI is the primary entry point; an unreadable decision must be
|
||||||
|
visible there, not only in the log."""
|
||||||
|
import cli.main as m
|
||||||
|
from cli.models import AnalystType
|
||||||
|
|
||||||
|
printed = []
|
||||||
|
|
||||||
|
class _Graph:
|
||||||
|
graph = propagator = None
|
||||||
|
|
||||||
|
def create_run_state(self, *a, **k):
|
||||||
|
return {"messages": []}
|
||||||
|
|
||||||
|
def record_decision(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def process_signal(self, text):
|
||||||
|
from tradingagents.graph.signal_processing import SignalProcessor
|
||||||
|
return SignalProcessor.process_signal(None, text)
|
||||||
|
|
||||||
|
def get_graph_args(self, callbacks=None):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def begin_checkpoint(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def checkpoint_input(self, state):
|
||||||
|
return state
|
||||||
|
|
||||||
|
def clear_checkpoint_on_success(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def end_checkpoint(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stream(self, *a, **k):
|
||||||
|
yield {"messages": [], "final_trade_decision": REFUSAL}
|
||||||
|
|
||||||
|
fake = _Graph()
|
||||||
|
fake.graph = fake
|
||||||
|
fake.propagator = fake
|
||||||
|
monkeypatch.setattr(m, "TradingAgentsGraph", lambda *a, **k: fake)
|
||||||
|
monkeypatch.setattr(m, "create_layout", lambda: None)
|
||||||
|
monkeypatch.setattr(m, "update_display", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(m, "Live", type("L", (), {"__init__": lambda s, *a, **k: None,
|
||||||
|
"__enter__": lambda s: s,
|
||||||
|
"__exit__": lambda s, *a: False}))
|
||||||
|
monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a)))
|
||||||
|
monkeypatch.setattr(m, "display_complete_report", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N")
|
||||||
|
monkeypatch.setattr(m, "get_user_selections", lambda: {
|
||||||
|
"ticker": "NVDA", "analysis_date": "2026-01-10",
|
||||||
|
"analysts": [AnalystType.MARKET], "asset_type": "stock",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(m, "_build_run_config", lambda s, c: {
|
||||||
|
"data_cache_dir": str(tmp_path / "c"), "results_dir": str(tmp_path / "r")})
|
||||||
|
|
||||||
|
m.run_analysis()
|
||||||
|
|
||||||
|
assert any("review" in line.lower() for line in printed), printed[-5:]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("module, factory, must_name", [
|
||||||
|
("tradingagents.agents.managers.portfolio_manager", "create_portfolio_manager", "Rating"),
|
||||||
|
("tradingagents.agents.managers.research_manager", "create_research_manager", "Recommendation"),
|
||||||
|
("tradingagents.agents.trader.trader", "create_trader", "Action"),
|
||||||
|
])
|
||||||
|
def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_name):
|
||||||
|
"""The field descriptions live in the schema, which a provider without
|
||||||
|
structured output never sees. Without the format in the prompt body, the
|
||||||
|
fallback answer is prose nobody can read a rating from."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class _LLM:
|
||||||
|
def invoke(self, prompt, *a, **k):
|
||||||
|
seen.append(prompt if isinstance(prompt, str) else str(prompt))
|
||||||
|
return AIMessage("**Rating**: Hold\n\nnothing to do")
|
||||||
|
|
||||||
|
def with_structured_output(self, *a, **k):
|
||||||
|
raise NotImplementedError # force the free-text path
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"company_of_interest": "NVDA", "trade_date": "2026-08-14", "asset_type": "stock",
|
||||||
|
"instrument_context": "", "market_report": "M", "sentiment_report": "S",
|
||||||
|
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
|
||||||
|
"trader_investment_plan": "T", "past_context": "", "portfolio_context": "",
|
||||||
|
"investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h",
|
||||||
|
"current_response": "", "judge_decision": "", "count": 2},
|
||||||
|
"risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3,
|
||||||
|
"aggressive_history": "", "conservative_history": "", "neutral_history": "",
|
||||||
|
"current_aggressive_response": "", "current_conservative_response": "",
|
||||||
|
"current_neutral_response": "", "judge_decision": ""},
|
||||||
|
}
|
||||||
|
getattr(mod, factory)(_LLM())(state)
|
||||||
|
|
||||||
|
prompt = " ".join(seen)
|
||||||
|
assert "## Output" in prompt, "no output-format section in the prompt"
|
||||||
|
section = prompt.split("## Output", 1)[1]
|
||||||
|
assert f"**{must_name}**" in section, section[:300]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Tests for the RSS-first Reddit fetcher, its 429 backoff, the opt-in JSON
|
"""Tests for the Reddit RSS fetcher: one combined request, its 429 backoff, and
|
||||||
path's degradation (#862), and chunked-transfer error handling (#1024)."""
|
chunked-transfer error handling (#1024)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -36,8 +36,9 @@ def _resp(read_fn):
|
|||||||
def __exit__(self_inner, *a):
|
def __exit__(self_inner, *a):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def read(self_inner):
|
def read(self_inner, size=-1):
|
||||||
return read_fn()
|
data = read_fn()
|
||||||
|
return data if size is None or size < 0 else data[:size]
|
||||||
return _Resp()
|
return _Resp()
|
||||||
|
|
||||||
|
|
||||||
@@ -79,46 +80,13 @@ class TestRssParsing:
|
|||||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", limit=5, timeout=5.0)
|
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", limit=5, timeout=5.0)
|
||||||
assert len(posts) == 2
|
assert len(posts) == 2
|
||||||
assert posts[0]["title"] == "NVDA earnings beat, stock pops"
|
assert posts[0]["title"] == "NVDA earnings beat, stock pops"
|
||||||
assert posts[0]["source"] == "rss"
|
|
||||||
assert posts[0]["score"] is None
|
|
||||||
assert posts[0]["num_comments"] is None
|
|
||||||
assert posts[0]["created_utc"] > 0
|
assert posts[0]["created_utc"] > 0
|
||||||
assert "datacenter unit" in posts[0]["selftext"]
|
assert "datacenter unit" in posts[0]["selftext"]
|
||||||
|
assert posts[0]["subreddit"] == "stocks"
|
||||||
|
|
||||||
def test_malformed_xml_fails_open(self):
|
def test_malformed_xml_reports_unavailable(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
|
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
|
||||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestFetchSubredditIsRssFirst:
|
|
||||||
"""The default per-subreddit fetch goes straight to RSS — it must not hit
|
|
||||||
the WAF-blocked JSON endpoint, which only burned rate-limit budget."""
|
|
||||||
|
|
||||||
def test_delegates_to_rss_without_touching_json(self):
|
|
||||||
sentinel = [{"title": "x", "source": "rss", "score": None,
|
|
||||||
"num_comments": None, "created_utc": None, "selftext": ""}]
|
|
||||||
with patch.object(reddit, "_fetch_subreddit_rss", return_value=sentinel) as rss, \
|
|
||||||
patch.object(reddit, "urlopen",
|
|
||||||
side_effect=AssertionError("JSON endpoint must not be called")):
|
|
||||||
out = reddit._fetch_subreddit("NVDA", "stocks", 5, 5.0)
|
|
||||||
rss.assert_called_once()
|
|
||||||
assert out is sentinel
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestJsonPathFallsBackToRss:
|
|
||||||
"""The opt-in JSON path still degrades to RSS on a 403 (kept for #862)."""
|
|
||||||
|
|
||||||
def test_403_triggers_rss(self):
|
|
||||||
err = HTTPError("url", 403, "Blocked", {}, None)
|
|
||||||
rss_posts = [{"title": "x", "source": "rss", "score": None,
|
|
||||||
"num_comments": None, "created_utc": None, "selftext": ""}]
|
|
||||||
with patch.object(reddit, "urlopen", side_effect=err), \
|
|
||||||
patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts) as rss:
|
|
||||||
out = reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
|
||||||
rss.assert_called_once()
|
|
||||||
assert out and out[0]["source"] == "rss"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -138,7 +106,7 @@ class TestRss429Backoff:
|
|||||||
patch.object(reddit.time, "sleep"):
|
patch.object(reddit.time, "sleep"):
|
||||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
assert op.call_count == 2 # one retry, then gives up cleanly
|
assert op.call_count == 2 # one retry, then gives up cleanly
|
||||||
assert posts == []
|
assert posts is None
|
||||||
|
|
||||||
def test_retry_after_header_is_honoured(self):
|
def test_retry_after_header_is_honoured(self):
|
||||||
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
|
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, None)
|
||||||
@@ -147,21 +115,43 @@ class TestRss429Backoff:
|
|||||||
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
slept.assert_called_once_with(12.0)
|
slept.assert_called_once_with(12.0)
|
||||||
|
|
||||||
|
def test_retry_after_zero_is_honoured_not_treated_as_absent(self):
|
||||||
|
# A valid "Retry-After: 0" means retry at once; it must not fall through
|
||||||
|
# to the fallback wait (the earlier `or 5.0` bug turned 0 into 5s).
|
||||||
|
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "0"}, None)
|
||||||
|
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
|
||||||
|
patch.object(reddit.time, "sleep") as slept:
|
||||||
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
|
slept.assert_called_once_with(0.0)
|
||||||
|
|
||||||
|
def test_headerless_429_fallback_is_jittered(self):
|
||||||
|
# No Retry-After -> our own ~5s fallback, jittered so concurrent runs
|
||||||
|
# don't retry in lockstep (kept within a tight band).
|
||||||
|
err = HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||||
|
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
|
||||||
|
patch.object(reddit.time, "sleep") as slept:
|
||||||
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
|
slept.assert_called_once()
|
||||||
|
(wait,), _ = slept.call_args
|
||||||
|
assert 48.0 <= wait <= 72.0 # 60s +/-20% jitter
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestChunkedTransferErrorsHandled:
|
class TestChunkedTransferErrorsHandled:
|
||||||
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
|
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
|
||||||
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
|
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
|
||||||
|
|
||||||
def test_rss_incomplete_read_degrades_to_empty(self):
|
def test_rss_incomplete_read_reports_unavailable(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
|
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
|
||||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
|
||||||
|
|
||||||
def test_json_incomplete_read_falls_back_to_rss(self):
|
def test_oversized_rss_feed_is_refused_not_parsed(self):
|
||||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \
|
# A hostile/misbehaving endpoint streaming an unbounded body must not be
|
||||||
patch.object(reddit, "_fetch_subreddit_rss", return_value=[]) as rss:
|
# read into memory before parsing; overflow degrades to an empty feed.
|
||||||
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
big = _resp(lambda: b"x" * 100)
|
||||||
rss.assert_called_once()
|
with patch.object(reddit, "_MAX_FEED_BYTES", 10), \
|
||||||
|
patch.object(reddit, "urlopen", return_value=big):
|
||||||
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -172,25 +162,12 @@ class TestFormatterHandlesRssPosts:
|
|||||||
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
||||||
"selftext": "great quarter", "source": "rss",
|
"selftext": "great quarter", "source": "rss",
|
||||||
}]
|
}]
|
||||||
with patch.object(reddit, "_fetch_subreddit", return_value=rss_posts):
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts):
|
||||||
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",))
|
||||||
assert "via RSS feed" in out
|
assert "↑" not in out # RSS has no scores; none are invented
|
||||||
assert "↑" not in out # no fake score arrow
|
|
||||||
assert "NVDA pops" in out
|
assert "NVDA pops" in out
|
||||||
assert "great quarter" in out
|
assert "great quarter" in out
|
||||||
|
|
||||||
def test_json_posts_still_show_counts(self):
|
|
||||||
json_posts = [{
|
|
||||||
"title": "NVDA pops", "score": 1234, "num_comments": 56,
|
|
||||||
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
|
||||||
"selftext": "",
|
|
||||||
}]
|
|
||||||
with patch.object(reddit, "_fetch_subreddit", return_value=json_posts):
|
|
||||||
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
|
|
||||||
assert "1234↑" in out
|
|
||||||
assert "56c" in out
|
|
||||||
assert "via RSS" not in out
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestCryptoSearchTerm:
|
class TestCryptoSearchTerm:
|
||||||
@@ -199,12 +176,12 @@ class TestCryptoSearchTerm:
|
|||||||
def _captured_ticker(self, ticker):
|
def _captured_ticker(self, ticker):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
|
||||||
def fake_fetch(t, sub, limit, timeout):
|
def fake_fetch(t, subs, limit, timeout, **kwargs):
|
||||||
seen["ticker"] = t
|
seen["ticker"] = t
|
||||||
return []
|
return []
|
||||||
|
|
||||||
with patch.object(reddit, "_fetch_subreddit", side_effect=fake_fetch):
|
with patch.object(reddit, "_fetch_subreddit_rss", side_effect=fake_fetch):
|
||||||
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",), inter_request_delay=0)
|
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",))
|
||||||
return seen["ticker"]
|
return seen["ticker"]
|
||||||
|
|
||||||
def test_crypto_pair_searches_base(self):
|
def test_crypto_pair_searches_base(self):
|
||||||
@@ -212,3 +189,86 @@ class TestCryptoSearchTerm:
|
|||||||
|
|
||||||
def test_equity_passes_through(self):
|
def test_equity_passes_through(self):
|
||||||
assert self._captured_ticker("NVDA") == "NVDA"
|
assert self._captured_ticker("NVDA") == "NVDA"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestOneRequestForAllSubreddits:
|
||||||
|
"""Reddit's anonymous RSS allows about one request per minute per IP, so a
|
||||||
|
request per subreddit spent a back-off on nearly every run. One combined
|
||||||
|
feed (``r/a+b+c``) carries each entry's subreddit, so nothing is lost."""
|
||||||
|
|
||||||
|
def _post(self, sub, title="NVDA pops"):
|
||||||
|
return {"title": title, "score": None, "num_comments": None,
|
||||||
|
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
||||||
|
"selftext": "", "source": "rss", "subreddit": sub}
|
||||||
|
|
||||||
|
def test_all_subreddits_share_one_request(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def record(t, subs, limit, timeout):
|
||||||
|
calls.append((subs, limit))
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", side_effect=record):
|
||||||
|
reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b", "c"), limit_per_sub=5)
|
||||||
|
# One full page, so a busy subreddit cannot crowd the others out.
|
||||||
|
assert calls == [("a+b+c", reddit._FEED_PAGE)]
|
||||||
|
|
||||||
|
def test_posts_are_grouped_back_by_subreddit(self):
|
||||||
|
posts = [self._post("b", "FROM B"), self._post("a", "FROM A")]
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=posts):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert out.index("r/a") < out.index("FROM A") < out.index("r/b") < out.index("FROM B")
|
||||||
|
|
||||||
|
def test_failed_request_is_unavailable_not_silence(self):
|
||||||
|
# #1295: a throttled fetch must not read as "no posts found".
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=None):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert "Reddit unavailable" in out
|
||||||
|
assert "no Reddit posts found" not in out
|
||||||
|
|
||||||
|
def test_genuine_empty_still_reports_no_posts(self):
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=[]):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert "no Reddit posts found" in out
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
def test_subreddit_with_no_posts_is_listed_when_others_have_some(self):
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=[self._post("a")]):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert "r/b: <no posts found" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_posts_from_an_unrequested_or_unnamed_subreddit_are_not_dropped():
|
||||||
|
posts = [
|
||||||
|
{"title": "ELSEWHERE", "created_utc": None, "selftext": "", "subreddit": "options"},
|
||||||
|
{"title": "NO LABEL", "created_utc": None, "selftext": "", "subreddit": ""},
|
||||||
|
]
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=posts):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert "ELSEWHERE" in out and "r/options" in out
|
||||||
|
assert "NO LABEL" in out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_each_subreddit_keeps_its_own_quota():
|
||||||
|
busy = [{"title": f"A{i}", "created_utc": None, "selftext": "", "subreddit": "a"} for i in range(9)]
|
||||||
|
quiet = [{"title": "B0", "created_utc": None, "selftext": "", "subreddit": "b"}]
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=busy + quiet):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"), limit_per_sub=3)
|
||||||
|
assert "A0" in out and "A2" in out and "A3" not in out # capped per subreddit
|
||||||
|
assert "B0" in out # not crowded out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_empty_subreddit_on_a_full_page_is_not_called_empty():
|
||||||
|
# A full page may have cut a quieter subreddit's posts off, so its absence
|
||||||
|
# from the page is not evidence of no posts.
|
||||||
|
full = [{"title": f"A{i}", "created_utc": None, "selftext": "", "subreddit": "a"}
|
||||||
|
for i in range(reddit._FEED_PAGE)]
|
||||||
|
with patch.object(reddit, "_fetch_subreddit_rss", return_value=full):
|
||||||
|
out = reddit.fetch_reddit_posts("NVDA", subreddits=("a", "b"))
|
||||||
|
assert "r/b: <no posts found" not in out
|
||||||
|
assert f"newest {reddit._FEED_PAGE}" in out
|
||||||
|
|||||||
239
tests/test_sec_edgar.py
Normal file
239
tests/test_sec_edgar.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
"""SEC EDGAR fundamentals: statements as they were filed, not as they read today.
|
||||||
|
|
||||||
|
Every other fundamentals vendor serves the current value of a past period and
|
||||||
|
cuts on the fiscal period end, so a run sees figures the company had not yet
|
||||||
|
filed, and later restatements replace what was actually published. EDGAR carries
|
||||||
|
the filing date of every fact, so a run can be limited to what was on file by its
|
||||||
|
own date.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import sec_edgar
|
||||||
|
from tradingagents.dataflows.errors import NoMarketDataError
|
||||||
|
|
||||||
|
_REAL_FETCH = sec_edgar._fetch_json
|
||||||
|
|
||||||
|
TICKER_MAP = {"0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."}}
|
||||||
|
|
||||||
|
|
||||||
|
def _fact(end, val, filed, form="10-K", fp="FY", start=None):
|
||||||
|
fact = {"end": end, "val": val, "filed": filed, "form": form, "fy": int(end[:4]), "fp": fp}
|
||||||
|
if start:
|
||||||
|
fact["start"] = start
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
|
FACTS = {
|
||||||
|
"cik": 320193,
|
||||||
|
"entityName": "Apple Inc.",
|
||||||
|
"facts": {"us-gaap": {
|
||||||
|
"Assets": {"units": {"USD": [
|
||||||
|
_fact("2008-09-27", 39_572_000_000, "2008-11-05"),
|
||||||
|
_fact("2008-09-27", 36_171_000_000, "2010-01-25", form="10-K/A"),
|
||||||
|
_fact("2022-03-26", 350_662_000_000, "2022-04-29", form="10-Q", fp="Q2"),
|
||||||
|
_fact("2024-09-28", 364_980_000_000, "2024-11-01"),
|
||||||
|
]}},
|
||||||
|
"Liabilities": {"units": {"USD": [_fact("2024-09-28", 308_030_000_000, "2024-11-01")]}},
|
||||||
|
"EarningsPerShareDiluted": {"units": {"USD/shares": [
|
||||||
|
_fact("2024-09-28", 6.08, "2024-11-01", start="2023-09-30"),
|
||||||
|
]}},
|
||||||
|
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
|
||||||
|
# One filing reports the quarter and the year to date under one end date.
|
||||||
|
_fact("2025-12-31", 81_300_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-10-01"),
|
||||||
|
_fact("2025-12-31", 158_900_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-07-01"),
|
||||||
|
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30"),
|
||||||
|
]}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_network_or_cache(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
|
monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: TICKER_MAP if "company_tickers" in url else FACTS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_us_filer_resolves_to_its_cik():
|
||||||
|
assert sec_edgar.cik_for("AAPL") == "0000320193"
|
||||||
|
assert sec_edgar.cik_for("aapl") == "0000320193"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_non_filer_is_reported_as_such_not_as_missing_data():
|
||||||
|
with pytest.raises(NoMarketDataError, match="not a US SEC filer"):
|
||||||
|
sec_edgar.get_balance_sheet("0700.HK", "annual", "2026-01-01")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_restated_figure_reads_as_it_did_at_the_time():
|
||||||
|
"""The value published then, not the correction filed later."""
|
||||||
|
as_filed = sec_edgar.get_balance_sheet("AAPL", "annual", "2009-06-30")
|
||||||
|
restated = sec_edgar.get_balance_sheet("AAPL", "annual", "2011-01-01")
|
||||||
|
assert "39572" in as_filed and "36171" not in as_filed
|
||||||
|
assert "36171" in restated
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_period_that_ended_but_was_not_filed_yet_is_not_served():
|
||||||
|
"""The fiscal year ended 2024-09-28; it reached the public on 2024-11-01."""
|
||||||
|
before = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-10-15")
|
||||||
|
after = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
|
assert "2024-09-28" not in before
|
||||||
|
assert "2024-09-28" in after and "364980" in after
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_quarter_is_not_confused_with_the_year_to_date():
|
||||||
|
"""One filing carries both spans under the same end date (#MSFT-shaped)."""
|
||||||
|
out = sec_edgar.get_income_statement("AAPL", "quarterly", "2026-06-01")
|
||||||
|
assert "81300" in out
|
||||||
|
assert "158900" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_line_the_filer_does_not_tag_is_named_unavailable():
|
||||||
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
|
assert "Stockholders Equity" in out and "unavailable" in out
|
||||||
|
assert "364980" in out # the rest of the statement still returns
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_report_states_the_vintage_rule():
|
||||||
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
|
assert "filed on or before 2024-11-15" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_filer_with_no_usable_facts_reads_differently_from_a_non_filer(monkeypatch):
|
||||||
|
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||||
|
lambda url: TICKER_MAP if "company_tickers" in url else {"facts": {}})
|
||||||
|
with pytest.raises(NoMarketDataError, match="no us-gaap facts"):
|
||||||
|
sec_edgar.get_balance_sheet("AAPL", "annual", "2026-01-01")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_company_facts_are_fetched_once_per_company_not_once_per_date(tmp_path, monkeypatch):
|
||||||
|
"""A sweep asks for many dates; the filing history is the same file."""
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||||
|
lambda url: calls.append(url) or (TICKER_MAP if "company_tickers" in url else FACTS))
|
||||||
|
for date in ("2024-11-15", "2025-01-15", "2025-06-15"):
|
||||||
|
sec_edgar.get_balance_sheet("AAPL", "annual", date)
|
||||||
|
assert len([u for u in calls if "companyfacts" in u]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_it_works_unconfigured_and_takes_the_caller_s_own_contact(monkeypatch):
|
||||||
|
"""SEC returns 403 for a User-Agent with no contact address, so the default
|
||||||
|
carries one; a caller who sets their own replaces it."""
|
||||||
|
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
|
||||||
|
assert "@" in sec_edgar._user_agent()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SEC_EDGAR_USER_AGENT", "MyDesk research@example.com")
|
||||||
|
assert sec_edgar._user_agent() == "MyDesk research@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
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
|
||||||
|
|
||||||
|
def _throttled(*a, **k):
|
||||||
|
raise requests.HTTPError(response=mock.Mock(status_code=429))
|
||||||
|
|
||||||
|
monkeypatch.setattr(sec_edgar.requests, "get", _throttled)
|
||||||
|
with pytest.raises(VendorRateLimitError):
|
||||||
|
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_values_do_not_break_the_columns():
|
||||||
|
"""Figures run to the billions; a thousands separator would split the field."""
|
||||||
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
|
body = [row for row in out.splitlines() if row.startswith("Total Assets")][0]
|
||||||
|
assert body.count(",") == out.splitlines()[3].count(",")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_per_share_figure_keeps_its_own_unit():
|
||||||
|
"""Statements are reported in millions, but EPS is dollars per share: scaling
|
||||||
|
it the same way prints a real figure as zero."""
|
||||||
|
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
|
||||||
|
row = [r for r in out.splitlines() if r.startswith("Diluted EPS")][0]
|
||||||
|
assert "6.08" in row
|
||||||
|
assert "USD/shares" in row or "per share" in row
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_every_row_has_one_cell_per_period():
|
||||||
|
"""An untagged line still has to line up with the columns, or the table is
|
||||||
|
misread by position."""
|
||||||
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
|
table = [r for r in out.splitlines() if r and not r.startswith("#")]
|
||||||
|
widths = {row.count(",") for row in table}
|
||||||
|
assert len(widths) == 1, table
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_server_error_lets_the_next_vendor_try(monkeypatch):
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tradingagents.dataflows.errors import VendorError
|
||||||
|
|
||||||
|
def _server_error(*a, **k):
|
||||||
|
raise requests.HTTPError(response=mock.Mock(status_code=503))
|
||||||
|
|
||||||
|
monkeypatch.setattr(sec_edgar.requests, "get", _server_error)
|
||||||
|
with pytest.raises(VendorError): # not a bare HTTPError
|
||||||
|
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_older_periods_fall_back_to_the_tag_the_filer_used_then():
|
||||||
|
"""Filers renamed lines when the revenue standard changed, so one tag covers
|
||||||
|
only recent years. Each period takes one tag, never a sum of two."""
|
||||||
|
facts = {"Revenues": {"units": {"USD": [_fact("2015-09-26", 233_715_000_000, "2015-10-28",
|
||||||
|
start="2014-09-28")]}},
|
||||||
|
"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}
|
||||||
|
assert unit == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both():
|
||||||
|
facts = {"Revenues": {"units": {"USD": [_fact("2024-09-28", 111, "2024-11-01", start="2023-09-30")]}},
|
||||||
|
"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}
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
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()
|
||||||
@@ -56,8 +56,9 @@ class TestParseRating:
|
|||||||
)
|
)
|
||||||
assert parse_rating(text) == "Sell"
|
assert parse_rating(text) == "Sell"
|
||||||
|
|
||||||
def test_no_rating_returns_default(self):
|
def test_no_rating_is_flagged_for_review_not_defaulted(self):
|
||||||
assert parse_rating("No clear directional signal at this time.") == "Hold"
|
# A decision nobody can read is not a Hold; recording one invents a call.
|
||||||
|
assert parse_rating("No clear directional signal at this time.") == RATING_REVIEW
|
||||||
|
|
||||||
def test_no_rating_custom_default(self):
|
def test_no_rating_custom_default(self):
|
||||||
assert parse_rating("Plain prose.", default="Underweight") == "Underweight"
|
assert parse_rating("Plain prose.", default="Underweight") == "Underweight"
|
||||||
@@ -116,9 +117,9 @@ class TestExtractRating:
|
|||||||
# substrings inside larger words must not match
|
# substrings inside larger words must not match
|
||||||
assert extract_rating("The buyer was holding shares.") is None
|
assert extract_rating("The buyer was holding shares.") is None
|
||||||
|
|
||||||
def test_parse_rating_keeps_silent_default_for_compat(self):
|
def test_parse_rating_defaults_to_review(self):
|
||||||
# parse_rating (used by the memory log) intentionally keeps Hold default.
|
# The memory log tags an unreadable decision REVIEW, never a tradeable rating.
|
||||||
assert parse_rating("No rating here.") == "Hold"
|
assert parse_rating("No rating here.") == RATING_REVIEW
|
||||||
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
|
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ def test_stocktwits_historical_window_excludes_recent(monkeypatch):
|
|||||||
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
|
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
|
||||||
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
|
||||||
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
assert "no StockTwits messages" in out
|
|
||||||
assert "2026-05-01..2026-05-08" in out
|
assert "2026-05-01..2026-05-08" in out
|
||||||
assert "Bullish: 1" not in out # the recent bullish message did not leak
|
assert "Bullish: 1" not in out # the recent bullish message did not leak
|
||||||
|
# Coverage starts after the window: unavailable, never a claim of silence.
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -101,21 +102,124 @@ def _epoch(date_str):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_reddit_historical_window_excludes_recent(monkeypatch):
|
def test_reddit_historical_window_excludes_recent(monkeypatch):
|
||||||
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
|
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
|
||||||
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: posts)
|
||||||
out = reddit.fetch_reddit_posts(
|
out = reddit.fetch_reddit_posts(
|
||||||
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
"AAPL", subreddits=("stocks",),
|
||||||
start_date="2026-05-01", end_date="2026-05-08",
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
)
|
)
|
||||||
assert "NOW" not in out
|
assert "NOW" not in out
|
||||||
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_reddit_live_window_keeps_in_range(monkeypatch):
|
def test_reddit_live_window_keeps_in_range(monkeypatch):
|
||||||
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
|
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
|
||||||
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: posts)
|
||||||
out = reddit.fetch_reddit_posts(
|
out = reddit.fetch_reddit_posts(
|
||||||
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
"AAPL", subreddits=("stocks",),
|
||||||
start_date="2026-05-01", end_date="2026-05-08",
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
)
|
)
|
||||||
assert "INRANGE" in out
|
assert "INRANGE" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- coverage vs absence --------------------------------------------------------
|
||||||
|
# The public feeds only serve recent items. When everything fetched postdates the
|
||||||
|
# window the source cannot answer for that date; reporting "no posts" there is a
|
||||||
|
# claim about the market that was never observed.
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_covered_but_empty_window_is_a_real_absence(monkeypatch):
|
||||||
|
# The stream reaches back before the window (an older message exists) yet
|
||||||
|
# nothing falls inside it: that is genuine silence.
|
||||||
|
msgs = [_msg("2026-08-30T12:00:00Z"), _msg("2026-04-20T12:00:00Z")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "no StockTwits messages" in out
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_covered_but_empty_window_is_a_real_absence(monkeypatch):
|
||||||
|
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"},
|
||||||
|
{"title": "OLD", "created_utc": _epoch("2026-04-20"), "source": "rss"}]
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: posts)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",),
|
||||||
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
|
)
|
||||||
|
assert "no reddit posts" in out.lower()
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_empty_feed_for_an_old_window_is_unavailable(monkeypatch):
|
||||||
|
# Search is limited to the last week, so an empty response says nothing
|
||||||
|
# about a window from months ago: there are no timestamps to go on, and the
|
||||||
|
# lookback bound alone must decide.
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",),
|
||||||
|
start_date="2024-05-01", end_date="2024-05-08",
|
||||||
|
)
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_live_empty_feed_is_a_real_absence(monkeypatch):
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
|
||||||
|
out = reddit.fetch_reddit_posts("AAPL", subreddits=("stocks",))
|
||||||
|
assert "no reddit posts" in out.lower() and "past 7 days" in out
|
||||||
|
assert "unavailable" not in out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_empty_stream_for_a_past_window_is_unavailable(monkeypatch):
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": []}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "unavailable" in out and "not an absence" in out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
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()
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",),
|
||||||
|
start_date=str(today - timedelta(days=10)), end_date=str(today - timedelta(days=5)),
|
||||||
|
)
|
||||||
|
assert "unavailable" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
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()
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit_rss", lambda *a, **k: [])
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",),
|
||||||
|
start_date=str(today - timedelta(days=7)), end_date=str(today),
|
||||||
|
)
|
||||||
|
assert "no reddit posts" in out.lower() and "unavailable" not in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",),
|
||||||
|
start_date=str(today - timedelta(days=6)), end_date=str(today - timedelta(days=5)),
|
||||||
|
)
|
||||||
|
assert "unavailable" in out and "no reddit posts" not in out.lower()
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ behavior we added for the Trader, Research Manager, and Sentiment Analyst
|
|||||||
so they share the same deterministic output shape.
|
so they share the same deterministic output shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from tradingagents.agents.analysts.sentiment_analyst import create_sentiment_analyst
|
from tradingagents.agents.analysts.sentiment_analyst import create_sentiment_analyst
|
||||||
|
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||||
from tradingagents.agents.managers.research_manager import create_research_manager
|
from tradingagents.agents.managers.research_manager import create_research_manager
|
||||||
from tradingagents.agents.schemas import (
|
from tradingagents.agents.schemas import (
|
||||||
PortfolioDecision,
|
PortfolioDecision,
|
||||||
@@ -59,12 +61,13 @@ class TestRenderTraderProposal:
|
|||||||
assert "**Position Sizing**: 6% of portfolio" in md
|
assert "**Position Sizing**: 6% of portfolio" in md
|
||||||
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in md
|
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in md
|
||||||
|
|
||||||
def test_optional_fields_omitted_when_absent(self):
|
def test_optional_fields_are_named_as_not_provided(self):
|
||||||
|
"""An omitted line reads as a field nobody asked for; the reader cannot
|
||||||
|
tell it from a level the trader declined to set."""
|
||||||
p = TraderProposal(action=TraderAction.SELL, reasoning="Guidance cut.")
|
p = TraderProposal(action=TraderAction.SELL, reasoning="Guidance cut.")
|
||||||
md = render_trader_proposal(p)
|
md = render_trader_proposal(p)
|
||||||
assert "Entry Price" not in md
|
for field in ("Entry Price", "Stop Loss", "Position Sizing"):
|
||||||
assert "Stop Loss" not in md
|
assert f"**{field}**: not provided" in md
|
||||||
assert "Position Sizing" not in md
|
|
||||||
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
|
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +100,43 @@ class TestNullishFloatCoercion:
|
|||||||
)
|
)
|
||||||
assert d.price_target is None
|
assert d.price_target is None
|
||||||
|
|
||||||
|
def test_percentage_answer_to_a_price_field_becomes_none(self):
|
||||||
|
# The Trader is asked for concrete levels and may answer a price field
|
||||||
|
# with a distance ("15%"), which failed the whole proposal (#1288).
|
||||||
|
# A percentage cannot be salvaged: 15% must not become a $15 stop.
|
||||||
|
for pct in ("15%", " 7.5% ", "-10%"):
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.BUY,
|
||||||
|
reasoning="x",
|
||||||
|
entry_price=pct,
|
||||||
|
stop_loss=pct,
|
||||||
|
)
|
||||||
|
assert p.entry_price is None
|
||||||
|
assert p.stop_loss is None
|
||||||
|
|
||||||
|
def test_human_formatted_price_is_reduced_to_its_number(self):
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.BUY,
|
||||||
|
reasoning="x",
|
||||||
|
entry_price="$1,234.50",
|
||||||
|
stop_loss="1,180",
|
||||||
|
)
|
||||||
|
assert p.entry_price == 1234.50
|
||||||
|
assert p.stop_loss == 1180.0
|
||||||
|
|
||||||
|
def test_one_bad_field_no_longer_fails_the_whole_proposal(self):
|
||||||
|
# Previously a single '15%' raised, forcing a free-text retry that lost
|
||||||
|
# the action and reasoning; now the rest of the proposal survives.
|
||||||
|
p = TraderProposal(
|
||||||
|
action=TraderAction.SELL,
|
||||||
|
reasoning="downgrade on margin compression",
|
||||||
|
entry_price="612.40",
|
||||||
|
stop_loss="15%",
|
||||||
|
)
|
||||||
|
assert p.action is TraderAction.SELL
|
||||||
|
assert p.entry_price == 612.40
|
||||||
|
assert p.stop_loss is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestRenderResearchPlan:
|
class TestRenderResearchPlan:
|
||||||
@@ -393,6 +433,21 @@ def _structured_sentiment_llm(captured: dict, report: SentimentReport | None = N
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestSentimentAnalystAgent:
|
class TestSentimentAnalystAgent:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stub_prefetched_sources(self, monkeypatch):
|
||||||
|
"""Stub the sources the analyst pre-fetches before prompting.
|
||||||
|
|
||||||
|
create_sentiment_analyst fetches news, StockTwits and Reddit itself, so
|
||||||
|
without this these tests hit the live network. A real Reddit 429 then
|
||||||
|
backs the fetcher off for a minute per subreddit, which is what turned
|
||||||
|
this file into a multi-minute hang.
|
||||||
|
"""
|
||||||
|
from tradingagents.agents.analysts import sentiment_analyst as sentiment
|
||||||
|
|
||||||
|
monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st")
|
||||||
|
monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd")
|
||||||
|
monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False)
|
||||||
|
|
||||||
def test_structured_path_produces_rendered_markdown(self):
|
def test_structured_path_produces_rendered_markdown(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
report = SentimentReport(
|
report = SentimentReport(
|
||||||
@@ -432,3 +487,62 @@ class TestSentimentAnalystAgent:
|
|||||||
llm.with_structured_output.return_value = structured
|
llm.with_structured_output.return_value = structured
|
||||||
llm.invoke.return_value = MagicMock(content=plain)
|
llm.invoke.return_value = MagicMock(content=plain)
|
||||||
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain
|
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("source", [
|
||||||
|
pytest.param(lambda: ResearchPlan.model_fields["recommendation"].description, id="ResearchPlan.recommendation"),
|
||||||
|
pytest.param(lambda: PortfolioDecision.model_fields["rating"].description, id="PortfolioDecision.rating"),
|
||||||
|
pytest.param(lambda: inspect.getsource(create_research_manager), id="research_manager prompt"),
|
||||||
|
pytest.param(lambda: inspect.getsource(create_portfolio_manager), id="portfolio_manager prompt"),
|
||||||
|
])
|
||||||
|
def test_conflict_alone_is_not_a_hold_trigger(source):
|
||||||
|
# The debate always contains conflicting arguments, so a Hold condition that
|
||||||
|
# conflict satisfies fires on every run and swallows directional calls
|
||||||
|
# (#1321). All four decision sites must state the same rule.
|
||||||
|
text = " ".join(source().split())
|
||||||
|
assert "conflict alone is not a reason to Hold" in text or \
|
||||||
|
"Conflicting arguments alone are not a reason to Hold" in text
|
||||||
|
assert "materially conflicting" not in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("written", ["150-160", "150 to 160", "around 150", "150/160", "~150"])
|
||||||
|
def test_a_price_written_as_a_range_drops_only_that_field(written):
|
||||||
|
"""Anything that is not a single number becomes None. Letting it through
|
||||||
|
fails the whole decision's validation, and the run falls back to free text,
|
||||||
|
losing every other field the model got right."""
|
||||||
|
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
|
||||||
|
|
||||||
|
decision = PortfolioDecision(rating=PortfolioRating.BUY, executive_summary="s",
|
||||||
|
investment_thesis="t", price_target=written)
|
||||||
|
assert decision.price_target is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_price_that_is_a_number_survives():
|
||||||
|
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
|
||||||
|
|
||||||
|
decision = PortfolioDecision(rating=PortfolioRating.BUY, executive_summary="s",
|
||||||
|
investment_thesis="t", price_target="$1,150.25")
|
||||||
|
assert decision.price_target == 1150.25
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_field_the_model_did_not_give_says_so():
|
||||||
|
"""An omitted line and a line never asked for read the same to an analyst."""
|
||||||
|
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating, render_pm_decision
|
||||||
|
|
||||||
|
rendered = render_pm_decision(PortfolioDecision(
|
||||||
|
rating=PortfolioRating.HOLD, executive_summary="s", investment_thesis="t"))
|
||||||
|
assert "Price Target" in rendered and "not provided" in rendered.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_trader_names_the_levels_it_did_not_give():
|
||||||
|
from tradingagents.agents.schemas import TraderAction, TraderProposal, render_trader_proposal
|
||||||
|
|
||||||
|
rendered = render_trader_proposal(TraderProposal(action=TraderAction.HOLD, reasoning="r"))
|
||||||
|
for field in ("Entry Price", "Stop Loss", "Position Sizing"):
|
||||||
|
assert field in rendered
|
||||||
|
assert rendered.lower().count("not provided") == 3
|
||||||
|
|||||||
@@ -52,6 +52,27 @@ class TestNormalizeSymbol(unittest.TestCase):
|
|||||||
def test_empty_input_passthrough(self):
|
def test_empty_input_passthrough(self):
|
||||||
self.assertEqual(normalize_symbol(""), "")
|
self.assertEqual(normalize_symbol(""), "")
|
||||||
|
|
||||||
|
def test_hk_five_digit_code_repadded_to_four(self):
|
||||||
|
# HKEX lists up to 5-digit codes; Yahoo only accepts 4 (#957).
|
||||||
|
self.assertEqual(normalize_symbol("09992.HK"), "9992.HK")
|
||||||
|
self.assertEqual(normalize_symbol("00700.HK"), "0700.HK")
|
||||||
|
self.assertEqual(normalize_symbol("00001.HK"), "0001.HK")
|
||||||
|
|
||||||
|
def test_hk_four_digit_code_unchanged(self):
|
||||||
|
self.assertEqual(normalize_symbol("0700.HK"), "0700.HK")
|
||||||
|
self.assertEqual(normalize_symbol("9992.HK"), "9992.HK")
|
||||||
|
self.assertEqual(normalize_symbol("80737.HK"), "80737.HK")
|
||||||
|
|
||||||
|
def test_hk_short_code_padded_to_four(self):
|
||||||
|
self.assertEqual(normalize_symbol("700.HK"), "0700.HK")
|
||||||
|
|
||||||
|
def test_hk_code_case_insensitive_suffix(self):
|
||||||
|
self.assertEqual(normalize_symbol("09992.hk"), "9992.HK")
|
||||||
|
|
||||||
|
def test_shanghai_sh_suffix_maps_to_yahoo_ss(self):
|
||||||
|
self.assertEqual(normalize_symbol("600519.sh"), "600519.SS")
|
||||||
|
self.assertEqual(normalize_symbol("600519.SS"), "600519.SS")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestNoMarketDataError(unittest.TestCase):
|
class TestNoMarketDataError(unittest.TestCase):
|
||||||
|
|||||||
140
tests/test_tool_date_enforcement.py
Normal file
140
tests/test_tool_date_enforcement.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Dated tools take the analysis date from graph state, not from the model.
|
||||||
|
|
||||||
|
Every point-in-time guard behind a tool trusts the date it is given. A model that
|
||||||
|
omits the date, or passes today's instead of the analysis date, would otherwise
|
||||||
|
walk past them. The run's trade_date is injected from state and hidden from the
|
||||||
|
model-visible schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||||
|
from langgraph.prebuilt import ToolNode
|
||||||
|
|
||||||
|
from tradingagents.agents.utils import (
|
||||||
|
core_stock_tools,
|
||||||
|
fundamental_data_tools,
|
||||||
|
macro_data_tools,
|
||||||
|
market_data_validation_tools,
|
||||||
|
news_data_tools,
|
||||||
|
technical_indicators_tools,
|
||||||
|
)
|
||||||
|
from tradingagents.dataflows.date_window import as_of, as_of_window
|
||||||
|
|
||||||
|
TRADE_DATE = "2026-08-14"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("requested, expected", [
|
||||||
|
("2026-09-14", TRADE_DATE), # later than the run: clamped
|
||||||
|
("2026-08-01", "2026-08-01"), # earlier: narrows, allowed
|
||||||
|
(None, TRADE_DATE), # omitted
|
||||||
|
("Sept 1", TRADE_DATE), # unparseable
|
||||||
|
("", TRADE_DATE),
|
||||||
|
])
|
||||||
|
def test_as_of_takes_the_earlier_date(requested, expected):
|
||||||
|
assert as_of(requested, TRADE_DATE) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_as_of_without_a_trade_date_passes_the_request_through():
|
||||||
|
assert as_of("2026-09-14", "") == "2026-09-14"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("start, end, expected", [
|
||||||
|
("2026-08-01", "2026-09-14", ("2026-08-01", TRADE_DATE)), # end clamped
|
||||||
|
("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged
|
||||||
|
("2026-09-01", "2026-09-08", ("2026-08-07", TRADE_DATE)), # wholly later: span kept, moved back
|
||||||
|
])
|
||||||
|
def test_as_of_window(start, end, expected):
|
||||||
|
assert as_of_window(start, end, TRADE_DATE) == expected
|
||||||
|
|
||||||
|
|
||||||
|
DATED_TOOLS = [
|
||||||
|
core_stock_tools.get_stock_data,
|
||||||
|
fundamental_data_tools.get_fundamentals,
|
||||||
|
fundamental_data_tools.get_balance_sheet,
|
||||||
|
fundamental_data_tools.get_cashflow,
|
||||||
|
fundamental_data_tools.get_income_statement,
|
||||||
|
news_data_tools.get_news,
|
||||||
|
news_data_tools.get_global_news,
|
||||||
|
technical_indicators_tools.get_indicators,
|
||||||
|
macro_data_tools.get_macro_indicators,
|
||||||
|
market_data_validation_tools.get_verified_market_snapshot,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("tool", DATED_TOOLS, ids=lambda t: t.name)
|
||||||
|
def test_trade_date_is_hidden_from_the_model(tool):
|
||||||
|
assert "trade_date" not in tool.tool_call_schema.model_json_schema()["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
class _State(MessagesState):
|
||||||
|
trade_date: str
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tool, args, module):
|
||||||
|
"""Call the tool through a ToolNode in a graph carrying the run's trade_date."""
|
||||||
|
graph = StateGraph(_State)
|
||||||
|
graph.add_node("tools", ToolNode([tool]))
|
||||||
|
graph.add_edge(START, "tools")
|
||||||
|
graph.add_edge("tools", END)
|
||||||
|
with mock.patch.object(module, "route_to_vendor", return_value="ok") as routed:
|
||||||
|
graph.compile().invoke({
|
||||||
|
"messages": [AIMessage("", tool_calls=[{"name": tool.name, "args": args, "id": "1"}])],
|
||||||
|
"trade_date": TRADE_DATE,
|
||||||
|
})
|
||||||
|
return routed.call_args.args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_statement_tool_with_omitted_date_uses_the_run_date():
|
||||||
|
args = _run(fundamental_data_tools.get_balance_sheet, {"ticker": "AAPL"}, fundamental_data_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(fundamental_data_tools.get_fundamentals,
|
||||||
|
{"ticker": "AAPL", "curr_date": "2026-09-14"}, fundamental_data_tools)
|
||||||
|
assert args == ("get_fundamentals", "AAPL", TRADE_DATE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_future_window_from_the_model_is_clamped():
|
||||||
|
args = _run(core_stock_tools.get_stock_data,
|
||||||
|
{"symbol": "AAPL", "start_date": "2026-08-01", "end_date": "2026-09-14"}, core_stock_tools)
|
||||||
|
assert args == ("get_stock_data", "AAPL", "2026-08-01", TRADE_DATE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_direct_call_without_state_is_unchanged():
|
||||||
|
with mock.patch.object(news_data_tools, "route_to_vendor", return_value="ok") as routed:
|
||||||
|
news_data_tools.get_news.func("AAPL", "2026-09-01", "2026-09-08")
|
||||||
|
assert routed.call_args.args == ("get_news", "AAPL", "2026-09-01", "2026-09-08")
|
||||||
|
|
||||||
|
|
||||||
|
# --- the run date itself (#1319) -------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("bad", ["2026-9-10", "2026-09-10 00:00", "Sept 10", None])
|
||||||
|
def test_propagate_rejects_a_non_canonical_date(bad):
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||||
|
object.__new__(TradingAgentsGraph).propagate("AAPL", bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_propagate_rejects_a_future_date(monkeypatch):
|
||||||
|
import tradingagents.graph.trading_graph as tg
|
||||||
|
|
||||||
|
monkeypatch.setattr(tg, "get_current_date", lambda: "2026-09-10")
|
||||||
|
with pytest.raises(ValueError, match="future"):
|
||||||
|
object.__new__(tg.TradingAgentsGraph).propagate("AAPL", "2026-09-11")
|
||||||
248
tests/test_undated_tools_as_of.py
Normal file
248
tests/test_undated_tools_as_of.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Insider filings and prediction-market odds are bounded by the run's trade date.
|
||||||
|
|
||||||
|
Neither tool takes a date from the model, so the run's trade_date is injected from
|
||||||
|
graph state. Insider filings carry dates and are filtered to it; Polymarket serves
|
||||||
|
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.utils import news_data_tools, prediction_markets_tools
|
||||||
|
from tradingagents.dataflows import alpha_vantage_news, polymarket, y_finance
|
||||||
|
|
||||||
|
|
||||||
|
def _insider_frame(*dates):
|
||||||
|
return pd.DataFrame({
|
||||||
|
"Shares": [100] * len(dates),
|
||||||
|
"Text": [f"Sale at price {100 + i} per share." for i in range(len(dates))],
|
||||||
|
"Start Date": pd.to_datetime(list(dates)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _yf_insider(frame, curr_date):
|
||||||
|
ticker = mock.Mock(insider_transactions=frame)
|
||||||
|
with mock.patch.object(y_finance.yf, "Ticker", return_value=ticker):
|
||||||
|
return y_finance.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" in out # where coverage starts
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
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())
|
||||||
|
req.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("tool", [news_data_tools.get_insider_transactions,
|
||||||
|
prediction_markets_tools.get_prediction_markets], ids=lambda t: t.name)
|
||||||
|
def test_trade_date_is_injected_not_model_visible(tool):
|
||||||
|
assert "trade_date" in tool.func.__code__.co_varnames
|
||||||
|
props = tool.tool_call_schema.model_json_schema()["properties"]
|
||||||
|
assert "trade_date" not in props and "curr_date" not in props
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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."""
|
||||||
|
from tradingagents.agents.utils.agent_utils 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()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch):
|
||||||
|
from tradingagents.agents.utils.agent_utils import build_instrument_context
|
||||||
|
from tradingagents.dataflows.utils import get_current_date
|
||||||
|
|
||||||
|
today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"},
|
||||||
|
curr_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
|
||||||
|
|
||||||
|
from tradingagents.dataflows import y_finance
|
||||||
|
|
||||||
|
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(y_finance.yf, "Ticker", return_value=ticker):
|
||||||
|
out = y_finance.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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value():
|
||||||
|
"""The per-day fallback returned an empty string for a failed read, so the
|
||||||
|
table rendered a row per day with nothing after the colon: an analyst reads
|
||||||
|
that as "no value on that day" rather than "could not be obtained"."""
|
||||||
|
from tradingagents.dataflows import y_finance
|
||||||
|
from tradingagents.dataflows.errors import VendorError
|
||||||
|
|
||||||
|
with mock.patch.object(y_finance.StockstatsUtils, "get_stock_stats",
|
||||||
|
side_effect=RuntimeError("cache parse failed")), \
|
||||||
|
pytest.raises(VendorError):
|
||||||
|
y_finance.get_stockstats_indicator("AAPL", "rsi", "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("func, args", [
|
||||||
|
# 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")),
|
||||||
|
])
|
||||||
|
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
|
||||||
|
the chain stops and the analyst reads the error message as if it were data.
|
||||||
|
yfinance serves the default path, so this is the one that matters most."""
|
||||||
|
from tradingagents.dataflows import y_finance
|
||||||
|
from tradingagents.dataflows.errors import VendorError
|
||||||
|
|
||||||
|
with mock.patch.object(y_finance.yf, "Ticker", side_effect=RuntimeError("yahoo hiccup")), \
|
||||||
|
pytest.raises(VendorError):
|
||||||
|
getattr(y_finance, func)(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("func, args", [
|
||||||
|
("get_news_yfinance", ("AAPL", "2026-08-25", "2026-09-01")),
|
||||||
|
("get_global_news_yfinance", ("2026-09-01", 7, 5)),
|
||||||
|
])
|
||||||
|
def test_a_yfinance_news_failure_is_a_vendor_error_not_a_report(func, args):
|
||||||
|
from tradingagents.dataflows import yfinance_news
|
||||||
|
from tradingagents.dataflows.errors import VendorError
|
||||||
|
|
||||||
|
target = "Ticker" if "global" not in func else "Search"
|
||||||
|
with mock.patch.object(yfinance_news.yf, target, side_effect=RuntimeError("yahoo hiccup")), \
|
||||||
|
pytest.raises(VendorError):
|
||||||
|
getattr(yfinance_news, func)(*args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch):
|
||||||
|
"""yfinance returns an empty frame when it cannot reach Yahoo, with no
|
||||||
|
exception. Reporting that as "no data for AAPL" tells the analyst the
|
||||||
|
company has no balance sheet, when the truth is we could not ask."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from tradingagents.dataflows import stockstats_utils, y_finance
|
||||||
|
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
|
||||||
|
|
||||||
|
empty = mock.Mock(quarterly_balance_sheet=pd.DataFrame(), balance_sheet=pd.DataFrame())
|
||||||
|
monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: empty)
|
||||||
|
|
||||||
|
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
|
||||||
|
with pytest.raises(VendorRateLimitError, match="unreachable"):
|
||||||
|
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
|
||||||
|
with pytest.raises(NoMarketDataError):
|
||||||
|
y_finance.get_balance_sheet("AAPL", "annual", "2026-09-01")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
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 interface
|
||||||
|
from tradingagents.dataflows.errors import VendorRateLimitError
|
||||||
|
|
||||||
|
def _down(*a, **k):
|
||||||
|
raise VendorRateLimitError("Yahoo Finance is unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setitem(interface.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down)
|
||||||
|
|
||||||
|
out = interface.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01")
|
||||||
|
|
||||||
|
assert "unavailable" in out.lower() and "unreachable" in out.lower()
|
||||||
|
assert "delisted" not in out.lower() # not a claim about the symbol
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_the_price_path_also_tells_an_outage_from_an_unknown_symbol(monkeypatch):
|
||||||
|
"""Prices are the most-used path, so an outage there must not read as a
|
||||||
|
delisted symbol either."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from tradingagents.dataflows import stockstats_utils, y_finance
|
||||||
|
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
|
||||||
|
|
||||||
|
monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: mock.Mock(history=lambda **k: pd.DataFrame()))
|
||||||
|
|
||||||
|
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: False)
|
||||||
|
with pytest.raises(VendorRateLimitError, match="unreachable"):
|
||||||
|
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stockstats_utils, "vendor_reachable", lambda url: True)
|
||||||
|
with pytest.raises(NoMarketDataError):
|
||||||
|
y_finance.get_YFin_data_online("AAPL", "2026-09-01", "2026-09-10")
|
||||||
@@ -26,7 +26,7 @@ def create_fundamentals_analyst(llm):
|
|||||||
"You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, and company financial history to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
|
"You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, and company financial history to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. 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."
|
+ " 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."
|
||||||
+ " Use the available tools: `get_fundamentals` for comprehensive company analysis, `get_balance_sheet`, `get_cashflow`, and `get_income_statement` for specific financial statements."
|
+ " Use the available tools: `get_fundamentals` for comprehensive company analysis, `get_balance_sheet`, `get_cashflow`, and `get_income_statement` for specific financial statements."
|
||||||
+ get_language_instruction(),
|
+ get_language_instruction()
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = ChatPromptTemplate.from_messages(
|
prompt = ChatPromptTemplate.from_messages(
|
||||||
@@ -37,8 +37,7 @@ def create_fundamentals_analyst(llm):
|
|||||||
" Use the provided tools to progress towards answering the question."
|
" Use the provided tools to progress towards answering the question."
|
||||||
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
||||||
" will help where you left off. Execute what you can to make progress."
|
" will help where you left off. Execute what you can to make progress."
|
||||||
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
|
" Report what your tools support; another agent decides the trade."
|
||||||
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
|
|
||||||
" You have access to the following tools: {tool_names}."
|
" You have access to the following tools: {tool_names}."
|
||||||
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
||||||
"{system_message}",
|
"{system_message}",
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ Write a very detailed and nuanced report of the trends you observe. Provide spec
|
|||||||
" Use the provided tools to progress towards answering the question."
|
" Use the provided tools to progress towards answering the question."
|
||||||
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
||||||
" will help where you left off. Execute what you can to make progress."
|
" will help where you left off. Execute what you can to make progress."
|
||||||
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
|
" Report what your tools support; another agent decides the trade."
|
||||||
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
|
|
||||||
" You have access to the following tools: {tool_names}."
|
" You have access to the following tools: {tool_names}."
|
||||||
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
||||||
"{system_message}",
|
"{system_message}",
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ def create_news_analyst(llm):
|
|||||||
" Use the provided tools to progress towards answering the question."
|
" Use the provided tools to progress towards answering the question."
|
||||||
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
" If you are unable to fully answer, that's OK; another assistant with different tools"
|
||||||
" will help where you left off. Execute what you can to make progress."
|
" will help where you left off. Execute what you can to make progress."
|
||||||
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
|
" Report what your tools support; another agent decides the trade."
|
||||||
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
|
|
||||||
" You have access to the following tools: {tool_names}."
|
" You have access to the following tools: {tool_names}."
|
||||||
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}\n"
|
||||||
"{system_message}",
|
"{system_message}",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ the LLM is invoked and injects them into the prompt as structured blocks:
|
|||||||
user-labeled Bullish/Bearish sentiment tags
|
user-labeled Bullish/Bearish sentiment tags
|
||||||
3. Reddit posts — r/wallstreetbets, r/stocks, r/investing
|
3. Reddit posts — r/wallstreetbets, r/stocks, r/investing
|
||||||
|
|
||||||
|
Each source is trimmed to the analysis window. These text feeds serve recent
|
||||||
|
items and are not archived as of a past date, so sentiment inputs for a
|
||||||
|
historical run are not guaranteed to be point-in-time.
|
||||||
|
|
||||||
The agent does not use tool-calling; the data is in the prompt from
|
The agent does not use tool-calling; the data is in the prompt from
|
||||||
turn 0. Output uses the structured-output pattern (json_schema for
|
turn 0. Output uses the structured-output pattern (json_schema for
|
||||||
OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic), falling
|
OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic), falling
|
||||||
@@ -89,8 +93,7 @@ def create_sentiment_analyst(llm):
|
|||||||
(
|
(
|
||||||
"system",
|
"system",
|
||||||
"You are a helpful AI assistant, collaborating with other assistants."
|
"You are a helpful AI assistant, collaborating with other assistants."
|
||||||
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
|
" Report what your tools support; another agent decides the trade."
|
||||||
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
|
|
||||||
# No tool-calling here: the data is pre-fetched into the
|
# No tool-calling here: the data is pre-fetched into the
|
||||||
# prompt, so tool-range wording would only invite a
|
# prompt, so tool-range wording would only invite a
|
||||||
# hallucinated tool call (#1130).
|
# hallucinated tool call (#1130).
|
||||||
@@ -156,7 +159,7 @@ Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish /
|
|||||||
<end_of_stocktwits>
|
<end_of_stocktwits>
|
||||||
|
|
||||||
### Reddit posts — r/wallstreetbets, r/stocks, r/investing (past 7 days)
|
### Reddit posts — r/wallstreetbets, r/stocks, r/investing (past 7 days)
|
||||||
Community discussion. Engagement signal via upvote score and comment count. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term).
|
Community discussion, without vote or comment counts. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term).
|
||||||
|
|
||||||
<start_of_reddit>
|
<start_of_reddit>
|
||||||
{reddit_block}
|
{reddit_block}
|
||||||
@@ -168,7 +171,7 @@ Community discussion. Engagement signal via upvote score and comment count. Subr
|
|||||||
|
|
||||||
2. **Look for cross-source divergences.** If news framing is bearish but StockTwits is overwhelmingly bullish, that mismatch is itself a signal — it can mean retail is leaning into a thesis the news flow hasn't caught up to (or vice versa, that retail is chasing while institutions are cautious).
|
2. **Look for cross-source divergences.** If news framing is bearish but StockTwits is overwhelmingly bullish, that mismatch is itself a signal — it can mean retail is leaning into a thesis the news flow hasn't caught up to (or vice versa, that retail is chasing while institutions are cautious).
|
||||||
|
|
||||||
3. **Weight Reddit posts by engagement.** A 400-upvote / 200-comment thread reflects community attention; a 3-upvote post is noise. Read the body excerpts for context — the title alone often misleads.
|
3. **Read Reddit posts for substance.** The feed carries no vote or comment counts, so judge a post by its body excerpt, not its title alone, and do not infer engagement.
|
||||||
|
|
||||||
4. **Distinguish opinion from event.** A news headline ("Nvidia announces $500M Corning deal") is an event; a StockTwits post ("buying NVDA, this is going to moon") is opinion. Both are inputs but should be weighted differently in your conclusions.
|
4. **Distinguish opinion from event.** A news headline ("Nvidia announces $500M Corning deal") is an event; a StockTwits post ("buying NVDA, this is going to moon") is opinion. Both are inputs but should be weighted differently in your conclusions.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
|
|||||||
from tradingagents.agents.utils.agent_utils import (
|
from tradingagents.agents.utils.agent_utils import (
|
||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
|
get_portfolio_context_from_state,
|
||||||
)
|
)
|
||||||
from tradingagents.agents.utils.structured import (
|
from tradingagents.agents.utils.structured import (
|
||||||
NO_EXTERNAL_TOOLS,
|
NO_EXTERNAL_TOOLS,
|
||||||
@@ -27,6 +28,7 @@ def create_portfolio_manager(llm):
|
|||||||
|
|
||||||
def portfolio_manager_node(state) -> dict:
|
def portfolio_manager_node(state) -> dict:
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
|
portfolio_context = get_portfolio_context_from_state(state)
|
||||||
|
|
||||||
history = state["risk_debate_state"]["history"]
|
history = state["risk_debate_state"]["history"]
|
||||||
risk_debate_state = state["risk_debate_state"]
|
risk_debate_state = state["risk_debate_state"]
|
||||||
@@ -44,6 +46,8 @@ def create_portfolio_manager(llm):
|
|||||||
|
|
||||||
{instrument_context}
|
{instrument_context}
|
||||||
|
|
||||||
|
{portfolio_context}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Rating Scale** (use exactly one):
|
**Rating Scale** (use exactly one):
|
||||||
@@ -62,7 +66,15 @@ def create_portfolio_manager(llm):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Be decisive and ground every conclusion in specific evidence from the analysts.
|
Ground every conclusion in specific evidence from the analysts. The risk debate always contains conflicting stances; deciding which is stronger is the job, so conflict alone is not a reason to Hold. Commit to the stronger case, sized by how decisively it wins. Choose Hold only when the evidence is still balanced after that weighing, or too thin to support a call; do not force a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Write these sections, in this order, starting with the rating on its own line:
|
||||||
|
|
||||||
|
- **Rating**: exactly one of Buy / Overweight / Hold / Underweight / Sell
|
||||||
|
- **Executive Summary**: the call and how to act on it
|
||||||
|
- **Investment Thesis**: the evidence that decided it, and what would change it
|
||||||
|
|
||||||
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
|
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
|
||||||
|
|
||||||
|
|||||||
@@ -36,13 +36,21 @@ def create_research_manager(llm):
|
|||||||
- **Underweight**: Cautious view; recommend trimming exposure
|
- **Underweight**: Cautious view; recommend trimming exposure
|
||||||
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
|
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
|
||||||
|
|
||||||
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced.
|
The debate always contains conflicting arguments; deciding which side is stronger is the job, so conflict alone is not a reason to Hold. Commit to the side with the stronger case, sized by how decisively it wins. Choose Hold only when the evidence is still balanced after that weighing, or too thin to support a call; do not manufacture a direction to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Debate History:**
|
**Debate History:**
|
||||||
{history}
|
{history}
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Write these sections, in this order, starting with the recommendation on its own line:
|
||||||
|
|
||||||
|
- **Recommendation**: exactly one of Buy / Overweight / Hold / Underweight / Sell
|
||||||
|
- **Rationale**: which arguments decided it
|
||||||
|
- **Strategic Actions**: concrete steps for the trader, sized against a standard allocation
|
||||||
|
|
||||||
{NO_EXTERNAL_TOOLS}""" + get_language_instruction()
|
{NO_EXTERNAL_TOOLS}""" + get_language_instruction()
|
||||||
|
|
||||||
investment_plan = invoke_structured_or_freetext(
|
investment_plan = invoke_structured_or_freetext(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from tradingagents.agents.utils.agent_utils import (
|
|||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
opponent_argument_or_opening,
|
opponent_argument_or_opening,
|
||||||
|
report_or_absent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -14,10 +15,10 @@ def create_bear_researcher(llm):
|
|||||||
current_response = opponent_argument_or_opening(
|
current_response = opponent_argument_or_opening(
|
||||||
investment_debate_state.get("current_response", ""), "bull analyst"
|
investment_debate_state.get("current_response", ""), "bull analyst"
|
||||||
)
|
)
|
||||||
market_research_report = state["market_report"]
|
market_research_report = report_or_absent(state["market_report"], "market")
|
||||||
sentiment_report = state["sentiment_report"]
|
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
|
||||||
news_report = state["news_report"]
|
news_report = report_or_absent(state["news_report"], "news")
|
||||||
fundamentals_report = state["fundamentals_report"]
|
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
asset_type = state.get("asset_type", "stock")
|
asset_type = state.get("asset_type", "stock")
|
||||||
target_label = "stock" if asset_type == "stock" else "asset"
|
target_label = "stock" if asset_type == "stock" else "asset"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from tradingagents.agents.utils.agent_utils import (
|
|||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
opponent_argument_or_opening,
|
opponent_argument_or_opening,
|
||||||
|
report_or_absent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -14,10 +15,10 @@ def create_bull_researcher(llm):
|
|||||||
current_response = opponent_argument_or_opening(
|
current_response = opponent_argument_or_opening(
|
||||||
investment_debate_state.get("current_response", ""), "bear analyst"
|
investment_debate_state.get("current_response", ""), "bear analyst"
|
||||||
)
|
)
|
||||||
market_research_report = state["market_report"]
|
market_research_report = report_or_absent(state["market_report"], "market")
|
||||||
sentiment_report = state["sentiment_report"]
|
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
|
||||||
news_report = state["news_report"]
|
news_report = report_or_absent(state["news_report"], "news")
|
||||||
fundamentals_report = state["fundamentals_report"]
|
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
asset_type = state.get("asset_type", "stock")
|
asset_type = state.get("asset_type", "stock")
|
||||||
target_label = "stock" if asset_type == "stock" else "asset"
|
target_label = "stock" if asset_type == "stock" else "asset"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from tradingagents.agents.utils.agent_utils import (
|
from tradingagents.agents.utils.agent_utils import (
|
||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
|
get_portfolio_context_from_state,
|
||||||
opponent_argument_or_opening,
|
opponent_argument_or_opening,
|
||||||
|
report_or_absent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +20,12 @@ def create_aggressive_debator(llm):
|
|||||||
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
||||||
)
|
)
|
||||||
|
|
||||||
market_research_report = state["market_report"]
|
market_research_report = report_or_absent(state["market_report"], "market")
|
||||||
sentiment_report = state["sentiment_report"]
|
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
|
||||||
news_report = state["news_report"]
|
news_report = report_or_absent(state["news_report"], "news")
|
||||||
fundamentals_report = state["fundamentals_report"]
|
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
|
portfolio_context = get_portfolio_context_from_state(state)
|
||||||
|
|
||||||
trader_decision = state["trader_investment_plan"]
|
trader_decision = state["trader_investment_plan"]
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ def create_aggressive_debator(llm):
|
|||||||
Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments:
|
Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments:
|
||||||
|
|
||||||
{instrument_context}
|
{instrument_context}
|
||||||
|
{portfolio_context}
|
||||||
Market Research Report: {market_research_report}
|
Market Research Report: {market_research_report}
|
||||||
Social Media Sentiment Report: {sentiment_report}
|
Social Media Sentiment Report: {sentiment_report}
|
||||||
Latest World Affairs Report: {news_report}
|
Latest World Affairs Report: {news_report}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from tradingagents.agents.utils.agent_utils import (
|
from tradingagents.agents.utils.agent_utils import (
|
||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
|
get_portfolio_context_from_state,
|
||||||
opponent_argument_or_opening,
|
opponent_argument_or_opening,
|
||||||
|
report_or_absent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +20,12 @@ def create_conservative_debator(llm):
|
|||||||
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
||||||
)
|
)
|
||||||
|
|
||||||
market_research_report = state["market_report"]
|
market_research_report = report_or_absent(state["market_report"], "market")
|
||||||
sentiment_report = state["sentiment_report"]
|
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
|
||||||
news_report = state["news_report"]
|
news_report = report_or_absent(state["news_report"], "news")
|
||||||
fundamentals_report = state["fundamentals_report"]
|
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
|
portfolio_context = get_portfolio_context_from_state(state)
|
||||||
|
|
||||||
trader_decision = state["trader_investment_plan"]
|
trader_decision = state["trader_investment_plan"]
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ def create_conservative_debator(llm):
|
|||||||
Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision:
|
Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision:
|
||||||
|
|
||||||
{instrument_context}
|
{instrument_context}
|
||||||
|
{portfolio_context}
|
||||||
Market Research Report: {market_research_report}
|
Market Research Report: {market_research_report}
|
||||||
Social Media Sentiment Report: {sentiment_report}
|
Social Media Sentiment Report: {sentiment_report}
|
||||||
Latest World Affairs Report: {news_report}
|
Latest World Affairs Report: {news_report}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from tradingagents.agents.utils.agent_utils import (
|
from tradingagents.agents.utils.agent_utils import (
|
||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
|
get_portfolio_context_from_state,
|
||||||
opponent_argument_or_opening,
|
opponent_argument_or_opening,
|
||||||
|
report_or_absent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +20,12 @@ def create_neutral_debator(llm):
|
|||||||
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
|
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
|
||||||
)
|
)
|
||||||
|
|
||||||
market_research_report = state["market_report"]
|
market_research_report = report_or_absent(state["market_report"], "market")
|
||||||
sentiment_report = state["sentiment_report"]
|
sentiment_report = report_or_absent(state["sentiment_report"], "sentiment")
|
||||||
news_report = state["news_report"]
|
news_report = report_or_absent(state["news_report"], "news")
|
||||||
fundamentals_report = state["fundamentals_report"]
|
fundamentals_report = report_or_absent(state["fundamentals_report"], "fundamentals")
|
||||||
instrument_context = get_instrument_context_from_state(state)
|
instrument_context = get_instrument_context_from_state(state)
|
||||||
|
portfolio_context = get_portfolio_context_from_state(state)
|
||||||
|
|
||||||
trader_decision = state["trader_investment_plan"]
|
trader_decision = state["trader_investment_plan"]
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ def create_neutral_debator(llm):
|
|||||||
Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision:
|
Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision:
|
||||||
|
|
||||||
{instrument_context}
|
{instrument_context}
|
||||||
|
{portfolio_context}
|
||||||
Market Research Report: {market_research_report}
|
Market Research Report: {market_research_report}
|
||||||
Social Media Sentiment Report: {sentiment_report}
|
Social Media Sentiment Report: {sentiment_report}
|
||||||
Latest World Affairs Report: {news_report}
|
Latest World Affairs Report: {news_report}
|
||||||
|
|||||||
@@ -31,9 +31,31 @@ _NULLISH_FLOAT = {"", "none", "n/a", "na", "null", "nil", "-", "tbd", "unknown"}
|
|||||||
|
|
||||||
|
|
||||||
def _coerce_optional_float(value):
|
def _coerce_optional_float(value):
|
||||||
if isinstance(value, str) and value.strip().lower() in _NULLISH_FLOAT:
|
"""Normalise an LLM-written optional numeric field before validation.
|
||||||
return None
|
|
||||||
|
Three shapes show up in practice: a placeholder string ("None", "N/A") in
|
||||||
|
place of an omitted value (#1058); a percentage where a price was asked for
|
||||||
|
("15%", #1288); and a human-formatted price ("$1,234.50"). A percentage
|
||||||
|
cannot be salvaged into an absolute level -- reading "15%" as 15 would put a
|
||||||
|
stop at $15 on a $600 stock -- so it is dropped like a placeholder, leaving
|
||||||
|
one bad field to null out instead of failing the whole proposal. A formatted
|
||||||
|
price is reduced to its number.
|
||||||
|
|
||||||
|
Anything that is not a single number is dropped the same way. A range
|
||||||
|
("150-160") or a hedge ("around 150") would otherwise reach pydantic, fail
|
||||||
|
validation, and discard the whole decision, losing every field the model got
|
||||||
|
right along with the price.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
|
text = value.strip()
|
||||||
|
if text.lower() in _NULLISH_FLOAT or text.endswith("%"):
|
||||||
|
return None
|
||||||
|
cleaned = text.replace(",", "").lstrip("$€£¥").strip()
|
||||||
|
try:
|
||||||
|
return float(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -82,9 +104,10 @@ class ResearchPlan(BaseModel):
|
|||||||
recommendation: PortfolioRating = Field(
|
recommendation: PortfolioRating = Field(
|
||||||
description=(
|
description=(
|
||||||
"The investment recommendation. Exactly one of Buy / Overweight / "
|
"The investment recommendation. Exactly one of Buy / Overweight / "
|
||||||
"Hold / Underweight / Sell. Reserve Hold for situations where the "
|
"Hold / Underweight / Sell. Conflicting arguments alone are not a "
|
||||||
"evidence on both sides is genuinely balanced; otherwise commit to "
|
"reason to Hold: commit to the stronger side, sized by how "
|
||||||
"the side with the stronger arguments."
|
"decisively it wins. Choose Hold only when the evidence is still "
|
||||||
|
"balanced after weighing, or too thin to support a call."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rationale: str = Field(
|
rationale: str = Field(
|
||||||
@@ -97,7 +120,9 @@ class ResearchPlan(BaseModel):
|
|||||||
strategic_actions: str = Field(
|
strategic_actions: str = Field(
|
||||||
description=(
|
description=(
|
||||||
"Concrete steps for the trader to implement the recommendation, "
|
"Concrete steps for the trader to implement the recommendation, "
|
||||||
"including position sizing guidance consistent with the rating."
|
"including sizing guidance relative to a standard allocation. The "
|
||||||
|
"research team does not see the caller's holdings; the trader and "
|
||||||
|
"portfolio manager apply the actual position."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,11 +163,19 @@ class TraderProposal(BaseModel):
|
|||||||
)
|
)
|
||||||
entry_price: float | None = Field(
|
entry_price: float | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional entry price target in the instrument's quote currency.",
|
description=(
|
||||||
|
"Optional entry price target as an absolute number in the instrument's "
|
||||||
|
"quote currency (e.g. 189.5), never a percentage or a range. Omit it "
|
||||||
|
"if you cannot state a specific level."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
stop_loss: float | None = Field(
|
stop_loss: float | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional stop-loss price in the instrument's quote currency.",
|
description=(
|
||||||
|
"Optional stop-loss as an absolute price in the instrument's quote "
|
||||||
|
"currency (e.g. 172.0), never a percentage. Convert a percentage "
|
||||||
|
"distance to the price level it implies, or omit it."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
position_sizing: str | None = Field(
|
position_sizing: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -167,12 +200,12 @@ def render_trader_proposal(proposal: TraderProposal) -> str:
|
|||||||
"",
|
"",
|
||||||
f"**Reasoning**: {proposal.reasoning}",
|
f"**Reasoning**: {proposal.reasoning}",
|
||||||
]
|
]
|
||||||
if proposal.entry_price is not None:
|
# Named even when absent, so a reader can tell a level the trader chose not
|
||||||
parts.extend(["", f"**Entry Price**: {proposal.entry_price}"])
|
# to give from one the schema never asked for.
|
||||||
if proposal.stop_loss is not None:
|
for label, value in (("Entry Price", proposal.entry_price),
|
||||||
parts.extend(["", f"**Stop Loss**: {proposal.stop_loss}"])
|
("Stop Loss", proposal.stop_loss),
|
||||||
if proposal.position_sizing:
|
("Position Sizing", proposal.position_sizing)):
|
||||||
parts.extend(["", f"**Position Sizing**: {proposal.position_sizing}"])
|
parts.extend(["", f"**{label}**: {value if value is not None and value != '' else 'not provided'}"])
|
||||||
parts.extend([
|
parts.extend([
|
||||||
"",
|
"",
|
||||||
f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**",
|
f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**",
|
||||||
@@ -197,7 +230,11 @@ class PortfolioDecision(BaseModel):
|
|||||||
rating: PortfolioRating = Field(
|
rating: PortfolioRating = Field(
|
||||||
description=(
|
description=(
|
||||||
"The final position rating. Exactly one of Buy / Overweight / Hold / "
|
"The final position rating. Exactly one of Buy / Overweight / Hold / "
|
||||||
"Underweight / Sell, picked based on the analysts' debate."
|
"Underweight / Sell, picked based on the analysts' debate. "
|
||||||
|
"Conflicting arguments alone are not a reason to Hold: commit to the "
|
||||||
|
"stronger side, sized by how decisively it wins. Choose Hold only "
|
||||||
|
"when the evidence is still balanced after weighing, or too thin to "
|
||||||
|
"support a call."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
executive_summary: str = Field(
|
executive_summary: str = Field(
|
||||||
@@ -243,10 +280,11 @@ def render_pm_decision(decision: PortfolioDecision) -> str:
|
|||||||
"",
|
"",
|
||||||
f"**Investment Thesis**: {decision.investment_thesis}",
|
f"**Investment Thesis**: {decision.investment_thesis}",
|
||||||
]
|
]
|
||||||
if decision.price_target is not None:
|
# Named even when absent: a missing line reads as a field nobody asked for,
|
||||||
parts.extend(["", f"**Price Target**: {decision.price_target}"])
|
# so a reader cannot tell "no target" from "target not reported".
|
||||||
if decision.time_horizon:
|
target = decision.price_target if decision.price_target is not None else "not provided"
|
||||||
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"])
|
parts.extend(["", f"**Price Target**: {target}"])
|
||||||
|
parts.extend(["", f"**Time Horizon**: {decision.time_horizon or 'not provided'}"])
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from tradingagents.agents.schemas import TraderProposal, render_trader_proposal
|
|||||||
from tradingagents.agents.utils.agent_utils import (
|
from tradingagents.agents.utils.agent_utils import (
|
||||||
get_instrument_context_from_state,
|
get_instrument_context_from_state,
|
||||||
get_language_instruction,
|
get_language_instruction,
|
||||||
|
get_portfolio_context_from_state,
|
||||||
)
|
)
|
||||||
from tradingagents.agents.utils.structured import (
|
from tradingagents.agents.utils.structured import (
|
||||||
NO_EXTERNAL_TOOLS,
|
NO_EXTERNAL_TOOLS,
|
||||||
@@ -31,6 +32,7 @@ def create_trader(llm):
|
|||||||
# report is empty when the user did not select the market analyst, so
|
# report is empty when the user did not select the market analyst, so
|
||||||
# only offer it (and the grounding instruction) when it has content.
|
# only offer it (and the grounding instruction) when it has content.
|
||||||
market_report = (state["market_report"] or "").strip()
|
market_report = (state["market_report"] or "").strip()
|
||||||
|
portfolio_context = get_portfolio_context_from_state(state)
|
||||||
|
|
||||||
if market_report:
|
if market_report:
|
||||||
grounding = (
|
grounding = (
|
||||||
@@ -50,6 +52,13 @@ def create_trader(llm):
|
|||||||
"You are a trading agent analyzing market data to make investment decisions. "
|
"You are a trading agent analyzing market data to make investment decisions. "
|
||||||
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
|
"Based on your analysis, provide a specific recommendation to buy, sell, or hold. "
|
||||||
+ grounding
|
+ grounding
|
||||||
|
# Entry/stop are numeric price fields. Asking for concrete
|
||||||
|
# levels invites a percentage ("15%"), which is not a price
|
||||||
|
# and fails the structured parse (#1288).
|
||||||
|
+ "State entry price and stop-loss as absolute price levels in the "
|
||||||
|
"instrument's quote currency (for example 189.5), never a percentage "
|
||||||
|
"or a range; convert a percentage distance to the price level it "
|
||||||
|
"implies, or omit the field if you cannot state a number. "
|
||||||
+ NO_EXTERNAL_TOOLS
|
+ NO_EXTERNAL_TOOLS
|
||||||
+ get_language_instruction()
|
+ get_language_instruction()
|
||||||
),
|
),
|
||||||
@@ -60,8 +69,17 @@ def create_trader(llm):
|
|||||||
f"Here is the research team's investment plan for {company_name}. "
|
f"Here is the research team's investment plan for {company_name}. "
|
||||||
f"{instrument_context}\n\n"
|
f"{instrument_context}\n\n"
|
||||||
f"{report_section}"
|
f"{report_section}"
|
||||||
|
f"{portfolio_context}\n\n"
|
||||||
f"Proposed Investment Plan:\n{investment_plan}\n\n"
|
f"Proposed Investment Plan:\n{investment_plan}\n\n"
|
||||||
f"Make an informed, strategic trading decision."
|
"Make an informed, strategic trading decision.\n\n"
|
||||||
|
"## Output\n\n"
|
||||||
|
"Write these sections, in this order, starting with the action "
|
||||||
|
"on its own line:\n\n"
|
||||||
|
"- **Action**: exactly one of Buy / Hold / Sell. A research "
|
||||||
|
"recommendation of Overweight is a Buy and Underweight is a Sell, "
|
||||||
|
"sized by how strong the case is; conflict alone is not a Hold.\n"
|
||||||
|
"- **Reasoning**: why, against the plan and the price structure\n"
|
||||||
|
"- **Entry Price**, **Stop Loss**, **Position Sizing**: when you can state them"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -74,3 +74,4 @@ class AgentState(MessagesState):
|
|||||||
]
|
]
|
||||||
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
|
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
|
||||||
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
|
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"]
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ __all__ = [
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from tradingagents.dataflows.utils import get_current_date # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def get_language_instruction() -> str:
|
def get_language_instruction() -> str:
|
||||||
"""Return a prompt instruction for the configured output language.
|
"""Return a prompt instruction for the configured output language.
|
||||||
@@ -137,6 +139,7 @@ def build_instrument_context(
|
|||||||
ticker: str,
|
ticker: str,
|
||||||
asset_type: str = "stock",
|
asset_type: str = "stock",
|
||||||
identity: Mapping[str, str] | None = None,
|
identity: Mapping[str, str] | None = None,
|
||||||
|
curr_date: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Describe the exact instrument so agents preserve identity and ticker.
|
"""Describe the exact instrument so agents preserve identity and ticker.
|
||||||
|
|
||||||
@@ -144,6 +147,11 @@ def build_instrument_context(
|
|||||||
:func:`resolve_instrument_identity`), the company name and business
|
:func:`resolve_instrument_identity`), the company name and business
|
||||||
classification are injected so agents anchor to the real company rather
|
classification are injected so agents anchor to the real company rather
|
||||||
than pattern-matching the price chart to a wrong one (#814).
|
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.
|
||||||
"""
|
"""
|
||||||
is_crypto = asset_type == "crypto"
|
is_crypto = asset_type == "crypto"
|
||||||
instrument_label = "asset" if is_crypto else "instrument"
|
instrument_label = "asset" if is_crypto else "instrument"
|
||||||
@@ -174,6 +182,13 @@ def build_instrument_context(
|
|||||||
"Do not substitute a different company or ticker unless a tool "
|
"Do not substitute a different company or ticker unless a tool "
|
||||||
"result explicitly disproves this resolved identity."
|
"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"({today}), not necessarily on {curr_date}: a name or "
|
||||||
|
f"classification changed since then would read as the current one."
|
||||||
|
)
|
||||||
|
|
||||||
if is_crypto:
|
if is_crypto:
|
||||||
context += (
|
context += (
|
||||||
@@ -201,6 +216,37 @@ def get_instrument_context_from_state(state: Mapping[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def report_or_absent(text: str, source: str) -> str:
|
||||||
|
"""An analyst's report, or a marker saying it was never produced.
|
||||||
|
|
||||||
|
A report is empty when its analyst was not selected, refused, or returned
|
||||||
|
nothing. Interpolating that into a labelled section presents an absence as a
|
||||||
|
blank finding, and the reading agent fills it in from nothing, the same way
|
||||||
|
an empty opponent argument used to invite an invented rebuttal (#1176).
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return f"(No {source} report in this run: it is not available, not an empty finding.)"
|
||||||
|
|
||||||
|
|
||||||
|
def get_portfolio_context_from_state(state: Mapping[str, Any]) -> str:
|
||||||
|
"""Return the caller's portfolio block, or a notice that none was given.
|
||||||
|
|
||||||
|
A run without portfolio context must not read as a flat book: the agents
|
||||||
|
would otherwise size as if the caller held nothing, which is a claim about
|
||||||
|
an account we were never told about.
|
||||||
|
"""
|
||||||
|
context = state.get("portfolio_context")
|
||||||
|
if isinstance(context, str) and context.strip():
|
||||||
|
return context
|
||||||
|
return (
|
||||||
|
"Portfolio context: not provided. You do not know the caller's current "
|
||||||
|
"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 create_msg_delete():
|
||||||
def delete_messages(state):
|
def delete_messages(state):
|
||||||
"""Clear messages and add a context-anchored placeholder.
|
"""Clear messages and add a context-anchored placeholder.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import as_of_window
|
||||||
from tradingagents.dataflows.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ def get_stock_data(
|
|||||||
symbol: Annotated[str, "ticker symbol of the company"],
|
symbol: Annotated[str, "ticker symbol of the company"],
|
||||||
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
|
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
|
||||||
end_date: Annotated[str, "End 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:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve stock price data (OHLCV) for a given ticker symbol.
|
Retrieve stock price data (OHLCV) for a given ticker symbol.
|
||||||
@@ -21,4 +24,5 @@ def get_stock_data(
|
|||||||
Returns:
|
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 stock price data for the specified ticker symbol in the specified 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)
|
return route_to_vendor("get_stock_data", symbol, start_date, end_date)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import as_of
|
||||||
from tradingagents.dataflows.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ from tradingagents.dataflows.interface import route_to_vendor
|
|||||||
def get_fundamentals(
|
def get_fundamentals(
|
||||||
ticker: Annotated[str, "ticker symbol"],
|
ticker: Annotated[str, "ticker symbol"],
|
||||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"],
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"],
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve comprehensive fundamental data for a given ticker symbol.
|
Retrieve comprehensive fundamental data for a given ticker symbol.
|
||||||
@@ -19,7 +22,7 @@ def get_fundamentals(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted report containing comprehensive fundamental data
|
str: A formatted report containing comprehensive fundamental data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_fundamentals", ticker, curr_date)
|
return route_to_vendor("get_fundamentals", ticker, as_of(curr_date, trade_date))
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -27,6 +30,7 @@ def get_balance_sheet(
|
|||||||
ticker: Annotated[str, "ticker symbol"],
|
ticker: Annotated[str, "ticker symbol"],
|
||||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve balance sheet data for a given ticker symbol.
|
Retrieve balance sheet data for a given ticker symbol.
|
||||||
@@ -38,7 +42,7 @@ def get_balance_sheet(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted report containing balance sheet data
|
str: A formatted report containing balance sheet data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_balance_sheet", ticker, freq, curr_date)
|
return route_to_vendor("get_balance_sheet", ticker, freq, as_of(curr_date, trade_date))
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -46,6 +50,7 @@ def get_cashflow(
|
|||||||
ticker: Annotated[str, "ticker symbol"],
|
ticker: Annotated[str, "ticker symbol"],
|
||||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve cash flow statement data for a given ticker symbol.
|
Retrieve cash flow statement data for a given ticker symbol.
|
||||||
@@ -57,7 +62,7 @@ def get_cashflow(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted report containing cash flow statement data
|
str: A formatted report containing cash flow statement data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_cashflow", ticker, freq, curr_date)
|
return route_to_vendor("get_cashflow", ticker, freq, as_of(curr_date, trade_date))
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -65,6 +70,7 @@ def get_income_statement(
|
|||||||
ticker: Annotated[str, "ticker symbol"],
|
ticker: Annotated[str, "ticker symbol"],
|
||||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve income statement data for a given ticker symbol.
|
Retrieve income statement data for a given ticker symbol.
|
||||||
@@ -76,4 +82,4 @@ def get_income_statement(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted report containing income statement data
|
str: A formatted report containing income statement data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_income_statement", ticker, freq, curr_date)
|
return route_to_vendor("get_income_statement", ticker, freq, as_of(curr_date, trade_date))
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import as_of
|
||||||
from tradingagents.dataflows.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ def get_macro_indicators(
|
|||||||
look_back_days: Annotated[
|
look_back_days: Annotated[
|
||||||
int | None, "Trailing window length in days; omit for a 1-year window"
|
int | None, "Trailing window length in days; omit for a 1-year window"
|
||||||
] = None,
|
] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve a macroeconomic indicator time series from FRED (Federal Reserve
|
Retrieve a macroeconomic indicator time series from FRED (Federal Reserve
|
||||||
@@ -33,4 +36,4 @@ def get_macro_indicators(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted markdown report of the macro series
|
str: A formatted markdown report of the macro series
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_macro_indicators", indicator, curr_date, look_back_days)
|
return route_to_vendor("get_macro_indicators", indicator, as_of(curr_date, trade_date), look_back_days)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import as_of
|
||||||
from tradingagents.dataflows.market_data_validator import build_verified_market_snapshot
|
from tradingagents.dataflows.market_data_validator import build_verified_market_snapshot
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ def get_verified_market_snapshot(
|
|||||||
look_back_days: Annotated[
|
look_back_days: Annotated[
|
||||||
int, "number of recent trading rows to include for sanity-checking"
|
int, "number of recent trading rows to include for sanity-checking"
|
||||||
] = 30,
|
] = 30,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Deterministic verification snapshot for exact market-data claims.
|
"""Deterministic verification snapshot for exact market-data claims.
|
||||||
|
|
||||||
@@ -20,4 +23,4 @@ def get_verified_market_snapshot(
|
|||||||
price levels, Bollinger bands, RSI, MACD, moving averages, support /
|
price levels, Bollinger bands, RSI, MACD, moving averages, support /
|
||||||
resistance, or historical comparisons, and treat it as the source of truth.
|
resistance, or historical comparisons, and treat it as the source of truth.
|
||||||
"""
|
"""
|
||||||
return build_verified_market_snapshot(symbol, curr_date, look_back_days)
|
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days)
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ class TradingMemoryLog:
|
|||||||
"""Append pending entry at end of propagate(). No LLM call."""
|
"""Append pending entry at end of propagate(). No LLM call."""
|
||||||
if not self._log_path:
|
if not self._log_path:
|
||||||
return
|
return
|
||||||
# Idempotency guard: fast raw-text scan instead of full parse
|
# Idempotency guard: fast raw-text scan instead of full parse. Any entry
|
||||||
|
# for this ticker and date blocks another, pending or settled: a re-run
|
||||||
|
# after the outcome landed would otherwise count the same decision twice
|
||||||
|
# in past context and in every aggregate over the log.
|
||||||
if self._log_path.exists():
|
if self._log_path.exists():
|
||||||
raw = self._log_path.read_text(encoding="utf-8")
|
raw = self._log_path.read_text(encoding="utf-8")
|
||||||
for line in raw.splitlines():
|
for line in raw.splitlines():
|
||||||
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("| pending]"):
|
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"):
|
||||||
return
|
return
|
||||||
rating = parse_rating(final_trade_decision)
|
rating = parse_rating(final_trade_decision)
|
||||||
tag = f"[{trade_date} | {ticker} | {rating} | pending]"
|
tag = f"[{trade_date} | {ticker} | {rating} | pending]"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
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.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ def get_news(
|
|||||||
ticker: Annotated[str, "Ticker symbol"],
|
ticker: Annotated[str, "Ticker symbol"],
|
||||||
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
|
start_date: Annotated[str, "Start date in yyyy-mm-dd format"],
|
||||||
end_date: Annotated[str, "End 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:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve news data for a given ticker symbol.
|
Retrieve news data for a given ticker symbol.
|
||||||
@@ -21,6 +24,7 @@ def get_news(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted string containing news data
|
str: A formatted string containing news data
|
||||||
"""
|
"""
|
||||||
|
start_date, end_date = as_of_window(start_date, end_date, trade_date)
|
||||||
return route_to_vendor("get_news", ticker, start_date, end_date)
|
return route_to_vendor("get_news", ticker, start_date, end_date)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -28,6 +32,7 @@ def get_global_news(
|
|||||||
curr_date: Annotated[str, "Current date in yyyy-mm-dd format"],
|
curr_date: Annotated[str, "Current date in yyyy-mm-dd format"],
|
||||||
look_back_days: Annotated[int | None, "Days to look back; omit to use the configured default"] = None,
|
look_back_days: Annotated[int | None, "Days to look back; omit to use the configured default"] = None,
|
||||||
limit: Annotated[int | None, "Max articles to return; omit to use the configured default"] = None,
|
limit: Annotated[int | None, "Max articles to return; omit to use the configured default"] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve global news data.
|
Retrieve global news data.
|
||||||
@@ -43,11 +48,12 @@ def get_global_news(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted string containing global news data
|
str: A formatted string containing global news data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_global_news", curr_date, look_back_days, limit)
|
return route_to_vendor("get_global_news", as_of(curr_date, trade_date), look_back_days, limit)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
def get_insider_transactions(
|
def get_insider_transactions(
|
||||||
ticker: Annotated[str, "ticker symbol"],
|
ticker: Annotated[str, "ticker symbol"],
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve insider transaction information about a company.
|
Retrieve insider transaction information about a company.
|
||||||
@@ -57,4 +63,4 @@ def get_insider_transactions(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A report of insider transaction data
|
str: A report of insider transaction data
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_insider_transactions", ticker)
|
return route_to_vendor("get_insider_transactions", ticker, trade_date or None)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
from tradingagents.dataflows.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ def get_prediction_markets(
|
|||||||
"'US election', or a sector/company event.",
|
"'US election', or a sector/company event.",
|
||||||
],
|
],
|
||||||
limit: Annotated[int | None, "Max markets to return; omit for a default of 6"] = None,
|
limit: Annotated[int | None, "Max markets to return; omit for a default of 6"] = None,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve live, market-implied probabilities for forward-looking events from
|
Retrieve live, market-implied probabilities for forward-looking events from
|
||||||
@@ -28,4 +30,4 @@ def get_prediction_markets(
|
|||||||
Returns:
|
Returns:
|
||||||
str: A formatted markdown report of matching prediction markets
|
str: A formatted markdown report of matching prediction markets
|
||||||
"""
|
"""
|
||||||
return route_to_vendor("get_prediction_markets", topic, limit)
|
return route_to_vendor("get_prediction_markets", topic, limit, trade_date or None)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
|
|||||||
|
|
||||||
Centralising it here avoids drift between those call sites.
|
Centralising it here avoids drift between those call sites.
|
||||||
|
|
||||||
``extract_rating`` returns ``None`` when no rating can be found, so the graph can
|
``extract_rating`` returns ``None`` when no rating can be found, and every
|
||||||
surface an explicit ``REVIEW`` signal instead of a fabricated ``Hold`` (#1170).
|
caller turns that into ``REVIEW`` rather than a tradeable position: a decision
|
||||||
``parse_rating`` keeps the legacy silent-default behaviour for callers (e.g. the
|
nobody can read is not a Hold, and a Hold recorded in its place is quoted back to
|
||||||
memory log) that need a rating string regardless.
|
the next run as a call that was never made (#1170).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -32,9 +32,13 @@ RATING_REVIEW = "REVIEW"
|
|||||||
|
|
||||||
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
|
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
|
||||||
|
|
||||||
# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown
|
# Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown
|
||||||
# bold wrappers and either a colon or hyphen separator.
|
# bold wrappers and any dash or colon a model writes as the separator.
|
||||||
_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE)
|
_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\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).
|
# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match).
|
||||||
_RATING_WORD_RE = re.compile(
|
_RATING_WORD_RE = re.compile(
|
||||||
@@ -54,25 +58,31 @@ def extract_rating(text: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
norm = unicodedata.normalize("NFKC", text)
|
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
|
||||||
for line in norm.splitlines():
|
for line in norm.splitlines():
|
||||||
|
if _RATING_SCALE_RE.search(line):
|
||||||
|
continue
|
||||||
m = _RATING_LABEL_RE.search(line)
|
m = _RATING_LABEL_RE.search(line)
|
||||||
if m and m.group(1).lower() in _RATING_SET:
|
if m and m.group(1).lower() in _RATING_SET:
|
||||||
return m.group(1).capitalize()
|
labelled = m.group(1).capitalize()
|
||||||
|
if labelled:
|
||||||
|
return labelled
|
||||||
|
|
||||||
m = _RATING_WORD_RE.search(norm)
|
# No label. A single rating word in the text is the call; several are an
|
||||||
if m:
|
# argument, and picking one of them reports a direction nobody decided --
|
||||||
return m.group(1).capitalize()
|
# 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 None
|
return named.pop() if len(named) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def parse_rating(text: str, default: str = "Hold") -> str:
|
def parse_rating(text: str, default: str = RATING_REVIEW) -> str:
|
||||||
"""Extract a 5-tier rating, falling back to ``default`` when none is found.
|
"""Extract a 5-tier rating, or ``REVIEW`` when the decision has none.
|
||||||
|
|
||||||
Legacy convenience wrapper: it always returns a rating string, so an
|
For callers that need a string for every decision, such as the memory log's
|
||||||
unparseable decision silently becomes ``default`` (``Hold``). Callers that
|
entry tag. The default is the review sentinel, never a tradeable rating.
|
||||||
must distinguish "no rating" from a real Hold should use
|
|
||||||
:func:`extract_rating` (or the graph's REVIEW-surfacing signal) instead.
|
|
||||||
"""
|
"""
|
||||||
rating = extract_rating(text)
|
rating = extract_rating(text)
|
||||||
return rating if rating is not None else default
|
return rating if rating is not None else default
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.prebuilt import InjectedState
|
||||||
|
|
||||||
|
from tradingagents.dataflows.date_window import as_of
|
||||||
from tradingagents.dataflows.interface import route_to_vendor
|
from tradingagents.dataflows.interface import route_to_vendor
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ def get_indicators(
|
|||||||
indicator: Annotated[str, "technical indicator to get the analysis and report of"],
|
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"],
|
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,
|
look_back_days: Annotated[int, "how many days to look back"] = 30,
|
||||||
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Retrieve a single technical indicator for a given ticker symbol.
|
Retrieve a single technical indicator for a given ticker symbol.
|
||||||
@@ -25,6 +28,7 @@ def get_indicators(
|
|||||||
"""
|
"""
|
||||||
# LLMs sometimes pass multiple indicators as a comma-separated string;
|
# LLMs sometimes pass multiple indicators as a comma-separated string;
|
||||||
# split and process each individually.
|
# split and process each individually.
|
||||||
|
curr_date = as_of(curr_date, trade_date)
|
||||||
indicators = [i.strip().lower() for i in indicator.split(",") if i.strip()]
|
indicators = [i.strip().lower() for i in indicator.split(",") if i.strip()]
|
||||||
results = []
|
results = []
|
||||||
for ind in indicators:
|
for ind in indicators:
|
||||||
|
|||||||
201
tradingagents/backtest.py
Normal file
201
tradingagents/backtest.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Run the graph over a grid of tickers and dates, and score what came back.
|
||||||
|
|
||||||
|
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
|
||||||
|
rating and later settles it with realized and alpha return against the
|
||||||
|
instrument's regional benchmark, so there is nothing to record separately.
|
||||||
|
|
||||||
|
Scope: this evaluates decision quality. It is not a portfolio simulator, and
|
||||||
|
must not grow one. Turning a rating into a filled order needs a quantity, a fill
|
||||||
|
price and a cash ledger, none of which the system has; inventing them here would
|
||||||
|
put an execution model behind an evaluation tool. Cells are therefore
|
||||||
|
independent, and a portfolio, when given, is the same standing book for every
|
||||||
|
cell rather than a position carried forward.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
from tradingagents.agents.utils.rating import RATING_REVIEW
|
||||||
|
from tradingagents.dataflows.utils import get_current_date, safe_ticker_component
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_grid(start_date: str, end_date: str, every_n_days: int = 1) -> list[str]:
|
||||||
|
"""Analysis dates from ``start_date``, never past today.
|
||||||
|
|
||||||
|
A future date has no outcome to settle against, and the graph rejects one, so
|
||||||
|
the grid stops at the present rather than producing cells that cannot score.
|
||||||
|
"""
|
||||||
|
start, end = _canonical(start_date), _canonical(end_date)
|
||||||
|
if every_n_days < 1:
|
||||||
|
raise ValueError("every_n_days must be at least 1")
|
||||||
|
if end < start:
|
||||||
|
raise ValueError(f"the grid ends before it starts: {end_date} is before {start_date}")
|
||||||
|
|
||||||
|
last = min(end, datetime.strptime(get_current_date(), "%Y-%m-%d"))
|
||||||
|
dates, cursor = [], start
|
||||||
|
while cursor <= last:
|
||||||
|
dates.append(cursor.strftime("%Y-%m-%d"))
|
||||||
|
cursor += timedelta(days=every_n_days)
|
||||||
|
return dates
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical(date: str) -> datetime:
|
||||||
|
"""Parse a grid bound, rejecting anything the run date would also reject."""
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(str(date), "%Y-%m-%d")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"grid dates must be in YYYY-MM-DD format, got {date!r}") from exc
|
||||||
|
if parsed.strftime("%Y-%m-%d") != str(date):
|
||||||
|
raise ValueError(f"grid dates must be in YYYY-MM-DD format, got {date!r}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _alpha(entry: dict) -> float | None:
|
||||||
|
"""Alpha return of a settled entry, or None when it has not settled.
|
||||||
|
|
||||||
|
The log stores it as a percentage rounded to one decimal, so aggregates here
|
||||||
|
are accurate to 0.1 of a percentage point, not to the raw quote.
|
||||||
|
"""
|
||||||
|
text = (entry.get("alpha") or "").strip().rstrip("%")
|
||||||
|
try:
|
||||||
|
return float(text) / 100
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BacktestResult:
|
||||||
|
run_id: str
|
||||||
|
log_path: Path
|
||||||
|
cells_run: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
failures: list[tuple[str, str, str]] = field(default_factory=list)
|
||||||
|
settlement_failures: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# What each rating claims will happen, so an outcome can be scored against it.
|
||||||
|
# Hold claims no direction, so nothing about alpha proves it right or wrong.
|
||||||
|
_DIRECTION = {"Buy": 1, "Overweight": 1, "Hold": 0, "Underweight": -1, "Sell": -1}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RatingScore:
|
||||||
|
count: int
|
||||||
|
hit_rate: float | None
|
||||||
|
mean_alpha: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BacktestSummary:
|
||||||
|
resolved: int
|
||||||
|
pending: int
|
||||||
|
by_rating: dict[str, RatingScore]
|
||||||
|
unscored: int = 0
|
||||||
|
holding: str = ""
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
lines = [f"Resolved cells: {self.resolved} · pending: {self.pending}"
|
||||||
|
+ (f" · unscored: {self.unscored}" if self.unscored else "")]
|
||||||
|
for rating, score in self.by_rating.items():
|
||||||
|
called = (f"called the direction {score.hit_rate:.0%}"
|
||||||
|
if score.hit_rate is not None else "no direction claimed")
|
||||||
|
lines.append(
|
||||||
|
f"- {rating}: n={score.count}, {called}, "
|
||||||
|
f"mean alpha {score.mean_alpha:+.2%} vs the benchmark"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
if self.pending:
|
||||||
|
lines.append("Pending cells are not scored above; re-run to settle them.")
|
||||||
|
lines.append(
|
||||||
|
f"Alpha is measured over {self.holding} after each analysis date. "
|
||||||
|
"One model sampling per cell, and text feeds are not archived, so "
|
||||||
|
"these figures are indicative rather than repeatable."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(
|
||||||
|
tickers: list[str],
|
||||||
|
dates: list[str],
|
||||||
|
config: dict,
|
||||||
|
asset_type: str = "stock",
|
||||||
|
portfolio=None,
|
||||||
|
selected_analysts=("market", "social", "news", "fundamentals"),
|
||||||
|
run_id: str | None = None,
|
||||||
|
) -> BacktestResult:
|
||||||
|
"""Analyze every ticker on every date, into a decision 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.
|
||||||
|
"""
|
||||||
|
# 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.
|
||||||
|
run_id = safe_ticker_component(run_id or datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||||
|
run_dir = Path(config["results_dir"]) / "backtest" / run_id
|
||||||
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
run_config = {**config, "results_dir": str(run_dir),
|
||||||
|
"memory_log_path": str(run_dir / "trading_memory.md")}
|
||||||
|
|
||||||
|
graph = TradingAgentsGraph(selected_analysts, config=run_config)
|
||||||
|
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)))
|
||||||
|
|
||||||
|
# Settlement runs at the start of the next run for a ticker, so each ticker's
|
||||||
|
# last cell would stay pending without this pass.
|
||||||
|
for ticker in tickers:
|
||||||
|
try:
|
||||||
|
graph.settle_pending(ticker)
|
||||||
|
except Exception as exc: # reflection calls an LLM; one failure is not the sweep's
|
||||||
|
logger.warning("Settling %s failed: %s", ticker, exc)
|
||||||
|
result.settlement_failures.append((ticker, str(exc)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(memory_log: TradingMemoryLog) -> BacktestSummary:
|
||||||
|
"""Score the settled decisions in a log, by rating."""
|
||||||
|
entries = memory_log.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.
|
||||||
|
resolved = [(e, _alpha(e)) for e in entries
|
||||||
|
if not e["pending"] and e["rating"] != RATING_REVIEW]
|
||||||
|
resolved = [(e, a) for e, a in resolved if a is not None]
|
||||||
|
by_rating: dict[str, RatingScore] = {}
|
||||||
|
for rating in dict.fromkeys(e["rating"] for e, _ in resolved):
|
||||||
|
alphas = [a for e, a in resolved if e["rating"] == rating]
|
||||||
|
direction = _DIRECTION.get(rating, 0)
|
||||||
|
by_rating[rating] = RatingScore(
|
||||||
|
count=len(alphas),
|
||||||
|
hit_rate=(sum(a * direction > 0 for a in alphas) / len(alphas)) if direction else None,
|
||||||
|
mean_alpha=sum(alphas) / len(alphas),
|
||||||
|
)
|
||||||
|
unscored = sum(1 for e in entries if e["rating"] == RATING_REVIEW)
|
||||||
|
# Report the window the outcomes were actually measured over, from the log.
|
||||||
|
windows = {f"{e['holding'][:-1]} trading days" for e, _ in resolved
|
||||||
|
if (e.get("holding") or "").endswith("d")}
|
||||||
|
return BacktestSummary(resolved=len(resolved),
|
||||||
|
pending=len(entries) - len(resolved) - unscored,
|
||||||
|
by_rating=by_rating, unscored=unscored,
|
||||||
|
holding=", ".join(sorted(windows)) or "the configured window")
|
||||||
@@ -4,9 +4,9 @@ from datetime import datetime
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
|
||||||
|
|
||||||
from .errors import VendorNotConfiguredError, VendorRateLimitError
|
from .errors import VendorNotConfiguredError, VendorRateLimitError
|
||||||
|
from .utils import get_scrubbed
|
||||||
|
|
||||||
API_BASE_URL = "https://www.alphavantage.co/query"
|
API_BASE_URL = "https://www.alphavantage.co/query"
|
||||||
|
|
||||||
@@ -34,8 +34,13 @@ def get_api_key() -> str:
|
|||||||
)
|
)
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
def format_datetime_for_api(date_input) -> str:
|
def format_datetime_for_api(date_input, end_of_day: bool = False) -> str:
|
||||||
"""Convert various date formats to YYYYMMDDTHHMM format required by Alpha Vantage API."""
|
"""Convert various date formats to the YYYYMMDDTHHMM Alpha Vantage expects.
|
||||||
|
|
||||||
|
A plain date means midnight, which is the start of that day. For the end of
|
||||||
|
a window pass ``end_of_day`` so the day itself is inside it, rather than
|
||||||
|
dropping everything published on the analysis date.
|
||||||
|
"""
|
||||||
if isinstance(date_input, str):
|
if isinstance(date_input, str):
|
||||||
# If already in correct format, return as-is
|
# If already in correct format, return as-is
|
||||||
if len(date_input) == 13 and 'T' in date_input:
|
if len(date_input) == 13 and 'T' in date_input:
|
||||||
@@ -43,7 +48,7 @@ def format_datetime_for_api(date_input) -> str:
|
|||||||
# Try to parse common date formats
|
# Try to parse common date formats
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(date_input, "%Y-%m-%d")
|
dt = datetime.strptime(date_input, "%Y-%m-%d")
|
||||||
return dt.strftime("%Y%m%dT0000")
|
return dt.strftime("%Y%m%dT2359" if end_of_day else "%Y%m%dT0000")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M")
|
dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M")
|
||||||
@@ -66,10 +71,11 @@ def _make_api_request(function_name: str, params: dict) -> dict | str:
|
|||||||
AlphaVantageRateLimitError: When API rate limit is exceeded
|
AlphaVantageRateLimitError: When API rate limit is exceeded
|
||||||
"""
|
"""
|
||||||
# Create a copy of params to avoid modifying the original
|
# Create a copy of params to avoid modifying the original
|
||||||
|
api_key = get_api_key()
|
||||||
api_params = params.copy()
|
api_params = params.copy()
|
||||||
api_params.update({
|
api_params.update({
|
||||||
"function": function_name,
|
"function": function_name,
|
||||||
"apikey": get_api_key(),
|
"apikey": api_key,
|
||||||
"source": "trading_agents",
|
"source": "trading_agents",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,8 +89,9 @@ def _make_api_request(function_name: str, params: dict) -> dict | str:
|
|||||||
# Remove entitlement if it's None or empty
|
# Remove entitlement if it's None or empty
|
||||||
api_params.pop("entitlement", None)
|
api_params.pop("entitlement", None)
|
||||||
|
|
||||||
response = requests.get(API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT)
|
response = get_scrubbed(
|
||||||
response.raise_for_status()
|
API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT, secret=api_key
|
||||||
|
)
|
||||||
|
|
||||||
response_text = response.text
|
response_text = response.text
|
||||||
|
|
||||||
@@ -128,24 +135,18 @@ def _filter_csv_by_date_range(csv_data: str, start_date: str, end_date: str) ->
|
|||||||
if not csv_data or csv_data.strip() == "":
|
if not csv_data or csv_data.strip() == "":
|
||||||
return csv_data
|
return csv_data
|
||||||
|
|
||||||
try:
|
# Deliberately unguarded: TIME_SERIES_DAILY_ADJUSTED returns the full series
|
||||||
# Parse CSV data
|
# up to today, so this trim is the only thing keeping bars after end_date out
|
||||||
|
# of a historical run. Swallowing a parse failure would serve the untrimmed
|
||||||
|
# body, and with it future prices.
|
||||||
df = pd.read_csv(StringIO(csv_data))
|
df = pd.read_csv(StringIO(csv_data))
|
||||||
|
|
||||||
# Assume the first column is the date column (timestamp)
|
# Assume the first column is the date column (timestamp)
|
||||||
date_col = df.columns[0]
|
date_col = df.columns[0]
|
||||||
df[date_col] = pd.to_datetime(df[date_col])
|
df[date_col] = pd.to_datetime(df[date_col])
|
||||||
|
|
||||||
# Filter by date range
|
|
||||||
start_dt = pd.to_datetime(start_date)
|
start_dt = pd.to_datetime(start_date)
|
||||||
end_dt = pd.to_datetime(end_date)
|
end_dt = pd.to_datetime(end_date)
|
||||||
|
|
||||||
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
|
filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)]
|
||||||
|
|
||||||
# Convert back to CSV string
|
|
||||||
return filtered_df.to_csv(index=False)
|
return filtered_df.to_csv(index=False)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# If filtering fails, return original data with a warning
|
|
||||||
print(f"Warning: Failed to filter CSV data by date range: {e}")
|
|
||||||
return csv_data
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from .alpha_vantage_common import _make_api_request
|
from .alpha_vantage_common import _make_api_request
|
||||||
|
from .date_window import withhold_live_profile
|
||||||
|
|
||||||
|
|
||||||
def _filter_reports_by_date(result, curr_date: str):
|
def _filter_reports_by_date(result, curr_date: str):
|
||||||
@@ -31,13 +32,22 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str:
|
|||||||
"""
|
"""
|
||||||
Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage.
|
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``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticker (str): Ticker symbol of the company
|
ticker (str): Ticker symbol of the company
|
||||||
curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage)
|
curr_date (str): Analysis date, yyyy-mm-dd
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Company overview data including financial ratios and key metrics
|
str: Company overview data including financial ratios and key metrics
|
||||||
"""
|
"""
|
||||||
|
withheld = withhold_live_profile(curr_date, ticker)
|
||||||
|
if withheld:
|
||||||
|
return withheld
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"symbol": ticker,
|
"symbol": ticker,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
from .alpha_vantage_common import AlphaVantageNotConfiguredError, _make_api_request
|
import logging
|
||||||
|
|
||||||
|
from .alpha_vantage_common import _make_api_request
|
||||||
|
from .errors import NoMarketDataError, VendorError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_indicator(
|
def get_indicator(
|
||||||
@@ -60,8 +65,11 @@ def get_indicator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if indicator not in supported_indicators:
|
if indicator not in supported_indicators:
|
||||||
raise ValueError(
|
# A vendor error, not a caller error: another vendor may compute it, and
|
||||||
f"Indicator {indicator} is not supported. Please choose from: {list(supported_indicators.keys())}"
|
# the router decides. yfinance rejects a name nobody serves.
|
||||||
|
raise NoMarketDataError(
|
||||||
|
symbol, symbol,
|
||||||
|
f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
@@ -130,12 +138,13 @@ def get_indicator(
|
|||||||
"time_period": str(time_period),
|
"time_period": str(time_period),
|
||||||
"datatype": "csv"
|
"datatype": "csv"
|
||||||
})
|
})
|
||||||
elif indicator == "vwma":
|
|
||||||
# Alpha Vantage doesn't have direct VWMA, so we'll return an informative message
|
|
||||||
# In a real implementation, this would need to be calculated from OHLCV data
|
|
||||||
return f"## VWMA (Volume Weighted Moving Average) for {symbol}:\n\nVWMA calculation requires OHLCV data and is not directly available from Alpha Vantage API.\nThis indicator would need to be calculated from the raw stock data using volume-weighted price averaging.\n\n{indicator_descriptions.get('vwma', 'No description available.')}"
|
|
||||||
else:
|
else:
|
||||||
return f"Error: Indicator {indicator} not implemented yet."
|
# This vendor has no endpoint for the indicator. Raising lets the
|
||||||
|
# router try the next vendor, which computes it; returning prose
|
||||||
|
# counted as a successful answer and ended the chain here.
|
||||||
|
raise NoMarketDataError(
|
||||||
|
symbol, symbol, f"Alpha Vantage does not serve the {indicator} indicator"
|
||||||
|
)
|
||||||
|
|
||||||
# Parse CSV data and extract values for the date range
|
# Parse CSV data and extract values for the date range
|
||||||
lines = data.strip().split('\n')
|
lines = data.strip().split('\n')
|
||||||
@@ -205,11 +214,11 @@ def get_indicator(
|
|||||||
|
|
||||||
return result_str
|
return result_str
|
||||||
|
|
||||||
except AlphaVantageNotConfiguredError:
|
except VendorError:
|
||||||
# Vendor unavailable (no API key). Let it propagate so the router can
|
# Unavailable vendor, throttle, or an indicator this vendor does not
|
||||||
# fall back / emit the no-data sentinel instead of returning this as a
|
# serve. Let it propagate so the router falls back to a vendor that can,
|
||||||
# successful-looking error string.
|
# instead of returning the failure as a successful-looking string.
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting Alpha Vantage indicator data for {indicator}: {e}")
|
logger.warning("Alpha Vantage indicator %s failed: %s", indicator, e)
|
||||||
return f"Error retrieving {indicator} data: {str(e)}"
|
raise NoMarketDataError(symbol, symbol, f"{indicator} unavailable: {e}") from e
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from .alpha_vantage_common import _make_api_request, format_datetime_for_api
|
from .alpha_vantage_common import _make_api_request, format_datetime_for_api
|
||||||
|
from .config import get_config
|
||||||
|
|
||||||
|
|
||||||
def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
|
def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
|
||||||
@@ -15,30 +18,40 @@ def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
|
|||||||
Dictionary containing news sentiment data or JSON string.
|
Dictionary containing news sentiment data or JSON string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Without a limit the endpoint returns 50 articles, each with per-ticker
|
||||||
|
# sentiment arrays, and all of it reaches the prompt.
|
||||||
params = {
|
params = {
|
||||||
"tickers": ticker,
|
"tickers": ticker,
|
||||||
"time_from": format_datetime_for_api(start_date),
|
"time_from": format_datetime_for_api(start_date),
|
||||||
"time_to": format_datetime_for_api(end_date),
|
"time_to": format_datetime_for_api(end_date, end_of_day=True),
|
||||||
|
"limit": str(get_config()["news_article_limit"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
return _make_api_request("NEWS_SENTIMENT", params)
|
return _make_api_request("NEWS_SENTIMENT", params)
|
||||||
|
|
||||||
def get_global_news(curr_date, look_back_days: int = 7, limit: int = 50) -> dict[str, str] | str:
|
def get_global_news(curr_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.
|
"""Returns global market news & sentiment data without ticker-specific filtering.
|
||||||
|
|
||||||
Covers broad market topics like financial markets, economy, and more.
|
Covers broad market topics like financial markets, economy, and more.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
curr_date: Current date in yyyy-mm-dd format.
|
curr_date: Current date in yyyy-mm-dd format.
|
||||||
look_back_days: Number of days to look back (default 7).
|
look_back_days: Number of days to look back; ``None`` uses
|
||||||
limit: Maximum number of articles (default 50).
|
``global_news_lookback_days`` from the active config.
|
||||||
|
limit: Maximum number of articles; ``None`` uses
|
||||||
|
``global_news_article_limit`` from the active config.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing global news sentiment data or JSON string.
|
Dictionary containing global news sentiment data or JSON string.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# Calculate start date
|
config = get_config()
|
||||||
|
if look_back_days is None:
|
||||||
|
look_back_days = config["global_news_lookback_days"]
|
||||||
|
if limit is None:
|
||||||
|
limit = config["global_news_article_limit"]
|
||||||
|
|
||||||
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
start_dt = curr_dt - timedelta(days=look_back_days)
|
start_dt = curr_dt - timedelta(days=look_back_days)
|
||||||
start_date = start_dt.strftime("%Y-%m-%d")
|
start_date = start_dt.strftime("%Y-%m-%d")
|
||||||
@@ -46,20 +59,21 @@ def get_global_news(curr_date, look_back_days: int = 7, limit: int = 50) -> dict
|
|||||||
params = {
|
params = {
|
||||||
"topics": "financial_markets,economy_macro,economy_monetary",
|
"topics": "financial_markets,economy_macro,economy_monetary",
|
||||||
"time_from": format_datetime_for_api(start_date),
|
"time_from": format_datetime_for_api(start_date),
|
||||||
"time_to": format_datetime_for_api(curr_date),
|
"time_to": format_datetime_for_api(curr_date, end_of_day=True),
|
||||||
"limit": str(limit),
|
"limit": str(limit),
|
||||||
}
|
}
|
||||||
|
|
||||||
return _make_api_request("NEWS_SENTIMENT", params)
|
return _make_api_request("NEWS_SENTIMENT", params)
|
||||||
|
|
||||||
|
|
||||||
def get_insider_transactions(symbol: str) -> dict[str, str] | str:
|
def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[str, str] | str:
|
||||||
"""Returns latest and historical insider transactions by key stakeholders.
|
"""Returns latest and historical insider transactions by key stakeholders.
|
||||||
|
|
||||||
Covers transactions by founders, executives, board members, etc.
|
Covers transactions by founders, executives, board members, etc.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: Ticker symbol. Example: "IBM".
|
symbol: Ticker symbol. Example: "IBM".
|
||||||
|
curr_date: When given, only transactions on or before it (yyyy-mm-dd).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing insider transaction data or JSON string.
|
Dictionary containing insider transaction data or JSON string.
|
||||||
@@ -69,4 +83,9 @@ def get_insider_transactions(symbol: str) -> dict[str, str] | str:
|
|||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
}
|
}
|
||||||
|
|
||||||
return _make_api_request("INSIDER_TRANSACTIONS", params)
|
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)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from .utils import get_current_date
|
||||||
|
|
||||||
|
|
||||||
def to_utc(dt: datetime) -> datetime:
|
def to_utc(dt: datetime) -> datetime:
|
||||||
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
||||||
@@ -28,3 +30,92 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) ->
|
|||||||
if pub_dt is not None:
|
if pub_dt is not None:
|
||||||
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
|
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(timezone.utc) - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def coverage_gap(
|
||||||
|
dates, start_date: str, end_date: str, source: str, subject: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Placeholder for a window a feed did not fully observe, else None.
|
||||||
|
|
||||||
|
Yahoo news and the Reddit and StockTwits feeds return their latest items
|
||||||
|
whatever window is asked for, so "none found" over a window they never
|
||||||
|
observed would claim an absence nobody saw. A window is observed when
|
||||||
|
coverage reaches its first day and it ends by today; an empty result is then
|
||||||
|
a real absence and this returns None.
|
||||||
|
|
||||||
|
``dates`` are the returned items' timestamps, plus the lookback start for a
|
||||||
|
feed with a fixed lookback. The oldest one bounds coverage only for a feed
|
||||||
|
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)
|
||||||
|
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"
|
||||||
|
elif oldest.date() > datetime.strptime(start_date, "%Y-%m-%d").date():
|
||||||
|
reason = f"it only serves recent items (coverage starts {oldest:%Y-%m-%d})"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return f"<{source} unavailable for {start_date}..{end_date}: {reason}, so this is not an absence of {subject}>"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(date: str | None) -> datetime | None:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date, "%Y-%m-%d")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def as_of(requested: str | None, trade_date: str) -> str | None:
|
||||||
|
"""The date a tool serves: the model's date, but never later than the run's.
|
||||||
|
|
||||||
|
A model can omit the date or pass today's instead of the analysis date, which
|
||||||
|
would walk past every point-in-time guard behind the tool. An empty
|
||||||
|
``trade_date`` (a direct call outside a graph run) passes the request through.
|
||||||
|
"""
|
||||||
|
if not trade_date:
|
||||||
|
return requested
|
||||||
|
parsed = _parse(requested)
|
||||||
|
return requested if parsed is not None and parsed <= _parse(trade_date) else trade_date
|
||||||
|
|
||||||
|
|
||||||
|
def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, str]:
|
||||||
|
"""``[start, end]`` with its end clamped to the run date.
|
||||||
|
|
||||||
|
A window wholly after the run date keeps its length and moves back to end there.
|
||||||
|
"""
|
||||||
|
end = as_of(end_date, trade_date)
|
||||||
|
start, old_end = _parse(start_date), _parse(end_date)
|
||||||
|
if end == end_date or start is None or start <= _parse(end):
|
||||||
|
return start_date, end
|
||||||
|
span = (old_end - start) if old_end is not None and old_end >= start else timedelta(0)
|
||||||
|
return f"{_parse(end) - span:%Y-%m-%d}", end
|
||||||
|
|
||||||
|
|
||||||
|
def withhold_live_profile(curr_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
|
||||||
|
``OVERVIEW``) carry no historical vintage — not even name, sector and
|
||||||
|
industry, which move when a company renames or is reclassified — so serving
|
||||||
|
one into a run dated in the past leaks post-decision information (#1300).
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"# Company Fundamentals for {label}\n"
|
||||||
|
f"# Point-in-time as of: {curr_date}\n\n"
|
||||||
|
f"Profile fundamentals are withheld for this date. This vendor serves "
|
||||||
|
f"only present-day values ({today}) 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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,14 +12,21 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
import pytz
|
||||||
|
|
||||||
from .errors import VendorNotConfiguredError
|
from .errors import VendorNotConfiguredError
|
||||||
|
from .utils import get_scrubbed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FRED_API_BASE = "https://api.stlouisfed.org/fred"
|
FRED_API_BASE = "https://api.stlouisfed.org/fred"
|
||||||
|
|
||||||
|
# FRED's realtime clock runs on US Central (St. Louis Fed). It rejects a
|
||||||
|
# realtime date in its own future with a 400, so the vintage pin is clamped to
|
||||||
|
# this rather than the caller's local date (#1275). pytz (already a dependency)
|
||||||
|
# bundles its own tz database, so this works where system tzdata is absent.
|
||||||
|
FRED_TZ = pytz.timezone("America/Chicago")
|
||||||
|
|
||||||
# Network timeout (seconds) so a stalled request can't hang the agents,
|
# Network timeout (seconds) so a stalled request can't hang the agents,
|
||||||
# mirroring the Alpha Vantage client.
|
# mirroring the Alpha Vantage client.
|
||||||
REQUEST_TIMEOUT = 30
|
REQUEST_TIMEOUT = 30
|
||||||
@@ -115,11 +122,25 @@ def _resolve_series_id(indicator: str) -> str:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
caller's local clock, which can already be tomorrow in Chicago.
|
||||||
|
"""
|
||||||
|
return datetime.now(FRED_TZ).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def _request(path: str, params: dict) -> dict:
|
def _request(path: str, params: dict) -> dict:
|
||||||
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
|
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
|
||||||
api_params = {**params, "api_key": get_api_key(), "file_type": "json"}
|
api_key = get_api_key()
|
||||||
response = requests.get(
|
response = get_scrubbed(
|
||||||
f"{FRED_API_BASE}/{path}", params=api_params, timeout=REQUEST_TIMEOUT
|
f"{FRED_API_BASE}/{path}",
|
||||||
|
params={**params, "api_key": api_key, "file_type": "json"},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
secret=api_key,
|
||||||
|
passthrough=(400,),
|
||||||
)
|
)
|
||||||
# FRED returns 400 with a JSON {"error_message": ...} for unknown series IDs
|
# FRED returns 400 with a JSON {"error_message": ...} for unknown series IDs
|
||||||
# or malformed params; turn that into a clear, actionable error.
|
# or malformed params; turn that into a clear, actionable error.
|
||||||
@@ -129,7 +150,6 @@ def _request(path: str, params: dict) -> dict:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
message = response.text
|
message = response.text
|
||||||
raise ValueError(f"FRED request failed: {message}")
|
raise ValueError(f"FRED request failed: {message}")
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
@@ -144,11 +164,11 @@ def get_macro_data(
|
|||||||
indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury")
|
indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury")
|
||||||
or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10").
|
or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10").
|
||||||
curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window
|
curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window
|
||||||
AND pins the data vintage: FRED is queried with
|
AND pins the data vintage: FRED is queried with the realtime bounds
|
||||||
``realtime_start = realtime_end = curr_date`` so a historical run sees
|
set to ``curr_date`` (clamped to FRED's own today) so a historical
|
||||||
the values that were actually published by that date, not later
|
run sees the values that were actually published by that date, not
|
||||||
revisions. Without this, revision-prone series (CPI, GDP, ...) would
|
later revisions. Without this, revision-prone series (CPI, GDP, ...)
|
||||||
leak future information into a backtest (#1275).
|
would leak future information into a backtest (#1275).
|
||||||
look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS.
|
look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -161,11 +181,16 @@ def get_macro_data(
|
|||||||
end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d")
|
start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Pin the data vintage to curr_date. FRED defaults both realtime bounds to
|
# Pin the data vintage. FRED defaults both realtime bounds to today, serving
|
||||||
# today, which serves the LATEST revision of every observation; a single-day
|
# the LATEST revision of every observation; a single-day realtime interval
|
||||||
# realtime interval asks for the values known as of curr_date instead. This
|
# asks for the values known as of the pin instead, on both the metadata and
|
||||||
# is applied to both the metadata and observations requests (#1275).
|
# observations requests (#1275). Clamp to FRED's today: on a live run
|
||||||
realtime = {"realtime_start": curr_date, "realtime_end": curr_date}
|
# curr_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
|
||||||
|
# point-in-time behaviour is preserved.
|
||||||
|
pit = min(curr_date, _fred_today())
|
||||||
|
realtime = {"realtime_start": pit, "realtime_end": pit}
|
||||||
|
|
||||||
# Invalid LLM-supplied indicator: return guidance rather than raising, so a
|
# Invalid LLM-supplied indicator: return guidance rather than raising, so a
|
||||||
# bad argument doesn't abort the run (the routing layer also degrades macro
|
# bad argument doesn't abort the run (the routing layer also degrades macro
|
||||||
@@ -215,8 +240,10 @@ def get_macro_data(
|
|||||||
|
|
||||||
if not points:
|
if not points:
|
||||||
return header + (
|
return header + (
|
||||||
f"\nNo observations for {series_id} in this window. The series may "
|
f"\nNo observations for {series_id} in this window at the {pit} "
|
||||||
f"report less frequently than the window length; widen look_back_days."
|
f"vintage. The series may report less frequently than the window "
|
||||||
|
f"(try a longer look_back_days), or have no vintage published by "
|
||||||
|
f"then (unpublished as of {pit}, or before ALFRED coverage begins)."
|
||||||
)
|
)
|
||||||
|
|
||||||
first_date, first_val = points[0]
|
first_date, first_val = points[0]
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ from .errors import (
|
|||||||
)
|
)
|
||||||
from .fred import get_macro_data as get_fred_macro_data
|
from .fred import get_macro_data as get_fred_macro_data
|
||||||
from .polymarket import get_prediction_markets as get_polymarket_prediction_markets
|
from .polymarket import get_prediction_markets as get_polymarket_prediction_markets
|
||||||
|
from .sec_edgar import (
|
||||||
|
get_balance_sheet as get_sec_edgar_balance_sheet,
|
||||||
|
get_cashflow as get_sec_edgar_cashflow,
|
||||||
|
get_income_statement as get_sec_edgar_income_statement,
|
||||||
|
)
|
||||||
from .y_finance import (
|
from .y_finance import (
|
||||||
get_balance_sheet as get_yfinance_balance_sheet,
|
get_balance_sheet as get_yfinance_balance_sheet,
|
||||||
get_cashflow as get_yfinance_cashflow,
|
get_cashflow as get_yfinance_cashflow,
|
||||||
@@ -79,6 +84,7 @@ TOOLS_CATEGORIES = {
|
|||||||
|
|
||||||
VENDOR_LIST = [
|
VENDOR_LIST = [
|
||||||
"yfinance",
|
"yfinance",
|
||||||
|
"sec_edgar",
|
||||||
"fred",
|
"fred",
|
||||||
"polymarket",
|
"polymarket",
|
||||||
"alpha_vantage",
|
"alpha_vantage",
|
||||||
@@ -110,14 +116,17 @@ VENDOR_METHODS = {
|
|||||||
},
|
},
|
||||||
"get_balance_sheet": {
|
"get_balance_sheet": {
|
||||||
"alpha_vantage": get_alpha_vantage_balance_sheet,
|
"alpha_vantage": get_alpha_vantage_balance_sheet,
|
||||||
|
"sec_edgar": get_sec_edgar_balance_sheet,
|
||||||
"yfinance": get_yfinance_balance_sheet,
|
"yfinance": get_yfinance_balance_sheet,
|
||||||
},
|
},
|
||||||
"get_cashflow": {
|
"get_cashflow": {
|
||||||
"alpha_vantage": get_alpha_vantage_cashflow,
|
"alpha_vantage": get_alpha_vantage_cashflow,
|
||||||
|
"sec_edgar": get_sec_edgar_cashflow,
|
||||||
"yfinance": get_yfinance_cashflow,
|
"yfinance": get_yfinance_cashflow,
|
||||||
},
|
},
|
||||||
"get_income_statement": {
|
"get_income_statement": {
|
||||||
"alpha_vantage": get_alpha_vantage_income_statement,
|
"alpha_vantage": get_alpha_vantage_income_statement,
|
||||||
|
"sec_edgar": get_sec_edgar_income_statement,
|
||||||
"yfinance": get_yfinance_income_statement,
|
"yfinance": get_yfinance_income_statement,
|
||||||
},
|
},
|
||||||
# news_data
|
# news_data
|
||||||
@@ -193,6 +202,7 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||||||
vendor_chain = all_available_vendors
|
vendor_chain = all_available_vendors
|
||||||
|
|
||||||
last_no_data: NoMarketDataError | None = None
|
last_no_data: NoMarketDataError | None = None
|
||||||
|
last_unavailable: VendorRateLimitError | None = None
|
||||||
first_error: Exception | None = None
|
first_error: Exception | None = None
|
||||||
for vendor in vendor_chain:
|
for vendor in vendor_chain:
|
||||||
vendor_impl = VENDOR_METHODS[method][vendor]
|
vendor_impl = VENDOR_METHODS[method][vendor]
|
||||||
@@ -200,8 +210,11 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return impl_func(*args, **kwargs)
|
return impl_func(*args, **kwargs)
|
||||||
except VendorRateLimitError:
|
except VendorRateLimitError as e:
|
||||||
logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method)
|
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.
|
||||||
|
last_unavailable = e
|
||||||
continue
|
continue
|
||||||
except VendorNotConfiguredError as e:
|
except VendorNotConfiguredError as e:
|
||||||
logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method)
|
logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method)
|
||||||
@@ -250,6 +263,15 @@ def route_to_vendor(method: str, *args, **kwargs):
|
|||||||
# first real error (e.g. the primary vendor's network failure). Optional
|
# first real error (e.g. the primary vendor's network failure). Optional
|
||||||
# enrichment categories degrade to a sentinel instead, so flavour data can't
|
# enrichment categories degrade to a sentinel instead, so flavour data can't
|
||||||
# abort the run.
|
# 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 first_error is not None:
|
||||||
if category in OPTIONAL_CATEGORIES:
|
if category in OPTIONAL_CATEGORIES:
|
||||||
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
|
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
look-ahead rows, but we re-apply the cutoff defensively — this is a
|
look-ahead rows, but we re-apply the cutoff defensively — this is a
|
||||||
verification path, so it must not trust its input to be pre-filtered.
|
verification path, so it must not trust its input to be pre-filtered.
|
||||||
"""
|
"""
|
||||||
data = load_ohlcv(symbol, curr_date)
|
# As reported: this snapshot is quoted by the agents as exact prices, so a
|
||||||
|
# gap-filled cell would put the previous session's number under this date.
|
||||||
|
data = load_ohlcv(symbol, curr_date, fill_gaps=False)
|
||||||
if data is None or data.empty:
|
if data is None or data.empty:
|
||||||
raise ValueError(f"No OHLCV data available for {symbol}.")
|
raise ValueError(f"No OHLCV data available for {symbol}.")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from .utils import get_current_date
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
||||||
@@ -65,7 +67,7 @@ def _is_forward_looking(market: dict, now: datetime) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_prediction_markets(topic: str, limit: int | None = None) -> str:
|
def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str | None = None) -> str:
|
||||||
"""Return live prediction-market probabilities for an event topic.
|
"""Return live prediction-market probabilities for an event topic.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -73,12 +75,20 @@ def get_prediction_markets(topic: str, limit: int | None = None) -> str:
|
|||||||
"US election", or a sector/company event.
|
"US election", or a sector/company event.
|
||||||
limit: Max markets to return (ranked by traded volume); ``None`` uses
|
limit: Max markets to return (ranked by traded volume); ``None`` uses
|
||||||
DEFAULT_LIMIT.
|
DEFAULT_LIMIT.
|
||||||
|
curr_date: The analysis date. Polymarket serves only live odds, so a
|
||||||
|
date before today withholds them.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A markdown report of the most-traded open markets matching the topic,
|
A markdown report of the most-traded open markets matching the topic,
|
||||||
each with its implied probability, traded volume, resolution date, and
|
each with its implied probability, traded volume, resolution date, and
|
||||||
recent (1-week) move.
|
recent (1-week) move.
|
||||||
"""
|
"""
|
||||||
|
if curr_date and curr_date < get_current_date():
|
||||||
|
return (
|
||||||
|
f"Prediction-market odds are withheld for {curr_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."
|
||||||
|
)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
limit = DEFAULT_LIMIT
|
limit = DEFAULT_LIMIT
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"""Reddit search fetcher for ticker-specific discussion posts.
|
"""Reddit search fetcher for ticker-specific discussion posts.
|
||||||
|
|
||||||
Default path is Reddit's public Atom/RSS search feed
|
Reads Reddit's public Atom/RSS search feed, searching all subreddits in one
|
||||||
(``reddit.com/r/{sub}/search.rss``). The richer JSON search endpoint
|
combined request. The JSON search endpoint is WAF-blocked (``HTTP 403``) for
|
||||||
(``/search.json``) is reliably WAF-blocked (``HTTP 403``) for public clients
|
anonymous clients (#862), so RSS is the only path; it carries no score or comment
|
||||||
(issue #862), and probing it on every call only doubled our request volume
|
counts. On a 429 we back off once, honouring ``Retry-After``.
|
||||||
against Reddit's per-IP rate limit — tripping ``429`` on the RSS fallback — so
|
|
||||||
it is kept (``_fetch_subreddit_json``) but not used by default. On a 429 we back
|
A fetch that fails is reported as ``<unavailable>``, never as "no posts found":
|
||||||
off once (honouring ``Retry-After``). RSS lacks score / comment counts, so those
|
the two are different claims, and passing a rate-limited fetch off as silence
|
||||||
posts are marked and the formatter omits the metrics rather than printing fake
|
hands the sentiment analyst a signal that was never observed (#1295).
|
||||||
zeros.
|
|
||||||
|
|
||||||
No API key required. Returns formatted plaintext blocks ready for prompt
|
No API key required. Returns formatted plaintext blocks ready for prompt
|
||||||
injection and degrades gracefully — returns a placeholder string rather than
|
injection and degrades gracefully — returns a placeholder string rather than
|
||||||
@@ -19,18 +18,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from .date_window import in_window
|
from .date_window import coverage_gap, in_window
|
||||||
from .symbol_utils import crypto_base
|
from .symbol_utils import crypto_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -46,15 +45,25 @@ def _within_window(posts, start_date, end_date):
|
|||||||
return posts
|
return posts
|
||||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
kept = []
|
return [p for p in posts if in_window(_posted_at(p), start_dt, end_dt)]
|
||||||
for p in posts:
|
|
||||||
ts = p.get("created_utc")
|
|
||||||
created = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
def _posted_at(post) -> datetime | None:
|
||||||
if in_window(created, start_dt, end_dt):
|
"""A post's ``created_utc`` epoch as a UTC datetime, or None when missing."""
|
||||||
kept.append(p)
|
ts = post.get("created_utc")
|
||||||
return kept
|
return datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_dates(posts) -> list:
|
||||||
|
"""Dates that bound the feed's coverage. The search is limited to the last
|
||||||
|
week (``t=week``), so the lookback start bounds it even when nothing came
|
||||||
|
back; a full page may have cut older matches off, so then only the posts
|
||||||
|
themselves do."""
|
||||||
|
dates = [_posted_at(p) for p in posts]
|
||||||
|
if len(posts) < _FEED_PAGE:
|
||||||
|
dates.append(datetime.now(timezone.utc) - _SEARCH_LOOKBACK)
|
||||||
|
return dates
|
||||||
|
|
||||||
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
|
|
||||||
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
|
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
|
||||||
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
|
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
|
||||||
# blocks generic/anonymous tokens like bare "Mozilla/5.0" or "curl/…" but
|
# blocks generic/anonymous tokens like bare "Mozilla/5.0" or "curl/…" but
|
||||||
@@ -68,6 +77,14 @@ _ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"}
|
|||||||
# investing trend more measured. Caller can override.
|
# investing trend more measured. Caller can override.
|
||||||
DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing")
|
DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing")
|
||||||
|
|
||||||
|
# Reddit's maximum page size. A week of posts for a ticker across the default
|
||||||
|
# subreddits fits well inside one page, which keeps a high-volume subreddit from
|
||||||
|
# crowding the others out of a combined search.
|
||||||
|
_FEED_PAGE = 100
|
||||||
|
|
||||||
|
|
||||||
|
_SEARCH_LOOKBACK = timedelta(days=7) # matches t=week below
|
||||||
|
|
||||||
|
|
||||||
def _search_qs(ticker: str, limit: int) -> str:
|
def _search_qs(ticker: str, limit: int) -> str:
|
||||||
return urlencode({
|
return urlencode({
|
||||||
@@ -101,37 +118,83 @@ def _strip_html(content: str) -> str:
|
|||||||
return " ".join(html.unescape(text).split())
|
return " ".join(html.unescape(text).split())
|
||||||
|
|
||||||
|
|
||||||
|
# Headerless-429 backoff when Reddit gives no Retry-After. Measured against
|
||||||
|
# /r/{sub}/search.rss, a retry still 429s at 8s, 10s and 30s of spacing and
|
||||||
|
# succeeds at 60s, so a shorter wait spends the one retry on a request that
|
||||||
|
# cannot succeed (#1295). Jittered so several analyses sharing an IP don't
|
||||||
|
# retry in lockstep and re-collide on the limit.
|
||||||
|
_RETRY_FALLBACK_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _jitter(seconds: float, frac: float = 0.2) -> float:
|
||||||
|
"""Return ``seconds`` with +/-``frac`` random jitter, to desynchronize
|
||||||
|
concurrent runs pacing against the same per-IP limit."""
|
||||||
|
return seconds * (1.0 + random.uniform(-frac, frac))
|
||||||
|
|
||||||
|
|
||||||
def _retry_after_seconds(exc: HTTPError) -> float | None:
|
def _retry_after_seconds(exc: HTTPError) -> float | None:
|
||||||
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 30s."""
|
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 60s.
|
||||||
|
|
||||||
|
The cap matches ``_RETRY_FALLBACK_SECONDS``: honouring less than we would
|
||||||
|
wait on our own would spend the one retry on a request we already know is
|
||||||
|
too early.
|
||||||
|
|
||||||
|
Returns ``None`` only when the header is absent or unparseable; a valid
|
||||||
|
``Retry-After: 0`` returns ``0.0`` (retry at once), not ``None``.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
|
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
|
||||||
return min(float(val), 30.0) if val else None
|
return min(float(val), 60.0) if val is not None else None
|
||||||
except (ValueError, TypeError, AttributeError):
|
except (ValueError, TypeError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Reddit search feeds are small (a page of results); cap the read so a
|
||||||
|
# compromised or misbehaving endpoint can't stream an unbounded body into
|
||||||
|
# memory before we parse it. Overflow raises http.client.HTTPException, which
|
||||||
|
# both fetch paths already treat as a failed fetch (degrade to empty / RSS).
|
||||||
|
_MAX_FEED_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _read_capped(resp) -> bytes:
|
||||||
|
"""Read a response body bounded to ``_MAX_FEED_BYTES``, raising on overflow."""
|
||||||
|
data = resp.read(_MAX_FEED_BYTES + 1)
|
||||||
|
if len(data) > _MAX_FEED_BYTES:
|
||||||
|
raise http.client.HTTPException(
|
||||||
|
f"Reddit feed exceeded {_MAX_FEED_BYTES} bytes; refusing to parse"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _fetch_subreddit_rss(
|
def _fetch_subreddit_rss(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
sub: str,
|
sub: str,
|
||||||
limit: int,
|
limit: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
_retry: bool = True,
|
_retry: bool = True,
|
||||||
) -> list[dict]:
|
) -> list[dict] | None:
|
||||||
"""Default path: parse the public Atom search feed for a subreddit.
|
"""Default path: parse the public Atom search feed for a subreddit.
|
||||||
|
|
||||||
Carries no score / comment counts, so those fields are left None and the
|
``sub`` may be one subreddit or several joined with ``+``. On a 429 (Reddit's
|
||||||
post is tagged ``source="rss"`` for honest display. On a 429 (Reddit's
|
|
||||||
per-IP rate limit) we back off once — honouring ``Retry-After`` when
|
per-IP rate limit) we back off once — honouring ``Retry-After`` when
|
||||||
present — before giving up, so a transient burst doesn't blank the feed.
|
present — before giving up, so a transient burst doesn't blank the feed.
|
||||||
|
|
||||||
|
Returns ``[]`` when the search ran and matched nothing, and ``None`` when
|
||||||
|
the fetch itself failed. The caller must keep these apart: rendering a
|
||||||
|
failed fetch as "no posts found" hands the sentiment analyst an absence of
|
||||||
|
discussion that was never observed (#1295).
|
||||||
"""
|
"""
|
||||||
url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit))
|
url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit))
|
||||||
req = Request(url, headers={"User-Agent": _UA})
|
req = Request(url, headers={"User-Agent": _UA})
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
root = ET.fromstring(resp.read())
|
root = ET.fromstring(_read_capped(resp))
|
||||||
except HTTPError as exc:
|
except HTTPError as exc:
|
||||||
if exc.code == 429 and _retry:
|
if exc.code == 429 and _retry:
|
||||||
wait = _retry_after_seconds(exc) or 5.0
|
# Honour a server-supplied Retry-After exactly (including 0); jitter
|
||||||
|
# only our own fallback so concurrent runs don't retry in lockstep.
|
||||||
|
retry_after = _retry_after_seconds(exc)
|
||||||
|
wait = retry_after if retry_after is not None else _jitter(_RETRY_FALLBACK_SECONDS)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
|
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
|
||||||
sub, ticker, wait,
|
sub, ticker, wait,
|
||||||
@@ -139,90 +202,49 @@ def _fetch_subreddit_rss(
|
|||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False)
|
return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False)
|
||||||
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
||||||
return []
|
return None
|
||||||
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
||||||
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
||||||
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
||||||
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
||||||
return []
|
return None
|
||||||
|
|
||||||
posts = []
|
posts = []
|
||||||
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
|
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
|
||||||
title_el = entry.find("atom:title", _ATOM_NS)
|
title_el = entry.find("atom:title", _ATOM_NS)
|
||||||
published_el = entry.find("atom:published", _ATOM_NS)
|
published_el = entry.find("atom:published", _ATOM_NS)
|
||||||
content_el = entry.find("atom:content", _ATOM_NS)
|
content_el = entry.find("atom:content", _ATOM_NS)
|
||||||
|
category_el = entry.find("atom:category", _ATOM_NS)
|
||||||
posts.append({
|
posts.append({
|
||||||
"title": (title_el.text if title_el is not None else "") or "",
|
"title": (title_el.text if title_el is not None else "") or "",
|
||||||
"score": None,
|
|
||||||
"num_comments": None,
|
|
||||||
"created_utc": _iso_to_timestamp(
|
"created_utc": _iso_to_timestamp(
|
||||||
published_el.text if published_el is not None else None
|
published_el.text if published_el is not None else None
|
||||||
),
|
),
|
||||||
"selftext": _strip_html(content_el.text if content_el is not None else ""),
|
"selftext": _strip_html(content_el.text if content_el is not None else ""),
|
||||||
"source": "rss",
|
# A combined feed names each entry's subreddit; a single-subreddit
|
||||||
|
# feed may omit it, and then it can only be that one.
|
||||||
|
"subreddit": category_el.get("term") if category_el is not None
|
||||||
|
else (sub if "+" not in sub else ""),
|
||||||
})
|
})
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
|
|
||||||
def _fetch_subreddit_json(
|
|
||||||
ticker: str,
|
|
||||||
sub: str,
|
|
||||||
limit: int,
|
|
||||||
timeout: float,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Richer JSON search path (carries score / comment counts).
|
|
||||||
|
|
||||||
Reddit's WAF currently returns ``403 Blocked`` on this endpoint for
|
|
||||||
non-OAuth clients (issue #862), so it is NOT used by default — calling it on
|
|
||||||
every request only doubled our volume against the per-IP rate limit and
|
|
||||||
triggered 429s on the RSS fallback. Kept for the day the WAF relaxes or an
|
|
||||||
OAuth token is wired in; degrades to RSS on failure.
|
|
||||||
"""
|
|
||||||
url = _API.format(sub=sub, qs=_search_qs(ticker, limit))
|
|
||||||
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
|
||||||
try:
|
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
|
||||||
payload = json.loads(resp.read())
|
|
||||||
children = (payload.get("data") or {}).get("children") or []
|
|
||||||
return [c.get("data", {}) for c in children if isinstance(c, dict)]
|
|
||||||
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Reddit JSON fetch failed for r/%s · %s: %s — falling back to RSS feed.",
|
|
||||||
sub, ticker, exc,
|
|
||||||
)
|
|
||||||
return _fetch_subreddit_rss(ticker, sub, limit, timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_subreddit(
|
|
||||||
ticker: str,
|
|
||||||
sub: str,
|
|
||||||
limit: int,
|
|
||||||
timeout: float,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Fetch one subreddit, RSS-first.
|
|
||||||
|
|
||||||
The JSON search endpoint is reliably WAF-blocked (403) for public clients,
|
|
||||||
so we go straight to the RSS feed — which serves our identified User-Agent
|
|
||||||
reliably — halving our request volume against Reddit's per-IP rate limit.
|
|
||||||
"""
|
|
||||||
return _fetch_subreddit_rss(ticker, sub, limit, timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_reddit_posts(
|
def fetch_reddit_posts(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
subreddits: Iterable[str] = DEFAULT_SUBREDDITS,
|
subreddits: Iterable[str] = DEFAULT_SUBREDDITS,
|
||||||
|
*,
|
||||||
limit_per_sub: int = 5,
|
limit_per_sub: int = 5,
|
||||||
timeout: float = 10.0,
|
timeout: float = 10.0,
|
||||||
inter_request_delay: float = 1.0,
|
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
|
"""Fetch recent Reddit posts mentioning ``ticker`` across finance
|
||||||
subreddits and return them as a formatted plaintext block.
|
subreddits and return them as a formatted plaintext block.
|
||||||
|
|
||||||
``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to
|
All subreddits are searched in one combined feed (``r/a+b+c``): anonymous
|
||||||
stay under Reddit's public per-IP rate limit; combined with the RSS-first
|
RSS allows about one request per minute per IP, so a request per subreddit
|
||||||
path it makes 429s rare even when several analyses run back-to-back.
|
spent a back-off on almost every run. Each entry names its subreddit, and
|
||||||
|
posts are grouped back by it.
|
||||||
|
|
||||||
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to
|
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to
|
||||||
that window so a historical run does not leak current discussion into a
|
that window so a historical run does not leak current discussion into a
|
||||||
@@ -231,47 +253,51 @@ def fetch_reddit_posts(
|
|||||||
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
|
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
|
||||||
# ("BTC") so the query actually matches discussion instead of near-nothing.
|
# ("BTC") so the query actually matches discussion instead of near-nothing.
|
||||||
ticker = crypto_base(ticker) or ticker
|
ticker = crypto_base(ticker) or ticker
|
||||||
blocks = []
|
subreddits = list(subreddits)
|
||||||
total_posts = 0
|
label = ", ".join(f"r/{s}" for s in subreddits)
|
||||||
for i, sub in enumerate(subreddits):
|
fetched = _fetch_subreddit_rss(ticker, "+".join(subreddits), _FEED_PAGE, timeout)
|
||||||
if i > 0:
|
if fetched is None:
|
||||||
time.sleep(inter_request_delay)
|
return f"<Reddit unavailable: fetch failed ({label}); this is not an absence of discussion>"
|
||||||
posts = _within_window(_fetch_subreddit(ticker, sub, limit_per_sub, timeout),
|
|
||||||
start_date, end_date)
|
|
||||||
total_posts += len(posts)
|
|
||||||
if not posts:
|
|
||||||
blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>")
|
|
||||||
continue
|
|
||||||
|
|
||||||
via_rss = any(p.get("source") == "rss" for p in posts)
|
window = bool(start_date and end_date)
|
||||||
header = f"r/{sub} — {len(posts)} recent posts mentioning {ticker.upper()}"
|
posts = _within_window(fetched, start_date, end_date)
|
||||||
header += " (via RSS feed; scores/comments unavailable):" if via_rss else ":"
|
if not posts:
|
||||||
lines = [header]
|
gap = window and coverage_gap(
|
||||||
for p in posts:
|
_coverage_dates(fetched), start_date, end_date,
|
||||||
title = (p.get("title") or "").replace("\n", " ").strip()
|
"Reddit search", f"discussion of {ticker.upper()}",
|
||||||
score = p.get("score")
|
|
||||||
comments = p.get("num_comments")
|
|
||||||
created = p.get("created_utc")
|
|
||||||
created_str = (
|
|
||||||
time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?"
|
|
||||||
)
|
)
|
||||||
# Score / comment counts are absent on the RSS fallback path —
|
period = f"within {start_date}..{end_date}" if window else "in the past 7 days"
|
||||||
# show them only when present rather than printing fake zeros.
|
return gap or f"<no Reddit posts found mentioning {ticker.upper()} across {label} {period}>"
|
||||||
meta = created_str
|
|
||||||
if score is not None and comments is not None:
|
# Group by the subreddit each entry names, in the requested order. Nothing
|
||||||
meta += f" · {score:>4}↑ · {comments:>3}c"
|
# is dropped: an unlabelled post from a one-subreddit request belongs to it,
|
||||||
|
# and any other name gets its own block.
|
||||||
|
by_sub = {s.lower(): (s, []) for s in subreddits}
|
||||||
|
for p in posts:
|
||||||
|
name = p.get("subreddit") or (subreddits[0] if len(subreddits) == 1 else "unknown")
|
||||||
|
by_sub.setdefault(name.lower(), (name, []))[1].append(p)
|
||||||
|
|
||||||
|
page_full = len(fetched) >= _FEED_PAGE
|
||||||
|
blocks = []
|
||||||
|
for sub, sub_posts in by_sub.values():
|
||||||
|
if not sub_posts:
|
||||||
|
blocks.append(
|
||||||
|
f"r/{sub}: <not among the newest {_FEED_PAGE} matches across {label}>"
|
||||||
|
if page_full else f"r/{sub}: <no posts found mentioning {ticker.upper()}>"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
sub_posts = sub_posts[:limit_per_sub] # the feed is newest-first
|
||||||
|
lines = [f"r/{sub} — {len(sub_posts)} recent posts mentioning {ticker.upper()}:"]
|
||||||
|
for p in sub_posts:
|
||||||
|
title = (p.get("title") or "").replace("\n", " ").strip()
|
||||||
|
created = p.get("created_utc")
|
||||||
|
created_str = time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?"
|
||||||
selftext = (p.get("selftext") or "").replace("\n", " ").strip()
|
selftext = (p.get("selftext") or "").replace("\n", " ").strip()
|
||||||
if len(selftext) > 240:
|
if len(selftext) > 240:
|
||||||
selftext = selftext[:240] + "…"
|
selftext = selftext[:240] + "…"
|
||||||
lines.append(
|
lines.append(
|
||||||
f" [{meta}] {title}"
|
f" [{created_str}] {title}"
|
||||||
+ (f"\n body excerpt: {selftext}" if selftext else "")
|
+ (f"\n body excerpt: {selftext}" if selftext else "")
|
||||||
)
|
)
|
||||||
blocks.append("\n".join(lines))
|
blocks.append("\n".join(lines))
|
||||||
|
|
||||||
if total_posts == 0:
|
|
||||||
return (
|
|
||||||
f"<no Reddit posts found mentioning {ticker.upper()} across "
|
|
||||||
f"{', '.join(f'r/{s}' for s in subreddits)} in the past 7 days>"
|
|
||||||
)
|
|
||||||
return "\n\n".join(blocks)
|
return "\n\n".join(blocks)
|
||||||
|
|||||||
229
tradingagents/dataflows/sec_edgar.py
Normal file
229
tradingagents/dataflows/sec_edgar.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""Company statements as they were filed, from SEC EDGAR.
|
||||||
|
|
||||||
|
Every other fundamentals vendor serves a period's current value and cuts the
|
||||||
|
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``
|
||||||
|
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.
|
||||||
|
|
||||||
|
Access needs no key or account, only a User-Agent identifying the caller, which
|
||||||
|
SEC requires and refuses requests without. US filers only: anything absent from
|
||||||
|
EDGAR's ticker map falls through to the next configured vendor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime
|
||||||
|
from importlib import metadata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import get_config
|
||||||
|
from .errors import NoMarketDataError, VendorRateLimitError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
|
||||||
|
_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
|
||||||
|
|
||||||
|
# A filing history only changes when something new is filed, so one fetch per
|
||||||
|
# company per day serves every date a run asks about.
|
||||||
|
_CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
# Line items, each with the tags filers use for it, best first. First match wins
|
||||||
|
# and values are never summed across tags: a company reporting revenue under two
|
||||||
|
# tags would otherwise be counted twice.
|
||||||
|
_STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = {
|
||||||
|
"balance_sheet": [
|
||||||
|
("Total Assets", ("Assets",)),
|
||||||
|
("Current Assets", ("AssetsCurrent",)),
|
||||||
|
("Cash and Equivalents", ("CashAndCashEquivalentsAtCarryingValue",)),
|
||||||
|
("Total Liabilities", ("Liabilities",)),
|
||||||
|
("Current Liabilities", ("LiabilitiesCurrent",)),
|
||||||
|
("Stockholders Equity", ("StockholdersEquity",
|
||||||
|
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest")),
|
||||||
|
],
|
||||||
|
"income_statement": [
|
||||||
|
("Revenue", ("RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues",
|
||||||
|
"SalesRevenueNet")),
|
||||||
|
("Cost of Revenue", ("CostOfRevenue", "CostOfGoodsAndServicesSold")),
|
||||||
|
("Gross Profit", ("GrossProfit",)),
|
||||||
|
("Operating Income", ("OperatingIncomeLoss",)),
|
||||||
|
("Net Income", ("NetIncomeLoss",)),
|
||||||
|
("Diluted EPS", ("EarningsPerShareDiluted",)),
|
||||||
|
],
|
||||||
|
"cashflow": [
|
||||||
|
("Operating Cash Flow", ("NetCashProvidedByUsedInOperatingActivities",
|
||||||
|
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations")),
|
||||||
|
("Investing Cash Flow", ("NetCashProvidedByUsedInInvestingActivities",)),
|
||||||
|
("Financing Cash Flow", ("NetCashProvidedByUsedInFinancingActivities",)),
|
||||||
|
("Capital Expenditure", ("PaymentsToAcquirePropertyPlantAndEquipment",)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# A statement's figures cover a span: a quarter is about 90 days, a year about
|
||||||
|
# 365. One filing reports both the quarter and the year to date under the same
|
||||||
|
# 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)}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_agent() -> str:
|
||||||
|
"""Who SEC sees. No account or key exists; callers identify themselves.
|
||||||
|
|
||||||
|
www.sec.gov, which serves the ticker map, refuses a User-Agent carrying no
|
||||||
|
contact address: a client name alone or with a project URL gets 403, one
|
||||||
|
with an address gets 200. So the default carries a placeholder address and
|
||||||
|
the package version. Set SEC_EDGAR_USER_AGENT to your own name and address
|
||||||
|
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)"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Read a public EDGAR document, respecting SEC's identification rule."""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers={"User-Agent": _user_agent()}, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
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
|
||||||
|
except ValueError as exc:
|
||||||
|
raise VendorRateLimitError("SEC EDGAR returned an unreadable response") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_json(url: str, name: str) -> dict:
|
||||||
|
path = Path(get_config()["data_cache_dir"]) / "sec_edgar" / name
|
||||||
|
if path.exists() and time.time() - path.stat().st_mtime < _CACHE_TTL_SECONDS:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
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)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def cik_for(ticker: str) -> str | None:
|
||||||
|
"""The filer's CIK, or None when the ticker is not a US filer."""
|
||||||
|
table = _cached_json(_TICKERS_URL, "company_tickers.json")
|
||||||
|
wanted = ticker.strip().upper()
|
||||||
|
for entry in table.values():
|
||||||
|
if entry.get("ticker", "").upper() == wanted:
|
||||||
|
return f"{int(entry['cik_str']):010d}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int]) -> tuple[dict, str]:
|
||||||
|
"""({period end: value}, unit) for the first tag 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.
|
||||||
|
"""
|
||||||
|
low, high = span
|
||||||
|
values: dict[str, 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] = {}
|
||||||
|
for fact in unit_values:
|
||||||
|
if fact["filed"] > curr_date or fact["end"] 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
|
||||||
|
seen = latest.get(fact["end"])
|
||||||
|
if seen is None or fact["filed"] >= seen["filed"]:
|
||||||
|
latest[fact["end"]] = fact
|
||||||
|
if latest:
|
||||||
|
chosen_unit = unit
|
||||||
|
values.update({end: fact["val"] for end, 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")
|
||||||
|
cik = cik_for(ticker)
|
||||||
|
if cik is None:
|
||||||
|
raise NoMarketDataError(ticker, ticker, "not a US SEC filer")
|
||||||
|
|
||||||
|
facts = _cached_json(_FACTS_URL.format(cik=cik), f"CIK{cik}.json")
|
||||||
|
us_gaap = (facts.get("facts") or {}).get("us-gaap")
|
||||||
|
if not us_gaap:
|
||||||
|
raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts")
|
||||||
|
|
||||||
|
span = _SPANS["quarterly" if freq.lower() == "quarterly" else "annual"]
|
||||||
|
lines = {label: _as_of(us_gaap, tags, curr_date, span) for label, tags in _STATEMENTS[kind]}
|
||||||
|
periods = sorted({end for values, _ in lines.values() for end in values})
|
||||||
|
if not periods:
|
||||||
|
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_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"
|
||||||
|
)
|
||||||
|
rows = [",".join([""] + 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:
|
||||||
|
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
|
||||||
|
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
|
||||||
|
]
|
||||||
|
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_income_statement(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str:
|
||||||
|
"""Income statement as filed on or before ``curr_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")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -9,11 +9,14 @@ from stockstats import wrap
|
|||||||
from yfinance.exceptions import YFRateLimitError
|
from yfinance.exceptions import YFRateLimitError
|
||||||
|
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
|
from .errors import VendorRateLimitError
|
||||||
from .symbol_utils import NoMarketDataError, normalize_symbol
|
from .symbol_utils import NoMarketDataError, normalize_symbol
|
||||||
from .utils import safe_ticker_component
|
from .utils import safe_ticker_component, vendor_reachable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_YAHOO_HOST = "https://query2.finance.yahoo.com"
|
||||||
|
|
||||||
# A vendor's latest OHLCV row this many calendar days before the requested date
|
# A vendor's latest OHLCV row this many calendar days before the requested date
|
||||||
# is treated as stale. Generous enough to span long holiday weekends, tight
|
# is treated as stale. Generous enough to span long holiday weekends, tight
|
||||||
# enough to catch the year-old frames yfinance occasionally returns (#1021).
|
# enough to catch the year-old frames yfinance occasionally returns (#1021).
|
||||||
@@ -26,6 +29,17 @@ MAX_OHLCV_STALE_DAYS = 10
|
|||||||
OHLCV_CACHE_TTL_SECONDS = 900
|
OHLCV_CACHE_TTL_SECONDS = 900
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_empty(symbol: str, canonical: str, what: str) -> None:
|
||||||
|
"""Report an empty Yahoo result as an absence, or as an outage if it is one.
|
||||||
|
|
||||||
|
yfinance returns an empty frame for a failed request rather than raising, so
|
||||||
|
without this a Yahoo outage reads as "this symbol has no {what}".
|
||||||
|
"""
|
||||||
|
if not vendor_reachable(_YAHOO_HOST):
|
||||||
|
raise VendorRateLimitError(f"Yahoo Finance is unreachable; no {what} was retrieved")
|
||||||
|
raise NoMarketDataError(symbol, canonical, f"no {what}")
|
||||||
|
|
||||||
|
|
||||||
def yf_retry(func, max_retries=3, base_delay=2.0):
|
def yf_retry(func, max_retries=3, base_delay=2.0):
|
||||||
"""Execute a yfinance call with exponential backoff on rate limits.
|
"""Execute a yfinance call with exponential backoff on rate limits.
|
||||||
|
|
||||||
@@ -164,29 +178,31 @@ def _assert_ohlcv_not_stale(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _needs_same_day_refresh(data_file, curr_date_dt, today_date) -> bool:
|
def _cache_is_fresh(data_file, curr_date_dt, now) -> bool:
|
||||||
"""Whether a cached frame must be refetched to reflect the requested day.
|
"""Whether the symbol's cached download can serve this request.
|
||||||
|
|
||||||
The cache file is keyed per day, so without this a run started before the
|
The file holds the download made on the day it was written, so it serves
|
||||||
day's bar was final keeps serving that snapshot to every later run (#1150).
|
only that day. A current-day request also refetches once the file is older
|
||||||
Two distinct staleness cases exist for a current-day request: the bar may be
|
than the TTL: Yahoo publishes a partial daily candle during market hours,
|
||||||
missing entirely, or present but still in progress — Yahoo publishes a
|
whose ``Close`` is not the closing price, and row inspection cannot tell it
|
||||||
partial daily candle during market hours, whose ``Close`` is not the closing
|
from a final one (#1150).
|
||||||
price. Row inspection cannot tell a partial bar from a final one, so the TTL
|
|
||||||
governs every current-day cache. Historical requests always reuse the cache,
|
|
||||||
since those rows are immutable.
|
|
||||||
"""
|
"""
|
||||||
if curr_date_dt.date() < today_date.date():
|
written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file))
|
||||||
|
if written.date() != now.date():
|
||||||
return False
|
return False
|
||||||
return time.time() - os.path.getmtime(data_file) > OHLCV_CACHE_TTL_SECONDS
|
return curr_date_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFrame:
|
||||||
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
|
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
|
||||||
|
|
||||||
Downloads 5 years of data up to today and caches per symbol. On
|
Downloads 5 years of data up to today and caches per symbol. On
|
||||||
subsequent calls the cache is reused. Rows after curr_date are
|
subsequent calls the cache is reused. Rows after curr_date are
|
||||||
filtered out so backtests never see future prices.
|
filtered out so backtests never see future prices.
|
||||||
|
|
||||||
|
``fill_gaps`` carries prices forward over gaps so indicators compute on a
|
||||||
|
continuous series. Pass ``False`` to read the values as the vendor reported
|
||||||
|
them, leaving a cell that was never reported empty.
|
||||||
"""
|
"""
|
||||||
# Resolve broker/forex symbols (XAUUSD+ -> GC=F) to Yahoo's convention,
|
# Resolve broker/forex symbols (XAUUSD+ -> GC=F) to Yahoo's convention,
|
||||||
# then reject values that would escape the cache directory when
|
# then reject values that would escape the cache directory when
|
||||||
@@ -197,19 +213,19 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
config = get_config()
|
config = get_config()
|
||||||
curr_date_dt = pd.to_datetime(curr_date).normalize()
|
curr_date_dt = pd.to_datetime(curr_date).normalize()
|
||||||
|
|
||||||
# Cache uses a fixed window (5y to today) so one file per symbol.
|
# One cache file per symbol, holding the latest 5y-to-today download.
|
||||||
today_date = pd.Timestamp.today()
|
now = pd.Timestamp.today()
|
||||||
start_date = today_date - pd.DateOffset(years=5)
|
start_date = now - pd.DateOffset(years=5)
|
||||||
start_str = start_date.strftime("%Y-%m-%d")
|
start_str = start_date.strftime("%Y-%m-%d")
|
||||||
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
|
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
|
||||||
# when curr_date is the current day (#986). Look-ahead is still prevented by
|
# when curr_date is the current day (#986). Look-ahead is still prevented by
|
||||||
# the curr_date filter below.
|
# the curr_date filter below.
|
||||||
end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
os.makedirs(config["data_cache_dir"], exist_ok=True)
|
os.makedirs(config["data_cache_dir"], exist_ok=True)
|
||||||
data_file = os.path.join(
|
data_file = os.path.join(
|
||||||
config["data_cache_dir"],
|
config["data_cache_dir"],
|
||||||
f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv",
|
f"{safe_symbol}-YFin-data.csv",
|
||||||
)
|
)
|
||||||
|
|
||||||
# A cached file may be empty if a prior fetch failed (unknown symbol,
|
# A cached file may be empty if a prior fetch failed (unknown symbol,
|
||||||
@@ -218,12 +234,10 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
data = None
|
data = None
|
||||||
if os.path.exists(data_file):
|
if os.path.exists(data_file):
|
||||||
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
|
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
|
||||||
# Serve the cache only when it is usable and not a stale snapshot of the
|
|
||||||
# day being requested (#1150); otherwise fall through and refetch.
|
|
||||||
if (
|
if (
|
||||||
not cached.empty
|
not cached.empty
|
||||||
and "Close" in cached.columns
|
and "Close" in cached.columns
|
||||||
and not _needs_same_day_refresh(data_file, curr_date_dt, today_date)
|
and _cache_is_fresh(data_file, curr_date_dt, now)
|
||||||
):
|
):
|
||||||
data = cached
|
data = cached
|
||||||
|
|
||||||
@@ -239,9 +253,7 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
downloaded = _ensure_date_column(downloaded.reset_index())
|
downloaded = _ensure_date_column(downloaded.reset_index())
|
||||||
# Only cache real data — never persist an empty frame.
|
# Only cache real data — never persist an empty frame.
|
||||||
if downloaded.empty or "Close" not in downloaded.columns:
|
if downloaded.empty or "Close" not in downloaded.columns:
|
||||||
raise NoMarketDataError(
|
raise_for_empty(symbol, canonical, "price rows")
|
||||||
symbol, canonical, "Yahoo Finance returned no rows"
|
|
||||||
)
|
|
||||||
downloaded.to_csv(data_file, index=False, encoding="utf-8")
|
downloaded.to_csv(data_file, index=False, encoding="utf-8")
|
||||||
data = downloaded
|
data = downloaded
|
||||||
|
|
||||||
@@ -250,16 +262,26 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
|||||||
# Filter to curr_date to prevent look-ahead bias in backtesting.
|
# Filter to curr_date to prevent look-ahead bias in backtesting.
|
||||||
data = data[data["Date"] <= curr_date_dt]
|
data = data[data["Date"] <= curr_date_dt]
|
||||||
|
|
||||||
# Guard the latest in-range bar before dropping incomplete rows: a newest bar
|
# A closeless newest bar is an unsettled session, not a symbol without data.
|
||||||
# with no close is "not settled yet", not "does not exist". Silently dropping
|
# _fill_price_gaps below drops it, here and mid-series alike, so the frame
|
||||||
# it would make the previous trading day look like the latest (#1201); raise
|
# ends at the last settled bar; only a range with no close anywhere is no
|
||||||
# instead so the router surfaces it rather than fabricating a fallback.
|
# data (#1201, #1289).
|
||||||
if not data.empty and pd.isna(data["Close"].iloc[-1]):
|
if not data.empty and pd.isna(data["Close"].iloc[-1]):
|
||||||
|
settled = data["Close"].notna().to_numpy().nonzero()[0]
|
||||||
|
if settled.size == 0:
|
||||||
raise NoMarketDataError(
|
raise NoMarketDataError(
|
||||||
symbol, canonical, "latest in-range OHLCV bar has no closing price"
|
symbol, canonical, "no bar in range has a closing price"
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"%s: %d trailing bar(s) through %s have no closing price; using %s "
|
||||||
|
"as the latest close.", canonical, len(data) - settled[-1] - 1,
|
||||||
|
data["Date"].iloc[-1].date(), data["Date"].iloc[settled[-1]].date(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = _fill_price_gaps(data)
|
# Indicators need a continuous series, so gaps are carried forward. A caller
|
||||||
|
# that reports the numbers themselves asks for the frame as it was reported:
|
||||||
|
# a filled cell is the previous session's price under this session's date.
|
||||||
|
data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy()
|
||||||
|
|
||||||
# Reject a stale frame (latest row far older than curr_date) rather than
|
# Reject a stale frame (latest row far older than curr_date) rather than
|
||||||
# feeding year-old prices into indicators (#1021).
|
# feeding year-old prices into indicators (#1021).
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from .date_window import in_window
|
from .date_window import coverage_gap, in_window
|
||||||
from .symbol_utils import crypto_base
|
from .symbol_utils import crypto_base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,6 +30,16 @@ _API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
|
|||||||
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
||||||
|
|
||||||
|
|
||||||
|
def _created_at(message) -> datetime | None:
|
||||||
|
"""Parse a message's ISO 8601 ``created_at``; None when missing or malformed."""
|
||||||
|
raw = message.get("created_at")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
with contextlib.suppress(ValueError, TypeError):
|
||||||
|
return datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _within_window(messages, start_date, end_date):
|
def _within_window(messages, start_date, end_date):
|
||||||
"""Keep only messages published in [start_date, end_date] (look-ahead safe).
|
"""Keep only messages published in [start_date, end_date] (look-ahead safe).
|
||||||
|
|
||||||
@@ -41,16 +51,7 @@ def _within_window(messages, start_date, end_date):
|
|||||||
return messages
|
return messages
|
||||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
kept = []
|
return [m for m in messages if in_window(_created_at(m), start_dt, end_dt)]
|
||||||
for m in messages:
|
|
||||||
created = None
|
|
||||||
raw = m.get("created_at")
|
|
||||||
if raw:
|
|
||||||
with contextlib.suppress(ValueError, TypeError):
|
|
||||||
created = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
if in_window(created, start_dt, end_dt):
|
|
||||||
kept.append(m)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
|
|
||||||
def _stocktwits_symbol(ticker: str) -> str:
|
def _stocktwits_symbol(ticker: str) -> str:
|
||||||
@@ -75,9 +76,9 @@ def fetch_stocktwits_messages(
|
|||||||
formatted plaintext block ready for prompt injection.
|
formatted plaintext block ready for prompt injection.
|
||||||
|
|
||||||
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, messages are trimmed
|
When ``start_date``/``end_date`` (yyyy-mm-dd) are given, messages are trimmed
|
||||||
to that window. The StockTwits public stream only serves recent messages, so
|
to that window, so a historical run never sees today's chatter (#1220). The
|
||||||
for a historical run they all fall after the window and a clear placeholder
|
public stream only serves recent messages, so a window it cannot reach is
|
||||||
is returned rather than leaking today's chatter into a backtest (#1220).
|
reported as unavailable rather than as silence.
|
||||||
|
|
||||||
Returns a placeholder string when the endpoint is unreachable, the
|
Returns a placeholder string when the endpoint is unreachable, the
|
||||||
symbol has no messages, or the response shape is unexpected — the
|
symbol has no messages, or the response shape is unexpected — the
|
||||||
@@ -94,13 +95,17 @@ def fetch_stocktwits_messages(
|
|||||||
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
|
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
|
||||||
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
||||||
|
|
||||||
messages = data.get("messages", []) if isinstance(data, dict) else []
|
fetched = data.get("messages", []) if isinstance(data, dict) else []
|
||||||
messages = _within_window(messages, start_date, end_date)
|
messages = _within_window(fetched, start_date, end_date)
|
||||||
if not messages:
|
if not messages:
|
||||||
if start_date and end_date:
|
if start_date and end_date:
|
||||||
return (
|
gap = coverage_gap(
|
||||||
|
(_created_at(m) for m in fetched), start_date, end_date,
|
||||||
|
"StockTwits", f"messages about ${ticker.upper()}",
|
||||||
|
)
|
||||||
|
return gap or (
|
||||||
f"<no StockTwits messages for ${ticker.upper()} within "
|
f"<no StockTwits messages for ${ticker.upper()} within "
|
||||||
f"{start_date}..{end_date} (public stream serves only recent messages)>"
|
f"{start_date}..{end_date}>"
|
||||||
)
|
)
|
||||||
return f"<no StockTwits messages found for ${ticker.upper()}>"
|
return f"<no StockTwits messages found for ${ticker.upper()}>"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ differ from the broker / TradingView / MT5 style symbols users often type:
|
|||||||
EURUSD EURUSD=X spot forex pairs take a ``=X`` suffix
|
EURUSD EURUSD=X spot forex pairs take a ``=X`` suffix
|
||||||
BTCUSD BTC-USD crypto pairs use a ``-`` separator
|
BTCUSD BTC-USD crypto pairs use a ``-`` separator
|
||||||
SPX500, US500 ^GSPC index CFDs map to Yahoo index symbols
|
SPX500, US500 ^GSPC index CFDs map to Yahoo index symbols
|
||||||
|
09992.HK, 700.HK 9992.HK, 0700.HK HK codes are zero-padded to 4 digits
|
||||||
|
600519.SH 600519.SS Yahoo spells Shanghai ``.SS``
|
||||||
|
|
||||||
Passing the raw broker symbol to Yahoo returns an empty result, which the
|
Passing the raw broker symbol to Yahoo returns an empty result, which the
|
||||||
agents previously received as free text and could hallucinate a price
|
agents previously received as free text and could hallucinate a price
|
||||||
@@ -72,6 +74,10 @@ _ALIASES = {
|
|||||||
# Yahoo symbols may contain letters, digits, and these structural characters.
|
# Yahoo symbols may contain letters, digits, and these structural characters.
|
||||||
_YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$")
|
_YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$")
|
||||||
|
|
||||||
|
# HKEX codes as Yahoo spells them: the number zero-padded to 4 digits (#957).
|
||||||
|
_HK_CODE = re.compile(r"^(\d{1,5})\.HK$")
|
||||||
|
_SHANGHAI_SH = re.compile(r"^(\d{6})\.SH$")
|
||||||
|
|
||||||
|
|
||||||
# Crypto quote currencies that all map to Yahoo's USD pair. Yahoo lists only
|
# Crypto quote currencies that all map to Yahoo's USD pair. Yahoo lists only
|
||||||
# ``<BASE>-USD`` (not the USDT/USDC stablecoin pairs), so a broker symbol quoted
|
# ``<BASE>-USD`` (not the USDT/USDC stablecoin pairs), so a broker symbol quoted
|
||||||
@@ -109,7 +115,10 @@ def normalize_symbol(raw: str) -> str:
|
|||||||
2. Crypto rule: a known crypto base quoted in USD/USDT/USDC (dashed or
|
2. Crypto rule: a known crypto base quoted in USD/USDT/USDC (dashed or
|
||||||
not) -> ``BASE-USD``.
|
not) -> ``BASE-USD``.
|
||||||
3. Forex rule: six letters that are two ISO currency codes -> ``PAIR=X``.
|
3. Forex rule: six letters that are two ISO currency codes -> ``PAIR=X``.
|
||||||
4. Otherwise the upper-cased symbol is returned unchanged (plain
|
4. HK rule: a numeric ``.HK`` code -> Yahoo's 4-digit padding
|
||||||
|
(``09992.HK`` -> ``9992.HK``, ``700.HK`` -> ``0700.HK``).
|
||||||
|
5. Shanghai rule: ``600519.SH`` -> ``600519.SS``.
|
||||||
|
6. Otherwise the upper-cased symbol is returned unchanged (plain
|
||||||
equities, ETFs, Yahoo-native symbols like ``GC=F`` or ``^GSPC``).
|
equities, ETFs, Yahoo-native symbols like ``GC=F`` or ``^GSPC``).
|
||||||
|
|
||||||
A trailing ``+`` (broker CFD marker, e.g. ``XAUUSD+``) is stripped before
|
A trailing ``+`` (broker CFD marker, e.g. ``XAUUSD+``) is stripped before
|
||||||
@@ -130,6 +139,10 @@ def normalize_symbol(raw: str) -> str:
|
|||||||
canonical = crypto
|
canonical = crypto
|
||||||
elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES:
|
elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES:
|
||||||
canonical = f"{s}=X"
|
canonical = f"{s}=X"
|
||||||
|
elif hk := _HK_CODE.match(s):
|
||||||
|
canonical = f"{int(hk.group(1)):04d}.HK"
|
||||||
|
elif sh := _SHANGHAI_SH.match(s):
|
||||||
|
canonical = f"{sh.group(1)}.SS"
|
||||||
else:
|
else:
|
||||||
canonical = s
|
canonical = s
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import pandas as pd
|
import requests
|
||||||
|
|
||||||
SavePathType = Annotated[str, "File path to save data. If None, data is not saved."]
|
|
||||||
|
|
||||||
# Tickers can contain letters, digits, dot, dash, underscore, caret
|
# Tickers can contain letters, digits, dot, dash, underscore, caret
|
||||||
# (index symbols like ^GSPC), equals (futures like GC=F), and plus
|
# (index symbols like ^GSPC), equals (futures like GC=F), and plus
|
||||||
@@ -42,34 +39,39 @@ def safe_ticker_component(value: str, *, max_len: int = 32) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def save_output(data: pd.DataFrame, tag: str, save_path: SavePathType = None) -> None:
|
|
||||||
if save_path:
|
|
||||||
data.to_csv(save_path, encoding="utf-8")
|
|
||||||
print(f"{tag} saved to {save_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_date():
|
def get_current_date():
|
||||||
return date.today().strftime("%Y-%m-%d")
|
return date.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def decorate_all_methods(decorator):
|
def get_scrubbed(url: str, *, params: dict, timeout: float, secret: str, passthrough=()):
|
||||||
def class_decorator(cls):
|
"""``requests.get`` plus ``raise_for_status``, with ``secret`` kept out of errors.
|
||||||
for attr_name, attr_value in cls.__dict__.items():
|
|
||||||
if callable(attr_value):
|
|
||||||
setattr(cls, attr_name, decorator(attr_value))
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return class_decorator
|
Vendors that authenticate with a query parameter put the key in the URL, and
|
||||||
|
requests quotes the full URL in HTTP, connection and timeout errors, so any
|
||||||
|
log or traceback that records one would carry the key (#1324). A requests
|
||||||
|
error is re-raised as the same class with the key replaced and nothing
|
||||||
|
attached: no request or response (both hold the URL) and no exception chain,
|
||||||
|
which is why this raises after the ``except`` block rather than inside it.
|
||||||
|
Statuses in ``passthrough`` are returned for the caller to handle.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=params, timeout=timeout)
|
||||||
|
if response.status_code not in passthrough:
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
error = type(exc)(str(exc).replace(secret, "***")) if secret else exc
|
||||||
|
raise error
|
||||||
|
|
||||||
|
|
||||||
def get_next_weekday(date):
|
def vendor_reachable(url: str, timeout: float = 5.0) -> bool:
|
||||||
|
"""Whether the vendor answers at all, for telling silence from an outage.
|
||||||
|
|
||||||
if not isinstance(date, datetime):
|
A client that returns an empty result instead of raising leaves those two
|
||||||
date = datetime.strptime(date, "%Y-%m-%d")
|
cases indistinguishable. Called only when a result is empty.
|
||||||
|
"""
|
||||||
if date.weekday() >= 5:
|
try:
|
||||||
days_to_add = 7 - date.weekday()
|
requests.head(url, timeout=timeout, allow_redirects=True)
|
||||||
next_weekday = date + timedelta(days=days_to_add)
|
return True
|
||||||
return next_weekday
|
except requests.RequestException:
|
||||||
else:
|
return False
|
||||||
return date
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -5,14 +6,22 @@ import pandas as pd
|
|||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from .date_window import withhold_live_profile
|
||||||
|
from .errors import VendorError, VendorRateLimitError
|
||||||
from .stockstats_utils import (
|
from .stockstats_utils import (
|
||||||
StockstatsUtils,
|
StockstatsUtils,
|
||||||
_assert_ohlcv_not_stale,
|
_assert_ohlcv_not_stale,
|
||||||
filter_financials_by_date,
|
filter_financials_by_date,
|
||||||
load_ohlcv,
|
load_ohlcv,
|
||||||
|
raise_for_empty,
|
||||||
yf_retry,
|
yf_retry,
|
||||||
)
|
)
|
||||||
from .symbol_utils import NoMarketDataError, normalize_symbol
|
from .symbol_utils import NoMarketDataError, normalize_symbol
|
||||||
|
from .utils import vendor_reachable
|
||||||
|
|
||||||
|
_YAHOO_HOST = "https://query2.finance.yahoo.com"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_YFin_data_online(
|
def get_YFin_data_online(
|
||||||
@@ -38,9 +47,7 @@ def get_YFin_data_online(
|
|||||||
# instead of returning prose: the routing layer turns it into a single
|
# instead of returning prose: the routing layer turns it into a single
|
||||||
# unambiguous "no data" signal so the agent never fabricates a price.
|
# unambiguous "no data" signal so the agent never fabricates a price.
|
||||||
if data.empty:
|
if data.empty:
|
||||||
raise NoMarketDataError(
|
raise_for_empty(symbol, canonical, f"rows between {start_date} and {end_date}")
|
||||||
symbol, canonical, f"no rows between {start_date} and {end_date}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove timezone info from index for cleaner output
|
# Remove timezone info from index for cleaner output
|
||||||
if data.index.tz is not None:
|
if data.index.tz is not None:
|
||||||
@@ -185,10 +192,10 @@ def get_stock_stats_indicators_window(
|
|||||||
for date_str, value in date_values:
|
for date_str, value in date_values:
|
||||||
ind_string += f"{date_str}: {value}\n"
|
ind_string += f"{date_str}: {value}\n"
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting bulk stockstats data: {e}")
|
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
|
||||||
# Fallback to original implementation if bulk method fails
|
# Fallback to original implementation if bulk method fails
|
||||||
ind_string = ""
|
ind_string = ""
|
||||||
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
@@ -260,29 +267,43 @@ def get_stockstats_indicator(
|
|||||||
indicator,
|
indicator,
|
||||||
curr_date,
|
curr_date,
|
||||||
)
|
)
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise # Unknown/delisted symbol — let the router emit the sentinel
|
raise # Unknown/delisted symbol — let the router emit the sentinel
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
# An empty string renders as "2026-05-08: " in the indicator table, which
|
||||||
f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}"
|
# reads as no value that day rather than a read that failed. Raise so the
|
||||||
)
|
# router can try the next vendor or report the series unavailable.
|
||||||
return ""
|
raise NoMarketDataError(
|
||||||
|
symbol, symbol, f"{indicator} could not be read for {curr_date}: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
return str(indicator_value)
|
return str(indicator_value)
|
||||||
|
|
||||||
|
|
||||||
def get_fundamentals(
|
def get_fundamentals(
|
||||||
ticker: Annotated[str, "ticker symbol of the company"],
|
ticker: Annotated[str, "ticker symbol of the company"],
|
||||||
curr_date: Annotated[str, "current date (not used for yfinance)"] = None
|
curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None
|
||||||
):
|
):
|
||||||
"""Get company fundamentals overview from yfinance."""
|
"""Get company fundamentals overview from yfinance.
|
||||||
|
|
||||||
|
``Ticker.info`` is a present-day snapshot with no historical vintage, so a
|
||||||
|
past ``curr_date`` withholds it through the shared point-in-time guard
|
||||||
|
(``date_window.withhold_live_profile``, #1300).
|
||||||
|
"""
|
||||||
canonical = normalize_symbol(ticker)
|
canonical = normalize_symbol(ticker)
|
||||||
|
|
||||||
|
# Guard before the request: the response would only be discarded, and the
|
||||||
|
# answer does not depend on it.
|
||||||
|
withheld = withhold_live_profile(curr_date, canonical)
|
||||||
|
if withheld:
|
||||||
|
return withheld
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ticker_obj = yf.Ticker(canonical)
|
ticker_obj = yf.Ticker(canonical)
|
||||||
info = yf_retry(lambda: ticker_obj.info)
|
info = yf_retry(lambda: ticker_obj.info)
|
||||||
|
|
||||||
if not info:
|
if not info:
|
||||||
raise NoMarketDataError(ticker, canonical, "no fundamentals returned")
|
raise_for_empty(ticker, canonical, "fundamentals")
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
("Name", info.get("longName")),
|
("Name", info.get("longName")),
|
||||||
@@ -315,10 +336,7 @@ def get_fundamentals(
|
|||||||
("Free Cash Flow", info.get("freeCashflow")),
|
("Free Cash Flow", info.get("freeCashflow")),
|
||||||
]
|
]
|
||||||
|
|
||||||
lines = []
|
lines = [f"{label}: {v}" for label, v in fields if v is not None]
|
||||||
for label, value in fields:
|
|
||||||
if value is not None:
|
|
||||||
lines.append(f"{label}: {value}")
|
|
||||||
|
|
||||||
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
|
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
|
||||||
# unknown symbols, so `info` is truthy but every field is empty. Treat
|
# unknown symbols, so `info` is truthy but every field is empty. Treat
|
||||||
@@ -332,10 +350,10 @@ def get_fundamentals(
|
|||||||
|
|
||||||
return header + "\n".join(lines)
|
return header + "\n".join(lines)
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error retrieving fundamentals for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, canonical, f"fundamentals unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def get_balance_sheet(
|
def get_balance_sheet(
|
||||||
@@ -356,21 +374,22 @@ def get_balance_sheet(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
if data.empty:
|
||||||
raise NoMarketDataError(ticker, canonical, "no balance sheet data")
|
raise_for_empty(ticker, canonical, "balance sheet data")
|
||||||
|
|
||||||
# Convert to CSV string for consistency with other functions
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
|
|
||||||
# Add header information
|
# Add header information
|
||||||
header = f"# Balance Sheet data for {canonical} ({freq})\n"
|
header = f"# Balance Sheet data for {canonical} ({freq})\n"
|
||||||
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||||
|
header += _PERIOD_END_VINTAGE
|
||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error retrieving balance sheet for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, canonical, f"balance sheet unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def get_cashflow(
|
def get_cashflow(
|
||||||
@@ -391,21 +410,22 @@ def get_cashflow(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
if data.empty:
|
||||||
raise NoMarketDataError(ticker, canonical, "no cash flow data")
|
raise_for_empty(ticker, canonical, "cash flow data")
|
||||||
|
|
||||||
# Convert to CSV string for consistency with other functions
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
|
|
||||||
# Add header information
|
# Add header information
|
||||||
header = f"# Cash Flow data for {canonical} ({freq})\n"
|
header = f"# Cash Flow data for {canonical} ({freq})\n"
|
||||||
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||||
|
header += _PERIOD_END_VINTAGE
|
||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error retrieving cash flow for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, canonical, f"cash flow unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def get_income_statement(
|
def get_income_statement(
|
||||||
@@ -426,25 +446,49 @@ def get_income_statement(
|
|||||||
data = filter_financials_by_date(data, curr_date)
|
data = filter_financials_by_date(data, curr_date)
|
||||||
|
|
||||||
if data.empty:
|
if data.empty:
|
||||||
raise NoMarketDataError(ticker, canonical, "no income statement data")
|
raise_for_empty(ticker, canonical, "income statement data")
|
||||||
|
|
||||||
# Convert to CSV string for consistency with other functions
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
|
|
||||||
# Add header information
|
# Add header information
|
||||||
header = f"# Income Statement data for {canonical} ({freq})\n"
|
header = f"# Income Statement data for {canonical} ({freq})\n"
|
||||||
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||||
|
header += _PERIOD_END_VINTAGE
|
||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except NoMarketDataError:
|
except VendorError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error retrieving income statement for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, canonical, f"income statement unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
# Rows are dated by the transaction, which is when the insider traded, not when
|
||||||
|
# the market learned of it: a Form 4 is filed up to two business days later and
|
||||||
|
# this vendor reports no filing date, so the most recent rows may not have been
|
||||||
|
# public on the analysis date.
|
||||||
|
_TRANSACTION_DATE_VINTAGE = (
|
||||||
|
"# Rows are dated by transaction date. A trade becomes public when its Form 4 "
|
||||||
|
"is filed, up to two business days later, so the newest rows may not have been "
|
||||||
|
"known on this date.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# This vendor dates a statement by the period it covers, not by the day it was
|
||||||
|
# filed, and carries no filing date to do better. A company files weeks after its
|
||||||
|
# period ends, so a run dated in that gap can be served figures that were not yet
|
||||||
|
# public. Say so rather than implying the stricter guarantee (SEC EDGAR, which
|
||||||
|
# does carry filing dates, serves US filers as filed).
|
||||||
|
_PERIOD_END_VINTAGE = (
|
||||||
|
"# Periods are cut at the fiscal period end; this vendor does not report "
|
||||||
|
"filing dates, so the most recent period may not have been published yet.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_insider_transactions(
|
def get_insider_transactions(
|
||||||
ticker: Annotated[str, "ticker symbol of the company"]
|
ticker: Annotated[str, "ticker symbol of the company"],
|
||||||
|
curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None,
|
||||||
):
|
):
|
||||||
"""Get insider transactions data from yfinance."""
|
"""Get insider transactions data from yfinance."""
|
||||||
canonical = normalize_symbol(ticker)
|
canonical = normalize_symbol(ticker)
|
||||||
@@ -455,16 +499,29 @@ def get_insider_transactions(
|
|||||||
# Empty is normal here (many valid symbols have no insider filings),
|
# Empty is normal here (many valid symbols have no insider filings),
|
||||||
# so report it plainly rather than treating the symbol as invalid.
|
# so report it plainly rather than treating the symbol as invalid.
|
||||||
if data is None or data.empty:
|
if data is None or data.empty:
|
||||||
|
if not vendor_reachable(_YAHOO_HOST):
|
||||||
|
raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved")
|
||||||
return f"No insider transactions reported for symbol '{canonical}'"
|
return f"No insider transactions reported for symbol '{canonical}'"
|
||||||
|
|
||||||
|
if curr_date:
|
||||||
|
traded = data["Start Date"]
|
||||||
|
kept = data[traded <= pd.Timestamp(curr_date)]
|
||||||
|
if kept.empty:
|
||||||
|
return (
|
||||||
|
f"<insider transactions unavailable for {canonical} as of {curr_date}: "
|
||||||
|
f"Yahoo serves recent transactions only (coverage starts {traded.min():%Y-%m-%d})>"
|
||||||
|
)
|
||||||
|
data = kept
|
||||||
|
|
||||||
# Convert to CSV string for consistency with other functions
|
# Convert to CSV string for consistency with other functions
|
||||||
csv_string = data.to_csv()
|
csv_string = data.to_csv()
|
||||||
|
|
||||||
# Add header information
|
# Add header information
|
||||||
header = f"# Insider Transactions data for {canonical}\n"
|
header = f"# Insider Transactions data for {canonical}\n"
|
||||||
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||||
|
header += _TRANSACTION_DATE_VINTAGE
|
||||||
|
|
||||||
return header + csv_string
|
return header + csv_string
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error retrieving insider transactions for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import yfinance as yf
|
|||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .date_window import in_window
|
from .date_window import coverage_gap, in_window
|
||||||
|
from .errors import NoMarketDataError
|
||||||
from .stockstats_utils import yf_retry
|
from .stockstats_utils import yf_retry
|
||||||
from .symbol_utils import normalize_symbol
|
from .symbol_utils import normalize_symbol
|
||||||
|
|
||||||
@@ -84,10 +85,7 @@ def get_news_yfinance(
|
|||||||
resolved = "" if canonical == ticker else f" (resolved to {canonical})"
|
resolved = "" if canonical == ticker else f" (resolved to {canonical})"
|
||||||
try:
|
try:
|
||||||
stock = yf.Ticker(canonical)
|
stock = yf.Ticker(canonical)
|
||||||
news = yf_retry(lambda: stock.get_news(count=article_limit))
|
news = yf_retry(lambda: stock.get_news(count=article_limit)) or []
|
||||||
|
|
||||||
if not news:
|
|
||||||
return f"No news found for {ticker}{resolved}"
|
|
||||||
|
|
||||||
# Parse date range for filtering
|
# Parse date range for filtering
|
||||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
@@ -112,12 +110,16 @@ def get_news_yfinance(
|
|||||||
filtered_count += 1
|
filtered_count += 1
|
||||||
|
|
||||||
if filtered_count == 0:
|
if filtered_count == 0:
|
||||||
return f"No news found for {ticker}{resolved} between {start_date} and {end_date}"
|
gap = coverage_gap(
|
||||||
|
(_extract_article_data(a)["pub_date"] for a in news),
|
||||||
|
start_date, end_date, "Yahoo Finance news", f"news for {ticker}{resolved}",
|
||||||
|
)
|
||||||
|
return gap or f"No news found for {ticker}{resolved} between {start_date} and {end_date}"
|
||||||
|
|
||||||
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
|
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error fetching news for {ticker}: {str(e)}"
|
raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def get_global_news_yfinance(
|
def get_global_news_yfinance(
|
||||||
@@ -145,7 +147,11 @@ def get_global_news_yfinance(
|
|||||||
limit = config["global_news_article_limit"]
|
limit = config["global_news_article_limit"]
|
||||||
search_queries = config["global_news_queries"]
|
search_queries = config["global_news_queries"]
|
||||||
|
|
||||||
all_news = []
|
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
||||||
|
start_dt = curr_dt - relativedelta(days=look_back_days)
|
||||||
|
start_date = start_dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
in_window_news = []
|
||||||
seen_titles = set()
|
seen_titles = set()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -156,53 +162,39 @@ def get_global_news_yfinance(
|
|||||||
enable_fuzzy_query=True,
|
enable_fuzzy_query=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
if search.news:
|
for article in search.news or []:
|
||||||
for article in search.news:
|
# Window first: the limit counts what the run may read, so an
|
||||||
# Handle both flat and nested structures
|
# out-of-window article must not spend the budget or cut the
|
||||||
if "content" in article:
|
# remaining searches short (#1356). Flat articles are filtered
|
||||||
data = _extract_article_data(article)
|
# on the same rule, so none can leak future news (#1007).
|
||||||
title = data["title"]
|
|
||||||
else:
|
|
||||||
title = article.get("title", "")
|
|
||||||
|
|
||||||
# Deduplicate by title
|
|
||||||
if title and title not in seen_titles:
|
|
||||||
seen_titles.add(title)
|
|
||||||
all_news.append(article)
|
|
||||||
|
|
||||||
if len(all_news) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not all_news:
|
|
||||||
return f"No global news found for {curr_date}"
|
|
||||||
|
|
||||||
# Calculate date range
|
|
||||||
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d")
|
|
||||||
start_dt = curr_dt - relativedelta(days=look_back_days)
|
|
||||||
start_date = start_dt.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
news_str = ""
|
|
||||||
kept = 0
|
|
||||||
for article in all_news[:limit]:
|
|
||||||
# Extract uniformly (flat + nested) and apply the same look-ahead-safe
|
|
||||||
# window filter, so flat articles can't leak future news (#1007).
|
|
||||||
data = _extract_article_data(article)
|
data = _extract_article_data(article)
|
||||||
if not in_window(data["pub_date"], start_dt, curr_dt):
|
if not in_window(data["pub_date"], start_dt, curr_dt):
|
||||||
continue
|
continue
|
||||||
|
if data["title"] and data["title"] not in seen_titles:
|
||||||
|
seen_titles.add(data["title"])
|
||||||
|
in_window_news.append(data)
|
||||||
|
|
||||||
|
if len(in_window_news) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
news_str = ""
|
||||||
|
for data in in_window_news[:limit]:
|
||||||
news_str += f"### {data['title']} (source: {data['publisher']})\n"
|
news_str += f"### {data['title']} (source: {data['publisher']})\n"
|
||||||
if data["summary"]:
|
if data["summary"]:
|
||||||
news_str += f"{data['summary']}\n"
|
news_str += f"{data['summary']}\n"
|
||||||
if data["link"]:
|
if data["link"]:
|
||||||
news_str += f"Link: {data['link']}\n"
|
news_str += f"Link: {data['link']}\n"
|
||||||
news_str += "\n"
|
news_str += "\n"
|
||||||
kept += 1
|
|
||||||
|
|
||||||
# All candidates fell outside the window -> say so rather than return an
|
# Nothing fell inside the window -> say so rather than return an
|
||||||
# empty-bodied report (#993).
|
# empty-bodied report (#993).
|
||||||
if kept == 0:
|
if not news_str:
|
||||||
return f"No global news found between {start_date} and {curr_date}"
|
# Results merge several fuzzy searches, so their timestamps prove no
|
||||||
|
# continuous coverage; judge the window against the present only.
|
||||||
|
gap = coverage_gap((), start_date, curr_date, "Yahoo Finance global news", "market news")
|
||||||
|
return gap or f"No global news found between {start_date} and {curr_date}"
|
||||||
|
|
||||||
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}"
|
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error fetching global news: {str(e)}"
|
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ DEFAULT_CONFIG = _apply_env_overrides({
|
|||||||
# based on the ticker's exchange suffix. SPY remains the US default
|
# based on the ticker's exchange suffix. SPY remains the US default
|
||||||
# so the reflection label keeps reading "Alpha vs SPY" for US tickers
|
# so the reflection label keeps reading "Alpha vs SPY" for US tickers
|
||||||
# while non-US tickers get their regional index automatically.
|
# while non-US tickers get their regional index automatically.
|
||||||
|
# Trading days after the analysis date over which a decision's outcome is
|
||||||
|
# measured, for reflection and for the backtest figures.
|
||||||
|
"holding_period_days": 5,
|
||||||
"benchmark_ticker": None,
|
"benchmark_ticker": None,
|
||||||
"benchmark_map": {
|
"benchmark_map": {
|
||||||
".NS": "^NSEI", # NSE India (Nifty 50)
|
".NS": "^NSEI", # NSE India (Nifty 50)
|
||||||
|
|||||||
@@ -71,13 +71,19 @@ def checkpoint_step(data_dir: str | Path, ticker: str, date: str, signature: str
|
|||||||
|
|
||||||
|
|
||||||
def clear_all_checkpoints(data_dir: str | Path) -> int:
|
def clear_all_checkpoints(data_dir: str | Path) -> int:
|
||||||
"""Remove all checkpoint DBs. Returns number of files deleted."""
|
"""Remove all checkpoint databases. Returns the number of databases deleted.
|
||||||
|
|
||||||
|
SQLite keeps committed state in ``-wal`` and ``-shm`` files beside the
|
||||||
|
database, so deleting only the ``.db`` leaves a cleared checkpoint with data
|
||||||
|
still on disk.
|
||||||
|
"""
|
||||||
cp_dir = Path(data_dir) / "checkpoints"
|
cp_dir = Path(data_dir) / "checkpoints"
|
||||||
if not cp_dir.exists():
|
if not cp_dir.exists():
|
||||||
return 0
|
return 0
|
||||||
dbs = list(cp_dir.glob("*.db"))
|
dbs = list(cp_dir.glob("*.db"))
|
||||||
for db in dbs:
|
for db in dbs:
|
||||||
db.unlink()
|
for path in (db, *cp_dir.glob(f"{db.name}-*")):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
return len(dbs)
|
return len(dbs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Propagator:
|
|||||||
asset_type: str = "stock",
|
asset_type: str = "stock",
|
||||||
past_context: str = "",
|
past_context: str = "",
|
||||||
instrument_context: str = "",
|
instrument_context: str = "",
|
||||||
|
portfolio_context: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create the initial state for the agent graph.
|
"""Create the initial state for the agent graph.
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class Propagator:
|
|||||||
"instrument_context": instrument_context,
|
"instrument_context": instrument_context,
|
||||||
"trade_date": str(trade_date),
|
"trade_date": str(trade_date),
|
||||||
"past_context": past_context,
|
"past_context": past_context,
|
||||||
|
"portfolio_context": portfolio_context,
|
||||||
"investment_debate_state": InvestDebateState(
|
"investment_debate_state": InvestDebateState(
|
||||||
{
|
{
|
||||||
"bull_history": "",
|
"bull_history": "",
|
||||||
|
|||||||
@@ -9,20 +9,25 @@ class Reflector:
|
|||||||
def __init__(self, quick_thinking_llm: Any):
|
def __init__(self, quick_thinking_llm: Any):
|
||||||
"""Initialize the reflector with an LLM."""
|
"""Initialize the reflector with an LLM."""
|
||||||
self.quick_thinking_llm = quick_thinking_llm
|
self.quick_thinking_llm = quick_thinking_llm
|
||||||
self.log_reflection_prompt = self._get_log_reflection_prompt()
|
|
||||||
|
|
||||||
def _get_log_reflection_prompt(self) -> str:
|
def _system_prompt(self, holding_days: int) -> str:
|
||||||
"""Concise prompt for reflect_on_final_decision (Phase B log entries).
|
"""Concise prompt for reflect_on_final_decision (Phase B log entries).
|
||||||
|
|
||||||
Produces 2-4 sentences of plain prose — compact enough to be re-injected
|
Produces 2-4 sentences of plain prose, compact enough to be re-injected
|
||||||
into future agent prompts without bloating the context window.
|
into future agent prompts without bloating the context window. The
|
||||||
|
window is named because it bounds what the outcome can show: a thesis
|
||||||
|
written for months is not disproved by a week, and a lesson that ignores
|
||||||
|
the difference is read by later runs as an established failure.
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
"You are a trading analyst reviewing your own past decision now that the outcome is known.\n"
|
"You are a trading analyst reviewing your own past decision now that the outcome is known.\n"
|
||||||
|
f"The outcome covers {holding_days} trading days after the analysis date, "
|
||||||
|
"which may be shorter than the horizon the decision was written for.\n"
|
||||||
"Write exactly 2-4 sentences of plain prose (no bullets, no headers, no markdown).\n\n"
|
"Write exactly 2-4 sentences of plain prose (no bullets, no headers, no markdown).\n\n"
|
||||||
"Cover in order:\n"
|
"Cover in order:\n"
|
||||||
"1. Was the directional call correct? (cite the alpha figure)\n"
|
f"1. What the {holding_days}-day alpha shows about the directional call (cite the figure), "
|
||||||
"2. Which part of the investment thesis held or failed?\n"
|
"and say so plainly if the window is too short to judge the thesis.\n"
|
||||||
|
"2. Which part of the investment thesis this window supports or undercuts.\n"
|
||||||
"3. One concrete lesson to apply to the next similar analysis.\n\n"
|
"3. One concrete lesson to apply to the next similar analysis.\n\n"
|
||||||
"Be specific and terse. Your output will be stored verbatim in a decision log "
|
"Be specific and terse. Your output will be stored verbatim in a decision log "
|
||||||
"and re-read by future analysts, so every word must earn its place."
|
"and re-read by future analysts, so every word must earn its place."
|
||||||
@@ -34,6 +39,7 @@ class Reflector:
|
|||||||
raw_return: float,
|
raw_return: float,
|
||||||
alpha_return: float,
|
alpha_return: float,
|
||||||
benchmark_name: str = "SPY",
|
benchmark_name: str = "SPY",
|
||||||
|
holding_days: int = 5,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Single reflection call on the final trade decision with outcome context.
|
"""Single reflection call on the final trade decision with outcome context.
|
||||||
|
|
||||||
@@ -44,11 +50,11 @@ class Reflector:
|
|||||||
callers that haven't been updated to thread the benchmark through.
|
callers that haven't been updated to thread the benchmark through.
|
||||||
"""
|
"""
|
||||||
messages = [
|
messages = [
|
||||||
("system", self.log_reflection_prompt),
|
("system", self._system_prompt(holding_days)),
|
||||||
(
|
(
|
||||||
"human",
|
"human",
|
||||||
(
|
(
|
||||||
f"Raw return: {raw_return:+.1%}\n"
|
f"Raw return over {holding_days} trading days: {raw_return:+.1%}\n"
|
||||||
f"Alpha vs {benchmark_name}: {alpha_return:+.1%}\n\n"
|
f"Alpha vs {benchmark_name}: {alpha_return:+.1%}\n\n"
|
||||||
f"Final Decision:\n{final_decision}"
|
f"Final Decision:\n{final_decision}"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from tradingagents.agents.utils.agent_utils import (
|
|||||||
)
|
)
|
||||||
from tradingagents.agents.utils.memory import TradingMemoryLog
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
from tradingagents.dataflows.config import set_config
|
from tradingagents.dataflows.config import set_config
|
||||||
from tradingagents.dataflows.utils import safe_ticker_component
|
from tradingagents.dataflows.utils import get_current_date, safe_ticker_component
|
||||||
from tradingagents.default_config import DEFAULT_CONFIG
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
from tradingagents.llm_clients import create_llm_client
|
from tradingagents.llm_clients import create_llm_client
|
||||||
from tradingagents.reporting import write_report_tree
|
from tradingagents.reporting import write_report_tree
|
||||||
@@ -45,6 +45,20 @@ from .signal_processing import SignalProcessor
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_trade_date(trade_date) -> str:
|
||||||
|
"""The run date as a canonical ``YYYY-MM-DD`` string no later than today."""
|
||||||
|
value = str(trade_date)
|
||||||
|
try:
|
||||||
|
canonical = datetime.strptime(value, "%Y-%m-%d").strftime("%Y-%m-%d") == value
|
||||||
|
except ValueError:
|
||||||
|
canonical = False
|
||||||
|
if not canonical:
|
||||||
|
raise ValueError(f"trade_date must be a date in YYYY-MM-DD format, got {trade_date!r}")
|
||||||
|
if value > get_current_date():
|
||||||
|
raise ValueError(f"trade_date cannot be in the future: {value}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _coerce_max_retries(value):
|
def _coerce_max_retries(value):
|
||||||
"""Validate an ``llm_max_retries`` value to a non-negative int.
|
"""Validate an ``llm_max_retries`` value to a non-negative int.
|
||||||
|
|
||||||
@@ -260,11 +274,15 @@ class TradingAgentsGraph:
|
|||||||
entry, which is the right default because the alpha calculation works
|
entry, which is the right default because the alpha calculation works
|
||||||
in USD.
|
in USD.
|
||||||
"""
|
"""
|
||||||
|
from tradingagents.dataflows.symbol_utils import normalize_symbol
|
||||||
|
|
||||||
explicit = self.config.get("benchmark_ticker")
|
explicit = self.config.get("benchmark_ticker")
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
# Same alias mapping as the analyzed ticker; an unmapped alias finds
|
||||||
|
# no prices, and the decision would stay pending for good.
|
||||||
|
return normalize_symbol(explicit)
|
||||||
benchmark_map = self.config.get("benchmark_map", {})
|
benchmark_map = self.config.get("benchmark_map", {})
|
||||||
ticker_upper = ticker.upper()
|
ticker_upper = normalize_symbol(ticker)
|
||||||
for suffix, benchmark in benchmark_map.items():
|
for suffix, benchmark in benchmark_map.items():
|
||||||
if suffix and ticker_upper.endswith(suffix.upper()):
|
if suffix and ticker_upper.endswith(suffix.upper()):
|
||||||
return benchmark
|
return benchmark
|
||||||
@@ -288,7 +306,9 @@ class TradingAgentsGraph:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
start = datetime.strptime(trade_date, "%Y-%m-%d")
|
start = datetime.strptime(trade_date, "%Y-%m-%d")
|
||||||
end = start + timedelta(days=holding_days + 7) # buffer for weekends/holidays
|
# holding_days counts trading days, so ask for the calendar span they
|
||||||
|
# occupy (about 7 for every 5) plus a week for holidays.
|
||||||
|
end = start + timedelta(days=round(holding_days * 7 / 5) + 7)
|
||||||
end_str = end.strftime("%Y-%m-%d")
|
end_str = end.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Normalize so the realized-return lookup hits the same instrument
|
# Normalize so the realized-return lookup hits the same instrument
|
||||||
@@ -341,16 +361,25 @@ class TradingAgentsGraph:
|
|||||||
updates = []
|
updates = []
|
||||||
for entry in pending:
|
for entry in pending:
|
||||||
raw, alpha, days, resolution_date = self._fetch_returns(
|
raw, alpha, days, resolution_date = self._fetch_returns(
|
||||||
ticker, entry["date"], benchmark=benchmark,
|
ticker, entry["date"], self.config.get("holding_period_days", 5),
|
||||||
|
benchmark=benchmark,
|
||||||
)
|
)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
continue # price not available yet — try again next run
|
continue # price not available yet — try again next run
|
||||||
|
try:
|
||||||
reflection = self.reflector.reflect_on_final_decision(
|
reflection = self.reflector.reflect_on_final_decision(
|
||||||
final_decision=entry.get("decision", ""),
|
final_decision=entry.get("decision", ""),
|
||||||
raw_return=raw,
|
raw_return=raw,
|
||||||
alpha_return=alpha,
|
alpha_return=alpha,
|
||||||
benchmark_name=benchmark,
|
benchmark_name=benchmark,
|
||||||
|
holding_days=days,
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Reflection calls a provider, and this runs on the way into a
|
||||||
|
# new run: a transient failure leaves the entry pending for the
|
||||||
|
# next one rather than stopping the analysis that was asked for.
|
||||||
|
logger.warning("Reflection failed for %s on %s: %s", ticker, entry["date"], exc)
|
||||||
|
continue
|
||||||
updates.append({
|
updates.append({
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"trade_date": entry["date"],
|
"trade_date": entry["date"],
|
||||||
@@ -364,7 +393,8 @@ class TradingAgentsGraph:
|
|||||||
if updates:
|
if updates:
|
||||||
self.memory_log.batch_update_with_outcomes(updates)
|
self.memory_log.batch_update_with_outcomes(updates)
|
||||||
|
|
||||||
def resolve_instrument_context(self, ticker: str, asset_type: str = "stock") -> str:
|
def resolve_instrument_context(self, ticker: str, asset_type: str = "stock",
|
||||||
|
curr_date: str | None = None) -> str:
|
||||||
"""Resolve ticker identity once and return the full instrument context.
|
"""Resolve ticker identity once and return the full instrument context.
|
||||||
|
|
||||||
Deterministic yfinance lookup (cached, fail-open) injected into a
|
Deterministic yfinance lookup (cached, fail-open) injected into a
|
||||||
@@ -374,7 +404,7 @@ class TradingAgentsGraph:
|
|||||||
graph regardless of entry point.
|
graph regardless of entry point.
|
||||||
"""
|
"""
|
||||||
identity = resolve_instrument_identity(ticker)
|
identity = resolve_instrument_identity(ticker)
|
||||||
return build_instrument_context(ticker, asset_type, identity)
|
return build_instrument_context(ticker, asset_type, identity, curr_date)
|
||||||
|
|
||||||
def _memory_as_of(self, trade_date) -> str | None:
|
def _memory_as_of(self, trade_date) -> str | None:
|
||||||
"""Point-in-time cutoff for past-context lessons (#1251).
|
"""Point-in-time cutoff for past-context lessons (#1251).
|
||||||
@@ -387,7 +417,7 @@ class TradingAgentsGraph:
|
|||||||
td = str(trade_date)
|
td = str(trade_date)
|
||||||
return td if td < datetime.now().strftime("%Y-%m-%d") else None
|
return td if td < datetime.now().strftime("%Y-%m-%d") else None
|
||||||
|
|
||||||
def _run_signature(self, asset_type: str) -> str:
|
def _run_signature(self, asset_type: str, portfolio=None) -> str:
|
||||||
"""Graph-shape inputs that must invalidate a checkpoint if changed.
|
"""Graph-shape inputs that must invalidate a checkpoint if changed.
|
||||||
|
|
||||||
Keyed into the checkpoint thread ID so a resume under a different analyst
|
Keyed into the checkpoint thread ID so a resume under a different analyst
|
||||||
@@ -399,9 +429,11 @@ class TradingAgentsGraph:
|
|||||||
f"debate={self.config['max_debate_rounds']}",
|
f"debate={self.config['max_debate_rounds']}",
|
||||||
f"risk={self.config['max_risk_discuss_rounds']}",
|
f"risk={self.config['max_risk_discuss_rounds']}",
|
||||||
f"asset={asset_type}",
|
f"asset={asset_type}",
|
||||||
|
# None, an empty book and a changed book are three different runs.
|
||||||
|
f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}",
|
||||||
])
|
])
|
||||||
|
|
||||||
def propagate(self, company_name, trade_date, asset_type: str = "stock"):
|
def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
|
||||||
"""Run the trading agents graph for a company on a specific date.
|
"""Run the trading agents graph for a company on a specific date.
|
||||||
|
|
||||||
``asset_type`` selects between the stock pipeline (default) and the
|
``asset_type`` selects between the stock pipeline (default) and the
|
||||||
@@ -417,18 +449,16 @@ class TradingAgentsGraph:
|
|||||||
``tradingagents.agents.utils.rating.is_review`` before mapping it to the
|
``tradingagents.agents.utils.rating.is_review`` before mapping it to the
|
||||||
PortfolioRating enum.
|
PortfolioRating enum.
|
||||||
"""
|
"""
|
||||||
|
trade_date = _validate_trade_date(trade_date)
|
||||||
self.ticker = company_name
|
self.ticker = company_name
|
||||||
|
|
||||||
# Resolve any pending memory-log entries for this ticker before the pipeline runs.
|
with self.checkpoint_scope(company_name, trade_date, asset_type, portfolio) as thread_id_value:
|
||||||
self._resolve_pending_entries(company_name)
|
|
||||||
|
|
||||||
with self.checkpoint_scope(company_name, trade_date, asset_type) as thread_id_value:
|
|
||||||
return self._run_graph(
|
return self._run_graph(
|
||||||
company_name, trade_date, asset_type=asset_type,
|
company_name, trade_date, asset_type=asset_type,
|
||||||
checkpoint_thread_id=thread_id_value,
|
checkpoint_thread_id=thread_id_value, portfolio=portfolio,
|
||||||
)
|
)
|
||||||
|
|
||||||
def begin_checkpoint(self, company_name, trade_date, asset_type: str = "stock") -> str | None:
|
def begin_checkpoint(self, company_name, trade_date, asset_type: str = "stock", portfolio=None) -> str | None:
|
||||||
"""Recompile the graph with a per-ticker checkpointer and return the
|
"""Recompile the graph with a per-ticker checkpointer and return the
|
||||||
``thread_id`` to inject into the stream/invoke ``config`` (or ``None``
|
``thread_id`` to inject into the stream/invoke ``config`` (or ``None``
|
||||||
when checkpointing is disabled).
|
when checkpointing is disabled).
|
||||||
@@ -442,7 +472,7 @@ class TradingAgentsGraph:
|
|||||||
self._resuming = False
|
self._resuming = False
|
||||||
if not self.config.get("checkpoint_enabled"):
|
if not self.config.get("checkpoint_enabled"):
|
||||||
return None
|
return None
|
||||||
signature = self._run_signature(asset_type)
|
signature = self._run_signature(asset_type, portfolio)
|
||||||
self._checkpointer_ctx = get_checkpointer(self.config["data_cache_dir"], company_name)
|
self._checkpointer_ctx = get_checkpointer(self.config["data_cache_dir"], company_name)
|
||||||
saver = self._checkpointer_ctx.__enter__()
|
saver = self._checkpointer_ctx.__enter__()
|
||||||
self.graph = self.workflow.compile(checkpointer=saver)
|
self.graph = self.workflow.compile(checkpointer=saver)
|
||||||
@@ -476,19 +506,19 @@ class TradingAgentsGraph:
|
|||||||
self._resuming = False
|
self._resuming = False
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def checkpoint_scope(self, company_name, trade_date, asset_type: str = "stock"):
|
def checkpoint_scope(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
|
||||||
"""Context-manager form of begin/end_checkpoint for the propagate path."""
|
"""Context-manager form of begin/end_checkpoint for the propagate path."""
|
||||||
try:
|
try:
|
||||||
yield self.begin_checkpoint(company_name, trade_date, asset_type)
|
yield self.begin_checkpoint(company_name, trade_date, asset_type, portfolio)
|
||||||
finally:
|
finally:
|
||||||
self.end_checkpoint()
|
self.end_checkpoint()
|
||||||
|
|
||||||
def clear_checkpoint_on_success(self, company_name, trade_date, asset_type: str = "stock"):
|
def clear_checkpoint_on_success(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
|
||||||
"""Drop a completed run's checkpoint so a later run starts fresh (#1249)."""
|
"""Drop a completed run's checkpoint so a later run starts fresh (#1249)."""
|
||||||
if self.config.get("checkpoint_enabled"):
|
if self.config.get("checkpoint_enabled"):
|
||||||
clear_checkpoint(
|
clear_checkpoint(
|
||||||
self.config["data_cache_dir"], company_name, str(trade_date),
|
self.config["data_cache_dir"], company_name, str(trade_date),
|
||||||
self._run_signature(asset_type),
|
self._run_signature(asset_type, portfolio),
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_reports(self, final_state, ticker, save_path=None) -> Path:
|
def save_reports(self, final_state, ticker, save_path=None) -> Path:
|
||||||
@@ -506,24 +536,50 @@ class TradingAgentsGraph:
|
|||||||
)
|
)
|
||||||
return write_report_tree(final_state, ticker, save_path)
|
return write_report_tree(final_state, ticker, save_path)
|
||||||
|
|
||||||
def _run_graph(self, company_name, trade_date, asset_type: str = "stock",
|
def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
|
||||||
checkpoint_thread_id: str | None = None):
|
"""Build a run's initial state; propagate() and the CLI both start here.
|
||||||
"""Execute the graph and write the resulting state to disk and memory log."""
|
|
||||||
# Initialize state — inject memory log context for PM and the
|
Settles this ticker's pending decisions first, then injects the lessons
|
||||||
# deterministically resolved instrument identity for all agents. On a
|
known by the trade date for the Portfolio Manager (#1251) and the
|
||||||
# historical run, gate lessons to those whose outcome was known by the
|
resolved instrument identity for every agent (#814). An entry point that
|
||||||
# trade date so a backtest can't learn from the future (#1251).
|
assembled the state itself would skip the decision log.
|
||||||
past_context = self.memory_log.get_past_context(
|
"""
|
||||||
company_name, as_of=self._memory_as_of(trade_date)
|
self._resolve_pending_entries(company_name)
|
||||||
)
|
return self.propagator.create_initial_state(
|
||||||
instrument_context = self.resolve_instrument_context(company_name, asset_type)
|
|
||||||
init_agent_state = self.propagator.create_initial_state(
|
|
||||||
company_name,
|
company_name,
|
||||||
trade_date,
|
trade_date,
|
||||||
asset_type=asset_type,
|
asset_type=asset_type,
|
||||||
past_context=past_context,
|
past_context=self.memory_log.get_past_context(
|
||||||
instrument_context=instrument_context,
|
company_name, as_of=self._memory_as_of(trade_date)
|
||||||
|
),
|
||||||
|
instrument_context=self.resolve_instrument_context(company_name, asset_type, trade_date),
|
||||||
|
portfolio_context=portfolio.render(company_name) if portfolio is not None else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def settle_pending(self, company_name):
|
||||||
|
"""Settle this ticker's decisions whose holding window has now traded.
|
||||||
|
|
||||||
|
A run settles the ticker's earlier decisions on its way in, so the most
|
||||||
|
recent one stays pending until the next run for that ticker. A caller
|
||||||
|
that is done analyzing a ticker (a backtest sweep, a scheduled job) calls
|
||||||
|
this to settle it now.
|
||||||
|
"""
|
||||||
|
self._resolve_pending_entries(company_name)
|
||||||
|
|
||||||
|
def record_decision(self, company_name, trade_date, final_state):
|
||||||
|
"""Log a finished run's decision for reflection on the next same-ticker run."""
|
||||||
|
decision = final_state.get("final_trade_decision")
|
||||||
|
if not decision:
|
||||||
|
logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date)
|
||||||
|
return
|
||||||
|
self.memory_log.store_decision(
|
||||||
|
ticker=company_name, trade_date=trade_date, final_trade_decision=decision
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_graph(self, company_name, trade_date, asset_type: str = "stock",
|
||||||
|
checkpoint_thread_id: str | None = None, portfolio=None):
|
||||||
|
"""Execute the graph and write the resulting state to disk and memory log."""
|
||||||
|
init_agent_state = self.create_run_state(company_name, trade_date, asset_type, portfolio)
|
||||||
args = self.propagator.get_graph_args()
|
args = self.propagator.get_graph_args()
|
||||||
|
|
||||||
# Inject the checkpoint thread_id (from checkpoint_scope) so the same
|
# Inject the checkpoint thread_id (from checkpoint_scope) so the same
|
||||||
@@ -561,15 +617,10 @@ class TradingAgentsGraph:
|
|||||||
# Log state to disk.
|
# Log state to disk.
|
||||||
self._log_state(trade_date, final_state)
|
self._log_state(trade_date, final_state)
|
||||||
|
|
||||||
# Store decision for deferred reflection on the next same-ticker run.
|
self.record_decision(company_name, trade_date, final_state)
|
||||||
self.memory_log.store_decision(
|
|
||||||
ticker=company_name,
|
|
||||||
trade_date=trade_date,
|
|
||||||
final_trade_decision=final_state["final_trade_decision"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clear checkpoint on successful completion to avoid stale state.
|
# Clear checkpoint on successful completion to avoid stale state.
|
||||||
self.clear_checkpoint_on_success(company_name, trade_date, asset_type)
|
self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio)
|
||||||
|
|
||||||
return final_state, self.process_signal(final_state["final_trade_decision"])
|
return final_state, self.process_signal(final_state["final_trade_decision"])
|
||||||
|
|
||||||
@@ -613,7 +664,8 @@ class TradingAgentsGraph:
|
|||||||
|
|
||||||
log_path = directory / f"full_states_log_{trade_date}.json"
|
log_path = directory / f"full_states_log_{trade_date}.json"
|
||||||
with open(log_path, "w", encoding="utf-8") as f:
|
with open(log_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(self.log_states_dict[str(trade_date)], f, indent=4)
|
# Reports can be in any language and this file is read by a person.
|
||||||
|
json.dump(self.log_states_dict[str(trade_date)], f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
def process_signal(self, full_signal):
|
def process_signal(self, full_signal):
|
||||||
"""Process a signal to extract the core decision."""
|
"""Process a signal to extract the core decision."""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||||
@@ -5,6 +6,19 @@ from langchain_google_genai import ChatGoogleGenerativeAI
|
|||||||
from .base_client import BaseLLMClient, normalize_content
|
from .base_client import BaseLLMClient, normalize_content
|
||||||
from .validators import validate_model
|
from .validators import validate_model
|
||||||
|
|
||||||
|
_GEMINI_VERSION = re.compile(r"^gemini-(\d+)\.(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _accepts_minimal_thinking(model: str) -> bool:
|
||||||
|
"""Whether ``thinking_level="minimal"`` is accepted: numbered Flash models
|
||||||
|
before 3.8. Pro, 3.8+ and version-less aliases (which move between
|
||||||
|
generations) are treated as rejecting it."""
|
||||||
|
model_lc = model.lower()
|
||||||
|
match = _GEMINI_VERSION.match(model_lc)
|
||||||
|
return bool(match) and "pro" not in model_lc and (
|
||||||
|
(int(match.group(1)), int(match.group(2))) < (3, 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NormalizedChatGoogleGenerativeAI(ChatGoogleGenerativeAI):
|
class NormalizedChatGoogleGenerativeAI(ChatGoogleGenerativeAI):
|
||||||
"""ChatGoogleGenerativeAI with normalized content output.
|
"""ChatGoogleGenerativeAI with normalized content output.
|
||||||
@@ -42,12 +56,12 @@ class GoogleClient(BaseLLMClient):
|
|||||||
llm_kwargs["google_api_key"] = google_api_key
|
llm_kwargs["google_api_key"] = google_api_key
|
||||||
|
|
||||||
# Gemini 3.x takes the string ``thinking_level`` (the integer
|
# Gemini 3.x takes the string ``thinking_level`` (the integer
|
||||||
# ``thinking_budget`` was for the now-retired 2.5 line). Pro accepts
|
# ``thinking_budget`` was for the now-retired 2.5 line). Pro, Gemini
|
||||||
# low/high; Flash also accepts minimal/medium — so map an unsupported
|
# 3.8+ and the -latest aliases reject "minimal" with a 400; "low" is
|
||||||
# "minimal" on Pro to the nearest level it does accept.
|
# accepted everywhere, so it is the fallback.
|
||||||
thinking_level = self.kwargs.get("thinking_level")
|
thinking_level = self.kwargs.get("thinking_level")
|
||||||
if thinking_level:
|
if thinking_level:
|
||||||
if "pro" in self.model.lower() and thinking_level == "minimal":
|
if thinking_level == "minimal" and not _accepts_minimal_thinking(self.model):
|
||||||
thinking_level = "low"
|
thinking_level = "low"
|
||||||
llm_kwargs["thinking_level"] = thinking_level
|
llm_kwargs["thinking_level"] = thinking_level
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,12 @@ _GLM_MODELS: dict[str, list[ModelOption]] = {
|
|||||||
# "Custom model ID".
|
# "Custom model ID".
|
||||||
_QWEN_MODELS: dict[str, list[ModelOption]] = {
|
_QWEN_MODELS: dict[str, list[ModelOption]] = {
|
||||||
"quick": [
|
"quick": [
|
||||||
("Qwen 3.7 Plus - Latest, balanced speed/cost", "qwen3.7-plus"),
|
("Qwen 3.8 Flash - Latest fast model, 1M ctx", "qwen3.8-flash"),
|
||||||
("Qwen 3.6 Plus - Previous-gen balanced", "qwen3.6-plus"),
|
|
||||||
("Custom model ID", "custom"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
("Qwen 3.7 Max - Latest flagship, most intelligent, 1M ctx", "qwen3.7-max"),
|
("Qwen 3.8 Max - Latest flagship", "qwen3.8-max"),
|
||||||
("Qwen 3.6 Max - Previous-gen flagship", "qwen3.6-max"),
|
("Qwen 3.8 Flash - Fast alternative, 1M ctx", "qwen3.8-flash"),
|
||||||
("Qwen 3.7 Plus - Balanced alternative", "qwen3.7-plus"),
|
|
||||||
("Custom model ID", "custom"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -61,6 +59,23 @@ _QWEN_MODELS: dict[str, list[ModelOption]] = {
|
|||||||
# Shared model list for MiniMax's global and CN endpoints (same IDs).
|
# Shared model list for MiniMax's global and CN endpoints (same IDs).
|
||||||
# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api.
|
# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api.
|
||||||
# M3 carries a 1M-token context window; the M2.x line is 204,800 tokens.
|
# M3 carries a 1M-token context window; the M2.x line is 204,800 tokens.
|
||||||
|
# Kimi (Moonshot). Source: platform.kimi.ai/docs/models. "Custom model ID" stays
|
||||||
|
# available for models newer than this list. The k2.7-code variants are omitted:
|
||||||
|
# they are coding specialists, not analysis models.
|
||||||
|
_KIMI_MODELS: dict[str, list[ModelOption]] = {
|
||||||
|
"quick": [
|
||||||
|
("Kimi K2.6 - 256K ctx, thinking modes, agent tasks", "kimi-k2.6"),
|
||||||
|
("Kimi K3 - Flagship, 1M ctx", "kimi-k3"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
"deep": [
|
||||||
|
("Kimi K3 - Flagship, 1M ctx, native visual understanding", "kimi-k3"),
|
||||||
|
("Kimi K2.6 - 256K ctx, thinking modes, agent tasks", "kimi-k2.6"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_MINIMAX_MODELS: dict[str, list[ModelOption]] = {
|
_MINIMAX_MODELS: dict[str, list[ModelOption]] = {
|
||||||
"quick": [
|
"quick": [
|
||||||
("MiniMax-M3 - Latest, 1M ctx, native multimodal", "MiniMax-M3"),
|
("MiniMax-M3 - Latest, 1M ctx, native multimodal", "MiniMax-M3"),
|
||||||
@@ -84,46 +99,54 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
("GPT-5.6 Luna - Fast, cost-efficient frontier", "gpt-5.6-luna"),
|
("GPT-5.6 Luna - Fast, cost-efficient frontier", "gpt-5.6-luna"),
|
||||||
("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"),
|
("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"),
|
||||||
("GPT-5.4 Mini - Fast, strong coding and tool use", "gpt-5.4-mini"),
|
("GPT-5.4 Mini - Fast, strong coding and tool use", "gpt-5.4-mini"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
("GPT-5.6 - Latest frontier reasoning (Sol)", "gpt-5.6"),
|
("GPT-6 Astra - Latest frontier reasoning", "gpt-6-astra"),
|
||||||
|
("GPT-5.6 - Frontier reasoning (Sol)", "gpt-5.6"),
|
||||||
("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"),
|
("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"),
|
||||||
("GPT-5.5 - Previous-gen frontier, 1M context", "gpt-5.5"),
|
("GPT-5.5 - Previous-gen frontier, 1M context", "gpt-5.5"),
|
||||||
("GPT-5.4 - Cost-effective, 1M context", "gpt-5.4"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"quick": [
|
"quick": [
|
||||||
("Claude Sonnet 5 - Best speed and intelligence balance", "claude-sonnet-5"),
|
("Claude Sonnet 5 - Best speed and intelligence balance", "claude-sonnet-5"),
|
||||||
("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"),
|
("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
("Claude Fable 5 - Most capable, long-running agents", "claude-fable-5"),
|
("Claude Opus 5 - Frontier agentic and enterprise work", "claude-opus-5"),
|
||||||
("Claude Opus 4.8 - Frontier agentic coding and reasoning", "claude-opus-4-8"),
|
("Claude Fable 5.1 - Most capable, demanding long-horizon reasoning", "claude-fable-5-1"),
|
||||||
("Claude Sonnet 5 - Near-frontier intelligence at Sonnet cost", "claude-sonnet-5"),
|
("Claude Sonnet 5 - Near-frontier intelligence at Sonnet cost", "claude-sonnet-5"),
|
||||||
("Claude Opus 4.7 - Previous frontier, long-running agents", "claude-opus-4-7"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"google": {
|
"google": {
|
||||||
"quick": [
|
"quick": [
|
||||||
("Gemini 3.5 Flash - Latest, frontier agentic + coding (GA)", "gemini-3.5-flash"),
|
("Gemini 3.8 Flash - Most capable Flash", "gemini-3.8-flash"),
|
||||||
|
("Gemini 3.5 Flash Lite - Fast and cost-efficient", "gemini-3.5-flash-lite"),
|
||||||
("Gemini 3.1 Flash Lite - Most cost-efficient", "gemini-3.1-flash-lite"),
|
("Gemini 3.1 Flash Lite - Most cost-efficient", "gemini-3.1-flash-lite"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
|
("Gemini 3.8 Flash - Most capable Flash, 1M context", "gemini-3.8-flash"),
|
||||||
("Gemini 3.1 Pro - Reasoning-first, complex workflows (preview)", "gemini-3.1-pro-preview"),
|
("Gemini 3.1 Pro - Reasoning-first, complex workflows (preview)", "gemini-3.1-pro-preview"),
|
||||||
("Gemini 3.5 Flash - Latest GA, strong agentic + coding", "gemini-3.5-flash"),
|
("Gemini 3.5 Flash - Previous Flash, strong agentic + coding", "gemini-3.5-flash"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"xai": {
|
"xai": {
|
||||||
"quick": [
|
"quick": [
|
||||||
("Grok 4.3 - Latest flagship, fast with built-in reasoning", "grok-4.3"),
|
("Grok 4.6 - Latest flagship, fastest, 500K ctx", "grok-4.6"),
|
||||||
("Grok 4.20 (Non-Reasoning) - Speed-optimized", "grok-4.20-0309-non-reasoning"),
|
|
||||||
("Grok Build 0.1 - Coding-specialized, 256K ctx", "grok-build-0.1"),
|
("Grok Build 0.1 - Coding-specialized, 256K ctx", "grok-build-0.1"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
("Grok 4.3 - Latest flagship, built-in reasoning, 1M ctx", "grok-4.3"),
|
("Grok 4.6 - Latest flagship, 500K ctx", "grok-4.6"),
|
||||||
("Grok 4.20 (Reasoning) - Previous-gen reasoning", "grok-4.20-0309-reasoning"),
|
("Grok 4.5 - Previous flagship, coding and agentic", "grok-4.5"),
|
||||||
("Grok 4.20 Multi-Agent - Multi-agent reasoning", "grok-4.20-multi-agent-0309"),
|
("Grok 4.3 - Older generation, 1M ctx", "grok-4.3"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
# DeepSeek: the deepseek-chat / deepseek-reasoner aliases are deprecated
|
# DeepSeek: the deepseek-chat / deepseek-reasoner aliases are deprecated
|
||||||
@@ -132,12 +155,12 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
# handles the reasoning_content round-trip).
|
# handles the reasoning_content round-trip).
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"quick": [
|
"quick": [
|
||||||
("DeepSeek V4 Flash - Latest fast model, thinking + non-thinking", "deepseek-v4-flash"),
|
("DeepSeek Flash - V4.1 Flash, fast, 1M ctx", "deepseek-flash"),
|
||||||
("Custom model ID", "custom"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"deep": [
|
||||||
("DeepSeek V4 Pro - Latest flagship", "deepseek-v4-pro"),
|
("DeepSeek V4 Pro - Flagship", "deepseek-v4-pro"),
|
||||||
("DeepSeek V4 Flash - Fast, supports thinking", "deepseek-v4-flash"),
|
("DeepSeek Flash - V4.1 Flash, fast, 1M ctx", "deepseek-flash"),
|
||||||
("Custom model ID", "custom"),
|
("Custom model ID", "custom"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -151,6 +174,7 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
"glm-cn": _GLM_MODELS,
|
"glm-cn": _GLM_MODELS,
|
||||||
# MiniMax: same model IDs across global (.io) and China (.com) regions,
|
# MiniMax: same model IDs across global (.io) and China (.com) regions,
|
||||||
# so the two provider keys share one model list.
|
# so the two provider keys share one model list.
|
||||||
|
"kimi": _KIMI_MODELS,
|
||||||
"minimax": _MINIMAX_MODELS,
|
"minimax": _MINIMAX_MODELS,
|
||||||
"minimax-cn": _MINIMAX_MODELS,
|
"minimax-cn": _MINIMAX_MODELS,
|
||||||
# OpenRouter: fetched dynamically. Azure: any deployed model name.
|
# OpenRouter: fetched dynamically. Azure: any deployed model name.
|
||||||
@@ -182,8 +206,17 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
# changing) models — offer "Custom model ID" rather than a list that goes
|
# changing) models — offer "Custom model ID" rather than a list that goes
|
||||||
# stale. The endpoint + key are wired by the provider; the user picks the
|
# stale. The endpoint + key are wired by the provider; the user picks the
|
||||||
# model their account has access to.
|
# model their account has access to.
|
||||||
"mistral": _CUSTOM_ONLY,
|
"mistral": {
|
||||||
"kimi": _CUSTOM_ONLY,
|
"quick": [
|
||||||
|
("Mistral Small 4 - Fast, 262K ctx", "mistral-small-2603"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
"deep": [
|
||||||
|
("Mistral Medium 3.5 - 262K ctx", "mistral-medium-2604"),
|
||||||
|
("Mistral Small 4 - Fast, 262K ctx", "mistral-small-2603"),
|
||||||
|
("Custom model ID", "custom"),
|
||||||
|
],
|
||||||
|
},
|
||||||
"groq": _CUSTOM_ONLY,
|
"groq": _CUSTOM_ONLY,
|
||||||
"nvidia": _CUSTOM_ONLY,
|
"nvidia": _CUSTOM_ONLY,
|
||||||
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
|
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
|
||||||
@@ -196,8 +229,21 @@ def get_model_options(provider: str, mode: str) -> list[ModelOption]:
|
|||||||
return MODEL_OPTIONS[provider.lower()][mode]
|
return MODEL_OPTIONS[provider.lower()][mode]
|
||||||
|
|
||||||
|
|
||||||
|
# Served by the provider but not offered in the picker: models retired from the
|
||||||
|
# menu, and the explicit ID of a model listed under a shorter name. Known to
|
||||||
|
# validation so a config naming one runs without an unknown-model warning.
|
||||||
|
LEGACY_MODELS: dict[str, list[str]] = {
|
||||||
|
"openai": ["gpt-5.4", "gpt-5.6-sol"],
|
||||||
|
"xai": ["grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning",
|
||||||
|
"grok-4.20-multi-agent-0309"],
|
||||||
|
"deepseek": ["deepseek-v4-flash"],
|
||||||
|
"qwen": ["qwen3.7-max", "qwen3.7-plus", "qwen3.6-max", "qwen3.6-plus"],
|
||||||
|
"anthropic": ["claude-fable-5", "claude-opus-4-8", "claude-opus-4-7"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_known_models() -> dict[str, list[str]]:
|
def get_known_models() -> dict[str, list[str]]:
|
||||||
"""Build known model names from the shared CLI catalog."""
|
"""Build known model names from the shared CLI catalog plus legacy IDs."""
|
||||||
return {
|
return {
|
||||||
provider: sorted(
|
provider: sorted(
|
||||||
{
|
{
|
||||||
@@ -205,6 +251,7 @@ def get_known_models() -> dict[str, list[str]]:
|
|||||||
for options in mode_options.values()
|
for options in mode_options.values()
|
||||||
for _, value in options
|
for _, value in options
|
||||||
}
|
}
|
||||||
|
| set(LEGACY_MODELS.get(provider, []))
|
||||||
)
|
)
|
||||||
for provider, mode_options in MODEL_OPTIONS.items()
|
for provider, mode_options in MODEL_OPTIONS.items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,11 +168,11 @@ _PASSTHROUGH_KWARGS = (
|
|||||||
"api_key", "callbacks", "http_client", "http_async_client",
|
"api_key", "callbacks", "http_client", "http_async_client",
|
||||||
)
|
)
|
||||||
|
|
||||||
# OpenAI's ``reasoning_effort`` is only accepted by reasoning models — the GPT-5
|
# OpenAI's ``reasoning_effort`` is only accepted by reasoning models — GPT-5 and
|
||||||
# family and the o-series. Non-reasoning models (gpt-4.1, gpt-4o, ...) 400 with
|
# later, and the o-series. Non-reasoning models (gpt-4.1, gpt-4o, ...) 400 with
|
||||||
# "Unsupported parameter: 'reasoning.effort' is not supported with this model".
|
# "Unsupported parameter: 'reasoning.effort' is not supported with this model".
|
||||||
# Drop the kwarg for those rather than crash the run.
|
# Drop the kwarg for those rather than crash the run.
|
||||||
_OPENAI_REASONING_MODEL = re.compile(r"^(gpt-5|o[1-9])")
|
_OPENAI_REASONING_MODEL = re.compile(r"^(?:gpt-(?:[5-9]|[1-9]\d)|o[1-9])(?:[.-]|$)")
|
||||||
|
|
||||||
|
|
||||||
def _supports_reasoning_effort(model: str) -> bool:
|
def _supports_reasoning_effort(model: str) -> bool:
|
||||||
@@ -225,7 +225,8 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
|
|||||||
"groq": ProviderSpec(base_url="https://api.groq.com/openai/v1"),
|
"groq": ProviderSpec(base_url="https://api.groq.com/openai/v1"),
|
||||||
"nvidia": ProviderSpec(base_url="https://integrate.api.nvidia.com/v1"),
|
"nvidia": ProviderSpec(base_url="https://integrate.api.nvidia.com/v1"),
|
||||||
"ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
|
"ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
|
||||||
key_optional=True, placeholder_key="ollama"),
|
key_optional=True, placeholder_key="ollama",
|
||||||
|
chat_class=LocalCompatibleChatOpenAI),
|
||||||
# Generic endpoint: user supplies base_url; key optional (keyless local).
|
# Generic endpoint: user supplies base_url; key optional (keyless local).
|
||||||
"openai_compatible": ProviderSpec(
|
"openai_compatible": ProviderSpec(
|
||||||
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI
|
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI
|
||||||
|
|||||||
64
tradingagents/portfolio.py
Normal file
64
tradingagents/portfolio.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""The caller's book, as the decision agents see it.
|
||||||
|
|
||||||
|
Optional input to a run: what is held, at what average price, and how much cash
|
||||||
|
is free. Without it the agents cannot tell adding to a full position from
|
||||||
|
opening a new one. Three states are distinct and must stay so: a position, a
|
||||||
|
flat book, and no context at all, since treating "not provided" as "flat" would
|
||||||
|
invent a fact about the caller's account.
|
||||||
|
|
||||||
|
Broker-neutral by construction: quantities are generic units and the currency is
|
||||||
|
whatever label the caller passes, so nothing here implies a venue or an
|
||||||
|
execution path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
ticker: str = Field(description="Instrument symbol, e.g. AAPL")
|
||||||
|
quantity: float = Field(description="Signed units held; negative is short")
|
||||||
|
average_price: float | None = Field(default=None, description="Average entry price per unit")
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioContext(BaseModel):
|
||||||
|
cash: float | None = Field(default=None, description="Free cash available")
|
||||||
|
currency: str | None = Field(default=None, description="Currency label for cash and prices")
|
||||||
|
positions: list[Position] = Field(default_factory=list)
|
||||||
|
|
||||||
|
def position_in(self, ticker: str) -> Position | None:
|
||||||
|
return next((p for p in self.positions if p.ticker.upper() == ticker.strip().upper()), None)
|
||||||
|
|
||||||
|
def render(self, ticker: str) -> str:
|
||||||
|
"""The portfolio block for the decision agents, led by the analyzed instrument."""
|
||||||
|
symbol = ticker.strip().upper()
|
||||||
|
held = self.position_in(symbol)
|
||||||
|
if held is None:
|
||||||
|
lines = [f"- No current position in {symbol}"]
|
||||||
|
else:
|
||||||
|
price = f", average price {held.average_price:,.2f}" if held.average_price is not None else ""
|
||||||
|
lines = [f"- Current position in {symbol}: {held.quantity:,.4g} units{price}"]
|
||||||
|
if self.cash is not None:
|
||||||
|
lines.append(f"- Cash available: {self.cash:,.2f}{' ' + self.currency if self.currency else ''}")
|
||||||
|
others = [p for p in self.positions if p is not held]
|
||||||
|
if others:
|
||||||
|
lines.append("- Other positions: " + ", ".join(f"{p.ticker.upper()} {p.quantity:,.4g}" for p in others))
|
||||||
|
return "Portfolio at the analysis date:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
def fingerprint(self) -> str:
|
||||||
|
"""Stable digest of the book, so a changed one cannot resume a stale run."""
|
||||||
|
return hashlib.sha256(self.model_dump_json().encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def load_portfolio(path: str | Path) -> PortfolioContext:
|
||||||
|
"""Read a portfolio JSON file, failing before the run rather than mid-graph."""
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
return PortfolioContext.model_validate(data)
|
||||||
|
except (OSError, json.JSONDecodeError, ValidationError) as exc:
|
||||||
|
raise ValueError(f"portfolio file {path} is not usable: {exc}") from exc
|
||||||
Reference in New Issue
Block a user