mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a26ae17a1 | ||
|
|
a4acd8a174 | ||
|
|
2322dd9baa | ||
|
|
70b58c21dc | ||
|
|
2448d0a125 | ||
|
|
c95f83dfaf | ||
|
|
ecbe3e3a21 | ||
|
|
e93c5c53c2 | ||
|
|
45c1744b86 | ||
|
|
63be7fe7f1 | ||
|
|
30d42abd5d | ||
|
|
a2f51da917 | ||
|
|
b43bc31479 | ||
|
|
8db41f6bca | ||
|
|
51a245dbe1 | ||
|
|
43fc275b36 | ||
|
|
0ef56e6a33 | ||
|
|
539eae8fd6 | ||
|
|
9b98f09613 | ||
|
|
8b7ece8a3e | ||
|
|
a33fd4c0f1 | ||
|
|
7bbe33ab1d | ||
|
|
030b434585 | ||
|
|
3f6c082695 | ||
|
|
d78c698d0e | ||
|
|
40774ca042 | ||
|
|
01477f9afb | ||
|
|
0f70af2f31 | ||
|
|
43bd32befa | ||
|
|
a102afa090 | ||
|
|
daf1da9c35 | ||
|
|
b47a828a4f | ||
|
|
622f99d28a | ||
|
|
3570f2e1e6 | ||
|
|
85946c2f60 | ||
|
|
cbd17ac3e0 | ||
|
|
8ab24f30af | ||
|
|
2b2d685df6 | ||
|
|
a0120e1805 | ||
|
|
0b61effd6c | ||
|
|
ec3974b84e | ||
|
|
0405168f20 | ||
|
|
709fe2b646 | ||
|
|
517eeaf4b9 | ||
|
|
9ad98c55c5 | ||
|
|
ee1ece3347 | ||
|
|
7bb16c5daa | ||
|
|
a420ad0f3b | ||
|
|
c15200dc28 | ||
|
|
7aef10acbd | ||
|
|
03600f3121 | ||
|
|
6b6177ebf7 | ||
|
|
e3bc872982 | ||
|
|
cbc5f67d42 | ||
|
|
3cddf1e331 | ||
|
|
308757c999 | ||
|
|
eeb84aa63b | ||
|
|
9fd54f8368 | ||
|
|
7df18fc912 | ||
|
|
db059034a2 | ||
|
|
ddfb840ecf | ||
|
|
895ed130f9 | ||
|
|
295e84cd54 | ||
|
|
20d3b0782f | ||
|
|
4e7821d574 | ||
|
|
0c1231a405 | ||
|
|
e4be7cc5a3 | ||
|
|
a597063747 | ||
|
|
dab07688fb | ||
|
|
65608831f8 | ||
|
|
76add9048f | ||
|
|
7c8fe2fe9f | ||
|
|
2a58c2208f | ||
|
|
04f434e86d | ||
|
|
2e67782f20 | ||
|
|
1ff3f07a73 | ||
|
|
2f85be624e | ||
|
|
c93b92c7a4 | ||
|
|
d6762d6095 | ||
|
|
8694bd070d | ||
|
|
2c9f1bfe65 | ||
|
|
8a22594607 | ||
|
|
47cbb321fe | ||
|
|
e80636fc0e | ||
|
|
a66aa8fb94 | ||
|
|
3543e5397e | ||
|
|
d7b40a2a5c | ||
|
|
61522e103e | ||
|
|
e848b5e812 | ||
|
|
3e5e99b368 | ||
|
|
a2e7ac1599 | ||
|
|
b16fe53efe | ||
|
|
a5cb7cbd61 | ||
|
|
78d063dc5c | ||
|
|
819e813a14 | ||
|
|
800862405d | ||
|
|
f10daa2824 | ||
|
|
249caba06f | ||
|
|
a2f343bb54 | ||
|
|
879e2bb5da | ||
|
|
9f7abfcbd5 | ||
|
|
d13e9b7946 | ||
|
|
6b384f74f9 | ||
|
|
384fe1a3d2 | ||
|
|
0fcf13624e | ||
|
|
d0dd0420ad | ||
|
|
faaeebac70 | ||
|
|
0011b5ebf5 | ||
|
|
4f057e290c | ||
|
|
9e00c8117f | ||
|
|
78fe77f4e6 | ||
|
|
e1316686f8 | ||
|
|
9482cae188 | ||
|
|
19d22b54a9 | ||
|
|
704b7627f2 | ||
|
|
22bb91bd83 | ||
|
|
afdc6d4ec1 | ||
|
|
e2c850eb17 | ||
|
|
c405867bde | ||
|
|
db7e0a67e2 | ||
|
|
5bae826749 | ||
|
|
7e9e7b83c7 | ||
|
|
2c97bad45c | ||
|
|
99ec63f966 | ||
|
|
e7ec980021 | ||
|
|
f4519bcb84 | ||
|
|
4300b68f19 | ||
|
|
2d2c9e6d66 |
63
.env.example
63
.env.example
@@ -5,5 +5,68 @@ ANTHROPIC_API_KEY=
|
||||
XAI_API_KEY=
|
||||
DEEPSEEK_API_KEY=
|
||||
DASHSCOPE_API_KEY=
|
||||
DASHSCOPE_CN_API_KEY=
|
||||
ZHIPU_API_KEY=
|
||||
ZHIPU_CN_API_KEY=
|
||||
MINIMAX_API_KEY=
|
||||
MINIMAX_CN_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
MISTRAL_API_KEY=
|
||||
MOONSHOT_API_KEY=
|
||||
GROQ_API_KEY=
|
||||
NVIDIA_API_KEY=
|
||||
|
||||
# FRED (Federal Reserve macro data: rates, inflation, labor, growth). Free key: https://fred.stlouisfed.org/docs/api/api_key.html
|
||||
#FRED_API_KEY=
|
||||
|
||||
# Optional: a custom OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp,
|
||||
# relay). Select provider "openai_compatible" and set the base URL; the key is
|
||||
# optional (local servers need none).
|
||||
#OPENAI_COMPATIBLE_API_KEY=
|
||||
|
||||
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
|
||||
# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS
|
||||
# credential chain (env keys / ~/.aws/credentials / IAM role / AWS_PROFILE). Set
|
||||
# the region either way; a bearer token takes precedence when both are present.
|
||||
#AWS_BEARER_TOKEN_BEDROCK=
|
||||
#AWS_DEFAULT_REGION=us-west-2
|
||||
#AWS_PROFILE=
|
||||
|
||||
# Optional: point at a remote Ollama server. When unset, defaults to
|
||||
# the local instance at http://localhost:11434/v1. Convention follows
|
||||
# the broader Ollama ecosystem; both the CLI dropdown and programmatic
|
||||
# client pick this up.
|
||||
#OLLAMA_BASE_URL=http://your-ollama-host:11434/v1
|
||||
|
||||
# Optional: override DEFAULT_CONFIG without editing code.
|
||||
# Any TRADINGAGENTS_* variable below, when set, replaces the matching key
|
||||
# in tradingagents/default_config.py. Values are coerced to the type of
|
||||
# the existing default (bool / int / str), so "true"/"3" work as expected.
|
||||
# In the CLI, setting the LLM provider / models / backend URL / language
|
||||
# also skips the matching interactive selection step (useful for
|
||||
# OpenAI-compatible endpoints like opencode or LM Studio, and unattended runs).
|
||||
#TRADINGAGENTS_LLM_PROVIDER=openai
|
||||
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
|
||||
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
|
||||
#TRADINGAGENTS_LLM_BACKEND_URL=
|
||||
#TRADINGAGENTS_OUTPUT_LANGUAGE=English
|
||||
#TRADINGAGENTS_MAX_DEBATE_ROUNDS=1
|
||||
#TRADINGAGENTS_MAX_RISK_ROUNDS=1
|
||||
#TRADINGAGENTS_CHECKPOINT_ENABLED=false
|
||||
# Sampling temperature (lower = less run-to-run variation on models that
|
||||
# honor it). Unset leaves each provider at its default. See the README
|
||||
# "Reproducibility" note — no setting makes LLM output fully deterministic.
|
||||
#TRADINGAGENTS_TEMPERATURE=0.0
|
||||
# LLM SDK retry budget forwarded to every provider. Unset leaves each SDK at its
|
||||
# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling
|
||||
# on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run.
|
||||
#TRADINGAGENTS_LLM_MAX_RETRIES=6
|
||||
# Cap on output tokens forwarded to every provider (Gemini's max_output_tokens
|
||||
# too). Unset leaves each provider at its default. Set it to bound a model that
|
||||
# emits unbounded reasoning/output and hangs or trips a gateway idle timeout.
|
||||
#TRADINGAGENTS_MAX_TOKENS=8192
|
||||
# Provider-specific reasoning/thinking depth (optional; unset = provider
|
||||
# default). Setting one also skips the matching interactive prompt.
|
||||
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
|
||||
#TRADINGAGENTS_GOOGLE_THINKING_LEVEL=high
|
||||
#TRADINGAGENTS_ANTHROPIC_EFFORT=high
|
||||
|
||||
61
.github/workflows/ci.yml
vendored
Normal file
61
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: tests (py${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install (with dev extras)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
- name: Run test suite
|
||||
run: pytest -q
|
||||
|
||||
smoke-install:
|
||||
name: clean-install smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Fresh install (no dev extras) and import
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install .
|
||||
# Catches undeclared runtime deps (e.g. #994 python-dotenv): a bare
|
||||
# install must import the package and the CLI module.
|
||||
python -c "import tradingagents, cli.main; print('clean-install import OK')"
|
||||
|
||||
lint:
|
||||
name: ruff (strict, full repo)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install ruff
|
||||
run: pip install "ruff>=0.15"
|
||||
- name: Lint the repository
|
||||
# The repo is fully clean under the strict select, so we lint everything
|
||||
# (results/ and worklog/ are excluded via pyproject extend-exclude).
|
||||
run: ruff check .
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -217,3 +217,7 @@ __marimo__/
|
||||
|
||||
# Cache
|
||||
**/data_cache/
|
||||
|
||||
# Enterprise env file (secrets) and generated run reports
|
||||
.env.enterprise
|
||||
reports/
|
||||
|
||||
233
CHANGELOG.md
233
CHANGELOG.md
@@ -6,6 +6,239 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Breaking changes within the 0.x line are called out explicitly.
|
||||
|
||||
## [0.4.0] — 2026-08-31
|
||||
|
||||
Look-ahead and point-in-time fixes across the data and memory layers, clearer
|
||||
decision signals, working CLI checkpoint resume, and the GPT-5.6 / GLM-5.3 models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FRED macro look-ahead.** Historical macro requests were served from today's
|
||||
data vintage, leaking later revisions into a backtest; both the observations
|
||||
and metadata requests now pin the vintage to the as-of date. (#1275)
|
||||
- **Social sentiment look-ahead.** StockTwits and Reddit were fetched with no
|
||||
date, so a historical run showed today's chatter as if it were from the as-of
|
||||
date; the social path is now trimmed to the analysis window, via one shared
|
||||
UTC half-open window rule (`dataflows/date_window`) used by news too. (#1220)
|
||||
- **Memory point-in-time guard.** `get_past_context` returned every resolved
|
||||
lesson regardless of the run date; each resolved entry now records the date
|
||||
its outcome became known, and a historical run only sees lessons resolved by
|
||||
the trade date. (#1251)
|
||||
- **Premature reflection.** A decision was settled on a partial return if a rerun
|
||||
happened before its holding window fully traded; resolution now waits for the
|
||||
full window. (#1169)
|
||||
- **Latest OHLCV bar dropped.** The newest bar with a NaN close was silently
|
||||
dropped before the date cutoff, making the previous trading day look like the
|
||||
latest; dates are normalized per element (DST- and non-US-market safe) and a
|
||||
missing latest close raises rather than falling back. (#1201)
|
||||
- **Debate opening fabrication.** The first speaker in each debate round rebutted
|
||||
an empty opponent response, fabricating the other side; all five debators now
|
||||
open with their own case when no opponent has spoken. (#1176)
|
||||
- **Silent Hold.** An unparseable Portfolio Manager rating (including a fullwidth
|
||||
colon) was coerced to a tradeable Hold; it now surfaces a `REVIEW` sentinel,
|
||||
with `parse_rating` keeping its silent default for compatibility callers. (#1170)
|
||||
- **`--checkpoint` was a no-op on the CLI.** Checkpoint setup lived only in
|
||||
`propagate()`; the CLI streamed the checkpointer-less graph. The lifecycle is
|
||||
now shared, and a resume feeds `None` so LangGraph continues the interrupted
|
||||
run instead of duplicating messages. (#1249)
|
||||
- **DeepSeek via OpenRouter.** `deepseek/<id>` fell through to default
|
||||
capabilities and had object-form `tool_choice` forced on it; the official
|
||||
namespace is stripped so it reuses the native DeepSeek quirks. (#1199)
|
||||
- **Trader price grounding.** The Trader saw only the digested plan; it now also
|
||||
receives the technical market report so entry/stop levels anchor to real price
|
||||
structure. (#1167)
|
||||
|
||||
### Added
|
||||
|
||||
- **Configurable output-token cap.** `max_tokens` / `TRADINGAGENTS_MAX_TOKENS`,
|
||||
forwarded to every provider (Gemini as `max_output_tokens`), so a model that
|
||||
emits unbounded reasoning can be bounded instead of hanging. (#1204)
|
||||
- **Latest models.** Added the GPT-5.6 family (`gpt-5.6` / `gpt-5.6-terra` /
|
||||
`gpt-5.6-luna`) and GLM-5.3 (`glm-5.3`, `glm-5.3-flash`). The default models
|
||||
are now `gpt-5.6` (deep) and `gpt-5.6-luna` (quick).
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to everyone who reported these or sent a fix:
|
||||
|
||||
[@PyriteResearch](https://github.com/PyriteResearch), [@yiran1268](https://github.com/yiran1268), [@fabiolenine](https://github.com/fabiolenine), [@lx7720](https://github.com/lx7720), [@taro0915](https://github.com/taro0915), [@Jaswanth-Sriram-Veturi](https://github.com/Jaswanth-Sriram-Veturi), [@ariesy](https://github.com/ariesy), [@liangzj1999](https://github.com/liangzj1999), [@zkwang616](https://github.com/zkwang616), [@aniketshukla1](https://github.com/aniketshukla1), [@loulanyue](https://github.com/loulanyue), [@hudsonwa](https://github.com/hudsonwa), [@daleselaji-dev](https://github.com/daleselaji-dev), [@wolfoswald777-crypto](https://github.com/wolfoswald777-crypto).
|
||||
|
||||
## [0.3.1] — 2026-07-05
|
||||
|
||||
Correctness and stability patch: data look-ahead, graph-router crash-safety,
|
||||
checkpoint identity, crypto sentiment sources, and configurable resilience.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Alpha Vantage look-ahead filter now runs.** The fundamentals payload is a
|
||||
JSON string, so the dict-only guard skipped filtering and future-dated reports
|
||||
leaked into historical runs; parse before filtering. (#1115, @zachthebird)
|
||||
- **News analyst prompt matches the tool.** The prompt advertised
|
||||
`get_news(query, ...)` but the tool takes a ticker; aligned to stop
|
||||
hallucinated free-text query calls. (#1116, @shcheuk)
|
||||
- **Shared debate/risk routers can't crash mid-run.** Both routers return more
|
||||
targets than any one edge mapped; every edge now shares the complete path map,
|
||||
so a fall-through under prompt/i18n/refactor drift stays routable.
|
||||
(#1088, @Fr3ya, @sa7an7, @Sushanth012)
|
||||
- **Checkpoint resume respects graph shape.** The thread id folds in selected
|
||||
analysts, debate/risk depth, and asset mode, so a resume under different
|
||||
choices no longer continues the wrong graph. (#1089, @bossjoker1, @Ghraven)
|
||||
- **Crypto sentiment sources resolve.** StockTwits lists crypto as `<BASE>.X`
|
||||
(Yahoo's `BTC-USD` 404s) and Reddit needs the base symbol to match; the social
|
||||
path now maps crypto correctly for both. (#1113, @suremadoreai)
|
||||
|
||||
### Added
|
||||
|
||||
- **Configurable LLM retry budget.** `llm_max_retries` /
|
||||
`TRADINGAGENTS_LLM_MAX_RETRIES` is forwarded to every provider, so a transient
|
||||
429 burst no longer aborts a run. (#1091, @yanggaome)
|
||||
- **Bedrock API-key auth.** `AWS_BEARER_TOKEN_BEDROCK` authenticates Amazon
|
||||
Bedrock without AWS access keys and takes precedence over an ambient
|
||||
`AWS_PROFILE`. (#1103, @praxstack)
|
||||
- **Latest Claude models.** Added Claude Sonnet 5 (`claude-sonnet-5`) and
|
||||
Fable 5 (`claude-fable-5`); effort control now covers the Claude 5 line.
|
||||
|
||||
## [0.3.0] — 2026-06-22
|
||||
|
||||
Stabilization and extensibility release: a CI gate, a unified verified
|
||||
data-access contract, a provider and data-vendor registry, and a maintenance
|
||||
sweep that hardened config precedence, the model catalog, data resilience, and
|
||||
structured output.
|
||||
|
||||
### Added
|
||||
|
||||
- **CI gate.** GitHub Actions runs the pytest suite across Python 3.10-3.13,
|
||||
strict `ruff`, and a clean-install smoke that imports the package and CLI to
|
||||
catch undeclared dependencies. (#994, #197)
|
||||
- **Provider registry.** OpenAI-compatible providers register as a single spec,
|
||||
and a generic `openai_compatible` endpoint covers vLLM, LM Studio, and relays.
|
||||
Adds NVIDIA NIM, Kimi, Groq, Mistral, and a native Amazon Bedrock client.
|
||||
- **Macro and prediction-market vendors.** FRED macro indicators and Polymarket
|
||||
event probabilities, surfaced to the news and macro analysts.
|
||||
- **Programmatic report output.** `TradingAgentsGraph.save_reports()` writes the
|
||||
same report tree the CLI produces, for headless and API runs. (#1037)
|
||||
- **Env-configurable reasoning depth** via `TRADINGAGENTS_OPENAI_REASONING_EFFORT`,
|
||||
`TRADINGAGENTS_GOOGLE_THINKING_LEVEL`, and `TRADINGAGENTS_ANTHROPIC_EFFORT`,
|
||||
each gated to the models that accept it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Verified data-access contract.** Symbol normalization on every vendor path
|
||||
(identity, returns, CLI, news); the configured vendor list is the exact
|
||||
resolution chain with no silent fallback to unselected vendors; a typed
|
||||
`VendorError` taxonomy; look-ahead-safe news windows; stale-OHLCV rejection;
|
||||
inclusive yfinance date ranges.
|
||||
- **Config precedence.** An explicit `TRADINGAGENTS_*` value or CLI flag now wins
|
||||
over interactive defaults for debate and risk round counts,
|
||||
`--checkpoint / --no-checkpoint`, and the Docker provider profile; invalid
|
||||
boolean env values fail loudly. (#975, #976, #977)
|
||||
- **Current-generation model catalog.** Refreshed provider lineups; retired
|
||||
`gpt-4.1`, Claude Sonnet 4.5, and the Gemini 2.5 line.
|
||||
- **Optional vendors degrade** instead of aborting a run: a failed macro or
|
||||
prediction-market lookup returns a no-data sentinel.
|
||||
- **Analyst prompts lead with the current date** so tool-call date ranges anchor
|
||||
to the run date rather than the model's training cutoff. (#836)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Instrument identity.** Deterministic ticker-to-company resolution prevents
|
||||
wrong-company hallucination, and a verified market-data snapshot grounds price
|
||||
and indicator claims. (#814, #830)
|
||||
- **Social and market data sources.** Reddit RSS-first with 429 backoff,
|
||||
StockTwits transport hardening, and Alpha Vantage timeout plus
|
||||
key-versus-rate-limit handling.
|
||||
- **Structured output.** Local OpenAI-compatible servers no longer reject
|
||||
object-form `tool_choice`; a thinking model that returns no parsed result falls
|
||||
back to free text; null-ish strings in optional price fields coerce to `None`.
|
||||
(#1038, #1051, #1057)
|
||||
|
||||
### Removed
|
||||
|
||||
- The no-op `analyst_concurrency_limit` config knob; parallel analyst execution
|
||||
is planned for a later release. (#979)
|
||||
- The unused committed `uv.lock`. (#1030)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to everyone who shaped this release through code, design, and reports:
|
||||
|
||||
[@CadeYu](https://github.com/CadeYu), [@Zavianx](https://github.com/Zavianx), [@weijianz-opc](https://github.com/weijianz-opc), [@naltun](https://github.com/naltun), [@brahmasky](https://github.com/brahmasky), [@nik2208](https://github.com/nik2208), [@thieucong98](https://github.com/thieucong98), [@Derekko-web](https://github.com/Derekko-web), [@LukiPrince](https://github.com/LukiPrince), [@Eddieargenal](https://github.com/Eddieargenal), [@Ghraven](https://github.com/Ghraven), [@ms32035](https://github.com/ms32035), [@yting27](https://github.com/yting27), [@nyxst4ck](https://github.com/nyxst4ck), [@KenCheung-AIxFinance](https://github.com/KenCheung-AIxFinance), [@yangyusheng2n](https://github.com/yangyusheng2n), [@fareloj](https://github.com/fareloj), [@haosenwang1018](https://github.com/haosenwang1018), [@octo-patch](https://github.com/octo-patch), [@seifenk](https://github.com/seifenk), [@CaoYuhaoCarl](https://github.com/CaoYuhaoCarl), [@mihailnica10](https://github.com/mihailnica10), [@Dado-hash](https://github.com/Dado-hash), [@Handsomemikezzz](https://github.com/Handsomemikezzz), [@ydhawesome](https://github.com/ydhawesome), [@macd2](https://github.com/macd2), [@AyushKar2005](https://github.com/AyushKar2005), [@wildhuman](https://github.com/wildhuman), [@robert23kim](https://github.com/robert23kim), [@bngness](https://github.com/bngness), [@tedix-rodrigo](https://github.com/tedix-rodrigo), [@malaccan](https://github.com/malaccan), [@rfalken78](https://github.com/rfalken78), [@dengli1971-droid](https://github.com/dengli1971-droid), [@proofconcept39](https://github.com/proofconcept39), [@prasta1](https://github.com/prasta1), [@liximin](https://github.com/liximin), [@jeffhuen](https://github.com/jeffhuen), [@mazar](https://github.com/mazar), [@soyangelromero](https://github.com/soyangelromero), [@CNQQC](https://github.com/CNQQC), [@dovetaill](https://github.com/dovetaill), [@fperdigon](https://github.com/fperdigon), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@RSXLX](https://github.com/RSXLX).
|
||||
|
||||
## [0.2.5] — 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Grounded Sentiment Analyst.** The renamed `sentiment_analyst` now reads
|
||||
real Yahoo News, StockTwits, and Reddit data before generating its report,
|
||||
replacing the prior flow that could fabricate social posts under prompt
|
||||
pressure. (#557, #607)
|
||||
- **MiniMax provider** with the full M2.x catalog (M2.7 / M2.5 / M2.1 / M2
|
||||
plus highspeed variants, 204K context). Dual-region: Global
|
||||
(`MINIMAX_API_KEY`) and China (`MINIMAX_CN_API_KEY`).
|
||||
- **Dual-region Qwen and GLM** with separate keys per region — international
|
||||
(`DASHSCOPE_API_KEY`, `ZHIPU_API_KEY`) and China (`DASHSCOPE_CN_API_KEY`,
|
||||
`ZHIPU_CN_API_KEY`), selectable via a secondary region prompt. (#758)
|
||||
- **`TRADINGAGENTS_*` env-var configurability for `DEFAULT_CONFIG`.** Override
|
||||
`llm_provider`, deep/quick model IDs, `backend_url`, `output_language`,
|
||||
debate-round counts, checkpoint flag, and benchmark ticker via `.env` with
|
||||
type-aware coercion (string / int / bool). (#602)
|
||||
- **Interactive API-key detection in the CLI.** When the selected provider's
|
||||
key is missing, the CLI prompts for it and persists the value to `.env`
|
||||
so the analysis run continues without restart.
|
||||
- **Remote Ollama support.** `OLLAMA_BASE_URL` points the CLI and the
|
||||
programmatic client at a remote `ollama-serve`. The CLI surfaces the
|
||||
resolved endpoint and warns on common malformed inputs. Adds a
|
||||
`"Custom model ID"` option for models pulled via `ollama pull`. (#648, #768)
|
||||
- **Configurable news-fetch parameters** in `DEFAULT_CONFIG` — per-ticker
|
||||
article limit, macro headline limit, lookback window, and macro search
|
||||
queries. (#606, #683)
|
||||
- **Configurable alpha benchmark** for non-US tickers. Replaces hardcoded
|
||||
SPY with regional indices for `.NS` (^NSEI), `.T` (^N225), `.HK` (^HSI),
|
||||
`.L` (^FTSE), `.TO` (^GSPTSE), `.AX` (^AXJO), `.BO` (^BSESN); explicit
|
||||
`benchmark_ticker` override available. Eliminates FX drift dominating
|
||||
alpha for non-USD listings. (#628, #684)
|
||||
- **Multi-language output covers every user-facing agent** — researchers,
|
||||
risk debators, research manager, and trader, ending the previous
|
||||
partial-localization reports. (#575)
|
||||
- **Model catalog refresh.** OpenAI GPT-5.5 frontier, Anthropic Claude Opus
|
||||
4.7, Gemini 3.1 Flash-Lite GA, xAI Grok 4.20, Qwen 3.6 line. Versioned IDs
|
||||
only; auto-shifting aliases moved to the `"Custom model ID"` option.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sentiment Analyst** is now consistently named across the CLI dropdown,
|
||||
status panel, and final reports (previously the backend was renamed but
|
||||
the CLI still said "Social Analyst"). The `AnalystType.SOCIAL = "social"`
|
||||
wire value is kept for saved-config back-compat.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Structured output works on DeepSeek V4 / reasoner and MiniMax M2.x.**
|
||||
Those providers reject `tool_choice` per their tool-calling docs; the
|
||||
binding flow now skips it automatically via a capability table.
|
||||
- **`pip install .` installations pick up the project `.env`** when running
|
||||
the CLI as a console script. (#747)
|
||||
- **Reports save end-to-end** — streamed chunks were previously dropped from
|
||||
`complete_report.md`. (#719, #736)
|
||||
- **Ticker prompt preserves exchange suffixes** (`.SH`, `.SZ`, `.SS`, `.HK`,
|
||||
`.T`, etc.) for A-share, HK, Tokyo, and other non-US flows. (#770)
|
||||
- **Docker permission errors** no longer block first-run write to
|
||||
`~/.tradingagents/`. (#519, #627, #672, #771)
|
||||
- **Config state no longer leaks between runs** when sub-dicts are mutated;
|
||||
`set_config` partial updates preserve sibling defaults. (#788)
|
||||
- **`max_recur_limit` config actually applies** — previously read but not
|
||||
forwarded to the propagator. (#764)
|
||||
- **Missing-API-key error** names the exact env var to set. (#680)
|
||||
- **Quieter startup** — suppressed the noisy upstream
|
||||
`LangChainPendingDeprecationWarning` from langgraph-checkpoint; will be
|
||||
removed once that package ships its fix.
|
||||
|
||||
### Security
|
||||
|
||||
- **Ticker path-traversal validation** at every filesystem-path site (cache,
|
||||
checkpoint database, results) so a malicious ticker cannot escape its
|
||||
intended directory. (#618)
|
||||
|
||||
## [0.2.4] — 2026-04-25
|
||||
|
||||
### Added
|
||||
|
||||
@@ -18,7 +18,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN useradd --create-home appuser
|
||||
RUN useradd --create-home appuser \
|
||||
&& install -d -m 0755 -o appuser -g appuser /home/appuser/.tradingagents
|
||||
USER appuser
|
||||
WORKDIR /home/appuser/app
|
||||
|
||||
|
||||
100
README.md
100
README.md
@@ -5,12 +5,14 @@
|
||||
<div align="center" style="line-height: 1;">
|
||||
<a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a>
|
||||
<a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a>
|
||||
<a href="./assets/wechat.png" target="_blank"><img alt="WeChat" src="https://img.shields.io/badge/WeChat-TauricResearch-brightgreen?logo=wechat&logoColor=white"/></a>
|
||||
<a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a>
|
||||
<br>
|
||||
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/Join_GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
|
||||
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
<div align="center">
|
||||
<a href="https://github.com/TauricResearch" target="_blank"><img alt="TradingAgents #1 Repository of the Day" src="https://trendshift.io/api/badge/repositories/16192" width="250" height="55"/></a>
|
||||
</div>
|
||||
<br>
|
||||
<div align="center">
|
||||
<!-- Keep these links. Translations will automatically update with the README. -->
|
||||
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=de">Deutsch</a> |
|
||||
@@ -28,32 +30,26 @@
|
||||
# TradingAgents: Multi-Agents LLM Financial Trading Framework
|
||||
|
||||
## News
|
||||
- [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. 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. See [CHANGELOG.md](CHANGELOG.md) for the full list.
|
||||
- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support.
|
||||
- [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate.
|
||||
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening.
|
||||
- [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-03] **TradingAgents v0.2.3** released with multi-language support, GPT-5.4 family models, unified model catalog, backtesting date fidelity, and proxy support.
|
||||
- [2026-03] **TradingAgents v0.2.2** released with GPT-5.4/Gemini 3.1/Claude 4.6 model coverage, five-tier rating scale, OpenAI Responses API, Anthropic effort control, and cross-platform stability.
|
||||
- [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.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.star-history.com/#TauricResearch/TradingAgents&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" />
|
||||
<img alt="TradingAgents Star History" src="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" style="width: 80%; height: auto;" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
|
||||
|
||||
</div>
|
||||
|
||||
> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community.
|
||||
>
|
||||
> So we decided to fully open-source the framework. Looking forward to building impactful projects with you!
|
||||
|
||||
<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)
|
||||
|
||||
</div>
|
||||
|
||||
## TradingAgents Framework
|
||||
|
||||
TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy.
|
||||
@@ -64,11 +60,11 @@ TradingAgents is a multi-agent trading framework that mirrors the dynamics of re
|
||||
|
||||
> TradingAgents framework is designed for research purposes. Trading performance may vary based on many factors, including the chosen backbone language models, model temperature, trading periods, the quality of data, and other non-deterministic factors. [It is not intended as financial, investment, or trading advice.](https://tauric.ai/disclaimer/)
|
||||
|
||||
Our framework decomposes complex trading tasks into specialized roles. This ensures the system achieves a robust, scalable approach to market analysis and decision-making.
|
||||
Our framework decomposes complex trading tasks into specialized roles.
|
||||
|
||||
### Analyst Team
|
||||
- Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags.
|
||||
- Sentiment Analyst: Analyzes social media and public sentiment using sentiment scoring algorithms to gauge short-term market mood.
|
||||
- Sentiment Analyst: Aggregates news headlines, StockTwits, and Reddit chatter into a single sentiment read to gauge short-term market mood.
|
||||
- News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions.
|
||||
- Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements.
|
||||
|
||||
@@ -84,7 +80,7 @@ Our framework decomposes complex trading tasks into specialized roles. This ensu
|
||||
</p>
|
||||
|
||||
### Trader Agent
|
||||
- Composes reports from the analysts and researchers to make informed trading decisions. It determines the timing and magnitude of trades based on comprehensive market insights.
|
||||
- Composes reports from the analysts and researchers to make informed trading decisions, determining the timing and magnitude of trades.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/trader.png" width="70%" style="display: inline-block; margin: 0 2%;">
|
||||
@@ -110,7 +106,7 @@ cd TradingAgents
|
||||
|
||||
Create a virtual environment in any of your favorite environment managers:
|
||||
```bash
|
||||
conda create -n tradingagents python=3.13
|
||||
conda create -n tradingagents python=3.12
|
||||
conda activate tradingagents
|
||||
```
|
||||
|
||||
@@ -142,15 +138,23 @@ export GOOGLE_API_KEY=... # Google (Gemini)
|
||||
export ANTHROPIC_API_KEY=... # Anthropic (Claude)
|
||||
export XAI_API_KEY=... # xAI (Grok)
|
||||
export DEEPSEEK_API_KEY=... # DeepSeek
|
||||
export DASHSCOPE_API_KEY=... # Qwen (Alibaba DashScope)
|
||||
export ZHIPU_API_KEY=... # GLM (Zhipu)
|
||||
export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com)
|
||||
export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com)
|
||||
export ZHIPU_API_KEY=... # GLM via Z.AI (international)
|
||||
export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn)
|
||||
export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io)
|
||||
export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com)
|
||||
export OPENROUTER_API_KEY=... # OpenRouter
|
||||
export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
|
||||
```
|
||||
|
||||
For enterprise providers (e.g. Azure OpenAI, AWS Bedrock), copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
|
||||
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
|
||||
|
||||
For local models, configure Ollama with `llm_provider: "ollama"` in your config.
|
||||
For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-4-8-v1:0`.
|
||||
|
||||
For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull <name>`, and pick "Custom model ID" in the CLI for any model not listed by default.
|
||||
|
||||
For any other OpenAI-compatible server (vLLM, LM Studio, llama.cpp, or a custom relay), use `llm_provider: "openai_compatible"` and set the endpoint via `backend_url` (or `TRADINGAGENTS_LLM_BACKEND_URL`), e.g. `http://localhost:8000/v1` for vLLM or `http://localhost:1234/v1` for LM Studio. The model is whatever your server serves. No key is needed for local servers; set `OPENAI_COMPATIBLE_API_KEY` when the endpoint requires one.
|
||||
|
||||
Alternatively, copy `.env.example` to `.env` and fill in your keys:
|
||||
```bash
|
||||
@@ -166,6 +170,16 @@ 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.
|
||||
|
||||
### Markets and tickers
|
||||
|
||||
TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market.
|
||||
|
||||
- US: `AAPL`, `SPY`
|
||||
- Hong Kong: `0700.HK` · Tokyo: `7203.T` · London: `AZN.L`
|
||||
- India: `RELIANCE.NS`, `.BO` · Canada: `.TO` · Australia: `.AX`
|
||||
- China A-shares: Shanghai `.SS`, Shenzhen `.SZ` (e.g. `600519.SS` for Kweichow Moutai)
|
||||
- Crypto: `BTC-USD`, `ETH-USD`
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/cli/cli_init.png" width="100%" style="display: inline-block; margin: 0 2%;">
|
||||
</p>
|
||||
@@ -184,7 +198,7 @@ An interface will appear showing results as they load, letting you track the age
|
||||
|
||||
### Implementation Details
|
||||
|
||||
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise.
|
||||
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise.
|
||||
|
||||
### Python Usage
|
||||
|
||||
@@ -208,9 +222,9 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["llm_provider"] = "openai" # openai, google, anthropic, xai, deepseek, qwen, glm, openrouter, ollama, azure
|
||||
config["deep_think_llm"] = "gpt-5.4" # Model for complex reasoning
|
||||
config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks
|
||||
config["llm_provider"] = "openai" # e.g. openai, google, anthropic, deepseek, groq, ollama; openai_compatible covers any OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp, ...)
|
||||
config["deep_think_llm"] = "gpt-5.6" # Model for complex reasoning
|
||||
config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks
|
||||
config["max_debate_rounds"] = 2
|
||||
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
@@ -248,11 +262,31 @@ ta = TradingAgentsGraph(config=config)
|
||||
_, decision = ta.propagate("NVDA", "2026-01-15")
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
Language model sampling is non-deterministic. Even at a fixed temperature, providers do not guarantee byte-identical output across calls, and reasoning models (the default GPT-5.x family, and any thinking-mode model) vary the most because their internal reasoning is itself sampled.
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["llm_provider"] = "openai"
|
||||
config["temperature"] = 0.0
|
||||
# Reasoning models ignore temperature. For tighter reproducibility, set a
|
||||
# non-reasoning deep/quick model explicitly (e.g. via the Custom model ID option).
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Backtest results are not guaranteed to match any published figure. Returns depend on the model, the temperature, the date range, data quality, and the sampling above. Treat the framework as a research scaffold for studying multi-agent analysis, not as a strategy with a fixed, replicable return.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! Whether it's fixing a bug, improving documentation, or suggesting a new feature, your input helps make this project better. If you are interested in this line of research, please consider joining our open-source financial AI research community [Tauric Research](https://tauric.ai/).
|
||||
|
||||
Past contributions, including code, design feedback, and bug reports, are credited per release in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
Contributions are welcome: bug fixes, documentation, and feature ideas; past contributions are credited per release in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Citation
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 216 KiB |
@@ -1,4 +1,5 @@
|
||||
import getpass
|
||||
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
463
cli/main.py
463
cli/main.py
@@ -1,38 +1,70 @@
|
||||
from typing import Optional
|
||||
import datetime
|
||||
import typer
|
||||
from pathlib import Path
|
||||
from functools import wraps
|
||||
from rich.console import Console
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
load_dotenv(".env.enterprise", override=False)
|
||||
from rich.panel import Panel
|
||||
from rich.spinner import Spinner
|
||||
from rich.live import Live
|
||||
from rich.columns import Columns
|
||||
from rich.markdown import Markdown
|
||||
from rich.layout import Layout
|
||||
from rich.text import Text
|
||||
from rich.table import Table
|
||||
from collections import deque
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from rich.tree import Tree
|
||||
from collections import deque
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich import box
|
||||
from rich.align import Align
|
||||
from rich.console import Console
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.rule import Rule
|
||||
from rich.spinner import Spinner
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
from cli.models import AnalystType
|
||||
from cli.utils import *
|
||||
from cli.announcements import fetch_announcements, display_announcements
|
||||
from cli.announcements import display_announcements, fetch_announcements
|
||||
from cli.stats_handler import StatsCallbackHandler
|
||||
from cli.utils import (
|
||||
ask_anthropic_effort,
|
||||
ask_gemini_thinking_config,
|
||||
ask_glm_region,
|
||||
ask_minimax_region,
|
||||
ask_openai_reasoning_effort,
|
||||
ask_output_language,
|
||||
ask_qwen_region,
|
||||
confirm_ollama_endpoint,
|
||||
detect_asset_type,
|
||||
ensure_api_key,
|
||||
get_ticker,
|
||||
prompt_openai_compatible_url,
|
||||
resolve_backend_url,
|
||||
select_analysts,
|
||||
select_deep_thinking_agent,
|
||||
select_llm_provider,
|
||||
select_research_depth,
|
||||
select_shallow_thinking_agent,
|
||||
)
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
from tradingagents.graph.analyst_execution import (
|
||||
AnalystWallTimeTracker,
|
||||
build_analyst_execution_plan,
|
||||
get_initial_analyst_node,
|
||||
sync_analyst_tracker_from_chunk,
|
||||
)
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.reporting import write_report_tree
|
||||
|
||||
console = Console()
|
||||
|
||||
# prompt_toolkit's win32 output module is importable only on Windows (it asserts
|
||||
# the platform at import time), so gate on the platform rather than catching the
|
||||
# failure — that way a genuinely broken prompt_toolkit on Windows still surfaces
|
||||
# instead of silently disabling the handler below. Off Windows this stays an
|
||||
# empty tuple, which `except` accepts and never matches (#1138).
|
||||
if sys.platform == "win32": # pragma: no cover - platform dependent
|
||||
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
|
||||
|
||||
_NO_CONSOLE_ERRORS: tuple[type[BaseException], ...] = (NoConsoleScreenBufferError,)
|
||||
else:
|
||||
_NO_CONSOLE_ERRORS = ()
|
||||
|
||||
app = typer.Typer(
|
||||
name="TradingAgents",
|
||||
help="TradingAgents CLI: Multi-Agents LLM Financial Trading Framework",
|
||||
@@ -53,7 +85,7 @@ class MessageBuffer:
|
||||
# Analyst name mapping
|
||||
ANALYST_MAPPING = {
|
||||
"market": "Market Analyst",
|
||||
"social": "Social Analyst",
|
||||
"social": "Sentiment Analyst",
|
||||
"news": "News Analyst",
|
||||
"fundamentals": "Fundamentals Analyst",
|
||||
}
|
||||
@@ -63,7 +95,7 @@ class MessageBuffer:
|
||||
# finalizing_agent: which agent must be "completed" for this report to count as done
|
||||
REPORT_SECTIONS = {
|
||||
"market_report": ("market", "Market Analyst"),
|
||||
"sentiment_report": ("social", "Social Analyst"),
|
||||
"sentiment_report": ("social", "Sentiment Analyst"),
|
||||
"news_report": ("news", "News Analyst"),
|
||||
"fundamentals_report": ("fundamentals", "Fundamentals Analyst"),
|
||||
"investment_plan": (None, "Research Manager"),
|
||||
@@ -284,7 +316,7 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non
|
||||
all_teams = {
|
||||
"Analyst Team": [
|
||||
"Market Analyst",
|
||||
"Social Analyst",
|
||||
"Sentiment Analyst",
|
||||
"News Analyst",
|
||||
"Fundamentals Analyst",
|
||||
],
|
||||
@@ -463,7 +495,7 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non
|
||||
def get_user_selections():
|
||||
"""Get all user selections before starting the analysis display."""
|
||||
# Display ASCII art welcome message
|
||||
with open(Path(__file__).parent / "static" / "welcome.txt", "r", encoding="utf-8") as f:
|
||||
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
|
||||
welcome_ascii = f.read()
|
||||
|
||||
# Create welcome box content
|
||||
@@ -499,15 +531,36 @@ def get_user_selections():
|
||||
box_content += f"\n[dim]Default: {default}[/dim]"
|
||||
return Panel(box_content, border_style="blue", padding=(1, 2))
|
||||
|
||||
def thinking_value_or_prompt(env_var, config_key, label, box_title, box_body, prompt_fn):
|
||||
"""Return the env-configured reasoning/thinking value, or prompt for it.
|
||||
|
||||
When ``env_var`` is set the interactive choice is skipped and the value
|
||||
the env overlay placed on DEFAULT_CONFIG is used — mirroring the
|
||||
env-precedence rule applied to the other selection steps.
|
||||
"""
|
||||
if os.environ.get(env_var):
|
||||
value = DEFAULT_CONFIG[config_key]
|
||||
console.print(f"[green]✓ {label} from environment:[/green] {value}")
|
||||
return value
|
||||
console.print(create_question_box(box_title, box_body))
|
||||
return prompt_fn()
|
||||
|
||||
# Step 1: Ticker symbol
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 1: Ticker Symbol",
|
||||
"Enter the exact ticker symbol to analyze, including exchange suffix when needed (examples: SPY, CNC.TO, 7203.T, 0700.HK)",
|
||||
"Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)",
|
||||
"SPY",
|
||||
)
|
||||
)
|
||||
selected_ticker = get_ticker()
|
||||
asset_type = detect_asset_type(selected_ticker)
|
||||
# Only announce when it's not the default stock path, to avoid printing
|
||||
# "stock" on every run.
|
||||
if asset_type.value != "stock":
|
||||
console.print(
|
||||
f"[green]Detected asset type:[/green] {asset_type.value}"
|
||||
)
|
||||
|
||||
# Step 2: Analysis date
|
||||
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
@@ -520,7 +573,13 @@ def get_user_selections():
|
||||
)
|
||||
analysis_date = get_analysis_date()
|
||||
|
||||
# Step 3: Output language
|
||||
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
|
||||
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
|
||||
output_language = DEFAULT_CONFIG["output_language"]
|
||||
console.print(
|
||||
f"[green]✓ Output language from environment:[/green] {output_language}"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 3: Output Language",
|
||||
@@ -535,12 +594,26 @@ def get_user_selections():
|
||||
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
|
||||
)
|
||||
)
|
||||
selected_analysts = select_analysts()
|
||||
selected_analysts = select_analysts(asset_type)
|
||||
console.print(
|
||||
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
|
||||
)
|
||||
|
||||
# Step 5: Research depth
|
||||
# Step 5: Research depth (skipped when both round counts are set via env).
|
||||
# Research depth maps to the debate + risk round counts; when both are
|
||||
# supplied through TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS we keep
|
||||
# the run non-interactive and honor the env values (#977).
|
||||
depth_from_env = bool(os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS")) and bool(
|
||||
os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS")
|
||||
)
|
||||
if depth_from_env:
|
||||
selected_research_depth = DEFAULT_CONFIG["max_debate_rounds"]
|
||||
console.print(
|
||||
f"[green]✓ Research depth from environment:[/green] "
|
||||
f"{DEFAULT_CONFIG['max_debate_rounds']} debate / "
|
||||
f"{DEFAULT_CONFIG['max_risk_discuss_rounds']} risk rounds"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 5: Research Depth", "Select your research depth level"
|
||||
@@ -548,7 +621,21 @@ def get_user_selections():
|
||||
)
|
||||
selected_research_depth = select_research_depth()
|
||||
|
||||
# Step 6: LLM Provider
|
||||
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
|
||||
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
|
||||
# otherwise the provider's default endpoint — the same value the menu
|
||||
# would have picked.
|
||||
provider_from_env = bool(os.environ.get("TRADINGAGENTS_LLM_PROVIDER"))
|
||||
if provider_from_env:
|
||||
selected_llm_provider = DEFAULT_CONFIG["llm_provider"].lower()
|
||||
backend_url = resolve_backend_url(
|
||||
selected_llm_provider, env_url=DEFAULT_CONFIG["backend_url"]
|
||||
)
|
||||
console.print(f"[green]✓ LLM provider from environment:[/green] {selected_llm_provider}")
|
||||
console.print(f"[green]✓ Backend URL:[/green] {backend_url}")
|
||||
# Still confirm/persist the API key so the run doesn't fail later.
|
||||
ensure_api_key(selected_llm_provider)
|
||||
else:
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 6: LLM Provider", "Select your LLM provider"
|
||||
@@ -556,7 +643,46 @@ def get_user_selections():
|
||||
)
|
||||
selected_llm_provider, backend_url = select_llm_provider()
|
||||
|
||||
# Step 7: Thinking agents
|
||||
# Providers with regional endpoints prompt for the region as a secondary
|
||||
# step so the main dropdown stays clean (mainland China and international
|
||||
# accounts cannot share API keys).
|
||||
if selected_llm_provider == "qwen":
|
||||
selected_llm_provider, backend_url = ask_qwen_region()
|
||||
elif selected_llm_provider == "minimax":
|
||||
selected_llm_provider, backend_url = ask_minimax_region()
|
||||
elif selected_llm_provider == "glm":
|
||||
selected_llm_provider, backend_url = ask_glm_region()
|
||||
|
||||
# Honor an explicit env backend URL even when the provider was chosen
|
||||
# interactively, so it isn't overwritten by the menu default (#978).
|
||||
backend_url = resolve_backend_url(
|
||||
selected_llm_provider, backend_url, env_url=DEFAULT_CONFIG["backend_url"]
|
||||
)
|
||||
|
||||
# The generic OpenAI-compatible endpoint has no default; ask for it if
|
||||
# neither the menu nor the environment supplied one.
|
||||
if selected_llm_provider == "openai_compatible" and not backend_url:
|
||||
backend_url = prompt_openai_compatible_url()
|
||||
|
||||
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
|
||||
# before model selection so it's obvious where we're connecting.
|
||||
if selected_llm_provider == "ollama":
|
||||
confirm_ollama_endpoint(backend_url)
|
||||
|
||||
# Confirm the provider's API key is present; prompt the user to paste
|
||||
# one and persist it to .env if it's missing, so the analysis run
|
||||
# doesn't fail later at the first API call.
|
||||
ensure_api_key(selected_llm_provider)
|
||||
|
||||
# Step 7: Thinking agents (skipped when either model is set via environment)
|
||||
if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get("TRADINGAGENTS_DEEP_THINK_LLM"):
|
||||
selected_shallow_thinker = DEFAULT_CONFIG["quick_think_llm"]
|
||||
selected_deep_thinker = DEFAULT_CONFIG["deep_think_llm"]
|
||||
console.print(
|
||||
f"[green]✓ Thinking agents from environment:[/green] "
|
||||
f"quick={selected_shallow_thinker}, deep={selected_deep_thinker}"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
||||
@@ -565,39 +691,42 @@ def get_user_selections():
|
||||
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
|
||||
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
|
||||
|
||||
# Step 8: Provider-specific thinking configuration
|
||||
# Step 8: Provider-specific reasoning/thinking configuration. Each knob is
|
||||
# settable via its TRADINGAGENTS_* env var; when that var is set (or the
|
||||
# provider itself came from env) the prompt is skipped and the configured
|
||||
# value is used — same env-precedence rule as the steps above. None = each
|
||||
# provider's own default.
|
||||
thinking_level = None
|
||||
reasoning_effort = None
|
||||
anthropic_effort = None
|
||||
|
||||
provider_lower = selected_llm_provider.lower()
|
||||
if provider_lower == "google":
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 8: Thinking Mode",
|
||||
"Configure Gemini thinking mode"
|
||||
if provider_from_env:
|
||||
thinking_level = DEFAULT_CONFIG["google_thinking_level"]
|
||||
reasoning_effort = DEFAULT_CONFIG["openai_reasoning_effort"]
|
||||
anthropic_effort = DEFAULT_CONFIG["anthropic_effort"]
|
||||
elif provider_lower == "google":
|
||||
thinking_level = thinking_value_or_prompt(
|
||||
"TRADINGAGENTS_GOOGLE_THINKING_LEVEL", "google_thinking_level",
|
||||
"Gemini thinking mode", "Step 8: Thinking Mode",
|
||||
"Configure Gemini thinking mode", ask_gemini_thinking_config,
|
||||
)
|
||||
)
|
||||
thinking_level = ask_gemini_thinking_config()
|
||||
elif provider_lower == "openai":
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 8: Reasoning Effort",
|
||||
"Configure OpenAI reasoning effort level"
|
||||
reasoning_effort = thinking_value_or_prompt(
|
||||
"TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort",
|
||||
"Reasoning effort", "Step 8: Reasoning Effort",
|
||||
"Configure OpenAI reasoning effort level", ask_openai_reasoning_effort,
|
||||
)
|
||||
)
|
||||
reasoning_effort = ask_openai_reasoning_effort()
|
||||
elif provider_lower == "anthropic":
|
||||
console.print(
|
||||
create_question_box(
|
||||
"Step 8: Effort Level",
|
||||
"Configure Claude effort level"
|
||||
anthropic_effort = thinking_value_or_prompt(
|
||||
"TRADINGAGENTS_ANTHROPIC_EFFORT", "anthropic_effort",
|
||||
"Claude effort", "Step 8: Effort Level",
|
||||
"Configure Claude effort level", ask_anthropic_effort,
|
||||
)
|
||||
)
|
||||
anthropic_effort = ask_anthropic_effort()
|
||||
|
||||
return {
|
||||
"ticker": selected_ticker,
|
||||
"asset_type": asset_type.value,
|
||||
"analysis_date": analysis_date,
|
||||
"analysts": selected_analysts,
|
||||
"research_depth": selected_research_depth,
|
||||
@@ -612,11 +741,6 @@ def get_user_selections():
|
||||
}
|
||||
|
||||
|
||||
def get_ticker():
|
||||
"""Get ticker symbol from user input."""
|
||||
return typer.prompt("", default="SPY")
|
||||
|
||||
|
||||
def get_analysis_date():
|
||||
"""Get the analysis date from user input."""
|
||||
while True:
|
||||
@@ -637,93 +761,8 @@ def get_analysis_date():
|
||||
|
||||
|
||||
def save_report_to_disk(final_state, ticker: str, save_path: Path):
|
||||
"""Save complete analysis report to disk with organized subfolders."""
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
sections = []
|
||||
|
||||
# 1. Analysts
|
||||
analysts_dir = save_path / "1_analysts"
|
||||
analyst_parts = []
|
||||
if final_state.get("market_report"):
|
||||
analysts_dir.mkdir(exist_ok=True)
|
||||
(analysts_dir / "market.md").write_text(final_state["market_report"], encoding="utf-8")
|
||||
analyst_parts.append(("Market Analyst", final_state["market_report"]))
|
||||
if final_state.get("sentiment_report"):
|
||||
analysts_dir.mkdir(exist_ok=True)
|
||||
(analysts_dir / "sentiment.md").write_text(final_state["sentiment_report"], encoding="utf-8")
|
||||
analyst_parts.append(("Social Analyst", final_state["sentiment_report"]))
|
||||
if final_state.get("news_report"):
|
||||
analysts_dir.mkdir(exist_ok=True)
|
||||
(analysts_dir / "news.md").write_text(final_state["news_report"], encoding="utf-8")
|
||||
analyst_parts.append(("News Analyst", final_state["news_report"]))
|
||||
if final_state.get("fundamentals_report"):
|
||||
analysts_dir.mkdir(exist_ok=True)
|
||||
(analysts_dir / "fundamentals.md").write_text(final_state["fundamentals_report"], encoding="utf-8")
|
||||
analyst_parts.append(("Fundamentals Analyst", final_state["fundamentals_report"]))
|
||||
if analyst_parts:
|
||||
content = "\n\n".join(f"### {name}\n{text}" for name, text in analyst_parts)
|
||||
sections.append(f"## I. Analyst Team Reports\n\n{content}")
|
||||
|
||||
# 2. Research
|
||||
if final_state.get("investment_debate_state"):
|
||||
research_dir = save_path / "2_research"
|
||||
debate = final_state["investment_debate_state"]
|
||||
research_parts = []
|
||||
if debate.get("bull_history"):
|
||||
research_dir.mkdir(exist_ok=True)
|
||||
(research_dir / "bull.md").write_text(debate["bull_history"], encoding="utf-8")
|
||||
research_parts.append(("Bull Researcher", debate["bull_history"]))
|
||||
if debate.get("bear_history"):
|
||||
research_dir.mkdir(exist_ok=True)
|
||||
(research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8")
|
||||
research_parts.append(("Bear Researcher", debate["bear_history"]))
|
||||
if debate.get("judge_decision"):
|
||||
research_dir.mkdir(exist_ok=True)
|
||||
(research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8")
|
||||
research_parts.append(("Research Manager", debate["judge_decision"]))
|
||||
if research_parts:
|
||||
content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts)
|
||||
sections.append(f"## II. Research Team Decision\n\n{content}")
|
||||
|
||||
# 3. Trading
|
||||
if final_state.get("trader_investment_plan"):
|
||||
trading_dir = save_path / "3_trading"
|
||||
trading_dir.mkdir(exist_ok=True)
|
||||
(trading_dir / "trader.md").write_text(final_state["trader_investment_plan"], encoding="utf-8")
|
||||
sections.append(f"## III. Trading Team Plan\n\n### Trader\n{final_state['trader_investment_plan']}")
|
||||
|
||||
# 4. Risk Management
|
||||
if final_state.get("risk_debate_state"):
|
||||
risk_dir = save_path / "4_risk"
|
||||
risk = final_state["risk_debate_state"]
|
||||
risk_parts = []
|
||||
if risk.get("aggressive_history"):
|
||||
risk_dir.mkdir(exist_ok=True)
|
||||
(risk_dir / "aggressive.md").write_text(risk["aggressive_history"], encoding="utf-8")
|
||||
risk_parts.append(("Aggressive Analyst", risk["aggressive_history"]))
|
||||
if risk.get("conservative_history"):
|
||||
risk_dir.mkdir(exist_ok=True)
|
||||
(risk_dir / "conservative.md").write_text(risk["conservative_history"], encoding="utf-8")
|
||||
risk_parts.append(("Conservative Analyst", risk["conservative_history"]))
|
||||
if risk.get("neutral_history"):
|
||||
risk_dir.mkdir(exist_ok=True)
|
||||
(risk_dir / "neutral.md").write_text(risk["neutral_history"], encoding="utf-8")
|
||||
risk_parts.append(("Neutral Analyst", risk["neutral_history"]))
|
||||
if risk_parts:
|
||||
content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts)
|
||||
sections.append(f"## IV. Risk Management Team Decision\n\n{content}")
|
||||
|
||||
# 5. Portfolio Manager
|
||||
if risk.get("judge_decision"):
|
||||
portfolio_dir = save_path / "5_portfolio"
|
||||
portfolio_dir.mkdir(exist_ok=True)
|
||||
(portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8")
|
||||
sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}")
|
||||
|
||||
# Write consolidated report
|
||||
header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
(save_path / "complete_report.md").write_text(header + "\n\n".join(sections), encoding="utf-8")
|
||||
return save_path / "complete_report.md"
|
||||
"""Save the complete analysis report to disk (shared CLI/API writer)."""
|
||||
return write_report_tree(final_state, ticker, save_path)
|
||||
|
||||
|
||||
def display_complete_report(final_state):
|
||||
@@ -736,7 +775,7 @@ def display_complete_report(final_state):
|
||||
if final_state.get("market_report"):
|
||||
analysts.append(("Market Analyst", final_state["market_report"]))
|
||||
if final_state.get("sentiment_report"):
|
||||
analysts.append(("Social Analyst", final_state["sentiment_report"]))
|
||||
analysts.append(("Sentiment Analyst", final_state["sentiment_report"]))
|
||||
if final_state.get("news_report"):
|
||||
analysts.append(("News Analyst", final_state["news_report"]))
|
||||
if final_state.get("fundamentals_report"):
|
||||
@@ -798,7 +837,7 @@ def update_research_team_status(status):
|
||||
ANALYST_ORDER = ["market", "social", "news", "fundamentals"]
|
||||
ANALYST_AGENT_NAMES = {
|
||||
"market": "Market Analyst",
|
||||
"social": "Social Analyst",
|
||||
"social": "Sentiment Analyst",
|
||||
"news": "News Analyst",
|
||||
"fundamentals": "Fundamentals Analyst",
|
||||
}
|
||||
@@ -810,7 +849,7 @@ ANALYST_REPORT_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def update_analyst_statuses(message_buffer, chunk):
|
||||
def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None):
|
||||
"""Update analyst statuses based on accumulated report state.
|
||||
|
||||
Logic:
|
||||
@@ -824,6 +863,9 @@ def update_analyst_statuses(message_buffer, chunk):
|
||||
selected = message_buffer.selected_analysts
|
||||
found_active = False
|
||||
|
||||
if wall_time_tracker is not None:
|
||||
sync_analyst_tracker_from_chunk(wall_time_tracker, chunk)
|
||||
|
||||
for analyst_key in ANALYST_ORDER:
|
||||
if analyst_key not in selected:
|
||||
continue
|
||||
@@ -847,8 +889,11 @@ def update_analyst_statuses(message_buffer, chunk):
|
||||
message_buffer.update_agent_status(agent_name, "pending")
|
||||
|
||||
# When all analysts complete, transition research team to in_progress
|
||||
if not found_active and selected:
|
||||
if message_buffer.agent_status.get("Bull Researcher") == "pending":
|
||||
if (
|
||||
not found_active
|
||||
and selected
|
||||
and message_buffer.agent_status.get("Bull Researcher") == "pending"
|
||||
):
|
||||
message_buffer.update_agent_status("Bull Researcher", "in_progress")
|
||||
|
||||
def extract_content_string(content):
|
||||
@@ -926,13 +971,19 @@ def format_tool_args(args, max_length=80) -> str:
|
||||
return result[:max_length - 3] + "..."
|
||||
return result
|
||||
|
||||
def run_analysis(checkpoint: bool = False):
|
||||
# First get all user selections
|
||||
selections = get_user_selections()
|
||||
def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
|
||||
"""Assemble the run config from interactive selections, honoring env precedence.
|
||||
|
||||
# Create config with selected research depth
|
||||
Round counts and checkpoint follow "explicit env/flag wins": an env-applied
|
||||
value on DEFAULT_CONFIG is preserved unless the user overrode it on the CLI.
|
||||
"""
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
# Research depth sets both round counts, but an explicit env override
|
||||
# (TRADINGAGENTS_MAX_DEBATE_ROUNDS / _MAX_RISK_ROUNDS) wins over the
|
||||
# interactive selection — leave the env-applied value in place (#977).
|
||||
if not os.environ.get("TRADINGAGENTS_MAX_DEBATE_ROUNDS"):
|
||||
config["max_debate_rounds"] = selections["research_depth"]
|
||||
if not os.environ.get("TRADINGAGENTS_MAX_RISK_ROUNDS"):
|
||||
config["max_risk_discuss_rounds"] = selections["research_depth"]
|
||||
config["quick_think_llm"] = selections["shallow_thinker"]
|
||||
config["deep_think_llm"] = selections["deep_thinker"]
|
||||
@@ -943,7 +994,18 @@ def run_analysis(checkpoint: bool = False):
|
||||
config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort")
|
||||
config["anthropic_effort"] = selections.get("anthropic_effort")
|
||||
config["output_language"] = selections.get("output_language", "English")
|
||||
# --checkpoint/--no-checkpoint overrides only when explicitly given; omitting
|
||||
# the flag preserves TRADINGAGENTS_CHECKPOINT_ENABLED / the default (#976).
|
||||
if checkpoint is not None:
|
||||
config["checkpoint_enabled"] = checkpoint
|
||||
return config
|
||||
|
||||
|
||||
def run_analysis(checkpoint: bool | None = None):
|
||||
# First get all user selections
|
||||
selections = get_user_selections()
|
||||
|
||||
config = _build_run_config(selections, checkpoint)
|
||||
|
||||
# Create stats callback handler for tracking LLM/tool calls
|
||||
stats_handler = StatsCallbackHandler()
|
||||
@@ -951,6 +1013,8 @@ def run_analysis(checkpoint: bool = False):
|
||||
# Normalize analyst selection to predefined order (selection is a 'set', order is fixed)
|
||||
selected_set = {analyst.value for analyst in selections["analysts"]}
|
||||
selected_analyst_keys = [a for a in ANALYST_ORDER if a in selected_set]
|
||||
analyst_execution_plan = build_analyst_execution_plan(selected_analyst_keys)
|
||||
analyst_wall_time_tracker = AnalystWallTimeTracker(analyst_execution_plan)
|
||||
|
||||
# Initialize the graph with callbacks bound to LLMs
|
||||
graph = TradingAgentsGraph(
|
||||
@@ -1017,12 +1081,14 @@ def run_analysis(checkpoint: bool = False):
|
||||
# Now start the display layout
|
||||
layout = create_layout()
|
||||
|
||||
with Live(layout, refresh_per_second=4) as live:
|
||||
with Live(layout, refresh_per_second=4):
|
||||
# Initial display
|
||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
# Add initial messages
|
||||
message_buffer.add_message("System", f"Selected ticker: {selections['ticker']}")
|
||||
if selections["asset_type"] != "stock":
|
||||
message_buffer.add_message("System", f"Detected asset type: {selections['asset_type']}")
|
||||
message_buffer.add_message(
|
||||
"System", f"Analysis date: {selections['analysis_date']}"
|
||||
)
|
||||
@@ -1033,8 +1099,9 @@ def run_analysis(checkpoint: bool = False):
|
||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
# Update agent status to in_progress for the first analyst
|
||||
first_analyst = f"{selections['analysts'][0].value.capitalize()} Analyst"
|
||||
first_analyst = get_initial_analyst_node(analyst_execution_plan)
|
||||
message_buffer.update_agent_status(first_analyst, "in_progress")
|
||||
analyst_wall_time_tracker.mark_started(selected_analyst_keys[0])
|
||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
# Create spinner text
|
||||
@@ -1043,17 +1110,38 @@ def run_analysis(checkpoint: bool = False):
|
||||
)
|
||||
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
# Initialize state and get graph args with callbacks
|
||||
# Initialize state and get graph args with callbacks.
|
||||
# Resolve the instrument identity once here so all agents anchor to
|
||||
# the real company (#814); the CLI builds state directly rather than
|
||||
# going through propagate(), so this must happen on the CLI path too.
|
||||
instrument_context = graph.resolve_instrument_context(
|
||||
selections["ticker"], selections["asset_type"]
|
||||
)
|
||||
init_agent_state = graph.propagator.create_initial_state(
|
||||
selections["ticker"], selections["analysis_date"]
|
||||
selections["ticker"],
|
||||
selections["analysis_date"],
|
||||
asset_type=selections["asset_type"],
|
||||
instrument_context=instrument_context,
|
||||
)
|
||||
# Pass callbacks to graph config for tool execution tracking
|
||||
# (LLM tracking is handled separately via LLM constructor)
|
||||
args = graph.propagator.get_graph_args(callbacks=[stats_handler])
|
||||
|
||||
# Stream the analysis
|
||||
# Recompile with a checkpointer and inject the thread_id so --checkpoint
|
||||
# actually saves and resumes on the CLI path (#1249); a no-op when
|
||||
# checkpointing is disabled. Torn down in the finally below.
|
||||
checkpoint_tid = graph.begin_checkpoint(
|
||||
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
||||
)
|
||||
if checkpoint_tid is not None:
|
||||
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
|
||||
|
||||
# Stream the analysis. On resume, feed None so LangGraph continues the
|
||||
# interrupted run instead of re-appending the initial state (#1249); the
|
||||
# try/finally tears the checkpointer down even if the stream raises.
|
||||
trace = []
|
||||
for chunk in graph.graph.stream(init_agent_state, **args):
|
||||
try:
|
||||
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args):
|
||||
# Process all messages in chunk, deduplicating by message ID
|
||||
for message in chunk.get("messages", []):
|
||||
msg_id = getattr(message, "id", None)
|
||||
@@ -1074,7 +1162,11 @@ def run_analysis(checkpoint: bool = False):
|
||||
message_buffer.add_tool_call(tool_call.name, tool_call.args)
|
||||
|
||||
# Update analyst statuses based on report state (runs on every chunk)
|
||||
update_analyst_statuses(message_buffer, chunk)
|
||||
update_analyst_statuses(
|
||||
message_buffer,
|
||||
chunk,
|
||||
wall_time_tracker=analyst_wall_time_tracker,
|
||||
)
|
||||
|
||||
# Research Team - Handle Investment Debate State
|
||||
if chunk.get("investment_debate_state"):
|
||||
@@ -1136,8 +1228,7 @@ def run_analysis(checkpoint: bool = False):
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
|
||||
)
|
||||
if judge:
|
||||
if message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
||||
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
||||
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
|
||||
@@ -1152,9 +1243,20 @@ def run_analysis(checkpoint: bool = False):
|
||||
|
||||
trace.append(chunk)
|
||||
|
||||
# Get final state and decision
|
||||
final_state = trace[-1]
|
||||
decision = graph.process_signal(final_state["final_trade_decision"])
|
||||
# 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
|
||||
# so every report field populated across the run is present.
|
||||
final_state = {}
|
||||
for chunk in trace:
|
||||
final_state.update(chunk)
|
||||
|
||||
# Update all agent statuses to completed
|
||||
for agent in message_buffer.agent_status:
|
||||
@@ -1163,9 +1265,10 @@ def run_analysis(checkpoint: bool = False):
|
||||
message_buffer.add_message(
|
||||
"System", f"Completed analysis for {selections['analysis_date']}"
|
||||
)
|
||||
message_buffer.add_message("System", analyst_wall_time_tracker.format_summary())
|
||||
|
||||
# Update final report sections
|
||||
for section in message_buffer.report_sections.keys():
|
||||
for section in message_buffer.report_sections:
|
||||
if section in final_state:
|
||||
message_buffer.update_report_section(section, final_state[section])
|
||||
|
||||
@@ -1173,6 +1276,7 @@ def run_analysis(checkpoint: bool = False):
|
||||
|
||||
# Post-analysis prompts (outside Live context for clean interaction)
|
||||
console.print("\n[bold cyan]Analysis Complete![/bold cyan]\n")
|
||||
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
|
||||
|
||||
# Prompt to save report
|
||||
save_choice = typer.prompt("Save report?", default="Y").strip().upper()
|
||||
@@ -1199,10 +1303,11 @@ def run_analysis(checkpoint: bool = False):
|
||||
|
||||
@app.command()
|
||||
def analyze(
|
||||
checkpoint: bool = typer.Option(
|
||||
False,
|
||||
"--checkpoint",
|
||||
help="Enable checkpoint/resume: save state after each node so a crashed run can resume.",
|
||||
checkpoint: bool | None = typer.Option(
|
||||
None,
|
||||
"--checkpoint/--no-checkpoint",
|
||||
help="Enable/disable checkpoint-resume (save state after each node so a "
|
||||
"crashed run can resume). Omit to honor TRADINGAGENTS_CHECKPOINT_ENABLED.",
|
||||
),
|
||||
clear_checkpoints: bool = typer.Option(
|
||||
False,
|
||||
@@ -1214,7 +1319,19 @@ def analyze(
|
||||
from tradingagents.graph.checkpointer import clear_all_checkpoints
|
||||
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
|
||||
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
|
||||
try:
|
||||
run_analysis(checkpoint=checkpoint)
|
||||
except _NO_CONSOLE_ERRORS:
|
||||
# A terminal with no console buffer cannot host the interactive prompts.
|
||||
# Emit one actionable line on stderr instead of a prompt_toolkit
|
||||
# traceback; plain text, since rich may not render here either (#1138).
|
||||
typer.echo(
|
||||
"Error: no Windows console available. The interactive CLI needs a real "
|
||||
"console buffer — run it from Windows Terminal, PowerShell, or cmd.exe "
|
||||
"rather than a piped or embedded terminal.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnalystType(str, Enum):
|
||||
MARKET = "market"
|
||||
# Wire value stays "social" for saved-config and string-keyed-caller
|
||||
# back-compat; the user-facing label is "Sentiment Analyst".
|
||||
SOCIAL = "social"
|
||||
NEWS = "news"
|
||||
FUNDAMENTALS = "fundamentals"
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
STOCK = "stock"
|
||||
CRYPTO = "crypto"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import threading
|
||||
from typing import Any, Dict, List, Union
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.outputs import LLMResult
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.outputs import LLMResult
|
||||
|
||||
|
||||
class StatsCallbackHandler(BaseCallbackHandler):
|
||||
@@ -19,8 +19,8 @@ class StatsCallbackHandler(BaseCallbackHandler):
|
||||
|
||||
def on_llm_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
prompts: List[str],
|
||||
serialized: dict[str, Any],
|
||||
prompts: list[str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Increment LLM call counter when an LLM starts."""
|
||||
@@ -29,8 +29,8 @@ class StatsCallbackHandler(BaseCallbackHandler):
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
messages: List[List[Any]],
|
||||
serialized: dict[str, Any],
|
||||
messages: list[list[Any]],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Increment LLM call counter when a chat model starts."""
|
||||
@@ -57,7 +57,7 @@ class StatsCallbackHandler(BaseCallbackHandler):
|
||||
|
||||
def on_tool_start(
|
||||
self,
|
||||
serialized: Dict[str, Any],
|
||||
serialized: dict[str, Any],
|
||||
input_str: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
@@ -65,7 +65,7 @@ class StatsCallbackHandler(BaseCallbackHandler):
|
||||
with self._lock:
|
||||
self.tool_calls += 1
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
def get_stats(self) -> dict[str, Any]:
|
||||
"""Return current statistics."""
|
||||
with self._lock:
|
||||
return {
|
||||
|
||||
410
cli/utils.py
410
cli/utils.py
@@ -1,28 +1,52 @@
|
||||
import questionary
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import questionary
|
||||
from dotenv import find_dotenv, set_key
|
||||
from rich.console import Console
|
||||
|
||||
from cli.models import AnalystType
|
||||
from cli.models import AnalystType, AssetType
|
||||
from tradingagents.llm_clients.api_key_env import get_api_key_env
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
|
||||
console = Console()
|
||||
|
||||
TICKER_INPUT_EXAMPLES = "Examples: SPY, CNC.TO, 7203.T, 0700.HK"
|
||||
TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
|
||||
|
||||
ANALYST_ORDER = [
|
||||
("Market Analyst", AnalystType.MARKET),
|
||||
("Social Media Analyst", AnalystType.SOCIAL),
|
||||
("Sentiment Analyst", AnalystType.SOCIAL),
|
||||
("News Analyst", AnalystType.NEWS),
|
||||
("Fundamentals Analyst", AnalystType.FUNDAMENTALS),
|
||||
]
|
||||
|
||||
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
|
||||
|
||||
|
||||
def is_valid_ticker_input(value: str) -> bool:
|
||||
"""Whether a ticker entry is acceptable (charset + length).
|
||||
|
||||
Allows the characters Yahoo symbols use, including ``=`` for futures/forex
|
||||
like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is
|
||||
allowed (it defaults to SPY downstream).
|
||||
"""
|
||||
v = value.strip()
|
||||
return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32)
|
||||
|
||||
|
||||
def get_ticker() -> str:
|
||||
"""Prompt the user to enter a ticker symbol."""
|
||||
"""Prompt the user to enter a ticker symbol, preserving exchange suffixes.
|
||||
|
||||
Uses questionary.text (not typer.prompt, which strips trailing dot-suffixes
|
||||
like ``000404.SH`` on some shells) and validates the symbol charset so an
|
||||
obvious typo is caught before the run starts.
|
||||
"""
|
||||
ticker = questionary.text(
|
||||
f"Enter the exact ticker symbol to analyze ({TICKER_INPUT_EXAMPLES}):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a valid ticker symbol.",
|
||||
f"Enter ticker symbol (e.g. {TICKER_INPUT_EXAMPLES}):",
|
||||
validate=lambda x: (
|
||||
is_valid_ticker_input(x)
|
||||
or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK, GC=F."
|
||||
),
|
||||
style=questionary.Style(
|
||||
[
|
||||
("text", "fg:green"),
|
||||
@@ -31,18 +55,50 @@ def get_ticker() -> str:
|
||||
),
|
||||
).ask()
|
||||
|
||||
if not ticker:
|
||||
if ticker is None:
|
||||
console.print("\n[red]No ticker symbol provided. Exiting...[/red]")
|
||||
exit(1)
|
||||
|
||||
return normalize_ticker_symbol(ticker)
|
||||
return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY"
|
||||
|
||||
|
||||
def normalize_ticker_symbol(ticker: str) -> str:
|
||||
"""Normalize ticker input while preserving exchange suffixes."""
|
||||
"""Resolve user input to its canonical Yahoo symbol (single source of truth).
|
||||
|
||||
Delegates to the data layer's ``normalize_symbol`` so the symbol the CLI
|
||||
passes through the pipeline is exactly the one the data path will price
|
||||
(e.g. ``BTCUSD`` -> ``BTC-USD``, ``XAUUSD`` -> ``GC=F``). Falls back to the
|
||||
plain upper-case if the data layer is unavailable.
|
||||
"""
|
||||
try:
|
||||
from tradingagents.dataflows.symbol_utils import normalize_symbol
|
||||
|
||||
return normalize_symbol(ticker)
|
||||
except Exception:
|
||||
return ticker.strip().upper()
|
||||
|
||||
|
||||
def detect_asset_type(ticker: str) -> AssetType:
|
||||
"""Classify on the canonical symbol so e.g. BTCUSD and BTC-USDT both read as
|
||||
crypto (#981/#982), matching what the data path will actually fetch."""
|
||||
canonical = normalize_ticker_symbol(ticker)
|
||||
if canonical.endswith(CRYPTO_SUFFIXES):
|
||||
return AssetType.CRYPTO
|
||||
return AssetType.STOCK
|
||||
|
||||
|
||||
def filter_analysts_for_asset_type(
|
||||
analysts: list[AnalystType], asset_type: AssetType
|
||||
) -> list[AnalystType]:
|
||||
if asset_type != AssetType.CRYPTO:
|
||||
return analysts
|
||||
return [
|
||||
analyst
|
||||
for analyst in analysts
|
||||
if analyst != AnalystType.FUNDAMENTALS
|
||||
]
|
||||
|
||||
|
||||
def get_analysis_date() -> str:
|
||||
"""Prompt the user to enter a date in YYYY-MM-DD format."""
|
||||
import re
|
||||
@@ -76,12 +132,18 @@ def get_analysis_date() -> str:
|
||||
return date.strip()
|
||||
|
||||
|
||||
def select_analysts() -> List[AnalystType]:
|
||||
def select_analysts(asset_type: AssetType = AssetType.STOCK) -> list[AnalystType]:
|
||||
"""Select analysts using an interactive checkbox."""
|
||||
available_analysts = filter_analysts_for_asset_type(
|
||||
[value for _, value in ANALYST_ORDER],
|
||||
asset_type,
|
||||
)
|
||||
choices = questionary.checkbox(
|
||||
"Select Your [Analysts Team]:",
|
||||
choices=[
|
||||
questionary.Choice(display, value=value) for display, value in ANALYST_ORDER
|
||||
questionary.Choice(display, value=value)
|
||||
for display, value in ANALYST_ORDER
|
||||
if value in available_analysts
|
||||
],
|
||||
instruction="\n- Press Space to select/unselect analysts\n- Press 'a' to select/unselect all\n- Press Enter when done",
|
||||
validate=lambda x: len(x) > 0 or "You must select at least one analyst.",
|
||||
@@ -134,28 +196,74 @@ def select_research_depth() -> int:
|
||||
return choice
|
||||
|
||||
|
||||
def _fetch_openrouter_models() -> List[Tuple[str, str]]:
|
||||
# Mainstream OpenRouter chat-LLM provider namespaces. We surface the newest
|
||||
# models from these rather than the universal-newest, which is dominated by
|
||||
# niche/experimental releases. These are the general-purpose chat providers;
|
||||
# more enterprise/specialised namespaces (nvidia, cohere, amazon, ...) tend to
|
||||
# ship research/safety variants as their newest, so they're left out of the
|
||||
# shortlist. Provider names are stable (unlike model IDs), so this rarely needs
|
||||
# touching; anything not here is still reachable via Custom ID.
|
||||
_OPENROUTER_MAINSTREAM = {
|
||||
"openai", "anthropic", "google", "deepseek", "qwen", "mistralai",
|
||||
"meta-llama", "x-ai", "z-ai", "minimax", "moonshotai",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_openrouter_models() -> list[tuple[str, str]]:
|
||||
"""Fetch available models from the OpenRouter API."""
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
|
||||
resp.raise_for_status()
|
||||
models = resp.json().get("data", [])
|
||||
# Newest first so the top-N shown really is the latest available — the
|
||||
# API currently returns this order, but sort explicitly so the prompt's
|
||||
# "latest available" label holds regardless of response ordering.
|
||||
models.sort(key=lambda m: m.get("created") or 0, reverse=True)
|
||||
return [(m.get("name") or m["id"], m["id"]) for m in models]
|
||||
except Exception as e:
|
||||
console.print(f"\n[yellow]Could not fetch OpenRouter models: {e}[/yellow]")
|
||||
return []
|
||||
|
||||
|
||||
def select_openrouter_model() -> str:
|
||||
"""Select an OpenRouter model from the newest available, or enter a custom ID."""
|
||||
models = _fetch_openrouter_models()
|
||||
def _require_text(message: str, hint: str) -> str:
|
||||
"""Prompt for a required value; exit cleanly if the user cancels.
|
||||
|
||||
choices = [questionary.Choice(name, value=mid) for name, mid in models[:5]]
|
||||
``questionary.text(...).ask()`` returns None on Ctrl-C/Esc; mirror the
|
||||
exit-on-cancel behavior of the other required selections so a cancelled
|
||||
prompt never returns an empty model/deployment that would fail downstream.
|
||||
"""
|
||||
response = questionary.text(
|
||||
message,
|
||||
validate=lambda x: len(x.strip()) > 0 or hint,
|
||||
).ask()
|
||||
if response is None:
|
||||
console.print("\n[red]Cancelled. Exiting...[/red]")
|
||||
exit(1)
|
||||
return response.strip()
|
||||
|
||||
|
||||
def select_openrouter_model(mode: str) -> str:
|
||||
"""Select an OpenRouter model from the newest available, or enter a custom ID.
|
||||
|
||||
``mode`` ("quick"/"deep") labels the prompt so the two consecutive
|
||||
OpenRouter selections are distinguishable, like the other providers (#1000).
|
||||
"""
|
||||
models = _fetch_openrouter_models() # newest first
|
||||
# Prefer the newest from mainstream providers so the shortlist isn't crowded
|
||||
# out by niche/experimental releases; fall back to all if none match.
|
||||
mainstream = [
|
||||
(name, mid) for name, mid in models
|
||||
if not mid.startswith("~") # skip variant/alias duplicate routes
|
||||
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
|
||||
]
|
||||
top = (mainstream or models)[:5]
|
||||
|
||||
choices = [questionary.Choice(name, value=mid) for name, mid in top]
|
||||
choices.append(questionary.Choice("Custom model ID", value="custom"))
|
||||
|
||||
choice = questionary.select(
|
||||
"Select OpenRouter Model (latest available):",
|
||||
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
|
||||
choices=choices,
|
||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||
style=questionary.Style([
|
||||
@@ -165,33 +273,32 @@ def select_openrouter_model() -> str:
|
||||
]),
|
||||
).ask()
|
||||
|
||||
if choice is None or choice == "custom":
|
||||
return questionary.text(
|
||||
if choice is None:
|
||||
console.print("\n[red]No model selected. Exiting...[/red]")
|
||||
exit(1)
|
||||
if choice == "custom":
|
||||
return _require_text(
|
||||
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.",
|
||||
).ask().strip()
|
||||
|
||||
"Please enter a model ID.",
|
||||
)
|
||||
return choice
|
||||
|
||||
|
||||
def _prompt_custom_model_id() -> str:
|
||||
"""Prompt user to type a custom model ID."""
|
||||
return questionary.text(
|
||||
"Enter model ID:",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.",
|
||||
).ask().strip()
|
||||
return _require_text("Enter model ID:", "Please enter a model ID.")
|
||||
|
||||
|
||||
def _select_model(provider: str, mode: str) -> str:
|
||||
"""Select a model for the given provider and mode (quick/deep)."""
|
||||
if provider.lower() == "openrouter":
|
||||
return select_openrouter_model()
|
||||
return select_openrouter_model(mode)
|
||||
|
||||
if provider.lower() == "azure":
|
||||
return questionary.text(
|
||||
return _require_text(
|
||||
f"Enter Azure deployment name ({mode}-thinking):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a deployment name.",
|
||||
).ask().strip()
|
||||
"Please enter a deployment name.",
|
||||
)
|
||||
|
||||
choice = questionary.select(
|
||||
f"Select Your [{mode.title()}-Thinking LLM Engine]:",
|
||||
@@ -228,22 +335,77 @@ def select_deep_thinking_agent(provider) -> str:
|
||||
"""Select deep thinking llm engine using an interactive selection."""
|
||||
return _select_model(provider, "deep")
|
||||
|
||||
def select_llm_provider() -> tuple[str, str | None]:
|
||||
"""Select the LLM provider and its API endpoint."""
|
||||
# (display_name, provider_key, base_url)
|
||||
PROVIDERS = [
|
||||
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
||||
"""(display_name, provider_key, base_url) for every supported provider.
|
||||
|
||||
Shared by the interactive picker and by env-driven configuration so an
|
||||
env-set provider resolves to the same default endpoint the menu uses.
|
||||
Ollama users can point at a remote ollama-serve via OLLAMA_BASE_URL
|
||||
(convention from the broader Ollama ecosystem); falls back to the
|
||||
localhost default when unset.
|
||||
"""
|
||||
ollama_url = os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434/v1"
|
||||
return [
|
||||
("OpenAI", "openai", "https://api.openai.com/v1"),
|
||||
("Google", "google", None),
|
||||
("Anthropic", "anthropic", "https://api.anthropic.com/"),
|
||||
("xAI", "xai", "https://api.x.ai/v1"),
|
||||
("DeepSeek", "deepseek", "https://api.deepseek.com"),
|
||||
("Qwen", "qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
||||
("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"),
|
||||
("MiniMax", "minimax", "https://api.minimax.io/v1"),
|
||||
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
|
||||
("Mistral", "mistral", "https://api.mistral.ai/v1"),
|
||||
("Kimi (Moonshot)", "kimi", "https://api.moonshot.ai/v1"),
|
||||
("Groq", "groq", "https://api.groq.com/openai/v1"),
|
||||
("NVIDIA NIM", "nvidia", "https://integrate.api.nvidia.com/v1"),
|
||||
("Azure OpenAI", "azure", None),
|
||||
("Ollama", "ollama", "http://localhost:11434/v1"),
|
||||
("Amazon Bedrock", "bedrock", None),
|
||||
("Ollama", "ollama", ollama_url),
|
||||
("OpenAI-compatible (vLLM, LM Studio, llama.cpp, custom relay)", "openai_compatible", None),
|
||||
]
|
||||
|
||||
|
||||
def provider_default_url(provider_key: str) -> str | None:
|
||||
"""Return the default backend URL for a provider key, or None if unknown."""
|
||||
key = provider_key.lower()
|
||||
for _, pk, url in _llm_provider_table():
|
||||
if pk == key:
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def resolve_backend_url(
|
||||
provider: str, menu_url: str | None = None, env_url: str | None = None
|
||||
) -> str | None:
|
||||
"""Resolve the backend URL with the correct precedence.
|
||||
|
||||
An explicit env override (``env_url``, from ``TRADINGAGENTS_LLM_BACKEND_URL``
|
||||
via ``DEFAULT_CONFIG['backend_url']``) is honored regardless of how the
|
||||
provider was chosen — interactively or from the environment (#978).
|
||||
Otherwise the menu/region URL, then the provider's default.
|
||||
"""
|
||||
return env_url or menu_url or provider_default_url(provider)
|
||||
|
||||
|
||||
def prompt_openai_compatible_url() -> str:
|
||||
"""Prompt for a custom OpenAI-compatible endpoint base URL."""
|
||||
url = questionary.text(
|
||||
"Enter the OpenAI-compatible base URL "
|
||||
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
|
||||
validate=lambda x: x.strip().startswith(("http://", "https://"))
|
||||
or "Enter a URL starting with http:// or https://",
|
||||
).ask()
|
||||
if not url:
|
||||
console.print("\n[red]No endpoint URL provided. Exiting...[/red]")
|
||||
exit(1)
|
||||
return url.strip()
|
||||
|
||||
|
||||
def select_llm_provider() -> tuple[str, str | None]:
|
||||
"""Select the LLM provider and its API endpoint."""
|
||||
PROVIDERS = _llm_provider_table()
|
||||
|
||||
choice = questionary.select(
|
||||
"Select your LLM Provider:",
|
||||
choices=[
|
||||
@@ -289,7 +451,9 @@ def ask_openai_reasoning_effort() -> str:
|
||||
def ask_anthropic_effort() -> str | None:
|
||||
"""Ask for Anthropic effort level.
|
||||
|
||||
Controls token usage and response thoroughness on Claude 4.5+ and 4.6 models.
|
||||
Controls token usage and response thoroughness on Claude 4.5 / 4.6 / 4.7
|
||||
models. The API also accepts "max"; we expose low/medium/high as the
|
||||
common selection range.
|
||||
"""
|
||||
return questionary.select(
|
||||
"Select Effort Level:",
|
||||
@@ -326,6 +490,166 @@ def ask_gemini_thinking_config() -> str | None:
|
||||
).ask()
|
||||
|
||||
|
||||
def ask_glm_region() -> tuple[str, str]:
|
||||
"""Ask which GLM platform (Z.AI international vs BigModel China) to use.
|
||||
|
||||
Zhipu serves the same GLM models under two brands with separate
|
||||
accounts; keys aren't interchangeable. Returns (provider_key, backend_url).
|
||||
"""
|
||||
return questionary.select(
|
||||
"Select GLM platform:",
|
||||
choices=[
|
||||
questionary.Choice(
|
||||
"Z.AI — api.z.ai (international, uses ZHIPU_API_KEY)",
|
||||
value=("glm", "https://api.z.ai/api/paas/v4/"),
|
||||
),
|
||||
questionary.Choice(
|
||||
"BigModel — open.bigmodel.cn (China, uses ZHIPU_CN_API_KEY)",
|
||||
value=("glm-cn", "https://open.bigmodel.cn/api/paas/v4/"),
|
||||
),
|
||||
],
|
||||
style=questionary.Style([
|
||||
("selected", "fg:cyan noinherit"),
|
||||
("highlighted", "fg:cyan noinherit"),
|
||||
("pointer", "fg:cyan noinherit"),
|
||||
]),
|
||||
).ask()
|
||||
|
||||
|
||||
def ask_qwen_region() -> tuple[str, str]:
|
||||
"""Ask which Qwen region (international vs China) to use.
|
||||
|
||||
Alibaba DashScope exposes two endpoints with separate accounts —
|
||||
a key from one region does NOT authenticate against the other
|
||||
(fixes #758). Returns (provider_key, backend_url).
|
||||
"""
|
||||
return questionary.select(
|
||||
"Select Qwen region:",
|
||||
choices=[
|
||||
questionary.Choice(
|
||||
"International — dashscope-intl.aliyuncs.com (uses DASHSCOPE_API_KEY)",
|
||||
value=("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
||||
),
|
||||
questionary.Choice(
|
||||
"China — dashscope.aliyuncs.com (uses DASHSCOPE_CN_API_KEY)",
|
||||
value=("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
),
|
||||
],
|
||||
style=questionary.Style([
|
||||
("selected", "fg:cyan noinherit"),
|
||||
("highlighted", "fg:cyan noinherit"),
|
||||
("pointer", "fg:cyan noinherit"),
|
||||
]),
|
||||
).ask()
|
||||
|
||||
|
||||
def ask_minimax_region() -> tuple[str, str]:
|
||||
"""Ask which MiniMax region (global vs China) to use.
|
||||
|
||||
MiniMax exposes two endpoints with separate accounts — a key from
|
||||
one region does NOT authenticate against the other. Returns
|
||||
(provider_key, backend_url).
|
||||
"""
|
||||
return questionary.select(
|
||||
"Select MiniMax region:",
|
||||
choices=[
|
||||
questionary.Choice(
|
||||
"Global — api.minimax.io (uses MINIMAX_API_KEY)",
|
||||
value=("minimax", "https://api.minimax.io/v1"),
|
||||
),
|
||||
questionary.Choice(
|
||||
"China — api.minimaxi.com (uses MINIMAX_CN_API_KEY)",
|
||||
value=("minimax-cn", "https://api.minimaxi.com/v1"),
|
||||
),
|
||||
],
|
||||
style=questionary.Style([
|
||||
("selected", "fg:cyan noinherit"),
|
||||
("highlighted", "fg:cyan noinherit"),
|
||||
("pointer", "fg:cyan noinherit"),
|
||||
]),
|
||||
).ask()
|
||||
|
||||
|
||||
def confirm_ollama_endpoint(url: str) -> None:
|
||||
"""Show the resolved Ollama endpoint after provider selection.
|
||||
|
||||
Surfaces three things the user benefits from seeing before model
|
||||
selection: which URL we'll actually hit, where it came from
|
||||
(`OLLAMA_BASE_URL` vs default), and a soft warning if the URL is
|
||||
missing the scheme/port that ollama-serve expects. The warning is
|
||||
advisory only — we don't reject malformed input, since the user may
|
||||
be doing something deliberately unusual (e.g. a reverse-proxy path).
|
||||
"""
|
||||
from_env = os.environ.get("OLLAMA_BASE_URL")
|
||||
origin = " (from OLLAMA_BASE_URL)" if from_env and from_env == url else ""
|
||||
console.print(f"[green]✓ Using Ollama at {url}{origin}[/green]")
|
||||
|
||||
if not url.startswith(("http://", "https://")):
|
||||
console.print(
|
||||
f"[yellow]Note: {url!r} is missing a scheme. "
|
||||
f"Ollama-serve typically expects a URL like "
|
||||
f"http://<host>:11434/v1.[/yellow]"
|
||||
)
|
||||
elif ":11434" not in url and "://localhost" not in url and "://127.0.0.1" not in url:
|
||||
# Soft hint when the port differs from the ollama-serve default
|
||||
# and the host isn't local (where users sometimes proxy on :80).
|
||||
console.print(
|
||||
f"[yellow]Note: {url!r} doesn't include port 11434. "
|
||||
f"Make sure your remote ollama-serve listens on the port "
|
||||
f"shown above.[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
def ensure_api_key(provider: str) -> str | None:
|
||||
"""Make sure the API key for `provider` is available in the environment.
|
||||
|
||||
If the env var is already set, returns its value untouched. Otherwise
|
||||
interactively prompts the user, persists the value to the project's
|
||||
.env file via python-dotenv's set_key (creating .env if needed), and
|
||||
exports it into os.environ so the current process picks it up.
|
||||
|
||||
Returns None for providers that do not require a key (e.g. ollama)
|
||||
and for providers not found in the canonical mapping.
|
||||
"""
|
||||
env_var = get_api_key_env(provider)
|
||||
if env_var is None:
|
||||
return None # ollama / unknown — no key check possible
|
||||
|
||||
# Key-optional providers (generic OpenAI-compatible / local servers) read the
|
||||
# key when present but must never force an interactive prompt.
|
||||
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
|
||||
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
|
||||
if spec is not None and spec.key_optional:
|
||||
return os.environ.get(env_var)
|
||||
|
||||
existing = os.environ.get(env_var)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
console.print(
|
||||
f"\n[yellow]{env_var} is not set in your environment.[/yellow]"
|
||||
)
|
||||
key = questionary.password(
|
||||
f"Paste your {env_var} (will be saved to .env):",
|
||||
style=questionary.Style([
|
||||
("text", "fg:cyan"),
|
||||
("highlighted", "noinherit"),
|
||||
]),
|
||||
).ask()
|
||||
if not key:
|
||||
console.print(
|
||||
f"[red]Skipped. API calls will fail until {env_var} is set.[/red]"
|
||||
)
|
||||
return None
|
||||
|
||||
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
|
||||
Path(env_path).touch(exist_ok=True)
|
||||
set_key(env_path, env_var, key)
|
||||
os.environ[env_var] = key
|
||||
console.print(f"[green]Saved {env_var} to {env_path}[/green]")
|
||||
return key
|
||||
|
||||
|
||||
def ask_output_language() -> str:
|
||||
"""Ask for report output language."""
|
||||
choice = questionary.select(
|
||||
@@ -351,10 +675,14 @@ def ask_output_language() -> str:
|
||||
]),
|
||||
).ask()
|
||||
|
||||
# Output language has a sensible default, so a cancel falls back to English
|
||||
# rather than exiting the run (unlike the required model/provider prompts).
|
||||
if choice is None:
|
||||
return "English"
|
||||
if choice == "custom":
|
||||
return questionary.text(
|
||||
return (questionary.text(
|
||||
"Enter language name (e.g. Turkish, Vietnamese, Thai, Indonesian):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a language name.",
|
||||
).ask().strip()
|
||||
).ask() or "").strip() or "English"
|
||||
|
||||
return choice
|
||||
|
||||
@@ -20,7 +20,8 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- LLM_PROVIDER=ollama
|
||||
- TRADINGAGENTS_LLM_PROVIDER=ollama
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
volumes:
|
||||
- tradingagents_data:/home/appuser/.tradingagents
|
||||
depends_on:
|
||||
|
||||
24
main.py
24
main.py
@@ -1,24 +1,12 @@
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Create a custom config
|
||||
# DEFAULT_CONFIG already applies TRADINGAGENTS_* env-var overrides
|
||||
# (llm_provider, deep_think_llm, quick_think_llm, backend_url, etc.),
|
||||
# so users can switch models or endpoints purely via .env without
|
||||
# editing this script. Override individual keys here only when you
|
||||
# want a hard-coded value that should ignore the environment.
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["deep_think_llm"] = "gpt-5.4-mini" # Use a different model
|
||||
config["quick_think_llm"] = "gpt-5.4-mini" # Use a different model
|
||||
config["max_debate_rounds"] = 1 # Increase debate rounds
|
||||
|
||||
# Configure data vendors (default uses yfinance, no extra API keys needed)
|
||||
config["data_vendors"] = {
|
||||
"core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"technical_indicators": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"fundamental_data": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"news_data": "yfinance", # Options: alpha_vantage, yfinance
|
||||
}
|
||||
|
||||
# Initialize with custom config
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tradingagents"
|
||||
version = "0.2.4"
|
||||
version = "0.4.0"
|
||||
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"langgraph-checkpoint-sqlite>=2.0.0",
|
||||
"pandas>=2.3.0",
|
||||
"parsel>=1.10.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pytz>=2025.2",
|
||||
"questionary>=2.1.0",
|
||||
"redis>=6.2.0",
|
||||
@@ -29,7 +30,19 @@ dependencies = [
|
||||
"stockstats>=0.6.5",
|
||||
"tqdm>=4.67.1",
|
||||
"typing-extensions>=4.14.0",
|
||||
"yfinance>=0.2.63",
|
||||
"yfinance>=1.4.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.15",
|
||||
"pytest>=8.0",
|
||||
"pytest-subtests>=0.13",
|
||||
]
|
||||
# Amazon Bedrock support (AWS SigV4 auth + boto3). Optional so the core install
|
||||
# stays lean: pip install "tradingagents[bedrock]".
|
||||
bedrock = [
|
||||
"langchain-aws>=1.5.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -52,3 +65,24 @@ markers = [
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
extend-exclude = ["results", "worklog"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Standard "good defaults" rule set (pyflakes + pycodestyle + isort + bugbear +
|
||||
# pyupgrade + comprehensions/simplify). Line length (E501) and layout are owned
|
||||
# by the formatter; whole-repo `ruff format` adoption is deferred until the
|
||||
# open-PR backlog clears, to avoid mass merge conflicts.
|
||||
select = ["E", "W", "F", "I", "B", "UP", "C4", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/__init__.py" = ["F401"] # intentional re-exports
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# Keep multiple aliased names from one module in a single combined import block
|
||||
# (e.g. the vendor re-exports in interface.py) instead of one statement per name.
|
||||
combine-as-imports = true
|
||||
|
||||
@@ -21,7 +21,6 @@ added, plus the heuristic SignalProcessor.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||
@@ -30,15 +29,14 @@ 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-2.5-flash", None),
|
||||
"google": ("gemini-3.5-flash", None),
|
||||
"anthropic": ("claude-sonnet-4-6", None),
|
||||
"deepseek": ("deepseek-chat", None),
|
||||
"qwen": ("qwen-plus", None),
|
||||
"deepseek": ("deepseek-v4-flash", None),
|
||||
"qwen": ("qwen3.7-plus", None),
|
||||
"glm": ("glm-5", None),
|
||||
"xai": ("grok-4", None),
|
||||
"xai": ("grok-4.3", None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
test.py
5
test.py
@@ -1,5 +1,8 @@
|
||||
import time
|
||||
from tradingagents.dataflows.y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions
|
||||
|
||||
from tradingagents.dataflows.y_finance import (
|
||||
get_stock_stats_indicators_window,
|
||||
)
|
||||
|
||||
print("Testing optimized implementation with 30-day lookback:")
|
||||
start_time = time.time()
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -18,7 +18,11 @@ _API_KEY_ENV_VARS = (
|
||||
"XAI_API_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"DASHSCOPE_CN_API_KEY",
|
||||
"ZHIPU_API_KEY",
|
||||
"ZHIPU_CN_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"ALPHA_VANTAGE_API_KEY",
|
||||
@@ -28,7 +32,28 @@ _API_KEY_ENV_VARS = (
|
||||
@pytest.fixture(autouse=True)
|
||||
def _dummy_api_keys(monkeypatch):
|
||||
for env_var in _API_KEY_ENV_VARS:
|
||||
monkeypatch.setenv(env_var, os.environ.get(env_var, "placeholder"))
|
||||
# `or` not a .get default: an env var present but empty (e.g. a key left
|
||||
# blank in a .env copied from .env.example) must still get the placeholder.
|
||||
monkeypatch.setenv(env_var, os.environ.get(env_var) or "placeholder")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_config():
|
||||
"""Reset the global dataflows config before and after each test.
|
||||
|
||||
``set_config`` merges (it never clears keys absent from the override), so a
|
||||
test that sets e.g. ``tool_vendors`` would otherwise leak into later tests
|
||||
and make routing behavior order-dependent. Replace the global outright so
|
||||
every test starts from a clean DEFAULT_CONFIG.
|
||||
"""
|
||||
import copy
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.default_config as default_config
|
||||
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
yield
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
96
tests/test_alpha_vantage_hardening.py
Normal file
96
tests/test_alpha_vantage_hardening.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Alpha Vantage request hardening.
|
||||
|
||||
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
||||
responses mislabeled as rate limits and silently treated as transient), and
|
||||
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
|
||||
string, not a dict).
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.alpha_vantage_common as av
|
||||
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
|
||||
def _patched_get(body, capture=None):
|
||||
def fake_get(url, params=None, **kwargs):
|
||||
if capture is not None:
|
||||
capture.update(kwargs)
|
||||
return _FakeResponse(body)
|
||||
return fake_get
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_request_passes_timeout(monkeypatch):
|
||||
captured = {}
|
||||
monkeypatch.setattr(av.requests, "get", _patched_get("Date,Close\n2025-01-02,1.0", captured))
|
||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||
assert captured.get("timeout") == av.REQUEST_TIMEOUT # #990
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_rate_limit_detected(monkeypatch):
|
||||
body = '{"Information": "Our standard API rate limit is 25 requests per day. ... your API key ..."}'
|
||||
monkeypatch.setattr(av.requests, "get", _patched_get(body))
|
||||
with pytest.raises(av.AlphaVantageRateLimitError):
|
||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch):
|
||||
# AV's invalid-key notice mentions "API key"; it must NOT be treated as a
|
||||
# (transient) rate limit, but surface as a real configuration error (#991).
|
||||
body = ('{"Information": "the parameter apikey is invalid or missing. '
|
||||
'Please claim your free API key on (https://www.alphavantage.co/support/#api-key)."}')
|
||||
monkeypatch.setattr(av.requests, "get", _patched_get(body))
|
||||
with pytest.raises(av.AlphaVantageNotConfiguredError):
|
||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||
with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct
|
||||
monkeypatch.setattr(av.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
|
||||
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
|
||||
|
||||
|
||||
_FUNDAMENTALS_JSON = json.dumps({
|
||||
"symbol": "AAPL",
|
||||
"annualReports": [
|
||||
{"fiscalDateEnding": "2025-12-31", "totalAssets": "1"}, # future -> must drop
|
||||
{"fiscalDateEnding": "2023-12-31", "totalAssets": "2"}, # past -> must keep
|
||||
],
|
||||
"quarterlyReports": [
|
||||
{"fiscalDateEnding": "2024-06-30", "totalAssets": "3"}, # future -> must drop
|
||||
{"fiscalDateEnding": "2023-09-30", "totalAssets": "4"}, # past -> must keep
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch):
|
||||
# #1115: the payload arrives as a JSON *string*; the old dict-only guard let
|
||||
# future-dated fiscal periods leak into historical runs.
|
||||
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
|
||||
out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01")
|
||||
assert isinstance(out, str) # callers still receive a str
|
||||
parsed = json.loads(out)
|
||||
assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"]
|
||||
assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fundamentals_no_curr_date_passes_through(monkeypatch):
|
||||
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
|
||||
assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fundamentals_non_json_body_unchanged(monkeypatch):
|
||||
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
|
||||
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json"
|
||||
90
tests/test_analyst_execution.py
Normal file
90
tests/test_analyst_execution.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import unittest
|
||||
|
||||
from tradingagents.graph.analyst_execution import (
|
||||
AnalystWallTimeTracker,
|
||||
build_analyst_execution_plan,
|
||||
get_initial_analyst_node,
|
||||
sync_analyst_tracker_from_chunk,
|
||||
)
|
||||
|
||||
|
||||
class AnalystExecutionPlanTests(unittest.TestCase):
|
||||
def test_build_plan_preserves_selected_order(self):
|
||||
plan = build_analyst_execution_plan(["news", "market"])
|
||||
|
||||
self.assertEqual([spec.key for spec in plan.specs], ["news", "market"])
|
||||
self.assertEqual(plan.specs[0].agent_node, "News Analyst")
|
||||
self.assertEqual(plan.specs[0].tool_node, "tools_news")
|
||||
self.assertEqual(plan.specs[0].clear_node, "Msg Clear News")
|
||||
|
||||
def test_rejects_unknown_analyst_keys(self):
|
||||
with self.assertRaises(ValueError):
|
||||
build_analyst_execution_plan(["market", "macro"])
|
||||
|
||||
def test_get_initial_analyst_node_uses_plan_metadata(self):
|
||||
plan = build_analyst_execution_plan(["fundamentals", "news"])
|
||||
|
||||
self.assertEqual(
|
||||
get_initial_analyst_node(plan),
|
||||
"Fundamentals Analyst",
|
||||
)
|
||||
|
||||
def test_social_key_displays_as_sentiment_analyst(self):
|
||||
# The wire key stays "social" for saved-config back-compat, but the
|
||||
# user-visible agent_node label must match the v0.2.5 rename so the
|
||||
# wall-time summary and any future consumer of agent_node says
|
||||
# "Sentiment Analyst" rather than the legacy "Social Analyst".
|
||||
plan = build_analyst_execution_plan(["social"])
|
||||
spec = plan.specs[0]
|
||||
self.assertEqual(spec.key, "social")
|
||||
self.assertEqual(spec.agent_node, "Sentiment Analyst")
|
||||
self.assertEqual(spec.report_key, "sentiment_report")
|
||||
|
||||
|
||||
class AnalystWallTimeTrackerTests(unittest.TestCase):
|
||||
def test_records_wall_time_when_analyst_completes(self):
|
||||
plan = build_analyst_execution_plan(["market", "news"])
|
||||
tracker = AnalystWallTimeTracker(plan)
|
||||
|
||||
tracker.mark_started("market", started_at=10.0)
|
||||
tracker.mark_completed("market", completed_at=13.5)
|
||||
|
||||
self.assertEqual(tracker.get_wall_times(), {"market": 3.5})
|
||||
|
||||
def test_formats_summary_in_plan_order(self):
|
||||
plan = build_analyst_execution_plan(["news", "market"])
|
||||
tracker = AnalystWallTimeTracker(plan)
|
||||
|
||||
tracker.mark_started("market", started_at=20.0)
|
||||
tracker.mark_completed("market", completed_at=22.25)
|
||||
tracker.mark_started("news", started_at=10.0)
|
||||
tracker.mark_completed("news", completed_at=14.0)
|
||||
|
||||
self.assertEqual(
|
||||
tracker.format_summary(),
|
||||
"Analyst wall time: News 4.00s | Market 2.25s",
|
||||
)
|
||||
|
||||
def test_syncs_wall_time_from_sequential_chunks(self):
|
||||
plan = build_analyst_execution_plan(["market", "news"])
|
||||
tracker = AnalystWallTimeTracker(plan)
|
||||
|
||||
sync_analyst_tracker_from_chunk(tracker, {}, now=10.0)
|
||||
self.assertEqual(tracker.get_wall_times(), {})
|
||||
|
||||
sync_analyst_tracker_from_chunk(
|
||||
tracker,
|
||||
{"market_report": "done"},
|
||||
now=13.0,
|
||||
)
|
||||
self.assertEqual(tracker.get_wall_times(), {"market": 3.0})
|
||||
|
||||
sync_analyst_tracker_from_chunk(
|
||||
tracker,
|
||||
{"market_report": "done", "news_report": "done"},
|
||||
now=18.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
tracker.get_wall_times(),
|
||||
{"market": 3.0, "news": 5.0},
|
||||
)
|
||||
98
tests/test_anthropic_effort.py
Normal file
98
tests/test_anthropic_effort.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for Anthropic effort-parameter gating (#831).
|
||||
|
||||
Haiku (any version) and Sonnet 4.5 reject the ``effort`` parameter with a
|
||||
400. Only Opus 4.5+ and Sonnet 4.6+ accept it. The gate uses a per-family
|
||||
minimum version so future ``claude-{opus,sonnet}-X-Y`` releases inherit
|
||||
support automatically.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients import anthropic_client as mod
|
||||
|
||||
|
||||
def _capture_kwargs(monkeypatch):
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(
|
||||
mod, "NormalizedChatAnthropic",
|
||||
lambda **kwargs: captured.setdefault("kwargs", kwargs),
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEffortGate:
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"claude-haiku-4-5", "claude-haiku-5-0", "claude-haiku-4-7-preview",
|
||||
# Sonnet 4.5 (and earlier) 400 on effort — only Sonnet 4.6+ supports it.
|
||||
"claude-sonnet-4-5", "claude-sonnet-4-0",
|
||||
],
|
||||
)
|
||||
def test_unsupported_models_do_not_receive_effort(self, monkeypatch, model):
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(model=model, effort="medium", api_key="x").get_llm()
|
||||
assert "effort" not in captured["kwargs"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
],
|
||||
)
|
||||
def test_current_opus_and_sonnet_receive_effort(self, monkeypatch, model):
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(model=model, effort="high", api_key="x").get_llm()
|
||||
assert captured["kwargs"]["effort"] == "high"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
["claude-opus-5-0", "claude-opus-4-8", "claude-sonnet-5-0"],
|
||||
)
|
||||
def test_future_opus_sonnet_inherit_effort_via_pattern(self, monkeypatch, model):
|
||||
"""Forward-compat: new Opus/Sonnet versions don't need a code change."""
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(model=model, effort="low", api_key="x").get_llm()
|
||||
assert captured["kwargs"]["effort"] == "low"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
# Claude 5 family uses single-number version IDs; all are effort-capable.
|
||||
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5"],
|
||||
)
|
||||
def test_claude_5_family_receives_effort(self, monkeypatch, model):
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(model=model, effort="high", api_key="x").get_llm()
|
||||
assert captured["kwargs"]["effort"] == "high"
|
||||
|
||||
def test_mythos_preview_receives_effort(self, monkeypatch):
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(
|
||||
model="claude-mythos-preview", effort="medium", api_key="x"
|
||||
).get_llm()
|
||||
assert captured["kwargs"]["effort"] == "medium"
|
||||
|
||||
def test_unknown_anthropic_model_does_not_receive_effort(self, monkeypatch):
|
||||
"""Default is conservative — unknown models don't get effort to avoid 400s."""
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(
|
||||
model="claude-experimental-x", effort="medium", api_key="x"
|
||||
).get_llm()
|
||||
assert "effort" not in captured["kwargs"]
|
||||
|
||||
def test_other_kwargs_still_forwarded_when_effort_skipped(self, monkeypatch):
|
||||
"""Skipping effort must not break other passthrough kwargs."""
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
mod.AnthropicClient(
|
||||
model="claude-haiku-4-5",
|
||||
effort="medium",
|
||||
api_key="placeholder",
|
||||
max_tokens=1024,
|
||||
timeout=30,
|
||||
).get_llm()
|
||||
assert captured["kwargs"]["api_key"] == "placeholder"
|
||||
assert captured["kwargs"]["max_tokens"] == 1024
|
||||
assert captured["kwargs"]["timeout"] == 30
|
||||
assert "effort" not in captured["kwargs"]
|
||||
148
tests/test_api_key_env.py
Normal file
148
tests/test_api_key_env.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests for the canonical provider->env-var mapping and the CLI key-prompt helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.api_key_env import PROVIDER_API_KEY_ENV, get_api_key_env
|
||||
|
||||
# ---- Mapping coverage -----------------------------------------------------
|
||||
|
||||
|
||||
def test_every_select_llm_provider_choice_has_an_entry():
|
||||
"""select_llm_provider() must not present a provider the mapping doesn't know about."""
|
||||
# Mirrors the dropdown order in cli/utils.select_llm_provider so the two
|
||||
# stay in lockstep. Region-specific keys (qwen-cn / minimax-cn / glm-cn)
|
||||
# are reached via the secondary region prompt, so they must also be present.
|
||||
expected = {
|
||||
"openai", "google", "anthropic", "xai", "deepseek",
|
||||
"qwen", "qwen-cn",
|
||||
"glm", "glm-cn",
|
||||
"minimax", "minimax-cn",
|
||||
"openrouter", "azure", "ollama",
|
||||
}
|
||||
assert expected.issubset(PROVIDER_API_KEY_ENV.keys())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider,env_var",
|
||||
[
|
||||
("openai", "OPENAI_API_KEY"),
|
||||
("anthropic", "ANTHROPIC_API_KEY"),
|
||||
("google", "GOOGLE_API_KEY"),
|
||||
("azure", "AZURE_OPENAI_API_KEY"),
|
||||
("xai", "XAI_API_KEY"),
|
||||
("deepseek", "DEEPSEEK_API_KEY"),
|
||||
("qwen", "DASHSCOPE_API_KEY"),
|
||||
("qwen-cn", "DASHSCOPE_CN_API_KEY"),
|
||||
("glm", "ZHIPU_API_KEY"),
|
||||
("glm-cn", "ZHIPU_CN_API_KEY"),
|
||||
("minimax", "MINIMAX_API_KEY"),
|
||||
("minimax-cn", "MINIMAX_CN_API_KEY"),
|
||||
("openrouter", "OPENROUTER_API_KEY"),
|
||||
],
|
||||
)
|
||||
def test_known_providers_resolve(provider, env_var):
|
||||
assert get_api_key_env(provider) == env_var
|
||||
|
||||
|
||||
def test_ollama_has_no_key():
|
||||
assert get_api_key_env("ollama") is None
|
||||
|
||||
|
||||
def test_unknown_provider_returns_none():
|
||||
assert get_api_key_env("not-a-real-provider") is None
|
||||
|
||||
|
||||
def test_case_insensitive_lookup():
|
||||
assert get_api_key_env("OpenAI") == "OPENAI_API_KEY"
|
||||
assert get_api_key_env("QWEN-CN") == "DASHSCOPE_CN_API_KEY"
|
||||
|
||||
|
||||
# ---- ensure_api_key behavior ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_utils(monkeypatch):
|
||||
"""Import cli.utils with a fresh environment so module-level state is consistent."""
|
||||
import importlib
|
||||
|
||||
import cli.utils as cli_utils_module
|
||||
return importlib.reload(cli_utils_module)
|
||||
|
||||
|
||||
def test_ensure_api_key_returns_existing(monkeypatch, cli_utils):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-already-set")
|
||||
result = cli_utils.ensure_api_key("openai")
|
||||
assert result == "sk-already-set"
|
||||
|
||||
|
||||
def test_ensure_api_key_no_op_for_ollama(monkeypatch, cli_utils):
|
||||
# Even with no env var set, ollama should not prompt and should return None.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with patch.object(cli_utils, "questionary") as mock_q:
|
||||
result = cli_utils.ensure_api_key("ollama")
|
||||
assert result is None
|
||||
mock_q.password.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_api_key_unknown_provider_no_prompt(monkeypatch, cli_utils):
|
||||
with patch.object(cli_utils, "questionary") as mock_q:
|
||||
result = cli_utils.ensure_api_key("totally-fake-provider")
|
||||
assert result is None
|
||||
mock_q.password.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_api_key_prompts_and_writes_to_env(monkeypatch, tmp_path, cli_utils):
|
||||
"""When key is missing, user-pasted value must be written to .env AND os.environ."""
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-deepseek-test")})()
|
||||
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
|
||||
result = cli_utils.ensure_api_key("deepseek")
|
||||
|
||||
assert result == "sk-deepseek-test"
|
||||
assert os.environ["DEEPSEEK_API_KEY"] == "sk-deepseek-test"
|
||||
env_file = tmp_path / ".env"
|
||||
assert env_file.exists()
|
||||
assert "DEEPSEEK_API_KEY" in env_file.read_text()
|
||||
assert "sk-deepseek-test" in env_file.read_text()
|
||||
|
||||
|
||||
def test_ensure_api_key_user_cancels_returns_none(monkeypatch, tmp_path, cli_utils):
|
||||
"""Empty prompt response (user cancelled) must not write to .env."""
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
fake_prompt = type("P", (), {"ask": staticmethod(lambda: None)})()
|
||||
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
|
||||
result = cli_utils.ensure_api_key("xai")
|
||||
|
||||
assert result is None
|
||||
assert "XAI_API_KEY" not in os.environ
|
||||
# .env may or may not exist depending on find_dotenv's walk, but if it
|
||||
# does it must not contain the key.
|
||||
env_file = tmp_path / ".env"
|
||||
if env_file.exists():
|
||||
assert "XAI_API_KEY" not in env_file.read_text()
|
||||
|
||||
|
||||
def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_utils):
|
||||
"""An existing .env with other keys must be preserved on writeback."""
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("OPENAI_API_KEY=sk-existing\nOTHER=value\n")
|
||||
|
||||
fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-openrouter-new")})()
|
||||
with patch.object(cli_utils.questionary, "password", return_value=fake_prompt):
|
||||
cli_utils.ensure_api_key("openrouter")
|
||||
|
||||
content = env_file.read_text()
|
||||
assert "OPENAI_API_KEY" in content and "sk-existing" in content
|
||||
assert "OTHER=value" in content
|
||||
assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content
|
||||
80
tests/test_bedrock_provider.py
Normal file
80
tests/test_bedrock_provider.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Amazon Bedrock — first-class native client via the optional langchain-aws extra.
|
||||
|
||||
Auth uses the AWS credential chain (no single key env); the model is a Bedrock
|
||||
model ID / inference profile ID; langchain-aws is imported lazily with a clear
|
||||
install hint when the [bedrock] extra is absent.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.api_key_env import get_api_key_env
|
||||
from tradingagents.llm_clients.factory import create_llm_client
|
||||
from tradingagents.llm_clients.validators import validate_model
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_factory_routes_bedrock():
|
||||
client = create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0")
|
||||
assert type(client).__name__ == "BedrockClient"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bedrock_any_model_and_no_key_env():
|
||||
assert validate_model("bedrock", "any.model-id:0") is True
|
||||
# Bedrock uses the AWS credential chain, so there is no single key env.
|
||||
assert get_api_key_env("bedrock") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_helpful_error_when_langchain_aws_absent(monkeypatch):
|
||||
import tradingagents.llm_clients.bedrock_client as bc
|
||||
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
|
||||
monkeypatch.setitem(sys.modules, "langchain_aws", None) # force ImportError on import
|
||||
with pytest.raises(ImportError, match=r"bedrock"):
|
||||
create_llm_client("bedrock", "m").get_llm()
|
||||
|
||||
|
||||
def _capture_kwargs(monkeypatch):
|
||||
"""Stub _bedrock_class so the constructor kwargs are testable without the
|
||||
optional langchain-aws extra installed."""
|
||||
import tradingagents.llm_clients.bedrock_client as bc
|
||||
captured = {}
|
||||
|
||||
class _FakeChat:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(bc, "_bedrock_class", lambda: _FakeChat)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bearer_token_passed_as_api_key(monkeypatch):
|
||||
# #1103: a Bedrock API key authenticates without AWS access keys.
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bt-secret")
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
|
||||
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
|
||||
assert captured["api_key"] == "bt-secret"
|
||||
assert captured["region_name"] == "us-east-1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_bearer_token_omits_api_key(monkeypatch):
|
||||
# Without a token, fall back to the AWS credential chain (no api_key kwarg).
|
||||
captured = _capture_kwargs(monkeypatch)
|
||||
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
|
||||
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
|
||||
assert "api_key" not in captured
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_construction_when_extra_installed(monkeypatch):
|
||||
pytest.importorskip("langchain_aws")
|
||||
import tradingagents.llm_clients.bedrock_client as bc
|
||||
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
|
||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1")
|
||||
llm = create_llm_client("bedrock", "us.anthropic.claude-sonnet-5").get_llm()
|
||||
assert type(llm).__name__ == "NormalizedChatBedrockConverse"
|
||||
assert llm.region_name == "eu-west-1"
|
||||
155
tests/test_capabilities.py
Normal file
155
tests/test_capabilities.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for the LLM capability table."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.capabilities import (
|
||||
get_capabilities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExactIdMatches:
|
||||
def test_deepseek_chat_supports_tool_choice(self):
|
||||
caps = get_capabilities("deepseek-chat")
|
||||
assert caps.supports_tool_choice is True
|
||||
|
||||
def test_deepseek_reasoner_rejects_tool_choice(self):
|
||||
caps = get_capabilities("deepseek-reasoner")
|
||||
assert caps.supports_tool_choice is False
|
||||
assert caps.requires_reasoning_content_roundtrip is True
|
||||
|
||||
def test_deepseek_v4_flash_rejects_tool_choice(self):
|
||||
caps = get_capabilities("deepseek-v4-flash")
|
||||
assert caps.supports_tool_choice is False
|
||||
assert caps.requires_reasoning_content_roundtrip is True
|
||||
|
||||
def test_deepseek_v4_pro_rejects_tool_choice(self):
|
||||
caps = get_capabilities("deepseek-v4-pro")
|
||||
assert caps.supports_tool_choice is False
|
||||
assert caps.requires_reasoning_content_roundtrip is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPatternMatches:
|
||||
"""Forward-compat regex patterns catch unknown DeepSeek and MiniMax variants."""
|
||||
|
||||
def test_future_deepseek_v5_inherits_thinking_quirks(self):
|
||||
caps = get_capabilities("deepseek-v5-flash")
|
||||
assert caps.supports_tool_choice is False
|
||||
assert caps.requires_reasoning_content_roundtrip is True
|
||||
|
||||
def test_future_deepseek_v9_inherits_thinking_quirks(self):
|
||||
caps = get_capabilities("deepseek-v9-anything")
|
||||
assert caps.supports_tool_choice is False
|
||||
|
||||
def test_reasoner_variant_inherits_thinking_quirks(self):
|
||||
caps = get_capabilities("deepseek-reasoner-pro")
|
||||
assert caps.supports_tool_choice is False
|
||||
|
||||
def test_minimax_m3_inherits_thinking_quirks(self):
|
||||
caps = get_capabilities("MiniMax-M3")
|
||||
assert caps.supports_tool_choice is False
|
||||
|
||||
def test_future_minimax_m4_highspeed_inherits_thinking_quirks(self):
|
||||
caps = get_capabilities("MiniMax-M4-highspeed")
|
||||
assert caps.supports_tool_choice is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMinimaxExactMatches:
|
||||
"""MiniMax M2.x models reject langchain's function-spec dict tool_choice
|
||||
(official API enum: none/auto only)."""
|
||||
|
||||
def test_m2_7_rejects_tool_choice(self):
|
||||
caps = get_capabilities("MiniMax-M2.7")
|
||||
assert caps.supports_tool_choice is False
|
||||
assert caps.supports_json_mode is False # only MiniMax-Text-01 supports json_object
|
||||
|
||||
def test_m2_7_highspeed_rejects_tool_choice(self):
|
||||
assert get_capabilities("MiniMax-M2.7-highspeed").supports_tool_choice is False
|
||||
|
||||
def test_m2_1_rejects_tool_choice(self):
|
||||
assert get_capabilities("MiniMax-M2.1").supports_tool_choice is False
|
||||
|
||||
def test_m2_base_rejects_tool_choice(self):
|
||||
assert get_capabilities("MiniMax-M2").supports_tool_choice is False
|
||||
|
||||
def test_m2_x_requires_reasoning_split(self):
|
||||
# M2.x reasoning models need reasoning_split=True so <think> blocks
|
||||
# land in reasoning_details instead of content (#826).
|
||||
for model in ("MiniMax-M2.7", "MiniMax-M2.5-highspeed", "MiniMax-M2"):
|
||||
assert get_capabilities(model).requires_reasoning_split is True
|
||||
|
||||
def test_future_m3_inherits_reasoning_split(self):
|
||||
assert get_capabilities("MiniMax-M3-highspeed").requires_reasoning_split is True
|
||||
|
||||
def test_non_reasoning_minimax_does_not_get_reasoning_split(self):
|
||||
# Coding Plan, MiniMax-Text-01, and any non-M2-prefixed MiniMax model
|
||||
# reject the reasoning_split kwarg via the openai SDK's strict
|
||||
# validation (#826). Default capability has it disabled.
|
||||
for model in ("minimax-text-01", "MiniMax-Coding-Plan", "abab6.5-chat"):
|
||||
assert get_capabilities(model).requires_reasoning_split is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDefault:
|
||||
"""Unknown / non-DeepSeek models get the permissive default."""
|
||||
|
||||
def test_gpt_default(self):
|
||||
caps = get_capabilities("gpt-4.1")
|
||||
assert caps.supports_tool_choice is True
|
||||
assert caps.preferred_structured_method == "function_calling"
|
||||
|
||||
def test_grok_default(self):
|
||||
caps = get_capabilities("grok-4-0709")
|
||||
assert caps.supports_tool_choice is True
|
||||
|
||||
def test_unknown_model_default(self):
|
||||
caps = get_capabilities("totally-made-up-model-id")
|
||||
assert caps.supports_tool_choice is True
|
||||
|
||||
def test_exact_match_precedes_pattern(self):
|
||||
"""deepseek-chat must NOT match the v\\d regex."""
|
||||
caps = get_capabilities("deepseek-chat")
|
||||
assert caps.supports_tool_choice is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOpenRouterDeepSeekNamespace:
|
||||
"""OpenRouter namespaces DeepSeek as ``deepseek/<id>``; strip it so the
|
||||
same quirks apply as the native provider (#1199)."""
|
||||
|
||||
def test_prefixed_v4_flash_suppresses_tool_choice(self):
|
||||
# Was falling through to _DEFAULT (tool_choice on) -> slow object-form call.
|
||||
assert get_capabilities("deepseek/deepseek-v4-flash").supports_tool_choice is False
|
||||
|
||||
def test_prefixed_reasoner_suppresses_tool_choice(self):
|
||||
assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False
|
||||
|
||||
def test_prefixed_chat_selects_deepseek_chat_not_default(self):
|
||||
# Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False
|
||||
# is what distinguishes them (both keep tool_choice).
|
||||
caps = get_capabilities("deepseek/deepseek-chat")
|
||||
assert caps.supports_tool_choice is True
|
||||
assert caps.supports_json_schema is False # _DEEPSEEK_CHAT, not _DEFAULT
|
||||
|
||||
def test_only_official_namespace_is_stripped(self):
|
||||
# A third-party publisher whose model name WOULD match a deepseek pattern
|
||||
# must stay _DEFAULT: proves we strip only "deepseek/", not any "*/".
|
||||
caps = get_capabilities("tngtech/deepseek-v4-flash")
|
||||
assert caps.supports_tool_choice is True # not thinking
|
||||
assert caps.supports_json_schema is True # _DEFAULT
|
||||
|
||||
def test_native_ids_unchanged(self):
|
||||
assert get_capabilities("deepseek-v4-flash").supports_tool_choice is False
|
||||
assert get_capabilities("deepseek-chat").supports_tool_choice is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_capabilities_dataclass_is_frozen():
|
||||
"""Capability rows are immutable so they can be safely shared."""
|
||||
caps = get_capabilities("deepseek-chat")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
caps.supports_tool_choice = False # type: ignore[misc]
|
||||
155
tests/test_checkpoint_lifecycle.py
Normal file
155
tests/test_checkpoint_lifecycle.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""The checkpoint lifecycle is reusable so --checkpoint works on the CLI path (#1249).
|
||||
|
||||
Checkpoint setup previously lived only inside ``propagate``; the CLI streamed the
|
||||
checkpointer-less graph, so ``--checkpoint`` neither saved nor resumed. The
|
||||
lifecycle is now ``begin_checkpoint`` / ``end_checkpoint`` /
|
||||
``clear_checkpoint_on_success`` on TradingAgentsGraph, used by both paths. These
|
||||
tests drive that lifecycle exactly as the CLI does (begin -> stream self.graph ->
|
||||
clear/end) and prove state is saved and resumed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from typing import TypedDict
|
||||
|
||||
import pytest
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from tradingagents.graph.checkpointer import checkpoint_step
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
_should_crash = False
|
||||
|
||||
|
||||
class _State(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
def _node_a(state: _State) -> dict:
|
||||
return {"count": state["count"] + 1}
|
||||
|
||||
|
||||
def _node_b(state: _State) -> dict:
|
||||
if _should_crash:
|
||||
raise RuntimeError("simulated mid-stream crash")
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
|
||||
def _workflow() -> StateGraph:
|
||||
b = StateGraph(_State)
|
||||
b.add_node("analyst", _node_a)
|
||||
b.add_node("trader", _node_b)
|
||||
b.set_entry_point("analyst")
|
||||
b.add_edge("analyst", "trader")
|
||||
b.add_edge("trader", END)
|
||||
return b
|
||||
|
||||
|
||||
def _bare_graph(tmpdir, *, enabled=True):
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.config = {
|
||||
"checkpoint_enabled": enabled, "data_cache_dir": tmpdir,
|
||||
"max_debate_rounds": 1, "max_risk_discuss_rounds": 1,
|
||||
}
|
||||
g.selected_analysts = ("market",)
|
||||
g.workflow = _workflow()
|
||||
g.graph = g.workflow.compile()
|
||||
g._checkpointer_ctx = None
|
||||
return g
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_disabled_is_a_noop():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
g = _bare_graph(tmp, enabled=False)
|
||||
plain = g.graph
|
||||
assert g.begin_checkpoint("AAPL", "2026-05-08", "stock") is None
|
||||
assert g.graph is plain # graph not recompiled
|
||||
g.end_checkpoint() # safe no-op
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_begin_returns_thread_id_and_recompiles():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
g = _bare_graph(tmp)
|
||||
plain = g.graph
|
||||
tid = g.begin_checkpoint("AAPL", "2026-05-08", "stock")
|
||||
try:
|
||||
assert tid # a real thread_id
|
||||
assert g.graph is not plain # recompiled with a checkpointer
|
||||
finally:
|
||||
g.end_checkpoint()
|
||||
assert g._checkpointer_ctx is None # restored
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_checkpoint_input_is_none_only_when_resuming():
|
||||
global _should_crash
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
init = {"count": 0}
|
||||
args = ("AAPL", "2026-05-08", "stock")
|
||||
# Fresh run: no checkpoint yet -> stream the initial state, then crash.
|
||||
g1 = _bare_graph(tmp)
|
||||
tid = g1.begin_checkpoint(*args)
|
||||
try:
|
||||
assert g1._resuming is False
|
||||
assert g1.checkpoint_input(init) is init # not resuming -> initial state
|
||||
_should_crash = True
|
||||
with pytest.raises(RuntimeError):
|
||||
for _ in g1.graph.stream(init, config={"configurable": {"thread_id": tid}}):
|
||||
pass
|
||||
finally:
|
||||
g1.end_checkpoint()
|
||||
assert g1.checkpoint_input(init) is init # reset after teardown
|
||||
|
||||
# A later run finds the checkpoint -> resume by feeding None, not the
|
||||
# initial state (re-passing it would duplicate messages, #1249).
|
||||
_should_crash = False
|
||||
g2 = _bare_graph(tmp)
|
||||
g2.begin_checkpoint(*args)
|
||||
try:
|
||||
assert g2._resuming is True
|
||||
assert g2.checkpoint_input(init) is None
|
||||
finally:
|
||||
g2.end_checkpoint()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cli_style_usage_saves_then_resumes():
|
||||
global _should_crash
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg_args = ("AAPL", "2026-05-08", "stock")
|
||||
|
||||
# Run 1 (the CLI path): begin -> stream self.graph -> crash at 'trader'.
|
||||
_should_crash = True
|
||||
g1 = _bare_graph(tmp)
|
||||
tid = g1.begin_checkpoint(*cfg_args)
|
||||
args = {"config": {"configurable": {"thread_id": tid}}}
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
for _ in g1.graph.stream({"count": 0}, **args):
|
||||
pass
|
||||
finally:
|
||||
g1.end_checkpoint()
|
||||
|
||||
# A checkpoint was saved for this run signature (so --checkpoint works).
|
||||
|
||||
sig = g1._run_signature("stock")
|
||||
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is not None
|
||||
|
||||
# Run 2 (fresh graph, as a new CLI invocation): resume and finish.
|
||||
_should_crash = False
|
||||
g2 = _bare_graph(tmp)
|
||||
tid2 = g2.begin_checkpoint(*cfg_args)
|
||||
assert tid2 == tid # stable id -> same thread resumes
|
||||
try:
|
||||
result = g2.graph.invoke(None, config={"configurable": {"thread_id": tid2}})
|
||||
assert result["count"] == 11 # analyst(+1) resumed into trader(+10)
|
||||
g2.clear_checkpoint_on_success(*cfg_args)
|
||||
finally:
|
||||
g2.end_checkpoint()
|
||||
|
||||
# Cleared on success -> a later run starts fresh.
|
||||
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Test checkpoint resume: crash mid-analysis, re-run resumes from last node."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from tradingagents.graph.checkpointer import (
|
||||
@@ -143,5 +140,79 @@ class TestCheckpointResume(unittest.TestCase):
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
|
||||
|
||||
|
||||
class TestCheckpointSignature(unittest.TestCase):
|
||||
"""A different graph shape (analyst selection / depth / asset mode) must not
|
||||
resume the previous run's checkpoint (#1089)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.ticker = "TEST"
|
||||
self.date = "2026-04-20"
|
||||
|
||||
def test_empty_signature_is_legacy_id(self):
|
||||
self.assertEqual(
|
||||
thread_id(self.ticker, self.date),
|
||||
thread_id(self.ticker, self.date, ""),
|
||||
)
|
||||
|
||||
def test_signature_changes_thread_id(self):
|
||||
legacy = thread_id(self.ticker, self.date)
|
||||
sig_a = thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
|
||||
sig_b = thread_id(self.ticker, self.date, "analysts=market|asset=stock")
|
||||
self.assertNotEqual(sig_a, sig_b) # different graph shapes differ
|
||||
self.assertNotEqual(legacy, sig_a) # signature-keyed differs from legacy
|
||||
self.assertEqual( # same inputs are stable
|
||||
sig_a, thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
|
||||
)
|
||||
|
||||
def test_different_signature_starts_fresh(self):
|
||||
global _should_crash
|
||||
builder = _build_graph()
|
||||
sig1 = "analysts=market,news,fundamentals|asset=stock"
|
||||
sig2 = "analysts=market|asset=stock" # dropped analysts -> different graph
|
||||
|
||||
_should_crash = True
|
||||
tid1 = thread_id(self.ticker, self.date, sig1)
|
||||
with get_checkpointer(self.tmpdir, self.ticker) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
with self.assertRaises(RuntimeError):
|
||||
graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid1}})
|
||||
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
|
||||
# A different graph shape has no checkpoint to resume from.
|
||||
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date, sig2))
|
||||
|
||||
_should_crash = False
|
||||
tid2 = thread_id(self.ticker, self.date, sig2)
|
||||
self.assertNotEqual(tid1, tid2)
|
||||
with get_checkpointer(self.tmpdir, self.ticker) as saver:
|
||||
graph = builder.compile(checkpointer=saver)
|
||||
result = graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid2}})
|
||||
self.assertEqual(result["count"], 11)
|
||||
# sig1's checkpoint remains untouched.
|
||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
|
||||
|
||||
def test_run_signature_captures_graph_shape(self):
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
# Build a bare instance to exercise the pure helper without heavy __init__.
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.selected_analysts = ("market", "news")
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
|
||||
base = g._run_signature("stock")
|
||||
|
||||
self.assertNotEqual(base, g._run_signature("crypto")) # asset mode
|
||||
g.selected_analysts = ("market",)
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # analyst selection
|
||||
g.selected_analysts = ("market", "news")
|
||||
g.config = {"max_debate_rounds": 3, "max_risk_discuss_rounds": 1}
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # debate depth
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 5}
|
||||
self.assertNotEqual(base, g._run_signature("stock")) # risk depth
|
||||
# Stable for identical inputs.
|
||||
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
|
||||
self.assertEqual(base, g._run_signature("stock"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
69
tests/test_cli_config_precedence.py
Normal file
69
tests/test_cli_config_precedence.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""CLI config precedence (#976, #977).
|
||||
|
||||
An explicit environment override for the debate/risk round counts, or the
|
||||
checkpoint flag, must win over the interactive research-depth selection — the CLI
|
||||
must not clobber an env-configured value back to a prompt/flag default.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import cli.main as m
|
||||
|
||||
# Minimal selections dict shaped like get_user_selections()'s return value.
|
||||
SELECTIONS = {
|
||||
"research_depth": 5,
|
||||
"shallow_thinker": "gpt-5.4-mini",
|
||||
"deep_thinker": "gpt-5.5",
|
||||
"backend_url": None,
|
||||
"llm_provider": "openai",
|
||||
"google_thinking_level": None,
|
||||
"openai_reasoning_effort": None,
|
||||
"anthropic_effort": None,
|
||||
"output_language": "English",
|
||||
}
|
||||
|
||||
|
||||
def test_research_depth_sets_both_rounds_without_env(monkeypatch):
|
||||
for var in ("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "TRADINGAGENTS_MAX_RISK_ROUNDS"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
|
||||
assert cfg["max_debate_rounds"] == 5
|
||||
assert cfg["max_risk_discuss_rounds"] == 5
|
||||
|
||||
|
||||
def test_env_round_counts_win_over_selection(monkeypatch):
|
||||
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "2")
|
||||
monkeypatch.setenv("TRADINGAGENTS_MAX_RISK_ROUNDS", "4")
|
||||
# DEFAULT_CONFIG already reflects the env (applied at import); emulate that.
|
||||
patched = dict(m.DEFAULT_CONFIG, max_debate_rounds=2, max_risk_discuss_rounds=4)
|
||||
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
||||
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
|
||||
assert cfg["max_debate_rounds"] == 2 # env value, not research_depth=5
|
||||
assert cfg["max_risk_discuss_rounds"] == 4
|
||||
|
||||
|
||||
def test_partial_env_only_overrides_that_count(monkeypatch):
|
||||
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "2")
|
||||
monkeypatch.delenv("TRADINGAGENTS_MAX_RISK_ROUNDS", raising=False)
|
||||
patched = dict(m.DEFAULT_CONFIG, max_debate_rounds=2)
|
||||
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
||||
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
|
||||
assert cfg["max_debate_rounds"] == 2 # env wins
|
||||
assert cfg["max_risk_discuss_rounds"] == 5 # falls through to research_depth
|
||||
|
||||
|
||||
def test_checkpoint_none_preserves_env_default():
|
||||
patched = dict(m.DEFAULT_CONFIG, checkpoint_enabled=True) # e.g. env-enabled
|
||||
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
||||
cfg = m._build_run_config(SELECTIONS, checkpoint=None)
|
||||
assert cfg["checkpoint_enabled"] is True # not clobbered back to False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [True, False])
|
||||
def test_checkpoint_flag_overrides_env(flag):
|
||||
patched = dict(m.DEFAULT_CONFIG, checkpoint_enabled=not flag)
|
||||
with mock.patch.object(m, "DEFAULT_CONFIG", patched):
|
||||
cfg = m._build_run_config(SELECTIONS, checkpoint=flag)
|
||||
assert cfg["checkpoint_enabled"] is flag
|
||||
149
tests/test_cli_env_skip.py
Normal file
149
tests/test_cli_env_skip.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for env-driven CLI behavior (#897, #873).
|
||||
|
||||
The config-layer override (TRADINGAGENTS_* -> DEFAULT_CONFIG) is covered by
|
||||
test_env_overrides.py. These tests cover the CLI layer: an env-configured
|
||||
provider/model/language must skip its interactive prompt and use the value.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProviderDefaultUrl(unittest.TestCase):
|
||||
def test_known_providers_resolve(self):
|
||||
from cli.utils import provider_default_url
|
||||
self.assertEqual(provider_default_url("openai"), "https://api.openai.com/v1")
|
||||
self.assertEqual(provider_default_url("DeepSeek"), "https://api.deepseek.com")
|
||||
self.assertIsNone(provider_default_url("google")) # uses SDK default
|
||||
|
||||
def test_unknown_provider_returns_none(self):
|
||||
from cli.utils import provider_default_url
|
||||
self.assertIsNone(provider_default_url("not-a-provider"))
|
||||
|
||||
def test_ollama_honors_base_url_env(self):
|
||||
from cli.utils import provider_default_url
|
||||
with mock.patch.dict(os.environ, {"OLLAMA_BASE_URL": "http://host:1234/v1"}):
|
||||
self.assertEqual(provider_default_url("ollama"), "http://host:1234/v1")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCliSkipsPromptsFromEnv(unittest.TestCase):
|
||||
def test_env_config_skips_llm_prompts(self):
|
||||
import cli.main as m
|
||||
|
||||
env = {
|
||||
"TRADINGAGENTS_LLM_PROVIDER": "openai",
|
||||
"TRADINGAGENTS_DEEP_THINK_LLM": "kimi-k2.5",
|
||||
"TRADINGAGENTS_QUICK_THINK_LLM": "deepseek-v4-pro",
|
||||
"TRADINGAGENTS_LLM_BACKEND_URL": "https://opencode.ai/zen/go/v1",
|
||||
"TRADINGAGENTS_OUTPUT_LANGUAGE": "Japanese",
|
||||
}
|
||||
fake_cfg = dict(m.DEFAULT_CONFIG)
|
||||
fake_cfg.update({
|
||||
"llm_provider": "openai",
|
||||
"backend_url": "https://opencode.ai/zen/go/v1",
|
||||
"quick_think_llm": "deepseek-v4-pro",
|
||||
"deep_think_llm": "kimi-k2.5",
|
||||
"output_language": "Japanese",
|
||||
})
|
||||
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
|
||||
mock.patch.object(m, "fetch_announcements", return_value=None), \
|
||||
mock.patch.object(m, "display_announcements"), \
|
||||
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
|
||||
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
|
||||
mock.patch.object(m, "select_analysts", return_value=[]), \
|
||||
mock.patch.object(m, "select_research_depth", return_value=1), \
|
||||
mock.patch.object(m, "ensure_api_key") as ensure_key, \
|
||||
mock.patch.object(m, "select_llm_provider") as prompt_provider, \
|
||||
mock.patch.object(m, "ask_output_language") as prompt_lang, \
|
||||
mock.patch.object(m, "select_shallow_thinking_agent") as prompt_quick, \
|
||||
mock.patch.object(m, "select_deep_thinking_agent") as prompt_deep:
|
||||
sel = m.get_user_selections()
|
||||
|
||||
# None of the LLM selection prompts should have been shown.
|
||||
prompt_provider.assert_not_called()
|
||||
prompt_lang.assert_not_called()
|
||||
prompt_quick.assert_not_called()
|
||||
prompt_deep.assert_not_called()
|
||||
# API key is still verified for the env-configured provider.
|
||||
ensure_key.assert_called_once()
|
||||
|
||||
# The env values flow into the returned selections.
|
||||
self.assertEqual(sel["llm_provider"], "openai")
|
||||
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
|
||||
self.assertEqual(sel["shallow_thinker"], "deepseek-v4-pro")
|
||||
self.assertEqual(sel["deep_thinker"], "kimi-k2.5")
|
||||
self.assertEqual(sel["output_language"], "Japanese")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResearchDepthSkippedFromEnv(unittest.TestCase):
|
||||
def test_both_round_envs_skip_depth_prompt(self):
|
||||
import cli.main as m
|
||||
|
||||
env = {
|
||||
"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "2",
|
||||
"TRADINGAGENTS_MAX_RISK_ROUNDS": "4",
|
||||
}
|
||||
fake_cfg = dict(m.DEFAULT_CONFIG)
|
||||
fake_cfg.update({"max_debate_rounds": 2, "max_risk_discuss_rounds": 4})
|
||||
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
|
||||
mock.patch.object(m, "fetch_announcements", return_value=None), \
|
||||
mock.patch.object(m, "display_announcements"), \
|
||||
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
|
||||
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
|
||||
mock.patch.object(m, "select_analysts", return_value=[]), \
|
||||
mock.patch.object(m, "select_research_depth") as prompt_depth, \
|
||||
mock.patch.object(m, "ensure_api_key"), \
|
||||
mock.patch.object(m, "select_llm_provider", return_value=("openai", None)), \
|
||||
mock.patch.object(m, "ask_output_language", return_value="English"), \
|
||||
mock.patch.object(m, "select_shallow_thinking_agent", return_value="gpt-5.4-mini"), \
|
||||
mock.patch.object(m, "select_deep_thinking_agent", return_value="gpt-5.5"), \
|
||||
mock.patch.object(m, "ask_openai_reasoning_effort", return_value=None):
|
||||
sel = m.get_user_selections()
|
||||
|
||||
# The research-depth prompt is skipped; the value comes from the env config.
|
||||
prompt_depth.assert_not_called()
|
||||
self.assertEqual(sel["research_depth"], 2)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReasoningEffortSkippedFromEnv(unittest.TestCase):
|
||||
def test_effort_env_skips_step8_prompt(self):
|
||||
import cli.main as m
|
||||
|
||||
env = {"TRADINGAGENTS_OPENAI_REASONING_EFFORT": "high"}
|
||||
fake_cfg = dict(m.DEFAULT_CONFIG)
|
||||
fake_cfg.update({"openai_reasoning_effort": "high"})
|
||||
|
||||
with mock.patch.dict(os.environ, env, clear=False), \
|
||||
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
|
||||
mock.patch.object(m, "fetch_announcements", return_value=None), \
|
||||
mock.patch.object(m, "display_announcements"), \
|
||||
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
|
||||
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
|
||||
mock.patch.object(m, "select_analysts", return_value=[]), \
|
||||
mock.patch.object(m, "select_research_depth", return_value=1), \
|
||||
mock.patch.object(m, "ensure_api_key"), \
|
||||
mock.patch.object(m, "select_llm_provider", return_value=("openai", None)), \
|
||||
mock.patch.object(m, "ask_output_language", return_value="English"), \
|
||||
mock.patch.object(m, "select_shallow_thinking_agent", return_value="gpt-5.4-mini"), \
|
||||
mock.patch.object(m, "select_deep_thinking_agent", return_value="gpt-5.5"), \
|
||||
mock.patch.object(m, "ask_openai_reasoning_effort") as prompt_effort:
|
||||
sel = m.get_user_selections()
|
||||
|
||||
# The reasoning-effort prompt is skipped; the value comes from env config.
|
||||
prompt_effort.assert_not_called()
|
||||
self.assertEqual(sel["openai_reasoning_effort"], "high")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
57
tests/test_cli_no_console.py
Normal file
57
tests/test_cli_no_console.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""A terminal without a console buffer must fail with one actionable line (#1138).
|
||||
|
||||
prompt_toolkit raises NoConsoleScreenBufferError before the first prompt in
|
||||
non-interactive Windows terminals; the CLI should not surface that traceback.
|
||||
The Windows-only exception import must also stay inert on other platforms.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import cli.main as m
|
||||
|
||||
|
||||
def test_no_console_error_tuple_matches_platform():
|
||||
# Off Windows the win32 module is never imported (it asserts the platform),
|
||||
# so the tuple is empty — which `except` accepts and never matches. On
|
||||
# Windows it holds the real exception type, and a broken prompt_toolkit
|
||||
# would raise at import rather than silently disabling the handler.
|
||||
assert isinstance(m._NO_CONSOLE_ERRORS, tuple)
|
||||
assert all(issubclass(e, BaseException) for e in m._NO_CONSOLE_ERRORS)
|
||||
if sys.platform == "win32":
|
||||
assert m._NO_CONSOLE_ERRORS, "Windows must resolve the console error type"
|
||||
else:
|
||||
assert m._NO_CONSOLE_ERRORS == ()
|
||||
|
||||
|
||||
def test_missing_console_prints_actionable_message(monkeypatch):
|
||||
class _NoConsole(Exception):
|
||||
pass
|
||||
|
||||
# Simulate the Windows failure on any platform by registering a stand-in.
|
||||
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (_NoConsole,))
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise _NoConsole("No Windows console found. Are you running cmd.exe?")
|
||||
|
||||
monkeypatch.setattr(m, "run_analysis", _boom)
|
||||
|
||||
result = CliRunner().invoke(m.app, [])
|
||||
assert result.exit_code == 1
|
||||
assert "no Windows console available" in result.output
|
||||
# The raw prompt_toolkit traceback must not reach the user.
|
||||
assert "Traceback" not in result.output
|
||||
|
||||
|
||||
def test_unrelated_errors_still_propagate(monkeypatch):
|
||||
# The handler must stay narrow: only the console error is translated.
|
||||
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (RuntimeError,))
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise ValueError("unrelated")
|
||||
|
||||
monkeypatch.setattr(m, "run_analysis", _boom)
|
||||
result = CliRunner().invoke(m.app, [])
|
||||
assert isinstance(result.exception, ValueError)
|
||||
62
tests/test_cli_symbol_handling.py
Normal file
62
tests/test_cli_symbol_handling.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""CLI symbol validation/classification must agree with the data path.
|
||||
|
||||
Regressions for #980 (validation rejected GC=F), #981 (BTCUSD misclassified as
|
||||
stock), #982 (BTC-USDT accepted but unpriceable on Yahoo).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from cli.models import AssetType
|
||||
from cli.utils import detect_asset_type, is_valid_ticker_input, normalize_ticker_symbol
|
||||
from tradingagents.dataflows.symbol_utils import normalize_symbol
|
||||
|
||||
|
||||
# --- #982: stablecoin-quoted crypto normalizes to Yahoo's -USD pair ---
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("BTCUSD", "BTC-USD"),
|
||||
("BTCUSDT", "BTC-USD"),
|
||||
("BTC-USDT", "BTC-USD"),
|
||||
("BTC-USDC", "BTC-USD"),
|
||||
("ethusdt", "ETH-USD"),
|
||||
# non-crypto must be untouched
|
||||
("AAPL", "AAPL"),
|
||||
("GC=F", "GC=F"),
|
||||
("600519.SS", "600519.SS"),
|
||||
("EURUSD", "EURUSD=X"),
|
||||
])
|
||||
def test_normalize_symbol_crypto_and_passthrough(raw, expected):
|
||||
assert normalize_symbol(raw) == expected
|
||||
|
||||
|
||||
# --- #980: validation accepts Yahoo futures/forex symbols ---
|
||||
@pytest.mark.parametrize("value,ok", [
|
||||
("GC=F", True),
|
||||
("EURUSD=X", True),
|
||||
("AAPL", True),
|
||||
("0700.HK", True),
|
||||
("^GSPC", True),
|
||||
("", True), # empty -> defaults to SPY downstream
|
||||
("bad symbol!", False), # space + '!' rejected
|
||||
("A" * 40, False), # too long
|
||||
])
|
||||
def test_ticker_input_validation(value, ok):
|
||||
assert is_valid_ticker_input(value) is ok
|
||||
|
||||
|
||||
# --- #981/#982: asset-type classified on the canonical symbol ---
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("BTCUSD", AssetType.CRYPTO),
|
||||
("BTC-USDT", AssetType.CRYPTO),
|
||||
("BTC-USD", AssetType.CRYPTO),
|
||||
("ETHUSD", AssetType.CRYPTO),
|
||||
("AAPL", AssetType.STOCK),
|
||||
("GC=F", AssetType.STOCK),
|
||||
("600519.SS", AssetType.STOCK),
|
||||
])
|
||||
def test_detect_asset_type(raw, expected):
|
||||
assert detect_asset_type(raw) == expected
|
||||
|
||||
|
||||
def test_cli_normalize_delegates_to_data_layer():
|
||||
# CLI must produce the same canonical symbol the data path will price.
|
||||
for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"):
|
||||
assert normalize_ticker_symbol(raw) == normalize_symbol(raw)
|
||||
56
tests/test_crypto_asset_mode.py
Normal file
56
tests/test_crypto_asset_mode.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
|
||||
from cli.models import AnalystType, AssetType
|
||||
from cli.utils import detect_asset_type, filter_analysts_for_asset_type
|
||||
from tradingagents.graph.propagation import Propagator
|
||||
|
||||
|
||||
class CryptoAssetModeTests(unittest.TestCase):
|
||||
def test_detects_crypto_pair_symbols(self):
|
||||
self.assertEqual(detect_asset_type("BTC-USD"), AssetType.CRYPTO)
|
||||
self.assertEqual(detect_asset_type("eth-usd"), AssetType.CRYPTO)
|
||||
|
||||
def test_defaults_non_crypto_symbols_to_stock(self):
|
||||
self.assertEqual(detect_asset_type("AAPL"), AssetType.STOCK)
|
||||
self.assertEqual(detect_asset_type("SPY"), AssetType.STOCK)
|
||||
|
||||
def test_filters_out_fundamentals_analyst_for_crypto(self):
|
||||
analysts = [
|
||||
AnalystType.MARKET,
|
||||
AnalystType.SOCIAL,
|
||||
AnalystType.NEWS,
|
||||
AnalystType.FUNDAMENTALS,
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
filter_analysts_for_asset_type(analysts, AssetType.CRYPTO),
|
||||
[
|
||||
AnalystType.MARKET,
|
||||
AnalystType.SOCIAL,
|
||||
AnalystType.NEWS,
|
||||
],
|
||||
)
|
||||
|
||||
def test_keeps_all_analysts_for_stock(self):
|
||||
analysts = [
|
||||
AnalystType.MARKET,
|
||||
AnalystType.SOCIAL,
|
||||
AnalystType.NEWS,
|
||||
AnalystType.FUNDAMENTALS,
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
filter_analysts_for_asset_type(analysts, AssetType.STOCK),
|
||||
analysts,
|
||||
)
|
||||
|
||||
def test_propagator_includes_asset_type_in_initial_state(self):
|
||||
state = Propagator().create_initial_state(
|
||||
"BTC-USD", "2026-04-18", asset_type=AssetType.CRYPTO.value
|
||||
)
|
||||
|
||||
self.assertEqual(state["asset_type"], AssetType.CRYPTO.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
61
tests/test_dataflows_config.py
Normal file
61
tests/test_dataflows_config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Config isolation: get/set must not leak nested-dict references."""
|
||||
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows.config import get_config, set_config
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class DataflowsConfigIsolationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
set_config(copy.deepcopy(default_config.DEFAULT_CONFIG))
|
||||
|
||||
def test_get_config_returns_deep_copy(self):
|
||||
cfg = get_config()
|
||||
cfg["data_vendors"]["core_stock_apis"] = "alpha_vantage"
|
||||
cfg["tool_vendors"]["get_stock_data"] = "alpha_vantage"
|
||||
|
||||
fresh = get_config()
|
||||
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "yfinance")
|
||||
self.assertNotIn("get_stock_data", fresh["tool_vendors"])
|
||||
|
||||
def test_set_config_does_not_alias_caller_nested_dicts(self):
|
||||
custom = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
custom["data_vendors"]["core_stock_apis"] = "alpha_vantage"
|
||||
custom["tool_vendors"]["get_stock_data"] = "alpha_vantage"
|
||||
|
||||
set_config(custom)
|
||||
|
||||
custom["data_vendors"]["core_stock_apis"] = "yfinance"
|
||||
custom["tool_vendors"]["get_stock_data"] = "yfinance"
|
||||
|
||||
fresh = get_config()
|
||||
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage")
|
||||
self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage")
|
||||
|
||||
def test_partial_nested_update_preserves_existing_defaults(self):
|
||||
set_config(
|
||||
{
|
||||
"data_vendors": {
|
||||
"core_stock_apis": "alpha_vantage",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fresh = get_config()
|
||||
self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage")
|
||||
self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance")
|
||||
self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance")
|
||||
self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance")
|
||||
|
||||
def test_nested_dict_updates_merge_one_level_deep(self):
|
||||
set_config({"tool_vendors": {"get_stock_data": "alpha_vantage"}})
|
||||
set_config({"tool_vendors": {"get_news": "alpha_vantage"}})
|
||||
|
||||
fresh = get_config()
|
||||
self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage")
|
||||
self.assertEqual(fresh["tool_vendors"]["get_news"], "alpha_vantage")
|
||||
61
tests/test_date_boundaries.py
Normal file
61
tests/test_date_boundaries.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""yfinance treats ``end`` as exclusive; we must request one extra day so the
|
||||
requested end_date (and the current day) is actually included.
|
||||
|
||||
Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date
|
||||
row omitted).
|
||||
"""
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.stockstats_utils as su
|
||||
import tradingagents.dataflows.y_finance as yfin
|
||||
from tradingagents.dataflows.config import set_config
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_yfin_requests_inclusive_end(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeTicker:
|
||||
def __init__(self, symbol):
|
||||
pass
|
||||
|
||||
def history(self, start, end):
|
||||
captured["start"] = start
|
||||
captured["end"] = end
|
||||
idx = pd.to_datetime(["2025-05-08", "2025-05-09"])
|
||||
return pd.DataFrame(
|
||||
{"Open": [1.0, 2.0], "High": [1.0, 2.0], "Low": [1.0, 2.0],
|
||||
"Close": [1.0, 2.0], "Volume": [1, 2]},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(yfin.yf, "Ticker", FakeTicker)
|
||||
out = yfin.get_YFin_data_online("AAPL", "2025-05-01", "2025-05-09")
|
||||
|
||||
# end is requested one day past end_date so 2025-05-09 is included (#987).
|
||||
assert captured["end"] == "2025-05-10"
|
||||
# Header still reflects the requested range, not the internal +1 day.
|
||||
assert "to 2025-05-09" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
|
||||
set_config({"data_cache_dir": str(tmp_path)})
|
||||
captured = {}
|
||||
|
||||
def fake_download(symbol, start, end, **kwargs):
|
||||
captured["end"] = end
|
||||
idx = pd.to_datetime([pd.Timestamp.today().normalize()])
|
||||
return pd.DataFrame(
|
||||
{"Open": [100.0], "High": [100.0], "Low": [100.0],
|
||||
"Close": [100.0], "Volume": [1]},
|
||||
index=idx,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(su.yf, "download", fake_download)
|
||||
today = pd.Timestamp.today().strftime("%Y-%m-%d")
|
||||
su.load_ohlcv("AAPL", today)
|
||||
|
||||
expected_end = (pd.Timestamp.today() + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
assert captured["end"] == expected_end # tomorrow -> today's row included (#986)
|
||||
111
tests/test_debate_opening.py
Normal file
111
tests/test_debate_opening.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""The first speaker in each debate must not rebut a nonexistent argument (#1176).
|
||||
|
||||
Each debate round's opening speaker receives an empty opponent response; the
|
||||
prompt used to interpolate it into a "refute the opponent" instruction, so models
|
||||
fabricated the other side's position. All five debators (bull, bear, and the
|
||||
three risk analysts) now substitute an explicit opening marker when the opponent
|
||||
has not spoken, and pass a real argument through unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.researchers.bear_researcher import create_bear_researcher
|
||||
from tradingagents.agents.researchers.bull_researcher import create_bull_researcher
|
||||
from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator
|
||||
from tradingagents.agents.risk_mgmt.conservative_debator import create_conservative_debator
|
||||
from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator
|
||||
from tradingagents.agents.utils.agent_utils import opponent_argument_or_opening
|
||||
|
||||
_REPORTS = {
|
||||
"company_of_interest": "AAPL", "asset_type": "stock",
|
||||
"market_report": "m", "sentiment_report": "s",
|
||||
"news_report": "n", "fundamentals_report": "f",
|
||||
}
|
||||
|
||||
|
||||
def _capturing_llm(captured: dict):
|
||||
llm = MagicMock()
|
||||
llm.invoke.side_effect = lambda prompt: (
|
||||
captured.__setitem__("prompt", prompt) or MagicMock(content="argument")
|
||||
)
|
||||
return llm
|
||||
|
||||
|
||||
def _investment_state(current_response):
|
||||
return {
|
||||
**_REPORTS,
|
||||
"count": 0,
|
||||
"investment_debate_state": {
|
||||
"history": "", "bull_history": "", "bear_history": "",
|
||||
"current_response": current_response, "count": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _risk_state(**responses):
|
||||
base = {
|
||||
"current_aggressive_response": "", "current_conservative_response": "",
|
||||
"current_neutral_response": "", "history": "", "aggressive_history": "",
|
||||
"conservative_history": "", "neutral_history": "", "count": 0,
|
||||
}
|
||||
base.update(responses)
|
||||
return {**_REPORTS, "trader_investment_plan": "plan", "risk_debate_state": base}
|
||||
|
||||
|
||||
# --- shared helper ----------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_helper_marks_empty_and_passes_through():
|
||||
assert "has not spoken yet" in opponent_argument_or_opening("", "bear analyst")
|
||||
assert opponent_argument_or_opening(" real point ", "bear") == "real point"
|
||||
|
||||
|
||||
# --- researchers ------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"factory,opponent",
|
||||
[(create_bull_researcher, "bear"), (create_bear_researcher, "bull")],
|
||||
)
|
||||
def test_researcher_opening_has_no_phantom_opponent(factory, opponent):
|
||||
captured = {}
|
||||
factory(_capturing_llm(captured))(_investment_state(""))
|
||||
assert "has not spoken yet" in captured["prompt"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_researcher_passes_real_opponent_argument():
|
||||
captured = {}
|
||||
state = _investment_state("Bear Analyst: valuation is stretched")
|
||||
create_bull_researcher(_capturing_llm(captured))(state)
|
||||
assert "valuation is stretched" in captured["prompt"]
|
||||
assert "has not spoken yet" not in captured["prompt"]
|
||||
|
||||
|
||||
# --- risk debators ----------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"factory", [create_aggressive_debator, create_conservative_debator, create_neutral_debator]
|
||||
)
|
||||
def test_risk_opening_has_no_phantom_opponent(factory):
|
||||
captured = {}
|
||||
factory(_capturing_llm(captured))(_risk_state())
|
||||
# Both opponent slots were empty -> two opening markers, no fabricated args.
|
||||
assert captured["prompt"].count("has not spoken yet") == 2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_risk_passes_real_opponent_arguments():
|
||||
captured = {}
|
||||
state = _risk_state(
|
||||
current_conservative_response="Conservative Analyst: trim risk",
|
||||
current_neutral_response="Neutral Analyst: hold steady",
|
||||
)
|
||||
create_aggressive_debator(_capturing_llm(captured))(state)
|
||||
assert "trim risk" in captured["prompt"]
|
||||
assert "hold steady" in captured["prompt"]
|
||||
assert "has not spoken yet" not in captured["prompt"]
|
||||
239
tests/test_deepseek_reasoning.py
Normal file
239
tests/test_deepseek_reasoning.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Tests for DeepSeekChatOpenAI thinking-mode behaviour.
|
||||
|
||||
Two pieces verified:
|
||||
|
||||
1. ``reasoning_content`` is captured on receive into the AIMessage's
|
||||
``additional_kwargs`` and re-attached on send so DeepSeek's API
|
||||
sees the same value across turns.
|
||||
2. ``with_structured_output`` consults the capability table and
|
||||
suppresses ``tool_choice`` for models that reject it (V4 + reasoner),
|
||||
matching DeepSeek's official tool-calling pattern at
|
||||
https://api-docs.deepseek.com/guides/tool_calls.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.prompt_values import ChatPromptValue
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tradingagents.llm_clients.openai_client import (
|
||||
DeepSeekChatOpenAI,
|
||||
NormalizedChatOpenAI,
|
||||
_input_to_messages,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _input_to_messages — the helper that handles list / ChatPromptValue / other
|
||||
# (Gemini bot review note: non-list inputs must also work)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestInputToMessages:
|
||||
def test_list_input_returned_as_is(self):
|
||||
msgs = [HumanMessage(content="hi")]
|
||||
assert _input_to_messages(msgs) is msgs
|
||||
|
||||
def test_chat_prompt_value_unwrapped(self):
|
||||
msgs = [HumanMessage(content="hi")]
|
||||
prompt_value = ChatPromptValue(messages=msgs)
|
||||
assert _input_to_messages(prompt_value) == msgs
|
||||
|
||||
def test_string_input_yields_empty_list(self):
|
||||
# A bare string isn't a message-bearing input; the caller's normal
|
||||
# langchain conversion happens upstream of _get_request_payload.
|
||||
assert _input_to_messages("hello") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning content propagation across turns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepSeekReasoningContent:
|
||||
def _client(self):
|
||||
os.environ.setdefault("DEEPSEEK_API_KEY", "placeholder")
|
||||
return DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
|
||||
def test_capture_on_receive(self):
|
||||
"""When the response carries reasoning_content, it lands on the
|
||||
AIMessage's additional_kwargs so the next turn can echo it back."""
|
||||
client = self._client()
|
||||
result = client._create_chat_result(
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Plan: buy NVDA.",
|
||||
"reasoning_content": "Step 1: trend is up. Step 2: ...",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
)
|
||||
ai = result.generations[0].message
|
||||
assert ai.additional_kwargs["reasoning_content"] == "Step 1: trend is up. Step 2: ..."
|
||||
|
||||
def test_propagate_on_send(self):
|
||||
"""When an outgoing AIMessage carries reasoning_content, the request
|
||||
payload echoes it on the corresponding message dict."""
|
||||
client = self._client()
|
||||
prior = AIMessage(
|
||||
content="Plan",
|
||||
additional_kwargs={"reasoning_content": "weighed bull case"},
|
||||
)
|
||||
new_user = HumanMessage(content="Refine.")
|
||||
payload = client._get_request_payload([prior, new_user])
|
||||
# Find the assistant message in the payload
|
||||
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
|
||||
assert assistant_dicts, "assistant message missing from outgoing payload"
|
||||
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
|
||||
|
||||
def test_propagate_through_chat_prompt_value(self):
|
||||
"""Gemini bot review note: non-list inputs (ChatPromptValue) must
|
||||
also propagate reasoning_content."""
|
||||
client = self._client()
|
||||
prior = AIMessage(
|
||||
content="Plan",
|
||||
additional_kwargs={"reasoning_content": "weighed bull case"},
|
||||
)
|
||||
prompt_value = ChatPromptValue(messages=[prior, HumanMessage(content="Refine.")])
|
||||
payload = client._get_request_payload(prompt_value)
|
||||
assistant_dicts = [m for m in payload["messages"] if m.get("role") == "assistant"]
|
||||
assert assistant_dicts[0]["reasoning_content"] == "weighed bull case"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability-driven structured output: tool_choice suppressed for V4 + reasoner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bound_kwargs(runnable):
|
||||
"""Extract bind() kwargs from a with_structured_output result."""
|
||||
first = runnable.steps[0] if hasattr(runnable, "steps") else runnable
|
||||
return getattr(first, "kwargs", {})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStructuredOutputCapabilityDispatch:
|
||||
"""DeepSeek V4 and reasoner reject the tool_choice parameter
|
||||
(official guide: api-docs.deepseek.com/guides/tool_calls passes
|
||||
tools=[...] without tool_choice). Verify the capability dispatch
|
||||
suppresses tool_choice for those models and sends it for chat."""
|
||||
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
def _client(self, model):
|
||||
return DeepSeekChatOpenAI(
|
||||
model=model, api_key="placeholder", base_url="https://api.deepseek.com",
|
||||
)
|
||||
|
||||
def test_chat_sends_tool_choice(self):
|
||||
bound = self._client("deepseek-chat").with_structured_output(self._Sample)
|
||||
assert _bound_kwargs(bound).get("tool_choice") is not None
|
||||
|
||||
def test_reasoner_suppresses_tool_choice(self):
|
||||
bound = self._client("deepseek-reasoner").with_structured_output(self._Sample)
|
||||
# tool_choice is either absent or explicitly None — both are valid
|
||||
# signals that langchain's bind_tools will skip the parameter.
|
||||
assert _bound_kwargs(bound).get("tool_choice") in (None, ...) or \
|
||||
"tool_choice" not in _bound_kwargs(bound)
|
||||
|
||||
def test_v4_flash_suppresses_tool_choice(self):
|
||||
bound = self._client("deepseek-v4-flash").with_structured_output(self._Sample)
|
||||
assert _bound_kwargs(bound).get("tool_choice") is None or \
|
||||
"tool_choice" not in _bound_kwargs(bound)
|
||||
|
||||
def test_v4_pro_suppresses_tool_choice(self):
|
||||
bound = self._client("deepseek-v4-pro").with_structured_output(self._Sample)
|
||||
assert _bound_kwargs(bound).get("tool_choice") is None or \
|
||||
"tool_choice" not in _bound_kwargs(bound)
|
||||
|
||||
def test_future_v_variant_via_regex(self):
|
||||
"""Forward-compat: unknown deepseek-v\\d-* IDs inherit V4 quirks."""
|
||||
bound = self._client("deepseek-v5-hypothetical").with_structured_output(self._Sample)
|
||||
assert _bound_kwargs(bound).get("tool_choice") is None or \
|
||||
"tool_choice" not in _bound_kwargs(bound)
|
||||
|
||||
def test_schema_is_still_bound_as_tool(self):
|
||||
"""tool_choice is suppressed, but the schema is still bound as a tool —
|
||||
exactly matching DeepSeek's official tool-calling examples."""
|
||||
bound = self._client("deepseek-reasoner").with_structured_output(self._Sample)
|
||||
kwargs = _bound_kwargs(bound)
|
||||
tools = kwargs.get("tools", [])
|
||||
assert any(
|
||||
t.get("function", {}).get("name") == "_Sample" for t in tools
|
||||
), f"schema not bound as a tool: {tools}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live API: structured output round-trips against the real DeepSeek backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _has_real_deepseek_key():
|
||||
key = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||
return bool(key) and key != "placeholder"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(
|
||||
not _has_real_deepseek_key(),
|
||||
reason="DEEPSEEK_API_KEY not set (or placeholder); skipping live API call",
|
||||
)
|
||||
class TestDeepSeekLiveStructuredOutput:
|
||||
"""End-to-end: a real DeepSeek V4-flash call returns a typed instance.
|
||||
|
||||
Verifies the no-tool_choice path doesn't trigger the 400 reported in
|
||||
issue #678 and that the structured-output binding still parses to a
|
||||
Pydantic instance.
|
||||
"""
|
||||
|
||||
class _Pick(BaseModel):
|
||||
action: str
|
||||
confidence: float
|
||||
|
||||
def test_v4_flash_returns_structured_output(self):
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key=os.environ["DEEPSEEK_API_KEY"],
|
||||
base_url="https://api.deepseek.com",
|
||||
timeout=60,
|
||||
)
|
||||
bound = client.with_structured_output(self._Pick)
|
||||
result = bound.invoke(
|
||||
"Pick BUY or SELL or HOLD for a tech stock with strong earnings. "
|
||||
"Confidence is a float between 0 and 1."
|
||||
)
|
||||
assert isinstance(result, self._Pick)
|
||||
assert result.action in {"BUY", "SELL", "HOLD"}
|
||||
assert 0.0 <= result.confidence <= 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class isolation: NormalizedChatOpenAI does NOT have DeepSeek behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBaseClassIsolation:
|
||||
def test_normalized_does_not_propagate_reasoning_content(self):
|
||||
"""The general-purpose NormalizedChatOpenAI must not carry
|
||||
DeepSeek-specific behaviour. Only the subclass does."""
|
||||
assert not hasattr(NormalizedChatOpenAI, "_get_request_payload") or (
|
||||
NormalizedChatOpenAI._get_request_payload
|
||||
is NormalizedChatOpenAI.__bases__[0]._get_request_payload
|
||||
)
|
||||
129
tests/test_env_overrides.py
Normal file
129
tests/test_env_overrides.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for TRADINGAGENTS_* env-var overlay onto DEFAULT_CONFIG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.default_config as default_config_module
|
||||
|
||||
|
||||
def _reload_with_env(monkeypatch, **overrides):
|
||||
"""Set/clear env vars then reload default_config to re-evaluate DEFAULT_CONFIG."""
|
||||
for key in list(default_config_module._ENV_OVERRIDES):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, val in overrides.items():
|
||||
monkeypatch.setenv(key, val)
|
||||
return importlib.reload(default_config_module)
|
||||
|
||||
|
||||
def test_no_env_uses_built_in_defaults(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch)
|
||||
assert dc.DEFAULT_CONFIG["llm_provider"] == "openai"
|
||||
assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.6"
|
||||
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.6-luna"
|
||||
assert dc.DEFAULT_CONFIG["backend_url"] is None
|
||||
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1
|
||||
assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False
|
||||
|
||||
|
||||
def test_string_overrides(monkeypatch):
|
||||
dc = _reload_with_env(
|
||||
monkeypatch,
|
||||
TRADINGAGENTS_LLM_PROVIDER="google",
|
||||
TRADINGAGENTS_DEEP_THINK_LLM="gemini-3-pro-preview",
|
||||
TRADINGAGENTS_QUICK_THINK_LLM="gemini-3-flash-preview",
|
||||
TRADINGAGENTS_LLM_BACKEND_URL="https://example.invalid/v1",
|
||||
TRADINGAGENTS_OUTPUT_LANGUAGE="Chinese",
|
||||
)
|
||||
assert dc.DEFAULT_CONFIG["llm_provider"] == "google"
|
||||
assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gemini-3-pro-preview"
|
||||
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gemini-3-flash-preview"
|
||||
assert dc.DEFAULT_CONFIG["backend_url"] == "https://example.invalid/v1"
|
||||
assert dc.DEFAULT_CONFIG["output_language"] == "Chinese"
|
||||
|
||||
|
||||
def test_int_coercion(monkeypatch):
|
||||
dc = _reload_with_env(
|
||||
monkeypatch,
|
||||
TRADINGAGENTS_MAX_DEBATE_ROUNDS="3",
|
||||
TRADINGAGENTS_MAX_RISK_ROUNDS="2",
|
||||
)
|
||||
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 3
|
||||
assert isinstance(dc.DEFAULT_CONFIG["max_debate_rounds"], int)
|
||||
assert dc.DEFAULT_CONFIG["max_risk_discuss_rounds"] == 2
|
||||
assert isinstance(dc.DEFAULT_CONFIG["max_risk_discuss_rounds"], int)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("true", True), ("True", True), ("1", True), ("yes", True), ("on", True),
|
||||
("false", False), ("False", False), ("0", False), ("no", False), ("off", False),
|
||||
],
|
||||
)
|
||||
def test_bool_coercion(monkeypatch, raw, expected):
|
||||
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw)
|
||||
assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is expected
|
||||
|
||||
|
||||
def test_reasoning_thinking_overrides(monkeypatch):
|
||||
"""The provider reasoning/thinking knobs are env-configurable (non-interactive runs)."""
|
||||
dc = _reload_with_env(
|
||||
monkeypatch,
|
||||
TRADINGAGENTS_OPENAI_REASONING_EFFORT="high",
|
||||
TRADINGAGENTS_GOOGLE_THINKING_LEVEL="minimal",
|
||||
TRADINGAGENTS_ANTHROPIC_EFFORT="low",
|
||||
)
|
||||
assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] == "high"
|
||||
assert dc.DEFAULT_CONFIG["google_thinking_level"] == "minimal"
|
||||
assert dc.DEFAULT_CONFIG["anthropic_effort"] == "low"
|
||||
|
||||
|
||||
def test_reasoning_effort_defaults_to_none(monkeypatch):
|
||||
"""Unset reasoning/thinking knobs stay None so each provider uses its own default."""
|
||||
dc = _reload_with_env(monkeypatch)
|
||||
assert dc.DEFAULT_CONFIG["openai_reasoning_effort"] is None
|
||||
assert dc.DEFAULT_CONFIG["google_thinking_level"] is None
|
||||
assert dc.DEFAULT_CONFIG["anthropic_effort"] is None
|
||||
|
||||
|
||||
def test_empty_env_value_is_passthrough(monkeypatch):
|
||||
"""Empty TRADINGAGENTS_* values must not clobber the built-in default."""
|
||||
dc = _reload_with_env(
|
||||
monkeypatch,
|
||||
TRADINGAGENTS_LLM_PROVIDER="",
|
||||
TRADINGAGENTS_MAX_DEBATE_ROUNDS="",
|
||||
)
|
||||
assert dc.DEFAULT_CONFIG["llm_provider"] == "openai"
|
||||
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1
|
||||
|
||||
|
||||
def test_invalid_int_raises(monkeypatch):
|
||||
"""Garbage int values should surface a ValueError at import, not silently misconfigure."""
|
||||
monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "not-a-number")
|
||||
with pytest.raises(ValueError, match="TRADINGAGENTS_MAX_DEBATE_ROUNDS"):
|
||||
importlib.reload(default_config_module)
|
||||
# Restore module state for subsequent tests in this process
|
||||
monkeypatch.delenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", raising=False)
|
||||
importlib.reload(default_config_module)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["treu", "flase", "maybe", "2", "enabled"])
|
||||
def test_invalid_bool_raises(monkeypatch, bad):
|
||||
"""A misspelled boolean must fail loudly (like ints) instead of silently False."""
|
||||
monkeypatch.setenv("TRADINGAGENTS_CHECKPOINT_ENABLED", bad)
|
||||
with pytest.raises(ValueError, match="TRADINGAGENTS_CHECKPOINT_ENABLED"):
|
||||
importlib.reload(default_config_module)
|
||||
monkeypatch.delenv("TRADINGAGENTS_CHECKPOINT_ENABLED", raising=False)
|
||||
importlib.reload(default_config_module)
|
||||
|
||||
|
||||
def test_unknown_env_var_is_ignored(monkeypatch):
|
||||
"""Env vars outside _ENV_OVERRIDES must not bleed into DEFAULT_CONFIG."""
|
||||
dc = _reload_with_env(
|
||||
monkeypatch,
|
||||
TRADINGAGENTS_NONEXISTENT_KEY="oops",
|
||||
)
|
||||
assert "nonexistent_key" not in dc.DEFAULT_CONFIG
|
||||
235
tests/test_fred.py
Normal file
235
tests/test_fred.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""FRED macro vendor: alias resolution, configuration errors, output formatting,
|
||||
missing-value handling, lookahead-safe windowing, and router integration.
|
||||
|
||||
All API access is mocked, so these run without a network connection or a key.
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import fred, interface
|
||||
from tradingagents.dataflows.config import set_config
|
||||
|
||||
# A small, stable set of observations to format against.
|
||||
_META = {
|
||||
"seriess": [
|
||||
{
|
||||
"title": "Unemployment Rate",
|
||||
"units_short": "%",
|
||||
"frequency": "Monthly",
|
||||
"seasonal_adjustment_short": "SA",
|
||||
}
|
||||
]
|
||||
}
|
||||
_OBS = {
|
||||
"observations": [
|
||||
{"date": "2025-06-01", "value": "4.1"},
|
||||
{"date": "2025-07-01", "value": "4.3"},
|
||||
{"date": "2025-08-01", "value": "."}, # missing -> skipped
|
||||
{"date": "2025-09-01", "value": "4.4"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _request_stub(meta=_META, obs=_OBS):
|
||||
"""Build a _request replacement that dispatches on the endpoint path."""
|
||||
def _impl(path, params):
|
||||
if path == "series":
|
||||
return meta
|
||||
if path == "series/observations":
|
||||
return obs
|
||||
raise AssertionError(f"unexpected FRED path: {path}")
|
||||
return _impl
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class FredResolutionTests(unittest.TestCase):
|
||||
def test_alias_maps_to_series_id(self):
|
||||
self.assertEqual(fred._resolve_series_id("cpi"), "CPIAUCSL")
|
||||
self.assertEqual(fred._resolve_series_id("unemployment"), "UNRATE")
|
||||
|
||||
def test_alias_is_case_and_separator_insensitive(self):
|
||||
self.assertEqual(fred._resolve_series_id("Fed Funds Rate"), "FEDFUNDS")
|
||||
self.assertEqual(fred._resolve_series_id("10y-treasury"), "DGS10")
|
||||
|
||||
def test_unknown_alias_is_treated_as_raw_series_id(self):
|
||||
# Power users can pass any FRED series ID; we uppercase by convention.
|
||||
self.assertEqual(fred._resolve_series_id("dgs30"), "DGS30")
|
||||
self.assertEqual(fred._resolve_series_id("MyCustomSeries"), "MYCUSTOMSERIES")
|
||||
|
||||
def test_descriptive_phrase_is_rejected(self):
|
||||
# An LLM phrase (spaces / too long) is not a series ID — reject up front
|
||||
# with guidance rather than 400ing the API.
|
||||
for bad in ("bank of japan rate", "the unemployment number", "X" * 31):
|
||||
with self.assertRaises(ValueError):
|
||||
fred._resolve_series_id(bad)
|
||||
|
||||
def test_get_macro_data_returns_guidance_on_bad_indicator(self):
|
||||
# Invalid indicator -> actionable message, not a crash (no API call).
|
||||
out = fred.get_macro_data("bank of japan rate", "2026-01-01")
|
||||
self.assertIn("FRED", out)
|
||||
self.assertIn("not a known macro alias", out)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class FredConfigTests(unittest.TestCase):
|
||||
def test_missing_key_raises_not_configured(self):
|
||||
with mock.patch.dict("os.environ", {}, clear=True), \
|
||||
self.assertRaises(fred.FredNotConfiguredError):
|
||||
fred.get_api_key()
|
||||
|
||||
def test_not_configured_is_a_value_error(self):
|
||||
# Routing relies on this subclassing for "vendor unavailable" handling.
|
||||
self.assertTrue(issubclass(fred.FredNotConfiguredError, ValueError))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class FredFormattingTests(unittest.TestCase):
|
||||
def test_report_has_header_latest_change_and_table(self):
|
||||
with mock.patch.object(fred, "_request", side_effect=_request_stub()):
|
||||
out = fred.get_macro_data("unemployment", "2025-09-30", 365)
|
||||
self.assertIn("## FRED: Unemployment Rate (UNRATE)", out)
|
||||
self.assertIn("Units: %", out)
|
||||
self.assertIn("Frequency: Monthly (SA)", out)
|
||||
self.assertIn("**Latest:** 4.4 (2025-09-01)", out)
|
||||
# change over the window: 4.4 - 4.1 = +0.30
|
||||
self.assertIn("+0.30", out)
|
||||
self.assertIn("| 2025-06-01 | 4.1 |", out)
|
||||
|
||||
def test_missing_value_is_skipped(self):
|
||||
with mock.patch.object(fred, "_request", side_effect=_request_stub()):
|
||||
out = fred.get_macro_data("unemployment", "2025-09-30", 365)
|
||||
# the "." observation must not appear as a row
|
||||
self.assertNotIn("2025-08-01", out)
|
||||
|
||||
def test_empty_window_reports_no_observations(self):
|
||||
empty = {"observations": []}
|
||||
with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=empty)):
|
||||
out = fred.get_macro_data("unemployment", "2025-09-30", 30)
|
||||
self.assertIn("No observations", out)
|
||||
|
||||
def test_unknown_series_returns_not_found_message(self):
|
||||
# A well-formed but unknown series ID returns guidance, not a crash, so
|
||||
# the run is not aborted over an optional macro lookup.
|
||||
no_series = {"seriess": []}
|
||||
with mock.patch.object(fred, "_request", side_effect=_request_stub(meta=no_series)):
|
||||
out = fred.get_macro_data("totally_unknown_xyz", "2025-09-30", 30)
|
||||
self.assertIn("not found", out)
|
||||
|
||||
def test_long_series_is_truncated_but_change_uses_full_range(self):
|
||||
# Build > MAX_ROWS observations deterministically.
|
||||
obs = {
|
||||
"observations": [
|
||||
{"date": f"2025-01-{(i % 28) + 1:02d}", "value": str(i)}
|
||||
for i in range(fred.MAX_ROWS + 10)
|
||||
]
|
||||
}
|
||||
with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)):
|
||||
out = fred.get_macro_data("unemployment", "2025-12-31", 365)
|
||||
self.assertIn(f"most recent {fred.MAX_ROWS}", out)
|
||||
# change-over-window must reference the true first (0) and last value
|
||||
self.assertIn("from 0 ", out)
|
||||
body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")]
|
||||
self.assertEqual(len(body_rows), fred.MAX_ROWS)
|
||||
|
||||
def test_window_is_lookahead_safe(self):
|
||||
# observation_end must equal curr_date so a past date never pulls future data.
|
||||
captured = {}
|
||||
|
||||
def _capture(path, params):
|
||||
captured[path] = params
|
||||
return _META if path == "series" else _OBS
|
||||
|
||||
with mock.patch.object(fred, "_request", side_effect=_capture):
|
||||
fred.get_macro_data("unemployment", "2025-09-30", 90)
|
||||
obs_params = captured["series/observations"]
|
||||
self.assertEqual(obs_params["observation_end"], "2025-09-30")
|
||||
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back
|
||||
|
||||
def test_requests_pin_the_data_vintage(self):
|
||||
# #1275: both the metadata and observations requests must pin the vintage
|
||||
# to curr_date (clamped to FRED's today), or FRED serves the latest
|
||||
# revision and revision-prone series leak future information. A past
|
||||
# curr_date sits below FRED's today, so it pins through unchanged.
|
||||
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-01-01"), \
|
||||
mock.patch.object(fred, "_request", side_effect=_capture):
|
||||
fred.get_macro_data("cpi", "2025-09-30", 90)
|
||||
|
||||
for path in ("series", "series/observations"):
|
||||
self.assertEqual(captured[path]["realtime_start"], "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
|
||||
class FredRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def test_macro_category_routes_to_fred(self):
|
||||
self.assertEqual(
|
||||
interface.get_category_for_method("get_macro_indicators"), "macro_data"
|
||||
)
|
||||
set_config({"data_vendors": {"macro_data": "fred"}})
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_macro_indicators": {"fred": lambda *a, **k: "MACRO_OK"}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
|
||||
self.assertEqual(out, "MACRO_OK")
|
||||
|
||||
def test_not_configured_degrades_gracefully(self):
|
||||
# macro_data is optional: with only fred and no key, the router degrades
|
||||
# to a sentinel instead of aborting the run — a missing optional key must
|
||||
# not crash an analysis.
|
||||
set_config({"data_vendors": {"macro_data": "fred"}})
|
||||
|
||||
def _unconfigured(*a, **k):
|
||||
raise fred.FredNotConfiguredError("FRED_API_KEY not set")
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_macro_indicators": {"fred": _unconfigured}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-06-01", 365)
|
||||
self.assertIn("DATA_UNAVAILABLE", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -21,7 +21,7 @@ class TestGoogleApiKeyStandardization(unittest.TestCase):
|
||||
for msg, kwargs, expected_key in test_cases:
|
||||
with self.subTest(msg=msg):
|
||||
mock_chat.reset_mock()
|
||||
client = GoogleClient("gemini-2.5-flash", **kwargs)
|
||||
client = GoogleClient("gemini-3.5-flash", **kwargs)
|
||||
client.get_llm()
|
||||
call_kwargs = mock_chat.call_args[1]
|
||||
self.assertEqual(call_kwargs.get("google_api_key"), expected_key)
|
||||
|
||||
46
tests/test_google_thinking_level.py
Normal file
46
tests/test_google_thinking_level.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Gemini thinking_level forwarding (Gemini 3.x).
|
||||
|
||||
The catalog is Gemini 3.x only, which takes the string ``thinking_level``
|
||||
directly. Pro accepts low/high; Flash also accepts minimal/medium — an
|
||||
unsupported "minimal" on Pro is mapped to "low".
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.google_client import GoogleClient
|
||||
|
||||
|
||||
def _captured_kwargs(model, **kwargs):
|
||||
captured = {}
|
||||
with mock.patch.object(
|
||||
__import__("tradingagents.llm_clients.google_client", fromlist=["x"]),
|
||||
"NormalizedChatGoogleGenerativeAI",
|
||||
lambda **kw: captured.setdefault("kw", kw),
|
||||
):
|
||||
GoogleClient(model, api_key="x", **kwargs).get_llm()
|
||||
return captured["kw"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["minimal", "low", "medium", "high"])
|
||||
def test_flash_passes_thinking_level_through(level):
|
||||
kw = _captured_kwargs("gemini-3.5-flash", thinking_level=level)
|
||||
assert kw["thinking_level"] == level
|
||||
assert "thinking_budget" not in kw # the 2.5-era param is gone
|
||||
|
||||
|
||||
def test_pro_remaps_minimal_to_low():
|
||||
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="minimal")
|
||||
assert kw["thinking_level"] == "low" # Pro doesn't accept "minimal"
|
||||
|
||||
|
||||
def test_pro_keeps_high():
|
||||
kw = _captured_kwargs("gemini-3.1-pro-preview", thinking_level="high")
|
||||
assert kw["thinking_level"] == "high"
|
||||
|
||||
|
||||
def test_no_thinking_level_is_omitted():
|
||||
kw = _captured_kwargs("gemini-3.5-flash")
|
||||
assert "thinking_level" not in kw
|
||||
assert "thinking_budget" not in kw
|
||||
59
tests/test_i18n_coverage.py
Normal file
59
tests/test_i18n_coverage.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Every report-producing agent must apply the configured output language
|
||||
(#740/#801).
|
||||
|
||||
A non-English run should produce a fully localized report, not a mix of
|
||||
languages. The bug originally happened because several agents silently omitted
|
||||
the instruction (fixed in 6b384f7); this test codifies the invariant so a future
|
||||
refactor can't quietly drop it again.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.utils.agent_utils import get_language_instruction
|
||||
|
||||
_AGENTS_DIR = Path(__file__).resolve().parents[1] / "tradingagents" / "agents"
|
||||
|
||||
# Every node whose text reaches the saved report. If you add a report-producing
|
||||
# agent, add it here — and make it call get_language_instruction().
|
||||
REPORT_AGENTS = [
|
||||
"analysts/market_analyst.py",
|
||||
"analysts/news_analyst.py",
|
||||
"analysts/fundamentals_analyst.py",
|
||||
"analysts/sentiment_analyst.py",
|
||||
"researchers/bull_researcher.py",
|
||||
"researchers/bear_researcher.py",
|
||||
"managers/research_manager.py",
|
||||
"managers/portfolio_manager.py",
|
||||
"risk_mgmt/aggressive_debator.py",
|
||||
"risk_mgmt/conservative_debator.py",
|
||||
"risk_mgmt/neutral_debator.py",
|
||||
"trader/trader.py",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLanguageInstruction:
|
||||
def test_english_adds_no_tokens(self, monkeypatch):
|
||||
from tradingagents.dataflows.config import set_config
|
||||
set_config({"output_language": "English"})
|
||||
assert get_language_instruction() == ""
|
||||
|
||||
def test_non_english_emits_directive(self):
|
||||
from tradingagents.dataflows.config import set_config
|
||||
set_config({"output_language": "中文"})
|
||||
out = get_language_instruction()
|
||||
assert "中文" in out
|
||||
assert "entire response" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("rel", REPORT_AGENTS)
|
||||
def test_report_agent_applies_language_instruction(rel):
|
||||
path = _AGENTS_DIR / rel
|
||||
assert path.exists(), f"missing agent module: {rel}"
|
||||
src = path.read_text(encoding="utf-8")
|
||||
assert "get_language_instruction()" in src, (
|
||||
f"{rel} does not apply get_language_instruction(); its output would "
|
||||
f"ignore the configured output_language (#740/#801)."
|
||||
)
|
||||
170
tests/test_instrument_identity.py
Normal file
170
tests/test_instrument_identity.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Tests for deterministic instrument-identity resolution (#814) and the
|
||||
context-anchored message placeholder (#888)."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
create_msg_delete,
|
||||
get_instrument_context_from_state,
|
||||
resolve_instrument_identity,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class ResolveInstrumentIdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
resolve_instrument_identity.cache_clear()
|
||||
|
||||
def test_resolves_company_metadata_from_yfinance(self):
|
||||
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
|
||||
mock.return_value.info = {
|
||||
"longName": "TOTO LTD.",
|
||||
"shortName": "TOTO",
|
||||
"sector": "Industrials",
|
||||
"industry": "Building Products & Equipment",
|
||||
"exchange": "PNK",
|
||||
"quoteType": "EQUITY",
|
||||
}
|
||||
identity = resolve_instrument_identity("totdy")
|
||||
mock.assert_called_once_with("TOTDY")
|
||||
self.assertEqual(identity["company_name"], "TOTO LTD.")
|
||||
self.assertEqual(identity["sector"], "Industrials")
|
||||
self.assertEqual(identity["industry"], "Building Products & Equipment")
|
||||
self.assertEqual(identity["exchange"], "PNK")
|
||||
|
||||
def test_falls_back_to_short_name(self):
|
||||
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
|
||||
mock.return_value.info = {"shortName": "TOTO", "sector": "Industrials"}
|
||||
identity = resolve_instrument_identity("TOTDY")
|
||||
self.assertEqual(identity["company_name"], "TOTO")
|
||||
|
||||
def test_skips_placeholder_values(self):
|
||||
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
|
||||
mock.return_value.info = {"longName": " ", "sector": "None", "industry": "n/a"}
|
||||
identity = resolve_instrument_identity("TOTDY")
|
||||
self.assertEqual(identity, {})
|
||||
|
||||
def test_fails_open_on_exception(self):
|
||||
with patch(
|
||||
"tradingagents.agents.utils.agent_utils.yf.Ticker",
|
||||
side_effect=RuntimeError("rate limited"),
|
||||
):
|
||||
self.assertEqual(resolve_instrument_identity("TOTDY"), {})
|
||||
|
||||
def test_result_is_cached(self):
|
||||
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
|
||||
mock.return_value.info = {"longName": "TOTO LTD."}
|
||||
first = resolve_instrument_identity("TOTDY")
|
||||
second = resolve_instrument_identity("TOTDY")
|
||||
mock.assert_called_once() # second call served from cache
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class BuildInstrumentContextTests(unittest.TestCase):
|
||||
def test_mentions_exact_symbol_without_identity(self):
|
||||
context = build_instrument_context("7203.T")
|
||||
self.assertIn("7203.T", context)
|
||||
self.assertIn("exchange suffix", context)
|
||||
self.assertNotIn("Resolved identity", context)
|
||||
|
||||
def test_injects_resolved_identity(self):
|
||||
context = build_instrument_context(
|
||||
"TOTDY", "stock",
|
||||
{
|
||||
"company_name": "TOTO LTD.",
|
||||
"sector": "Industrials",
|
||||
"industry": "Building Products & Equipment",
|
||||
"exchange": "PNK",
|
||||
},
|
||||
)
|
||||
self.assertIn("Company: TOTO LTD.", context)
|
||||
self.assertIn("Industrials / Building Products & Equipment", context)
|
||||
self.assertIn("Exchange: PNK", context)
|
||||
self.assertIn("Do not substitute a different company", context)
|
||||
|
||||
def test_crypto_uses_name_label_and_keeps_hint(self):
|
||||
context = build_instrument_context(
|
||||
"BTC-USD", "crypto", {"company_name": "Bitcoin USD"}
|
||||
)
|
||||
self.assertIn("Name: Bitcoin USD", context)
|
||||
self.assertIn("crypto asset rather than a company", context)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class GetInstrumentContextFromStateTests(unittest.TestCase):
|
||||
def test_prefers_precomputed_context(self):
|
||||
state = {"company_of_interest": "TOTDY", "instrument_context": "PRECOMPUTED"}
|
||||
self.assertEqual(get_instrument_context_from_state(state), "PRECOMPUTED")
|
||||
|
||||
def test_fallback_is_network_free_ticker_only(self):
|
||||
# No instrument_context and no yfinance call — must not hit the network.
|
||||
with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock:
|
||||
context = get_instrument_context_from_state(
|
||||
{"company_of_interest": "NVDA", "asset_type": "stock"}
|
||||
)
|
||||
mock.assert_not_called()
|
||||
self.assertIn("NVDA", context)
|
||||
|
||||
def test_fallback_respects_asset_type(self):
|
||||
context = get_instrument_context_from_state(
|
||||
{"company_of_interest": "BTC-USD", "asset_type": "crypto"}
|
||||
)
|
||||
self.assertIn("crypto asset", context)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class ContextAnchoredPlaceholderTests(unittest.TestCase):
|
||||
"""#888 — the message-clear placeholder must not be a bare 'Continue'."""
|
||||
|
||||
def _run(self, state_extra):
|
||||
state = {
|
||||
"messages": [
|
||||
HumanMessage(content="old", id="h1"),
|
||||
AIMessage(content="reply", id="a1"),
|
||||
],
|
||||
**state_extra,
|
||||
}
|
||||
return create_msg_delete()(state)
|
||||
|
||||
def test_placeholder_is_not_bare_continue(self):
|
||||
result = self._run(
|
||||
{"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"}
|
||||
)
|
||||
placeholder = result["messages"][-1]
|
||||
self.assertIsInstance(placeholder, HumanMessage)
|
||||
self.assertNotEqual(placeholder.content.strip(), "Continue")
|
||||
|
||||
def test_placeholder_carries_resolved_identity(self):
|
||||
result = self._run(
|
||||
{
|
||||
"company_of_interest": "EC",
|
||||
"instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.",
|
||||
"trade_date": "2026-05-28",
|
||||
}
|
||||
)
|
||||
content = result["messages"][-1].content
|
||||
self.assertIn("Ecopetrol", content)
|
||||
self.assertIn("2026-05-28", content)
|
||||
|
||||
def test_old_messages_are_removed(self):
|
||||
result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"})
|
||||
removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)]
|
||||
humans = [m for m in result["messages"] if isinstance(m, HumanMessage)]
|
||||
self.assertEqual(len(removals), 2)
|
||||
self.assertEqual(len(humans), 1)
|
||||
|
||||
def test_safe_defaults_when_state_minimal(self):
|
||||
result = create_msg_delete()({"messages": [], "company_of_interest": "EC"})
|
||||
placeholder = result["messages"][-1]
|
||||
self.assertNotEqual(placeholder.content.strip(), "Continue")
|
||||
self.assertIn("EC", placeholder.content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
100
tests/test_llm_max_retries.py
Normal file
100
tests/test_llm_max_retries.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Configurable LLM SDK retry budget (#1090/#1091).
|
||||
|
||||
A single transient 429 burst used to kill an otherwise-healthy multi-agent run
|
||||
because each provider SDK's max_retries (default 2) was not exposed. This adds an
|
||||
opt-in llm_max_retries knob forwarded to every provider chat client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.default_config as default_config_module
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_retries
|
||||
|
||||
# --- coercion / validation -------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("value,expected", [(0, 0), (2, 2), (10, 10), ("6", 6)])
|
||||
def test_coerce_accepts_non_negative_ints_and_numeric_strings(value, expected):
|
||||
assert _coerce_max_retries(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [-1, "-3"])
|
||||
def test_coerce_rejects_negative(bad):
|
||||
with pytest.raises(ValueError, match=">= 0"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [True, False])
|
||||
def test_coerce_rejects_booleans(bad):
|
||||
with pytest.raises(ValueError, match="boolean"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
|
||||
def test_coerce_rejects_non_integers(bad):
|
||||
with pytest.raises(ValueError, match="integer"):
|
||||
_coerce_max_retries(bad)
|
||||
|
||||
|
||||
# --- forwarding into provider kwargs --------------------------------------
|
||||
|
||||
def _bare_graph(config):
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.config = config
|
||||
return g
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_not_forwarded_when_unset():
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": None})._get_provider_kwargs()
|
||||
assert "max_retries" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google"])
|
||||
def test_forwarded_across_providers(provider):
|
||||
kwargs = _bare_graph({"llm_provider": provider, "llm_max_retries": 6})._get_provider_kwargs()
|
||||
assert kwargs["max_retries"] == 6
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forwarded_env_string_is_coerced():
|
||||
# env vars arrive as strings; the consumer coerces (like temperature)
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": "4"})._get_provider_kwargs()
|
||||
assert kwargs["max_retries"] == 4
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_config_value_fails_loudly():
|
||||
with pytest.raises(ValueError):
|
||||
_bare_graph({"llm_provider": "openai", "llm_max_retries": -1})._get_provider_kwargs()
|
||||
|
||||
|
||||
# --- env overlay -----------------------------------------------------------
|
||||
|
||||
def _reload_with_env(monkeypatch, **overrides):
|
||||
for key in list(default_config_module._ENV_OVERRIDES):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, val in overrides.items():
|
||||
monkeypatch.setenv(key, val)
|
||||
return importlib.reload(default_config_module)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_is_none(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch)
|
||||
assert dc.DEFAULT_CONFIG["llm_max_retries"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_env_override_sets_config(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8")
|
||||
# None-default key: env value arrives as a string and is coerced downstream.
|
||||
assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8"
|
||||
assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8
|
||||
122
tests/test_llm_max_tokens.py
Normal file
122
tests/test_llm_max_tokens.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Configurable output-token cap (#1204).
|
||||
|
||||
Some model/gateway combinations (e.g. deepseek-v4-flash deployments) emit
|
||||
unbounded reasoning/output and hang or trip an idle timeout. An opt-in
|
||||
``max_tokens`` config knob is forwarded to every provider so a run can bound it;
|
||||
Gemini names the parameter ``max_output_tokens``, so it is forwarded under the
|
||||
right key per provider.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.default_config as default_config_module
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_tokens
|
||||
|
||||
# --- coercion / validation -------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("value,expected", [(1, 1), (8192, 8192), ("4096", 4096)])
|
||||
def test_coerce_accepts_positive_ints_and_numeric_strings(value, expected):
|
||||
assert _coerce_max_tokens(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [0, -1, "0", "-5"])
|
||||
def test_coerce_rejects_non_positive(bad):
|
||||
with pytest.raises(ValueError, match="> 0"):
|
||||
_coerce_max_tokens(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", [True, False])
|
||||
def test_coerce_rejects_booleans(bad):
|
||||
with pytest.raises(ValueError, match="boolean"):
|
||||
_coerce_max_tokens(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
|
||||
def test_coerce_rejects_non_integers(bad):
|
||||
with pytest.raises(ValueError, match="integer"):
|
||||
_coerce_max_tokens(bad)
|
||||
|
||||
|
||||
# --- forwarding into provider kwargs (right key per provider) --------------
|
||||
|
||||
def _bare_graph(config):
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.config = config
|
||||
return g
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_not_forwarded_when_unset():
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": None})._get_provider_kwargs()
|
||||
assert "max_tokens" not in kwargs
|
||||
assert "max_output_tokens" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider", ["openai", "anthropic", "deepseek", "openai_compatible"])
|
||||
def test_forwarded_as_max_tokens_for_non_google(provider):
|
||||
kwargs = _bare_graph({"llm_provider": provider, "max_tokens": 8192})._get_provider_kwargs()
|
||||
assert kwargs["max_tokens"] == 8192
|
||||
assert "max_output_tokens" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_forwarded_as_max_output_tokens_for_google():
|
||||
# Gemini's kwarg name differs; forwarding plain max_tokens would be rejected.
|
||||
kwargs = _bare_graph({"llm_provider": "google", "max_tokens": 8192})._get_provider_kwargs()
|
||||
assert kwargs["max_output_tokens"] == 8192
|
||||
assert "max_tokens" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_env_string_is_coerced():
|
||||
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": "4096"})._get_provider_kwargs()
|
||||
assert kwargs["max_tokens"] == 4096
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_value_fails_loudly():
|
||||
with pytest.raises(ValueError):
|
||||
_bare_graph({"llm_provider": "openai", "max_tokens": 0})._get_provider_kwargs()
|
||||
|
||||
|
||||
# --- client-side allowlists carry the kwarg --------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_openai_and_google_clients_accept_the_kwarg():
|
||||
from tradingagents.llm_clients import openai_client
|
||||
from tradingagents.llm_clients.google_client import GoogleClient # noqa: F401
|
||||
assert "max_tokens" in openai_client._PASSTHROUGH_KWARGS
|
||||
# Google client forwards max_output_tokens through construction.
|
||||
llm = GoogleClient("gemini-3.5-flash", api_key="x", max_output_tokens=8192).get_llm()
|
||||
assert getattr(llm, "max_output_tokens", None) == 8192
|
||||
|
||||
|
||||
# --- env overlay -----------------------------------------------------------
|
||||
|
||||
def _reload_with_env(monkeypatch, **overrides):
|
||||
for key in list(default_config_module._ENV_OVERRIDES):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
for key, val in overrides.items():
|
||||
monkeypatch.setenv(key, val)
|
||||
return importlib.reload(default_config_module)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_is_none(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch)
|
||||
assert dc.DEFAULT_CONFIG["max_tokens"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_env_override_sets_config(monkeypatch):
|
||||
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192")
|
||||
assert dc.DEFAULT_CONFIG["max_tokens"] == "8192"
|
||||
assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192
|
||||
76
tests/test_market_data_validator.py
Normal file
76
tests/test_market_data_validator.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for the deterministic market-data verification snapshot (#830/#881)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.market_data_validator as validator
|
||||
|
||||
|
||||
def _sample_ohlcv() -> pd.DataFrame:
|
||||
dates = pd.bdate_range("2026-04-01", "2026-05-20")
|
||||
closes = [100 + i for i in range(len(dates))]
|
||||
return pd.DataFrame({
|
||||
"Date": dates,
|
||||
"Open": [c - 0.5 for c in closes],
|
||||
"High": [c + 1.0 for c in closes],
|
||||
"Low": [c - 1.0 for c in closes],
|
||||
"Close": closes,
|
||||
"Volume": [1_000_000 + i for i in range(len(dates))],
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestVerifiedSnapshot:
|
||||
def test_excludes_future_rows(self, monkeypatch):
|
||||
data = pd.concat([
|
||||
_sample_ohlcv(),
|
||||
pd.DataFrame({"Date": [pd.Timestamp("2026-06-01")], "Open": [999.0],
|
||||
"High": [999.0], "Low": [999.0], "Close": [999.0], "Volume": [999]}),
|
||||
], ignore_index=True)
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: data)
|
||||
|
||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-13")
|
||||
assert "Verified market data snapshot for COF" in snap
|
||||
assert "Requested analysis date: 2026-05-13" in snap
|
||||
assert "Latest trading row used: 2026-05-13" in snap
|
||||
assert "999.00" not in snap # future row excluded
|
||||
assert "boll_lb" in snap # indicators present
|
||||
|
||||
def test_uses_previous_trading_day_when_date_is_weekend(self, monkeypatch):
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
||||
# 2026-05-16 is a Saturday; latest row should be Fri 2026-05-15
|
||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-16")
|
||||
assert "Latest trading row used: 2026-05-15" in snap
|
||||
assert "Recent verified closes" in snap
|
||||
|
||||
def test_raises_when_no_rows_on_or_before_date(self, monkeypatch):
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
||||
with pytest.raises(ValueError):
|
||||
validator.build_verified_market_snapshot("COF", "2020-01-01")
|
||||
|
||||
def test_raises_on_empty_data(self, monkeypatch):
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: pd.DataFrame())
|
||||
with pytest.raises(ValueError):
|
||||
validator.build_verified_market_snapshot("COF", "2026-05-13")
|
||||
|
||||
def test_look_back_window_capped_at_30(self, monkeypatch):
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
||||
snap = validator.build_verified_market_snapshot("COF", "2026-05-20", look_back_days=999)
|
||||
# last-N closes table has at most 30 data rows
|
||||
close_rows = [ln for ln in snap.splitlines() if ln.startswith("| 2026-")]
|
||||
assert 0 < len(close_rows) <= 30
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTool:
|
||||
def test_tool_delegates_to_builder(self, monkeypatch):
|
||||
from tradingagents.agents.utils.market_data_validation_tools import (
|
||||
get_verified_market_snapshot,
|
||||
)
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d: _sample_ohlcv())
|
||||
out = get_verified_market_snapshot.invoke(
|
||||
{"symbol": "COF", "curr_date": "2026-05-20"}
|
||||
)
|
||||
assert "Verified market data snapshot for COF" in out
|
||||
23
tests/test_market_toolnode.py
Normal file
23
tests/test_market_toolnode.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""The market analyst is bound (and prompt-instructed) to call
|
||||
get_verified_market_snapshot; if the executor ToolNode doesn't register it, the
|
||||
call fails and the model reports the tool "unavailable" and skips verification.
|
||||
|
||||
Regression guard for that wiring gap (snapshot bound to the LLM but missing from
|
||||
the market ToolNode).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_market_toolnode_can_execute_verified_snapshot():
|
||||
# _create_tool_nodes does not use self -> call unbound (avoids building LLMs).
|
||||
nodes = TradingAgentsGraph._create_tool_nodes(None)
|
||||
market_tools = set(nodes["market"].tools_by_name)
|
||||
assert "get_verified_market_snapshot" in market_tools, (
|
||||
"get_verified_market_snapshot is bound to the market analyst but not "
|
||||
"registered in the market ToolNode, so the model's call fails."
|
||||
)
|
||||
# the other core market tools must remain too
|
||||
assert {"get_stock_data", "get_indicators"} <= market_tools
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Tests for TradingMemoryLog — storage, deferred reflection, PM injection, legacy removal."""
|
||||
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
|
||||
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||
from tradingagents.graph.propagation import Propagator
|
||||
from tradingagents.graph.reflection import Reflector
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.graph.propagation import Propagator
|
||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||
|
||||
_SEP = TradingMemoryLog._SEPARATOR
|
||||
|
||||
@@ -53,9 +54,14 @@ def _resolve_entry(log, ticker, date, decision, reflection="Good call."):
|
||||
log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection)
|
||||
|
||||
|
||||
def _price_df(prices):
|
||||
"""Minimal DataFrame matching yfinance .history() output shape."""
|
||||
return pd.DataFrame({"Close": prices})
|
||||
def _price_df(prices, start="2026-01-05"):
|
||||
"""Minimal DataFrame matching yfinance .history() output shape.
|
||||
|
||||
Uses a DatetimeIndex like real yfinance output, so resolution-date
|
||||
extraction (stock.index[holding_days]) works (#1251).
|
||||
"""
|
||||
idx = pd.date_range(start=start, periods=len(prices), freq="D")
|
||||
return pd.DataFrame({"Close": prices}, index=idx)
|
||||
|
||||
|
||||
def _make_pm_state(past_context=""):
|
||||
@@ -495,35 +501,38 @@ class TestDeferredReflection:
|
||||
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
|
||||
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
|
||||
assert raw is not None and alpha is not None and days is not None
|
||||
assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
|
||||
assert days == 5
|
||||
# resolution date = the bar `days` sessions after the trade date (#1251)
|
||||
assert resolved == "2026-01-10"
|
||||
|
||||
def test_fetch_returns_too_recent(self):
|
||||
"""Only 1 data point available → returns (None, None, None), no crash."""
|
||||
"""Only 1 data point available → returns all-None, no crash."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
m = MagicMock()
|
||||
m.history.return_value = _price_df([100.0])
|
||||
mock_ticker_cls.return_value = m
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19")
|
||||
assert raw is None and alpha is None and days is None
|
||||
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19")
|
||||
assert (raw, alpha, days, resolved) == (None, None, None, None)
|
||||
|
||||
def test_fetch_returns_delisted(self):
|
||||
"""Empty DataFrame → returns (None, None, None), no crash."""
|
||||
"""Empty DataFrame → returns all-None, no crash."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
m = MagicMock()
|
||||
m.history.return_value = pd.DataFrame({"Close": []})
|
||||
mock_ticker_cls.return_value = m
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10")
|
||||
assert raw is None and alpha is None and days is None
|
||||
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10")
|
||||
assert (raw, alpha, days, resolved) == (None, None, None, None)
|
||||
|
||||
def test_fetch_returns_spy_shorter_than_stock(self):
|
||||
"""SPY having fewer rows than the stock must not raise IndexError."""
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
|
||||
spy_prices = [400.0, 402.0, 403.0]
|
||||
"""SPY having fewer rows than the stock (but still a full window) must
|
||||
not raise IndexError."""
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows
|
||||
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
def _make_ticker(sym):
|
||||
@@ -531,9 +540,123 @@ class TestDeferredReflection:
|
||||
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
|
||||
assert raw is not None and alpha is not None and days is not None
|
||||
assert days == 2
|
||||
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
|
||||
assert raw is not None and alpha is not None
|
||||
assert days == 5 # full holding window used for both series
|
||||
assert resolved == "2026-01-10"
|
||||
|
||||
def test_fetch_returns_incomplete_window_stays_pending(self):
|
||||
"""#1169: a rerun before the full holding window has traded returns
|
||||
unavailable (all-None) so the entry stays pending, rather than settling
|
||||
on a premature partial return."""
|
||||
stock_prices = [100.0, 102.0, 104.0] # only 3 rows; holding window is 5
|
||||
spy_prices = [400.0, 402.0, 404.0]
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
def _make_ticker(sym):
|
||||
m = MagicMock()
|
||||
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
result = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05")
|
||||
assert result == (None, None, None, None)
|
||||
|
||||
# TradingAgentsGraph._resolve_benchmark — picks index for alpha calc
|
||||
|
||||
def test_resolve_benchmark_explicit_override(self):
|
||||
"""config['benchmark_ticker'] wins for every ticker."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {
|
||||
"benchmark_ticker": "QQQ",
|
||||
"benchmark_map": {"": "SPY", ".T": "^N225"},
|
||||
}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "QQQ"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "QQQ"
|
||||
|
||||
def test_resolve_benchmark_suffix_map(self):
|
||||
"""Known suffixes route to their regional index."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {
|
||||
"benchmark_ticker": None,
|
||||
"benchmark_map": {
|
||||
".T": "^N225", ".HK": "^HSI", ".NS": "^NSEI",
|
||||
".L": "^FTSE", ".TO": "^GSPTSE", ".AX": "^AXJO",
|
||||
".BO": "^BSESN", "": "SPY",
|
||||
},
|
||||
}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.T") == "^N225"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "0700.HK") == "^HSI"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "RELIANCE.NS") == "^NSEI"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AZN.L") == "^FTSE"
|
||||
|
||||
def test_resolve_benchmark_china_a_shares(self):
|
||||
"""A-share tickers route to their exchange composite (uses the real
|
||||
default benchmark_map, since A-share support relies on it)."""
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {"benchmark_ticker": None,
|
||||
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "600519.SS") == "000001.SS"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "000001.SZ") == "399001.SZ"
|
||||
|
||||
def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
|
||||
"""US tickers (no dotted suffix) take the empty-suffix entry."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {
|
||||
"benchmark_ticker": None,
|
||||
"benchmark_map": {"": "SPY", ".T": "^N225"},
|
||||
}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "NVDA") == "SPY"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "AAPL") == "SPY"
|
||||
|
||||
def test_resolve_benchmark_unknown_suffix_falls_back(self):
|
||||
"""Unrecognised suffix (BRK.B, FAKE.XX) falls back to SPY."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {
|
||||
"benchmark_ticker": None,
|
||||
"benchmark_map": {"": "SPY", ".T": "^N225"},
|
||||
}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "FAKE.XX") == "SPY"
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "BRK.B") == "SPY"
|
||||
|
||||
def test_resolve_benchmark_case_insensitive(self):
|
||||
"""Suffix matching is case-insensitive so 7203.t resolves like 7203.T."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.config = {
|
||||
"benchmark_ticker": None,
|
||||
"benchmark_map": {".T": "^N225", "": "SPY"},
|
||||
}
|
||||
assert TradingAgentsGraph._resolve_benchmark(mock_graph, "7203.t") == "^N225"
|
||||
|
||||
def test_reflector_includes_benchmark_in_label(self):
|
||||
"""benchmark_name appears in the prompt label, not 'SPY' hardcoded."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.invoke.return_value.content = "Directionally correct."
|
||||
reflector = Reflector(mock_llm)
|
||||
reflector.reflect_on_final_decision(
|
||||
final_decision=DECISION_BUY,
|
||||
raw_return=0.05,
|
||||
alpha_return=0.02,
|
||||
benchmark_name="^N225",
|
||||
)
|
||||
messages = mock_llm.invoke.call_args[0][0]
|
||||
human_content = next(content for role, content in messages if role == "human")
|
||||
assert "Alpha vs ^N225:" in human_content
|
||||
assert "Alpha vs SPY:" not in human_content
|
||||
|
||||
def test_reflector_defaults_to_spy_for_unupdated_callers(self):
|
||||
"""Default benchmark_name keeps the SPY label for legacy callers."""
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.invoke.return_value.content = "ok"
|
||||
reflector = Reflector(mock_llm)
|
||||
reflector.reflect_on_final_decision(
|
||||
final_decision=DECISION_BUY,
|
||||
raw_return=0.05,
|
||||
alpha_return=0.02,
|
||||
)
|
||||
messages = mock_llm.invoke.call_args[0][0]
|
||||
human_content = next(content for role, content in messages if role == "human")
|
||||
assert "Alpha vs SPY:" in human_content
|
||||
|
||||
# TradingAgentsGraph._resolve_pending_entries
|
||||
|
||||
@@ -543,7 +666,7 @@ class TestDeferredReflection:
|
||||
log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.memory_log = log
|
||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5))
|
||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
||||
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
|
||||
mock_graph._fetch_returns.assert_not_called()
|
||||
assert len(log.get_pending_entries()) == 1
|
||||
@@ -557,7 +680,7 @@ class TestDeferredReflection:
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.memory_log = log
|
||||
mock_graph.reflector = mock_reflector
|
||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5))
|
||||
mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12"))
|
||||
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
|
||||
assert log.get_pending_entries() == []
|
||||
entries = log.load_entries()
|
||||
@@ -567,6 +690,20 @@ class TestDeferredReflection:
|
||||
assert "+5.0%" in entries[0]["raw"]
|
||||
assert "+2.0%" in entries[0]["alpha"]
|
||||
|
||||
def test_resolve_leaves_premature_entry_pending(self, tmp_path):
|
||||
"""#1169: when the outcome can't be settled yet (_fetch_returns None),
|
||||
the entry stays pending and the reflector is never called."""
|
||||
log = make_log(tmp_path)
|
||||
log.store_decision("NVDA", "2026-01-05", DECISION_BUY)
|
||||
mock_reflector = MagicMock()
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
mock_graph.memory_log = log
|
||||
mock_graph.reflector = mock_reflector
|
||||
mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None))
|
||||
TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
|
||||
assert len(log.get_pending_entries()) == 1 # still pending
|
||||
mock_reflector.reflect_on_final_decision.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portfolio Manager injection: past_context in state and prompt
|
||||
|
||||
95
tests/test_memory_pointintime.py
Normal file
95
tests/test_memory_pointintime.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Memory-log lessons must be point-in-time safe in a backtest (#1251).
|
||||
|
||||
get_past_context previously returned every resolved lesson regardless of the run
|
||||
date, so a historical run could learn from an outcome that had not happened yet.
|
||||
Resolved entries now record the date their outcome became known (``resolved:``),
|
||||
and get_past_context(as_of=...) filters on it. Legacy entries without a
|
||||
resolution date are excluded from a point-in-time query (conservative migration).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||
|
||||
|
||||
def _log(tmp_path):
|
||||
return TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")})
|
||||
|
||||
|
||||
def _resolve(log, ticker, date, resolution_date, reflection):
|
||||
log.store_decision(ticker, date, f"Rating: Buy\n{reflection}")
|
||||
log.update_with_outcome(
|
||||
ticker, date, 0.05, 0.02, 5, reflection, resolution_date=resolution_date,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolution_date_is_stored_and_parsed(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "outcome known 01-10")
|
||||
entry = log.load_entries()[0]
|
||||
assert entry["resolved"] == "2026-01-10"
|
||||
assert "resolved:2026-01-10" in (tmp_path / "mem.md").read_text()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_as_of_excludes_lessons_resolved_after_the_run_date(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
# Decision on 01-05, outcome only known on 01-10.
|
||||
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
|
||||
|
||||
# A run as-of 01-07 must NOT see it (the outcome was still in the future).
|
||||
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
|
||||
# A run as-of 01-10 (and later) sees it.
|
||||
assert "great trade" in log.get_past_context("NVDA", as_of="2026-01-10")
|
||||
assert "great trade" in log.get_past_context("NVDA", as_of="2026-02-01")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_as_of_is_unfiltered_live_behavior(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
|
||||
# Live run (no as_of): unchanged behavior, lesson is shown.
|
||||
assert "great trade" in log.get_past_context("NVDA")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_legacy_entry_without_resolution_date_excluded_in_backtest(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
# Simulate a pre-migration resolved entry: no resolution_date recorded.
|
||||
log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nlegacy lesson")
|
||||
log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "legacy lesson")
|
||||
entry = log.load_entries()[0]
|
||||
assert entry["resolved"] is None
|
||||
|
||||
# Conservative: excluded from a point-in-time query (can't prove it was known)...
|
||||
assert log.get_past_context("NVDA", as_of="2026-06-01") == ""
|
||||
# ...but still available on a live (unfiltered) run.
|
||||
assert "legacy lesson" in log.get_past_context("NVDA")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cross_ticker_lessons_are_also_gated(tmp_path):
|
||||
log = _log(tmp_path)
|
||||
_resolve(log, "AAPL", "2026-01-05", "2026-01-10", "cross lesson")
|
||||
# Querying a different ticker as-of before resolution: no cross lesson leaks.
|
||||
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
|
||||
assert "cross lesson" in log.get_past_context("NVDA", as_of="2026-01-10")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_memory_as_of_gates_historical_but_not_live():
|
||||
# The graph filters only for a past trade date; a current-date run passes
|
||||
# None so live behavior and legacy entries are unaffected (#1251).
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
past = "2024-01-01"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
future = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
assert g._memory_as_of(past) == past # backtest -> filter on the trade date
|
||||
assert g._memory_as_of(today) is None # live -> no filter
|
||||
assert g._memory_as_of(future) is None # future-dated run -> no filter
|
||||
73
tests/test_minimax.py
Normal file
73
tests/test_minimax.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for MinimaxChatOpenAI quirks.
|
||||
|
||||
Verifies the subclass injects ``reasoning_split=True`` into outgoing
|
||||
requests so M2.x reasoning models put their <think> block into
|
||||
``reasoning_details`` instead of polluting ``message.content``.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tradingagents.llm_clients.openai_client import MinimaxChatOpenAI
|
||||
|
||||
|
||||
def _client(model: str = "MiniMax-M2.7"):
|
||||
os.environ.setdefault("MINIMAX_API_KEY", "placeholder")
|
||||
return MinimaxChatOpenAI(
|
||||
model=model,
|
||||
api_key="placeholder",
|
||||
base_url="https://api.minimax.io/v1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMinimaxReasoningSplit:
|
||||
def test_reasoning_split_sent_via_extra_body_not_top_level(self):
|
||||
# Must be in extra_body, not top-level: the openai SDK validates
|
||||
# top-level params and rejects unknown ones like reasoning_split (#826).
|
||||
payload = _client()._get_request_payload([HumanMessage(content="hi")])
|
||||
assert payload.get("extra_body", {}).get("reasoning_split") is True
|
||||
assert "reasoning_split" not in payload # never top-level
|
||||
|
||||
def test_non_reasoning_minimax_does_not_inject_reasoning_split(self):
|
||||
"""Coding Plan / MiniMax-Text-01 / any non-M2-prefixed model must NOT
|
||||
receive reasoning_split at all (top-level or extra_body) (#826)."""
|
||||
for model in ("minimax-text-01", "MiniMax-Coding-Plan"):
|
||||
payload = _client(model)._get_request_payload(
|
||||
[HumanMessage(content="hi")]
|
||||
)
|
||||
assert "reasoning_split" not in payload
|
||||
assert "reasoning_split" not in payload.get("extra_body", {})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMinimaxStructuredOutputDispatch:
|
||||
"""M2.x models route through the capability table — tool_choice is
|
||||
suppressed but the schema is still bound as a tool."""
|
||||
|
||||
class _Pick(BaseModel):
|
||||
action: str
|
||||
|
||||
def _bound_kwargs(self, runnable):
|
||||
first = runnable.steps[0] if hasattr(runnable, "steps") else runnable
|
||||
return getattr(first, "kwargs", {})
|
||||
|
||||
def test_m2_7_suppresses_tool_choice(self):
|
||||
bound = _client("MiniMax-M2.7").with_structured_output(self._Pick)
|
||||
kwargs = self._bound_kwargs(bound)
|
||||
assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs
|
||||
|
||||
def test_m2_7_highspeed_suppresses_tool_choice(self):
|
||||
bound = _client("MiniMax-M2.7-highspeed").with_structured_output(self._Pick)
|
||||
kwargs = self._bound_kwargs(bound)
|
||||
assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs
|
||||
|
||||
def test_schema_still_bound_as_tool(self):
|
||||
bound = _client("MiniMax-M2.7").with_structured_output(self._Pick)
|
||||
tools = self._bound_kwargs(bound).get("tools", [])
|
||||
assert any(
|
||||
t.get("function", {}).get("name") == "_Pick" for t in tools
|
||||
), f"schema not bound: {tools}"
|
||||
25
tests/test_news_analyst_prompt.py
Normal file
25
tests/test_news_analyst_prompt.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Guard the news analyst prompt against tool-signature drift (#1116).
|
||||
|
||||
The prompt used to advertise ``get_news(query, ...)`` while the tool takes a
|
||||
``ticker``, tricking the LLM into hallucinating free-text query calls.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.agents.analysts.news_analyst as na
|
||||
from tradingagents.agents.utils.news_data_tools import get_news
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_news_takes_ticker_not_query():
|
||||
arg_names = set(get_news.args.keys())
|
||||
assert "ticker" in arg_names
|
||||
assert "query" not in arg_names
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_news_prompt_matches_get_news_signature():
|
||||
src = inspect.getsource(na)
|
||||
assert "get_news(ticker, start_date, end_date)" in src
|
||||
assert "get_news(query" not in src
|
||||
105
tests/test_news_lookahead.py
Normal file
105
tests/test_news_lookahead.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""yfinance news must not leak future-dated (or undated, in a backtest) articles
|
||||
into a historical window.
|
||||
|
||||
Regressions for #992 (flat articles bypassed the date filter), #1007 (global
|
||||
news injected future articles), #993 (empty-after-filter returned a blank body),
|
||||
and #1126 (inclusive upper bound leaked the midnight-after article; host-local
|
||||
timestamp parsing made filtering machine-dependent).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.yfinance_news as ynews
|
||||
from tradingagents.dataflows.date_window import in_window
|
||||
|
||||
|
||||
def _epoch(date_str):
|
||||
"""Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent)."""
|
||||
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_flat_article_publish_time_is_parsed():
|
||||
# #992: flat articles now carry a pub_date (was always None -> unfilterable).
|
||||
# #1126: parsed as UTC-aware, so the date can't shift with the host timezone.
|
||||
data = ynews._extract_article_data(
|
||||
{"title": "X", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-05-09")}
|
||||
)
|
||||
assert data["pub_date"] is not None
|
||||
assert data["pub_date"].tzinfo is not None
|
||||
assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_window_excludes_future_and_undated_in_backtest():
|
||||
start = datetime(2025, 5, 1)
|
||||
end = datetime(2025, 5, 9) # historical window (well in the past)
|
||||
inside = datetime(2025, 5, 5)
|
||||
future = datetime(2025, 6, 1)
|
||||
assert in_window(inside, start, end) is True
|
||||
assert in_window(future, start, end) is False # look-ahead blocked
|
||||
assert in_window(None, start, end) is False # undated -> excluded in backtest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_window_keeps_undated_in_live_window():
|
||||
# Live window (reaches today): undated articles can't be "future", so keep them.
|
||||
now = datetime.now(timezone.utc)
|
||||
assert in_window(None, now, now) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_upper_bound_is_exclusive():
|
||||
# #1126: an article stamped exactly midnight AFTER end_date leaked in under
|
||||
# the old inclusive bound; the whole of end_date itself must still be kept.
|
||||
start = datetime(2025, 5, 1)
|
||||
end = datetime(2025, 5, 9)
|
||||
midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc)
|
||||
last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc)
|
||||
assert in_window(midnight_after, start, end) is False
|
||||
assert in_window(last_moment, start, end) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_offset_aware_timestamp_is_converted_not_truncated():
|
||||
# #1126: 2025-05-10T01:00+05:00 is really 2025-05-09T20:00Z -> inside the
|
||||
# window. Stripping tzinfo (old behavior) misread it as 05-10 and dropped it.
|
||||
start = datetime(2025, 5, 1)
|
||||
end = datetime(2025, 5, 9)
|
||||
aware = datetime.fromisoformat("2025-05-10T01:00:00+05:00")
|
||||
assert in_window(aware, start, end) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_global_news_future_flat_article_excluded(monkeypatch):
|
||||
# #1007: a flat, future-dated global article must not appear in a historical run.
|
||||
future_article = {"title": "FUTURE EVENT", "publisher": "P", "link": "l",
|
||||
"providerPublishTime": _epoch("2025-06-01")}
|
||||
past_article = {"title": "PAST EVENT", "publisher": "P", "link": "l",
|
||||
"providerPublishTime": _epoch("2025-05-05")}
|
||||
|
||||
class FakeSearch:
|
||||
def __init__(self, *a, **k):
|
||||
self.news = [future_article, past_article]
|
||||
|
||||
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
||||
assert "PAST EVENT" in out
|
||||
assert "FUTURE EVENT" not in out # #1007
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_global_news_empty_after_filter_is_informative(monkeypatch):
|
||||
# #993: everything filtered out -> a clear message, not a blank-bodied report.
|
||||
only_future = {"title": "FUTURE", "publisher": "P", "link": "l",
|
||||
"providerPublishTime": _epoch("2025-06-01")}
|
||||
|
||||
class FakeSearch:
|
||||
def __init__(self, *a, **k):
|
||||
self.news = [only_future]
|
||||
|
||||
monkeypatch.setattr(ynews.yf, "Search", FakeSearch)
|
||||
out = ynews.get_global_news_yfinance("2025-05-09", look_back_days=7, limit=10)
|
||||
assert "No global news found" in out
|
||||
assert "###" not in out # no empty article body
|
||||
88
tests/test_no_data_handling.py
Normal file
88
tests/test_no_data_handling.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Tests that empty vendor results never become fabricated data.
|
||||
|
||||
Covers two systematic fixes:
|
||||
- load_ohlcv must not cache an empty download (cache poisoning), and must
|
||||
raise NoMarketDataError instead of returning an empty frame.
|
||||
- route_to_vendor must convert NoMarketDataError into a single explicit
|
||||
"NO_DATA_AVAILABLE" sentinel after all vendors are exhausted.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import interface, stockstats_utils
|
||||
from tradingagents.dataflows.config import set_config
|
||||
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoadOhlcvNoPoison(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = os.path.join(os.path.dirname(__file__), "_tmp_cache")
|
||||
os.makedirs(self._tmp, exist_ok=True)
|
||||
set_config({"data_cache_dir": self._tmp})
|
||||
|
||||
def tearDown(self):
|
||||
for f in os.listdir(self._tmp):
|
||||
os.remove(os.path.join(self._tmp, f))
|
||||
os.rmdir(self._tmp)
|
||||
|
||||
def test_empty_download_raises_and_does_not_cache(self):
|
||||
empty = pd.DataFrame()
|
||||
with mock.patch.object(stockstats_utils.yf, "download", return_value=empty), \
|
||||
self.assertRaises(NoMarketDataError):
|
||||
stockstats_utils.load_ohlcv("FAKE", "2026-01-01")
|
||||
# Nothing should have been written to the cache.
|
||||
self.assertEqual(os.listdir(self._tmp), [])
|
||||
|
||||
# A second call must re-attempt the fetch (no poisoned cache served).
|
||||
with mock.patch.object(stockstats_utils.yf, "download", return_value=empty) as dl2:
|
||||
with self.assertRaises(NoMarketDataError):
|
||||
stockstats_utils.load_ohlcv("FAKE", "2026-01-01")
|
||||
self.assertTrue(dl2.called)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRouteToVendorSentinel(unittest.TestCase):
|
||||
def test_no_data_from_all_vendors_returns_sentinel(self):
|
||||
def raises_no_data(symbol, *a, **k):
|
||||
raise NoMarketDataError(symbol, "GC=F", "no rows")
|
||||
|
||||
patched = {"yfinance": raises_no_data, "alpha_vantage": raises_no_data}
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS, {"get_stock_data": patched}, clear=False
|
||||
):
|
||||
result = interface.route_to_vendor(
|
||||
"get_stock_data", "XAUUSD+", "2026-01-01", "2026-01-10"
|
||||
)
|
||||
self.assertIn("NO_DATA_AVAILABLE", result)
|
||||
self.assertIn("XAUUSD+", result)
|
||||
self.assertIn("GC=F", result)
|
||||
self.assertIn("Do not estimate", result)
|
||||
|
||||
def test_unconfigured_fallback_does_not_mask_no_data(self):
|
||||
# When the primary vendor reports no data and the fallback is simply
|
||||
# unavailable (e.g. missing API key -> raises), the no-data sentinel
|
||||
# must win rather than the fallback's incidental error crashing out.
|
||||
def raises_no_data(symbol, *a, **k):
|
||||
raise NoMarketDataError(symbol, symbol, "no rows")
|
||||
|
||||
def raises_unavailable(symbol, *a, **k):
|
||||
raise ValueError("ALPHA_VANTAGE_API_KEY environment variable is not set.")
|
||||
|
||||
patched = {"yfinance": raises_no_data, "alpha_vantage": raises_unavailable}
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS, {"get_stock_data": patched}, clear=False
|
||||
):
|
||||
result = interface.route_to_vendor(
|
||||
"get_stock_data", "FAKE", "2026-01-01", "2026-01-10"
|
||||
)
|
||||
self.assertIn("NO_DATA_AVAILABLE", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
109
tests/test_ohlcv_cache_freshness.py
Normal file
109
tests/test_ohlcv_cache_freshness.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Same-day OHLCV cache must not serve a stale snapshot all day (#1150).
|
||||
|
||||
The cache file is keyed per day, so a run started before the day's bar was final
|
||||
would be reused by every later run, feeding a stale close into technical
|
||||
analysis. Two cases matter for a current-day request: the bar may be missing, or
|
||||
present but still in progress (Yahoo publishes a partial daily candle intraday).
|
||||
Refresh is bounded by a TTL so repeated runs cannot hammer the vendor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.stockstats_utils as su
|
||||
|
||||
TODAY = pd.Timestamp("2026-07-18")
|
||||
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
|
||||
|
||||
|
||||
def _write(tmp_path, name="cache.csv", age_seconds=0.0, last_date="2026-07-17"):
|
||||
f = tmp_path / name
|
||||
pd.DataFrame({"Date": [last_date], "Close": [1.0]}).to_csv(f, index=False)
|
||||
if age_seconds:
|
||||
old = time.time() - age_seconds
|
||||
os.utime(f, (old, old))
|
||||
return str(f)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_current_day_cache_past_ttl_is_refreshed(tmp_path):
|
||||
# Bar missing (rows stop at yesterday) and file older than the TTL -> refetch.
|
||||
assert su._needs_same_day_refresh(_write(tmp_path, age_seconds=STALE), TODAY, TODAY) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_partial_current_day_bar_is_still_refreshed(tmp_path):
|
||||
# Today's row is present but may be an in-progress candle whose Close is not
|
||||
# the closing price. Row inspection can't distinguish it, so the TTL governs.
|
||||
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
|
||||
assert su._needs_same_day_refresh(f, TODAY, TODAY) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_recent_cache_is_not_refetched(tmp_path):
|
||||
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
|
||||
assert su._needs_same_day_refresh(_write(tmp_path), TODAY, TODAY) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_historical_request_always_uses_cache(tmp_path):
|
||||
# Past dates are immutable: never refetch, however old the file is.
|
||||
past = pd.Timestamp("2026-05-01")
|
||||
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
|
||||
assert su._needs_same_day_refresh(f, past, TODAY) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
|
||||
"""End-to-end: the helper is actually wired into load_ohlcv's cache branch.
|
||||
|
||||
Without this, the unit tests above would still pass if the helper were never
|
||||
called from the real code path.
|
||||
"""
|
||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
|
||||
|
||||
# Pre-seed the cache file load_ohlcv will look for, aged past the TTL.
|
||||
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
||||
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
|
||||
pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False)
|
||||
old = time.time() - STALE
|
||||
os.utime(cache_file, (old, old))
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_download(*a, **k):
|
||||
calls.append(1)
|
||||
return pd.DataFrame(
|
||||
{"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]}
|
||||
).set_index("Date")
|
||||
|
||||
monkeypatch.setattr(su.yf, "download", _fake_download)
|
||||
|
||||
out = su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
|
||||
|
||||
assert calls, "stale same-day cache must trigger a refetch"
|
||||
assert 222.0 in out["Close"].values, "refreshed close must reach the caller"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
|
||||
# Mirror image: a fresh cache must NOT trigger a download.
|
||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
|
||||
|
||||
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
||||
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
|
||||
pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False)
|
||||
|
||||
def _fail_download(*a, **k):
|
||||
raise AssertionError("fresh cache must not refetch")
|
||||
|
||||
monkeypatch.setattr(su.yf, "download", _fail_download)
|
||||
su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
|
||||
136
tests/test_ohlcv_latest_bar.py
Normal file
136
tests/test_ohlcv_latest_bar.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""The latest trading day's bar must not silently vanish (#1201).
|
||||
|
||||
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
|
||||
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
|
||||
are normalized, and a latest in-range bar with no close raises rather than
|
||||
silently falling back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import stockstats_utils as su
|
||||
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||
|
||||
# --- date normalization -----------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_normalize_dates_strips_tz_and_normalizes_to_midnight():
|
||||
aware = pd.Series(pd.to_datetime(
|
||||
["2026-05-08 09:30:00-04:00", "2026-05-09 16:00:00-04:00"]
|
||||
))
|
||||
out = su._normalize_dates(aware)
|
||||
assert out.dt.tz is None
|
||||
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_normalize_dates_leaves_naive_dates_at_midnight():
|
||||
naive = pd.Series(pd.to_datetime(["2026-05-08 14:30:00", "2026-05-09 00:00:00"]))
|
||||
out = su._normalize_dates(naive)
|
||||
assert out.dt.tz is None
|
||||
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_normalize_dates_handles_mixed_dst_offsets():
|
||||
# 5y of US bars span DST; via a cache CSV they arrive as mixed-offset
|
||||
# strings, which pd.to_datetime can't unify. Each keeps its own local date.
|
||||
mixed = pd.Series([
|
||||
"2026-01-08 00:00:00-05:00", # EST
|
||||
"2026-06-08 00:00:00-04:00", # EDT
|
||||
"not-a-date", # -> NaT
|
||||
])
|
||||
out = su._normalize_dates(mixed)
|
||||
assert out.iloc[0] == pd.Timestamp("2026-01-08")
|
||||
assert out.iloc[1] == pd.Timestamp("2026-06-08")
|
||||
assert pd.isna(out.iloc[2])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_normalize_dates_keeps_positive_offset_local_date():
|
||||
# A Tokyo bar at local midnight (+09:00) must stay on its own calendar day,
|
||||
# not shift to the previous UTC day (which utc=True parsing would cause).
|
||||
jst = pd.Series(["2026-05-08 00:00:00+09:00"])
|
||||
assert su._normalize_dates(jst).iloc[0] == pd.Timestamp("2026-05-08")
|
||||
|
||||
|
||||
# --- fill vs guard responsibilities ----------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_clean_dataframe_keeps_nan_close_for_the_caller_to_inspect():
|
||||
# _clean_dataframe normalizes but no longer drops the NaN close itself.
|
||||
df = pd.DataFrame({"Date": ["2026-05-08", "2026-05-09"], "Close": [100.0, float("nan")]})
|
||||
cleaned = su._clean_dataframe(df)
|
||||
assert len(cleaned) == 2
|
||||
assert pd.isna(cleaned["Close"].iloc[-1])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fill_price_gaps_drops_nan_close_rows():
|
||||
df = pd.DataFrame({"Date": pd.to_datetime(["2026-05-07", "2026-05-08"]),
|
||||
"Close": [float("nan"), 100.0]})
|
||||
filled = su._fill_price_gaps(df)
|
||||
assert len(filled) == 1
|
||||
assert filled["Close"].iloc[0] == 100.0
|
||||
|
||||
|
||||
# --- load_ohlcv end-to-end (with a mocked cache read) -----------------------
|
||||
|
||||
def _run_load(monkeypatch, tmp_path, frame, curr_date):
|
||||
"""Drive load_ohlcv against a pre-seeded cache frame (no network)."""
|
||||
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||
today = pd.Timestamp(curr_date)
|
||||
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
|
||||
start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
||||
end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
(tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False))
|
||||
|
||||
def _fail_download(*a, **k):
|
||||
raise AssertionError("should use the seeded cache, not 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)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
|
||||
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
|
||||
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": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||
})
|
||||
with pytest.raises(NoMarketDataError, match="no closing price"):
|
||||
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_older_nan_close_row_is_still_dropped(monkeypatch, tmp_path):
|
||||
# A stale gap mid-series is dropped; the valid latest bar is served.
|
||||
frame = pd.DataFrame({
|
||||
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
|
||||
"Open": [100.0, 101.0, 102.0], "High": [101.0, 102.0, 103.0],
|
||||
"Low": [99.0, 100.0, 101.0],
|
||||
"Close": [100.5, float("nan"), 102.5], "Volume": [1_000_000, 1_000_000, 1_000_000],
|
||||
})
|
||||
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||
assert out["Close"].iloc[-1] == 102.5
|
||||
assert (out["Date"] == pd.Timestamp("2026-05-07")).sum() == 0 # the NaN row is gone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path):
|
||||
# A tz-aware/intraday latest bar on the cutoff day must not be filtered out
|
||||
# by a naive-vs-aware comparison.
|
||||
frame = pd.DataFrame({
|
||||
"Date": ["2026-05-07 09:30:00-04:00", "2026-05-08 09:30:00-04:00"],
|
||||
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||
"Close": [100.5, 101.5], "Volume": [1_000_000, 1_000_000],
|
||||
})
|
||||
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||
assert out["Close"].iloc[-1] == 101.5
|
||||
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")
|
||||
188
tests/test_ollama_base_url.py
Normal file
188
tests/test_ollama_base_url.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Tests for OLLAMA_BASE_URL env-var override across CLI and client paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _resync_reloaded_modules():
|
||||
"""Restore module state after this file's importlib.reload() calls.
|
||||
|
||||
Several tests below reload ``cli.utils`` to re-evaluate OLLAMA_BASE_URL.
|
||||
That leaves ``cli.main``'s star-imported names (e.g. get_ticker) bound to
|
||||
the pre-reload module objects, which breaks identity checks in unrelated
|
||||
tests that happen to run afterward. Re-sync once on teardown so the reload
|
||||
doesn't leak across test modules.
|
||||
"""
|
||||
yield
|
||||
import cli.main
|
||||
import cli.utils
|
||||
importlib.reload(cli.utils)
|
||||
importlib.reload(cli.main)
|
||||
|
||||
|
||||
# ---- openai_client side: registry-driven base_url resolution --------------
|
||||
|
||||
|
||||
def _reload_client():
|
||||
import tradingagents.llm_clients.openai_client as mod
|
||||
return importlib.reload(mod)
|
||||
|
||||
|
||||
def _base_url(mod, provider, **kwargs):
|
||||
return str(mod.OpenAIClient(model="m", provider=provider, **kwargs).get_llm().openai_api_base)
|
||||
|
||||
|
||||
def test_resolver_returns_default_when_env_unset(monkeypatch):
|
||||
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
|
||||
mod = _reload_client()
|
||||
assert _base_url(mod, "ollama") == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
def test_resolver_returns_env_when_set(monkeypatch):
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-ollama:11434/v1")
|
||||
mod = _reload_client()
|
||||
assert _base_url(mod, "ollama") == "http://remote-ollama:11434/v1"
|
||||
|
||||
|
||||
def test_resolver_evaluation_is_call_time(monkeypatch):
|
||||
"""Setting the env AFTER module import must still take effect."""
|
||||
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
|
||||
mod = _reload_client()
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://late-set:11434/v1")
|
||||
assert _base_url(mod, "ollama") == "http://late-set:11434/v1"
|
||||
|
||||
|
||||
def test_resolver_does_not_affect_other_providers(monkeypatch):
|
||||
"""OLLAMA_BASE_URL should NOT leak into xai/deepseek/etc."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://elsewhere/v1")
|
||||
mod = _reload_client()
|
||||
assert _base_url(mod, "xai") == "https://api.x.ai/v1"
|
||||
assert _base_url(mod, "deepseek") == "https://api.deepseek.com"
|
||||
|
||||
|
||||
def test_client_get_llm_picks_up_env(monkeypatch):
|
||||
"""End-to-end: OllamaClient.get_llm() respects OLLAMA_BASE_URL."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://my-ollama:11434/v1")
|
||||
mod = _reload_client()
|
||||
client = mod.OpenAIClient(model="llama3.1", provider="ollama")
|
||||
llm = client.get_llm()
|
||||
assert "my-ollama" in str(llm.openai_api_base)
|
||||
|
||||
|
||||
def test_explicit_base_url_overrides_env(monkeypatch):
|
||||
"""An explicit base_url passed to the client wins over the env var."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://env-set:11434/v1")
|
||||
mod = _reload_client()
|
||||
client = mod.OpenAIClient(
|
||||
model="llama3.1",
|
||||
provider="ollama",
|
||||
base_url="http://explicit:11434/v1",
|
||||
)
|
||||
llm = client.get_llm()
|
||||
assert "explicit" in str(llm.openai_api_base)
|
||||
assert "env-set" not in str(llm.openai_api_base)
|
||||
|
||||
|
||||
# ---- cli.utils side: select_llm_provider dropdown -------------------------
|
||||
|
||||
|
||||
def test_cli_dropdown_uses_env(monkeypatch):
|
||||
"""The Ollama entry in the CLI dropdown must reflect OLLAMA_BASE_URL."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://cli-remote:11434/v1")
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
# Reach inside the function via the same env-read it does at call time
|
||||
ollama_url = (
|
||||
__import__("os").environ.get("OLLAMA_BASE_URL")
|
||||
or "http://localhost:11434/v1"
|
||||
)
|
||||
assert ollama_url == "http://cli-remote:11434/v1"
|
||||
|
||||
|
||||
def test_cli_dropdown_default_when_unset(monkeypatch):
|
||||
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
ollama_url = (
|
||||
__import__("os").environ.get("OLLAMA_BASE_URL")
|
||||
or "http://localhost:11434/v1"
|
||||
)
|
||||
assert ollama_url == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
# ---- confirm_ollama_endpoint UX -------------------------------------------
|
||||
|
||||
|
||||
def test_confirm_endpoint_shows_default(monkeypatch, capsys):
|
||||
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
cli_utils.confirm_ollama_endpoint("http://localhost:11434/v1")
|
||||
out = capsys.readouterr().out
|
||||
assert "http://localhost:11434/v1" in out
|
||||
assert "OLLAMA_BASE_URL" not in out # not from env
|
||||
assert "Note" not in out # no warnings for the canonical default
|
||||
|
||||
|
||||
def test_confirm_endpoint_marks_env_origin(monkeypatch, capsys):
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host:11434/v1")
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
cli_utils.confirm_ollama_endpoint("http://remote-host:11434/v1")
|
||||
out = capsys.readouterr().out
|
||||
assert "http://remote-host:11434/v1" in out
|
||||
assert "OLLAMA_BASE_URL" in out
|
||||
|
||||
|
||||
def test_confirm_endpoint_warns_on_missing_scheme(monkeypatch, capsys):
|
||||
"""If user sets OLLAMA_BASE_URL=0.0.0.128, advise on the expected shape."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "0.0.0.128")
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
cli_utils.confirm_ollama_endpoint("0.0.0.128")
|
||||
out = capsys.readouterr().out
|
||||
assert "missing a scheme" in out
|
||||
assert "http://<host>:11434/v1" in out
|
||||
|
||||
|
||||
def test_confirm_endpoint_warns_on_non_default_port_remote(monkeypatch, capsys):
|
||||
"""A remote host with no :11434 gets a soft hint about port mismatch."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://remote-host/v1")
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
cli_utils.confirm_ollama_endpoint("http://remote-host/v1")
|
||||
out = capsys.readouterr().out
|
||||
assert "port 11434" in out
|
||||
|
||||
|
||||
def test_confirm_endpoint_quiet_on_local_no_port(monkeypatch, capsys):
|
||||
"""Local host without port shouldn't trigger the remote-port hint."""
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost/v1")
|
||||
import cli.utils as cli_utils
|
||||
importlib.reload(cli_utils)
|
||||
cli_utils.confirm_ollama_endpoint("http://localhost/v1")
|
||||
out = capsys.readouterr().out
|
||||
assert "Note" not in out # localhost is fine without explicit port
|
||||
|
||||
|
||||
def test_ollama_model_labels_no_local_suffix():
|
||||
"""Labels should no longer claim '(local)' since the endpoint is dynamic."""
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
for mode in ("quick", "deep"):
|
||||
labels = [label for label, _ in get_model_options("ollama", mode)]
|
||||
assert all("local" not in label for label in labels), labels
|
||||
|
||||
|
||||
def test_ollama_offers_custom_model_id():
|
||||
"""Ollama users with custom-pulled models can pick 'Custom model ID'."""
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
for mode in ("quick", "deep"):
|
||||
entries = get_model_options("ollama", mode)
|
||||
values = [v for _, v in 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
|
||||
assert values[-1] == "custom", f"'custom' should be last entry: {values}"
|
||||
100
tests/test_openai_compatible_provider.py
Normal file
100
tests/test_openai_compatible_provider.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Generic OpenAI-compatible provider (vLLM / LM Studio / llama.cpp / relays).
|
||||
|
||||
Verifies the user-supplied base_url is required and honored, the key is optional
|
||||
(keyless local default), Chat Completions (not the Responses API) is used, any
|
||||
model name is accepted, and the env backend URL precedence (#978).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.api_key_env import get_api_key_env
|
||||
from tradingagents.llm_clients.factory import create_llm_client
|
||||
from tradingagents.llm_clients.validators import validate_model
|
||||
|
||||
# Note: assert by class NAME, not isinstance — other tests reload the
|
||||
# openai_client module, which would otherwise create a second class identity.
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_factory_routes_to_openai_client():
|
||||
client = create_llm_client(
|
||||
provider="openai_compatible", model="my-model", base_url="http://localhost:8000/v1"
|
||||
)
|
||||
assert type(client).__name__ == "OpenAIClient"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_base_url_required(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="requires a base_url"):
|
||||
create_llm_client(provider="openai_compatible", model="m").get_llm()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_keyless_local_uses_placeholder_and_chat_completions(monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_COMPATIBLE_API_KEY", raising=False)
|
||||
llm = create_llm_client(
|
||||
provider="openai_compatible", model="qwen2.5", base_url="http://localhost:8000/v1"
|
||||
).get_llm()
|
||||
assert type(llm).__name__ == "LocalCompatibleChatOpenAI"
|
||||
assert str(llm.openai_api_base) == "http://localhost:8000/v1"
|
||||
# keyless local servers: a placeholder key is sent
|
||||
key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key
|
||||
assert key == "EMPTY"
|
||||
# must use Chat Completions, not OpenAI's Responses API
|
||||
assert getattr(llm, "use_responses_api", False) in (False, None)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_optional_key_from_env(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_COMPATIBLE_API_KEY", "sk-relay-123")
|
||||
llm = create_llm_client(
|
||||
provider="openai_compatible", model="m", base_url="https://relay.example/v1"
|
||||
).get_llm()
|
||||
key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key
|
||||
assert key == "sk-relay-123"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_any_model_accepted_no_forced_key():
|
||||
assert validate_model("openai_compatible", "literally-anything") is True
|
||||
# The key env exists (read for keyed relays) but the provider is marked
|
||||
# key-optional, so the CLI never forces a prompt and keyless servers work.
|
||||
assert get_api_key_env("openai_compatible") == "OPENAI_COMPATIBLE_API_KEY"
|
||||
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].key_optional is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_env_backend_url_precedence():
|
||||
# #978: explicit env URL wins over the menu/default regardless of provider source.
|
||||
from cli.utils import resolve_backend_url
|
||||
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url="http://proxy/v1") == "http://proxy/v1"
|
||||
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url=None) == "https://api.openai.com/v1"
|
||||
assert resolve_backend_url("deepseek", None, None) == "https://api.deepseek.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_structured_output_suppresses_object_tool_choice(monkeypatch):
|
||||
# LM Studio / vLLM reject the object-form tool_choice langchain sends for
|
||||
# function-calling structured output (#1057). The generic provider binds the
|
||||
# schema as a tool but must not force tool_choice.
|
||||
from langchain_openai import ChatOpenAI
|
||||
from pydantic import BaseModel
|
||||
|
||||
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",
|
||||
)
|
||||
llm = create_llm_client(
|
||||
provider="openai_compatible", model="local-llm-30b", base_url="http://localhost:1234/v1"
|
||||
).get_llm()
|
||||
out = llm.with_structured_output(Schema)
|
||||
assert out == "BOUND"
|
||||
assert captured["method"] == "function_calling"
|
||||
assert captured["tool_choice"] is None # not the object form
|
||||
42
tests/test_openai_reasoning_effort.py
Normal file
42
tests/test_openai_reasoning_effort.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""OpenAI ``reasoning_effort`` is gated to reasoning models.
|
||||
|
||||
Non-reasoning OpenAI models (gpt-4.1, gpt-4o, ...) 400 with "Unsupported
|
||||
parameter: 'reasoning.effort'". The client must drop the kwarg for those rather
|
||||
than forward it and crash the run. The GPT-5 family and the o-series accept it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.openai_client import (
|
||||
OpenAIClient,
|
||||
_supports_reasoning_effort,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected",
|
||||
[
|
||||
("gpt-5.5", True), ("gpt-5.4", True), ("gpt-5.4-mini", True),
|
||||
("gpt-5.5-pro", True), ("o1", True), ("o3-mini", True),
|
||||
("gpt-4.1", False), ("gpt-4o", False), ("gpt-4o-mini", False),
|
||||
("gpt-3.5-turbo", False),
|
||||
],
|
||||
)
|
||||
def test_supports_reasoning_effort(model, expected):
|
||||
assert _supports_reasoning_effort(model) is expected
|
||||
|
||||
|
||||
def _effort_on(model, monkeypatch):
|
||||
# A fake key lets get_llm() construct the client without a network call.
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
llm = OpenAIClient(model, provider="openai", reasoning_effort="low").get_llm()
|
||||
return getattr(llm, "reasoning_effort", None)
|
||||
|
||||
|
||||
def test_reasoning_model_receives_effort(monkeypatch):
|
||||
assert _effort_on("gpt-5.4-mini", monkeypatch) == "low"
|
||||
|
||||
|
||||
def test_non_reasoning_model_drops_effort(monkeypatch):
|
||||
# gpt-4.1 would 400 with reasoning_effort — it must be dropped.
|
||||
assert _effort_on("gpt-4.1", monkeypatch) is None
|
||||
43
tests/test_openai_responses_base_url.py
Normal file
43
tests/test_openai_responses_base_url.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""The Responses API only exists on native OpenAI; a custom base_url on the
|
||||
openai provider must fall back to Chat Completions (#1024)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.openai_client import (
|
||||
OpenAIClient,
|
||||
_is_native_openai_base_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class NativeBaseUrlTests:
|
||||
def test_unset_is_native(self):
|
||||
assert _is_native_openai_base_url(None) is True
|
||||
assert _is_native_openai_base_url("") is True
|
||||
|
||||
def test_openai_hosts_are_native(self):
|
||||
assert _is_native_openai_base_url("https://api.openai.com/v1") is True
|
||||
assert _is_native_openai_base_url("api.openai.com/v1") is True
|
||||
|
||||
def test_custom_endpoints_are_not_native(self):
|
||||
assert _is_native_openai_base_url("http://localhost:1234/v1") is False
|
||||
assert _is_native_openai_base_url("https://my-gateway.example.com/v1") is False
|
||||
assert _is_native_openai_base_url("https://api.openai.com.evil.com/v1") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class ResponsesApiSelectionTests:
|
||||
def test_native_openai_enables_responses_api(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
llm = OpenAIClient("gpt-5.5", provider="openai").get_llm()
|
||||
assert getattr(llm, "use_responses_api", False) is True
|
||||
|
||||
def test_custom_base_url_disables_responses_api(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
llm = OpenAIClient(
|
||||
"gpt-5.5", base_url="http://localhost:1234/v1", provider="openai"
|
||||
).get_llm()
|
||||
# use_responses_api should be absent/False so the client speaks Chat Completions.
|
||||
assert getattr(llm, "use_responses_api", False) is False
|
||||
122
tests/test_openrouter_model_select.py
Normal file
122
tests/test_openrouter_model_select.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""OpenRouter model selection: prompts are labeled by mode (#1000); required
|
||||
prompts exit cleanly on cancel; the output-language prompt defaults to English
|
||||
on cancel; and the OpenRouter list is newest-first."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from cli import utils
|
||||
|
||||
|
||||
def _asks(value):
|
||||
return mock.Mock(ask=mock.Mock(return_value=value))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOpenRouterPromptLabel:
|
||||
@pytest.mark.parametrize("mode,label", [("quick", "Quick-Thinking"), ("deep", "Deep-Thinking")])
|
||||
def test_prompt_states_the_mode(self, mode, label):
|
||||
captured = {}
|
||||
|
||||
def fake_select(message, **kwargs):
|
||||
captured["message"] = message
|
||||
return _asks("openrouter/some-model")
|
||||
|
||||
with mock.patch.object(utils, "_fetch_openrouter_models",
|
||||
return_value=[("Some Model", "openrouter/some-model")]), \
|
||||
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
|
||||
out = utils.select_openrouter_model(mode)
|
||||
|
||||
assert label in captured["message"]
|
||||
assert out == "openrouter/some-model"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOpenRouterLatestFirst:
|
||||
def test_models_sorted_newest_first(self):
|
||||
payload = {"data": [
|
||||
{"id": "old/model", "name": "Old", "created": 1000},
|
||||
{"id": "new/model", "name": "New", "created": 3000},
|
||||
{"id": "mid/model", "name": "Mid", "created": 2000},
|
||||
]}
|
||||
resp = mock.Mock()
|
||||
resp.json.return_value = payload
|
||||
resp.raise_for_status = mock.Mock()
|
||||
with mock.patch("requests.get", return_value=resp):
|
||||
out = utils._fetch_openrouter_models()
|
||||
assert [mid for _, mid in out] == ["new/model", "mid/model", "old/model"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMainstreamFilter:
|
||||
def test_dropdown_prefers_mainstream_over_niche(self):
|
||||
# _fetch returns newest-first; the shortlist should drop niche namespaces.
|
||||
models = [
|
||||
("Fusion", "openrouter/fusion"),
|
||||
("Niche", "nex-agi/nex-n2-pro:free"),
|
||||
("Claude", "anthropic/claude-x"),
|
||||
("GPT", "openai/gpt-x"),
|
||||
]
|
||||
captured = {}
|
||||
|
||||
def fake_select(message, **kwargs):
|
||||
captured["values"] = [c.value for c in kwargs["choices"]]
|
||||
return _asks("anthropic/claude-x")
|
||||
|
||||
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \
|
||||
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
|
||||
utils.select_openrouter_model("quick")
|
||||
|
||||
assert "anthropic/claude-x" in captured["values"]
|
||||
assert "openai/gpt-x" in captured["values"]
|
||||
assert "openrouter/fusion" not in captured["values"]
|
||||
assert "nex-agi/nex-n2-pro:free" not in captured["values"]
|
||||
assert "custom" in captured["values"] # escape hatch preserved
|
||||
|
||||
def test_falls_back_to_all_when_no_mainstream(self):
|
||||
models = [("Niche", "nex-agi/x"), ("Other", "thedrummer/y")]
|
||||
captured = {}
|
||||
|
||||
def fake_select(message, **kwargs):
|
||||
captured["values"] = [c.value for c in kwargs["choices"]]
|
||||
return _asks("nex-agi/x")
|
||||
|
||||
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \
|
||||
mock.patch.object(utils.questionary, "select", side_effect=fake_select):
|
||||
utils.select_openrouter_model("deep")
|
||||
|
||||
assert "nex-agi/x" in captured["values"] # fallback keeps the list usable
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCancelExitsCleanly:
|
||||
def test_dropdown_cancel_exits(self):
|
||||
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \
|
||||
mock.patch.object(utils.questionary, "select", return_value=_asks(None)), \
|
||||
pytest.raises(SystemExit):
|
||||
utils.select_openrouter_model("quick")
|
||||
|
||||
def test_custom_id_cancel_exits(self):
|
||||
with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \
|
||||
mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \
|
||||
mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \
|
||||
pytest.raises(SystemExit):
|
||||
utils.select_openrouter_model("deep")
|
||||
|
||||
def test_prompt_custom_model_id_cancel_exits(self):
|
||||
with mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \
|
||||
pytest.raises(SystemExit):
|
||||
utils._prompt_custom_model_id()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLanguageDefaultsToEnglish:
|
||||
def test_select_cancel_defaults_english(self):
|
||||
with mock.patch.object(utils.questionary, "select", return_value=_asks(None)):
|
||||
assert utils.ask_output_language() == "English"
|
||||
|
||||
def test_custom_language_cancel_defaults_english(self):
|
||||
with mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \
|
||||
mock.patch.object(utils.questionary, "text", return_value=_asks(None)):
|
||||
assert utils.ask_output_language() == "English"
|
||||
129
tests/test_polymarket.py
Normal file
129
tests/test_polymarket.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Polymarket prediction-market vendor: forward-looking filtering, volume
|
||||
ranking, formatting, graceful degradation, and router integration.
|
||||
|
||||
All API access is mocked, so these run without a network connection.
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import interface, polymarket
|
||||
from tradingagents.dataflows.config import set_config
|
||||
|
||||
|
||||
def _market(question, prob, *, volume, end_date, closed=False, wk=None):
|
||||
return {
|
||||
"question": question,
|
||||
"outcomes": '["Yes", "No"]',
|
||||
"outcomePrices": f'["{prob}", "{round(1 - prob, 4)}"]',
|
||||
"volumeNum": volume,
|
||||
"endDate": end_date,
|
||||
"closed": closed,
|
||||
"oneWeekPriceChange": wk,
|
||||
}
|
||||
|
||||
|
||||
# One event with a mix: a high-volume open market, a closed one, a past-dated
|
||||
# one, and a lower-volume open one. Far-future / far-past dates keep the test
|
||||
# independent of the real clock.
|
||||
_SEARCH = {
|
||||
"events": [
|
||||
{
|
||||
"markets": [
|
||||
_market("Open big?", 0.76, volume=5_000_000, end_date="2030-12-31T00:00:00Z", wk=-0.045),
|
||||
_market("Resolved already?", 1.0, volume=9_000_000, end_date="2030-12-31T00:00:00Z", closed=True),
|
||||
_market("Past event?", 0.5, volume=8_000_000, end_date="2020-01-01T00:00:00Z"),
|
||||
_market("Open small?", 0.30, volume=1_000, end_date="2030-06-30T00:00:00Z"),
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class PolymarketFilterTests(unittest.TestCase):
|
||||
def test_closed_and_past_markets_are_excluded(self):
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
out = polymarket.get_prediction_markets("anything", limit=10)
|
||||
self.assertIn("Open big?", out)
|
||||
self.assertIn("Open small?", out)
|
||||
self.assertNotIn("Resolved already?", out) # closed
|
||||
self.assertNotIn("Past event?", out) # endDate in the past
|
||||
|
||||
def test_ranked_by_volume(self):
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
out = polymarket.get_prediction_markets("anything", limit=10)
|
||||
self.assertLess(out.index("Open big?"), out.index("Open small?"))
|
||||
|
||||
def test_limit_caps_results(self):
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
out = polymarket.get_prediction_markets("anything", limit=1)
|
||||
self.assertIn("Open big?", out)
|
||||
self.assertNotIn("Open small?", out)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class PolymarketFormatTests(unittest.TestCase):
|
||||
def test_probability_volume_and_weekly_change_render(self):
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
out = polymarket.get_prediction_markets("anything", limit=10)
|
||||
self.assertIn("Yes 76%", out)
|
||||
self.assertIn("$5,000,000 volume", out)
|
||||
self.assertIn("resolves 2030-12-31", out)
|
||||
self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp
|
||||
|
||||
def test_weekly_change_omitted_when_absent(self):
|
||||
# "Open small?" has wk=None -> no 1-week clause on its line.
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
out = polymarket.get_prediction_markets("anything", limit=10)
|
||||
small_line = next(ln for ln in out.splitlines() if "Open small?" in ln)
|
||||
self.assertNotIn("1-week", small_line)
|
||||
|
||||
def test_no_matches_reports_clearly(self):
|
||||
with mock.patch.object(polymarket, "_request", return_value={"events": []}):
|
||||
out = polymarket.get_prediction_markets("obscure ticker", limit=6)
|
||||
self.assertIn("No open prediction markets", out)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class PolymarketResilienceTests(unittest.TestCase):
|
||||
def test_network_error_degrades_gracefully(self):
|
||||
# An external-service hiccup must not raise into the analyst.
|
||||
with mock.patch.object(
|
||||
polymarket, "_request", side_effect=requests.RequestException("boom")
|
||||
):
|
||||
out = polymarket.get_prediction_markets("Fed rate cut")
|
||||
self.assertIn("unavailable", out.lower())
|
||||
self.assertIn("Fed rate cut", out)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class PolymarketRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def test_category_routes_to_polymarket(self):
|
||||
self.assertEqual(
|
||||
interface.get_category_for_method("get_prediction_markets"),
|
||||
"prediction_markets",
|
||||
)
|
||||
set_config({"data_vendors": {"prediction_markets": "polymarket"}})
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_prediction_markets": {"polymarket": lambda *a, **k: "POLY_OK"}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor("get_prediction_markets", "fed", 5)
|
||||
self.assertEqual(out, "POLY_OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
59
tests/test_provider_registry.py
Normal file
59
tests/test_provider_registry.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""The OpenAI-compatible provider registry is the single source of truth for the
|
||||
family; this guards each provider's resolved config (base URL, subclass, auth,
|
||||
Responses API) so a future edit can't silently break one.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.openai_client import (
|
||||
OPENAI_COMPATIBLE_PROVIDERS,
|
||||
DeepSeekChatOpenAI,
|
||||
MinimaxChatOpenAI,
|
||||
NormalizedChatOpenAI,
|
||||
is_openai_compatible,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_registry_membership():
|
||||
assert is_openai_compatible("openai")
|
||||
assert is_openai_compatible("openai_compatible") # the generic endpoint
|
||||
# native (different API) clients are intentionally NOT in the registry
|
||||
assert not is_openai_compatible("anthropic")
|
||||
assert not is_openai_compatible("google")
|
||||
assert not is_openai_compatible("azure")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("provider,base_url,chat_class,responses", [
|
||||
("openai", None, NormalizedChatOpenAI, True),
|
||||
("xai", "https://api.x.ai/v1", NormalizedChatOpenAI, False),
|
||||
("deepseek", "https://api.deepseek.com", DeepSeekChatOpenAI, False),
|
||||
("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", NormalizedChatOpenAI, False),
|
||||
("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1", NormalizedChatOpenAI, False),
|
||||
("glm", "https://api.z.ai/api/paas/v4/", NormalizedChatOpenAI, False),
|
||||
("glm-cn", "https://open.bigmodel.cn/api/paas/v4/", NormalizedChatOpenAI, False),
|
||||
("minimax", "https://api.minimax.io/v1", MinimaxChatOpenAI, False),
|
||||
("minimax-cn", "https://api.minimaxi.com/v1", MinimaxChatOpenAI, False),
|
||||
("openrouter", "https://openrouter.ai/api/v1", NormalizedChatOpenAI, False),
|
||||
("mistral", "https://api.mistral.ai/v1", NormalizedChatOpenAI, False),
|
||||
("kimi", "https://api.moonshot.ai/v1", NormalizedChatOpenAI, False),
|
||||
("groq", "https://api.groq.com/openai/v1", NormalizedChatOpenAI, False),
|
||||
("nvidia", "https://integrate.api.nvidia.com/v1", NormalizedChatOpenAI, False),
|
||||
("ollama", "http://localhost:11434/v1", NormalizedChatOpenAI, False),
|
||||
])
|
||||
def test_registry_spec(provider, base_url, chat_class, responses):
|
||||
spec = OPENAI_COMPATIBLE_PROVIDERS[provider]
|
||||
assert spec.base_url == base_url
|
||||
assert spec.chat_class is chat_class
|
||||
assert spec.use_responses_api is responses
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_key_optionality():
|
||||
# Local/generic endpoints are key-optional; hosted APIs require a key.
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].key_optional is True
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].key_optional is True
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["openai_compatible"].require_base_url is True
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["xai"].key_optional is False
|
||||
# OLLAMA_BASE_URL is the only base-URL env override.
|
||||
assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].base_url_env == "OLLAMA_BASE_URL"
|
||||
243
tests/test_reddit_fallback.py
Normal file
243
tests/test_reddit_fallback.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""Tests for the RSS-first Reddit fetcher, its 429 backoff, the opt-in JSON
|
||||
path's degradation (#862), and chunked-transfer error handling (#1024)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import reddit
|
||||
|
||||
_SAMPLE_ATOM = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>NVDA earnings beat, stock pops</title>
|
||||
<published>2026-05-20T14:30:00+00:00</published>
|
||||
<content type="html"><!-- SC_OFF --><div class="md"><p>Great <b>quarter</b> for NVDA&#39;s datacenter unit.</p></div><!-- SC_ON --></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Is NVDA overvalued?</title>
|
||||
<published>2026-05-19T09:00:00Z</published>
|
||||
<content type="html"><p>Forward P/E discussion</p></content>
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
|
||||
|
||||
def _resp(read_fn):
|
||||
"""A minimal context-manager response whose read() runs ``read_fn``."""
|
||||
class _Resp:
|
||||
def __enter__(self_inner):
|
||||
return self_inner
|
||||
|
||||
def __exit__(self_inner, *a):
|
||||
return False
|
||||
|
||||
def read(self_inner, size=-1):
|
||||
data = read_fn()
|
||||
return data if size is None or size < 0 else data[:size]
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _atom_resp():
|
||||
return _resp(lambda: _SAMPLE_ATOM.encode("utf-8"))
|
||||
|
||||
|
||||
def _raise(exc):
|
||||
def _r():
|
||||
raise exc
|
||||
return _resp(_r)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIsoToTimestamp:
|
||||
def test_parses_offset_and_z(self):
|
||||
assert reddit._iso_to_timestamp("2026-05-20T14:30:00+00:00") > 0
|
||||
assert reddit._iso_to_timestamp("2026-05-19T09:00:00Z") > 0
|
||||
|
||||
def test_none_and_garbage_return_none(self):
|
||||
assert reddit._iso_to_timestamp(None) is None
|
||||
assert reddit._iso_to_timestamp("not-a-date") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStripHtml:
|
||||
def test_extracts_between_sc_markers_and_unescapes(self):
|
||||
raw = "<!-- SC_OFF --><div class=\"md\"><p>Great <b>quarter</b> & more</p></div><!-- SC_ON -->"
|
||||
assert reddit._strip_html(raw) == "Great quarter & more"
|
||||
|
||||
def test_empty(self):
|
||||
assert reddit._strip_html("") == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRssParsing:
|
||||
def test_parses_atom_entries(self):
|
||||
with patch.object(reddit, "urlopen", return_value=_atom_resp()):
|
||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", limit=5, timeout=5.0)
|
||||
assert len(posts) == 2
|
||||
assert posts[0]["title"] == "NVDA earnings beat, stock pops"
|
||||
assert posts[0]["source"] == "rss"
|
||||
assert posts[0]["score"] is None
|
||||
assert posts[0]["num_comments"] is None
|
||||
assert posts[0]["created_utc"] > 0
|
||||
assert "datacenter unit" in posts[0]["selftext"]
|
||||
|
||||
def test_malformed_xml_fails_open(self):
|
||||
with patch.object(reddit, "urlopen", return_value=_resp(lambda: b"<<not xml>>")):
|
||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFetchSubredditIsRssFirst:
|
||||
"""The default per-subreddit fetch goes straight to RSS — it must not hit
|
||||
the WAF-blocked JSON endpoint, which only burned rate-limit budget."""
|
||||
|
||||
def test_delegates_to_rss_without_touching_json(self):
|
||||
sentinel = [{"title": "x", "source": "rss", "score": None,
|
||||
"num_comments": None, "created_utc": None, "selftext": ""}]
|
||||
with patch.object(reddit, "_fetch_subreddit_rss", return_value=sentinel) as rss, \
|
||||
patch.object(reddit, "urlopen",
|
||||
side_effect=AssertionError("JSON endpoint must not be called")):
|
||||
out = reddit._fetch_subreddit("NVDA", "stocks", 5, 5.0)
|
||||
rss.assert_called_once()
|
||||
assert out is sentinel
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJsonPathFallsBackToRss:
|
||||
"""The opt-in JSON path still degrades to RSS on a 403 (kept for #862)."""
|
||||
|
||||
def test_403_triggers_rss(self):
|
||||
err = HTTPError("url", 403, "Blocked", {}, None)
|
||||
rss_posts = [{"title": "x", "source": "rss", "score": None,
|
||||
"num_comments": None, "created_utc": None, "selftext": ""}]
|
||||
with patch.object(reddit, "urlopen", side_effect=err), \
|
||||
patch.object(reddit, "_fetch_subreddit_rss", return_value=rss_posts) as rss:
|
||||
out = reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
||||
rss.assert_called_once()
|
||||
assert out and out[0]["source"] == "rss"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRss429Backoff:
|
||||
def test_429_then_success_retries_once(self):
|
||||
err = HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]) as op, \
|
||||
patch.object(reddit.time, "sleep") as slept:
|
||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||
assert op.call_count == 2 # original + exactly one retry
|
||||
slept.assert_called_once() # backed off before retrying
|
||||
assert len(posts) == 2
|
||||
|
||||
def test_429_twice_gives_up_after_one_retry(self):
|
||||
err = HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||
with patch.object(reddit, "urlopen", side_effect=[err, err]) as op, \
|
||||
patch.object(reddit.time, "sleep"):
|
||||
posts = reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||
assert op.call_count == 2 # one retry, then gives up cleanly
|
||||
assert posts == []
|
||||
|
||||
def test_retry_after_header_is_honoured(self):
|
||||
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "12"}, 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(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 4.0 <= wait <= 6.0 # 5s +/-20% jitter
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestChunkedTransferErrorsHandled:
|
||||
"""IncompleteRead/RemoteDisconnected come from http.client and are NOT
|
||||
OSErrors, so they were previously uncaught and crashed the pipeline (#1024)."""
|
||||
|
||||
def test_rss_incomplete_read_degrades_to_empty(self):
|
||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))):
|
||||
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
||||
|
||||
def test_json_incomplete_read_falls_back_to_rss(self):
|
||||
with patch.object(reddit, "urlopen", return_value=_raise(http.client.IncompleteRead(b""))), \
|
||||
patch.object(reddit, "_fetch_subreddit_rss", return_value=[]) as rss:
|
||||
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
||||
rss.assert_called_once()
|
||||
|
||||
def test_oversized_rss_feed_is_refused_not_parsed(self):
|
||||
# A hostile/misbehaving endpoint streaming an unbounded body must not be
|
||||
# read into memory before parsing; overflow degrades to an empty feed.
|
||||
big = _resp(lambda: b"x" * 100)
|
||||
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) == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFormatterHandlesRssPosts:
|
||||
def test_rss_posts_omit_fake_counts_and_note_source(self):
|
||||
rss_posts = [{
|
||||
"title": "NVDA pops", "score": None, "num_comments": None,
|
||||
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
||||
"selftext": "great quarter", "source": "rss",
|
||||
}]
|
||||
with patch.object(reddit, "_fetch_subreddit", return_value=rss_posts):
|
||||
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
|
||||
assert "via RSS feed" in out
|
||||
assert "↑" not in out # no fake score arrow
|
||||
assert "NVDA pops" in out
|
||||
assert "great quarter" in out
|
||||
|
||||
def test_json_posts_still_show_counts(self):
|
||||
json_posts = [{
|
||||
"title": "NVDA pops", "score": 1234, "num_comments": 56,
|
||||
"created_utc": reddit._iso_to_timestamp("2026-05-20T14:30:00Z"),
|
||||
"selftext": "",
|
||||
}]
|
||||
with patch.object(reddit, "_fetch_subreddit", return_value=json_posts):
|
||||
out = reddit.fetch_reddit_posts("NVDA", subreddits=("stocks",), inter_request_delay=0)
|
||||
assert "1234↑" in out
|
||||
assert "56c" in out
|
||||
assert "via RSS" not in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCryptoSearchTerm:
|
||||
"""A crypto pair (BTC-USD) barely matches Reddit text; search the base (#1113)."""
|
||||
|
||||
def _captured_ticker(self, ticker):
|
||||
seen = {}
|
||||
|
||||
def fake_fetch(t, sub, limit, timeout):
|
||||
seen["ticker"] = t
|
||||
return []
|
||||
|
||||
with patch.object(reddit, "_fetch_subreddit", side_effect=fake_fetch):
|
||||
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",), inter_request_delay=0)
|
||||
return seen["ticker"]
|
||||
|
||||
def test_crypto_pair_searches_base(self):
|
||||
assert self._captured_ticker("BTC-USD") == "BTC"
|
||||
|
||||
def test_equity_passes_through(self):
|
||||
assert self._captured_ticker("NVDA") == "NVDA"
|
||||
50
tests/test_reporting.py
Normal file
50
tests/test_reporting.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Report parity: the shared writer produces the report tree for the CLI and the
|
||||
programmatic API alike (#1037)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.reporting import write_report_tree
|
||||
|
||||
|
||||
def _state():
|
||||
return {
|
||||
"market_report": "MKT",
|
||||
"news_report": "NEWS",
|
||||
"investment_debate_state": {"judge_decision": "RM PLAN"},
|
||||
"trader_investment_plan": "TRADE",
|
||||
"risk_debate_state": {"judge_decision": "PM DECISION"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_write_report_tree_creates_files(tmp_path):
|
||||
out = write_report_tree(_state(), "AAPL", tmp_path)
|
||||
assert out.name == "complete_report.md"
|
||||
assert (tmp_path / "1_analysts" / "market.md").read_text() == "MKT"
|
||||
assert (tmp_path / "1_analysts" / "news.md").read_text() == "NEWS"
|
||||
assert (tmp_path / "2_research" / "manager.md").read_text() == "RM PLAN"
|
||||
assert (tmp_path / "3_trading" / "trader.md").read_text() == "TRADE"
|
||||
assert (tmp_path / "5_portfolio" / "decision.md").read_text() == "PM DECISION"
|
||||
complete = out.read_text()
|
||||
assert "Trading Analysis Report: AAPL" in complete
|
||||
assert "MKT" in complete and "PM DECISION" in complete
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_reports_explicit_path(tmp_path):
|
||||
# Unbound: with an explicit save_path, the method doesn't touch self/config.
|
||||
out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path)
|
||||
assert (tmp_path / "complete_report.md").exists()
|
||||
assert out == tmp_path / "complete_report.md"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_reports_defaults_under_results_dir(tmp_path):
|
||||
mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)})
|
||||
out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL")
|
||||
assert out.exists()
|
||||
assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_<stamp>/...
|
||||
assert out.parent.name.startswith("AAPL_")
|
||||
81
tests/test_risk_router_path_map.py
Normal file
81
tests/test_risk_router_path_map.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Shared-router / path_map completeness (#1088).
|
||||
|
||||
Both `should_continue_risk_analysis` (three risk edges) and
|
||||
`should_continue_debate` (two research-debate edges) are single routers whose
|
||||
return set is larger than any one edge previously mapped. Each edge now shares a
|
||||
complete path map (`RISK_ANALYSIS_PATH_MAP` / `DEBATE_PATH_MAP`), so a
|
||||
fall-through return can never hit a missing entry -- which would crash LangGraph
|
||||
mid-run on prompt/i18n/refactor drift in the speaker labels.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tradingagents.graph.conditional_logic import ConditionalLogic
|
||||
from tradingagents.graph.setup import DEBATE_PATH_MAP, RISK_ANALYSIS_PATH_MAP
|
||||
|
||||
|
||||
def _state(latest_speaker, count=0):
|
||||
return {"risk_debate_state": {"latest_speaker": latest_speaker, "count": count}}
|
||||
|
||||
|
||||
def _debate_state(current_response, count=0):
|
||||
return {"investment_debate_state": {"current_response": current_response, "count": count}}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("latest_speaker", [
|
||||
"Aggressive", "Aggressive Analyst",
|
||||
"Conservative", "Conservative Analyst",
|
||||
"Neutral", "Neutral Analyst",
|
||||
"", # drift: empty label
|
||||
"Aggressive Risk Analyst", # drift: node renamed
|
||||
"Agresivo", # drift: i18n / translated label
|
||||
])
|
||||
def test_router_return_always_routable(latest_speaker):
|
||||
logic = ConditionalLogic(max_risk_discuss_rounds=1)
|
||||
target = logic.should_continue_risk_analysis(_state(latest_speaker))
|
||||
assert target in RISK_ANALYSIS_PATH_MAP
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_router_terminates_at_round_limit():
|
||||
logic = ConditionalLogic(max_risk_discuss_rounds=1)
|
||||
# count >= 3 * rounds routes to the Portfolio Manager (debate ends)
|
||||
assert logic.should_continue_risk_analysis(_state("Neutral", count=3)) == "Portfolio Manager"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_path_map_covers_full_router_range():
|
||||
logic = ConditionalLogic(max_risk_discuss_rounds=1)
|
||||
returns = {
|
||||
logic.should_continue_risk_analysis(_state(s, c))
|
||||
for s in ("Aggressive", "Conservative", "Neutral", "drift")
|
||||
for c in (0, 99)
|
||||
}
|
||||
# Every value the router can emit is a key in the shared map...
|
||||
assert returns <= set(RISK_ANALYSIS_PATH_MAP)
|
||||
# ...and the terminal target is reachable.
|
||||
assert "Portfolio Manager" in returns
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("current_response", [
|
||||
"Bull", "Bull Researcher", "Bear", "Bear Researcher",
|
||||
"", # drift: empty label
|
||||
"Optimista", # drift: i18n / translated label
|
||||
])
|
||||
def test_debate_router_return_always_routable(current_response):
|
||||
logic = ConditionalLogic(max_debate_rounds=1)
|
||||
target = logic.should_continue_debate(_debate_state(current_response))
|
||||
assert target in DEBATE_PATH_MAP
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_debate_path_map_covers_full_router_range():
|
||||
logic = ConditionalLogic(max_debate_rounds=1)
|
||||
returns = {
|
||||
logic.should_continue_debate(_debate_state(s, c))
|
||||
for s in ("Bull", "Bear", "drift")
|
||||
for c in (0, 99)
|
||||
}
|
||||
assert returns <= set(DEBATE_PATH_MAP)
|
||||
assert "Research Manager" in returns # terminal reachable
|
||||
57
tests/test_safe_ticker_component.py
Normal file
57
tests/test_safe_ticker_component.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Tests for the ticker path-component validator that blocks directory traversal."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows.utils import safe_ticker_component
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSafeTickerComponent(unittest.TestCase):
|
||||
def test_accepts_common_ticker_formats(self):
|
||||
for ticker in ("AAPL", "BRK-B", "BRK.A", "0700.HK", "7203.T", "BHP.AX", "^GSPC"):
|
||||
self.assertEqual(safe_ticker_component(ticker), ticker)
|
||||
|
||||
def test_accepts_futures_and_forex_formats(self):
|
||||
# Futures use '=' (GC=F gold, CL=F crude), forex/CFD symbols use '+'.
|
||||
for ticker in ("GC=F", "CL=F", "ES=F", "XAUUSD+", "EURUSD+"):
|
||||
self.assertEqual(safe_ticker_component(ticker), ticker)
|
||||
|
||||
def test_rejects_path_separators(self):
|
||||
for bad in (".", "..", "../etc", "a/b", "a\\b", "/abs", "..\\..\\x"):
|
||||
with self.assertRaises(ValueError):
|
||||
safe_ticker_component(bad)
|
||||
|
||||
def test_rejects_null_byte_and_whitespace(self):
|
||||
for bad in ("AAP L", "AAPL\x00", "AAPL\n", "\tAAPL"):
|
||||
with self.assertRaises(ValueError):
|
||||
safe_ticker_component(bad)
|
||||
|
||||
def test_rejects_empty_or_non_string(self):
|
||||
for bad in ("", None, 123, b"AAPL"):
|
||||
with self.assertRaises(ValueError):
|
||||
safe_ticker_component(bad)
|
||||
|
||||
def test_rejects_overlong_input(self):
|
||||
with self.assertRaises(ValueError):
|
||||
safe_ticker_component("A" * 33)
|
||||
|
||||
def test_rejects_dot_only_values(self):
|
||||
# '.' and '..' pass the regex but traverse when used as a path
|
||||
# component (e.g. ``Path(results_dir) / ticker / "logs"``).
|
||||
for bad in (".", "..", "...", "...."):
|
||||
with self.assertRaises(ValueError):
|
||||
safe_ticker_component(bad)
|
||||
|
||||
def test_traversal_string_does_not_escape_join(self):
|
||||
"""Sanity: sanitized values stay within base when joined."""
|
||||
base = os.path.realpath("/tmp/cache")
|
||||
ticker = safe_ticker_component("AAPL")
|
||||
joined = os.path.realpath(os.path.join(base, f"{ticker}.csv"))
|
||||
self.assertTrue(joined.startswith(base + os.sep))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,10 +10,15 @@ to it.
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating
|
||||
from tradingagents.agents.utils.rating import (
|
||||
RATING_REVIEW,
|
||||
RATINGS_5_TIER,
|
||||
extract_rating,
|
||||
is_review,
|
||||
parse_rating,
|
||||
)
|
||||
from tradingagents.graph.signal_processing import SignalProcessor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heuristic parser
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -85,6 +90,51 @@ class TestSignalProcessor:
|
||||
llm.invoke.assert_not_called()
|
||||
llm.with_structured_output.assert_not_called()
|
||||
|
||||
def test_default_when_no_rating_present(self):
|
||||
def test_unparseable_signal_is_review_not_silent_hold(self):
|
||||
# #1170: an unrecognizable decision must surface REVIEW, not a fabricated
|
||||
# tradeable Hold.
|
||||
sp = SignalProcessor()
|
||||
assert sp.process_signal("Plain prose without a recommendation.") == "Hold"
|
||||
signal = sp.process_signal("Plain prose without a recommendation.")
|
||||
assert signal == RATING_REVIEW
|
||||
assert is_review(signal)
|
||||
assert signal not in RATINGS_5_TIER
|
||||
|
||||
def test_fullwidth_colon_is_parsed_not_reviewed(self):
|
||||
# #1170: `Rating:Overweight` (fullwidth colon) used to defeat the regex
|
||||
# and silently become Hold; NFKC normalization now parses it.
|
||||
sp = SignalProcessor()
|
||||
assert sp.process_signal("Rating:Overweight\n理由はこちら。") == "Overweight"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExtractRating:
|
||||
def test_returns_none_when_absent(self):
|
||||
assert extract_rating("No directional call here.") is None
|
||||
assert extract_rating("") is None
|
||||
|
||||
def test_whole_word_only(self):
|
||||
# substrings inside larger words must not match
|
||||
assert extract_rating("The buyer was holding shares.") is None
|
||||
|
||||
def test_parse_rating_keeps_silent_default_for_compat(self):
|
||||
# parse_rating (used by the memory log) intentionally keeps Hold default.
|
||||
assert parse_rating("No rating here.") == "Hold"
|
||||
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphSignalContract:
|
||||
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
|
||||
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
|
||||
|
||||
def _bare_graph(self):
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.signal_processor = SignalProcessor()
|
||||
return g
|
||||
|
||||
def test_graph_surfaces_review(self):
|
||||
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
|
||||
|
||||
def test_graph_returns_rating(self):
|
||||
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"
|
||||
|
||||
121
tests/test_social_lookahead.py
Normal file
121
tests/test_social_lookahead.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Historical social sentiment must not leak current data into a backtest (#1220).
|
||||
|
||||
StockTwits and Reddit fetchers pull only recent items, so for a historical run
|
||||
they must be trimmed to the analysis window (and yield a clear placeholder when
|
||||
nothing qualifies) rather than showing today's chatter as if it were from the
|
||||
as-of date. All three sources share dataflows.date_window.in_window.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import reddit, stocktwits
|
||||
from tradingagents.dataflows.date_window import in_window
|
||||
|
||||
|
||||
class _JsonResp:
|
||||
"""Minimal urlopen() context-manager stub returning a JSON body."""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._body = json.dumps(payload).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
# --- shared window helper ---------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_in_window_bounds_and_exclusive_upper():
|
||||
start = datetime(2026, 5, 1)
|
||||
end = datetime(2026, 5, 9)
|
||||
assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True
|
||||
assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True
|
||||
# exactly midnight after end -> excluded (no leak)
|
||||
assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False
|
||||
# offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z
|
||||
assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_in_window_undated_excluded_in_backtest_kept_live():
|
||||
old = datetime(2026, 5, 9)
|
||||
assert in_window(None, datetime(2026, 5, 1), old) is False # historical
|
||||
now = datetime.now(timezone.utc)
|
||||
assert in_window(None, now, now) is True # live
|
||||
|
||||
|
||||
# --- StockTwits -------------------------------------------------------------
|
||||
|
||||
def _msg(created_iso, sentiment=None):
|
||||
return {
|
||||
"created_at": created_iso,
|
||||
"user": {"username": "u"},
|
||||
"entities": {"sentiment": {"basic": sentiment}},
|
||||
"body": "text",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stocktwits_historical_window_excludes_recent(monkeypatch):
|
||||
# All messages are "today"; a run as-of a past week must show none of them.
|
||||
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
|
||||
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")
|
||||
assert "no StockTwits messages" in out
|
||||
assert "2026-05-01..2026-05-08" in out
|
||||
assert "Bullish: 1" not in out # the recent bullish message did not leak
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stocktwits_live_window_keeps_in_range(monkeypatch):
|
||||
msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")]
|
||||
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 "Total: 2" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stocktwits_no_window_is_unfiltered(monkeypatch):
|
||||
msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")]
|
||||
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||
out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates
|
||||
assert "Total: 1" in out
|
||||
|
||||
|
||||
# --- Reddit -----------------------------------------------------------------
|
||||
|
||||
def _epoch(date_str):
|
||||
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reddit_historical_window_excludes_recent(monkeypatch):
|
||||
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
|
||||
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||
out = reddit.fetch_reddit_posts(
|
||||
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||
start_date="2026-05-01", end_date="2026-05-08",
|
||||
)
|
||||
assert "NOW" not in out
|
||||
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reddit_live_window_keeps_in_range(monkeypatch):
|
||||
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
|
||||
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||
out = reddit.fetch_reddit_posts(
|
||||
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||
start_date="2026-05-01", end_date="2026-05-08",
|
||||
)
|
||||
assert "INRANGE" in out
|
||||
70
tests/test_stockstats_date_column.py
Normal file
70
tests/test_stockstats_date_column.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for tolerating a non-`Date` index column in stockstats_utils (#890).
|
||||
|
||||
Guards against a download frame whose date column is `index` or `Datetime`
|
||||
instead of `Date`, which would otherwise silently drop every indicator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import stockstats_utils as su
|
||||
|
||||
|
||||
def _ohlcv(date_col: str) -> pd.DataFrame:
|
||||
"""OHLCV frame whose date column is named `date_col`."""
|
||||
dates = pd.bdate_range("2026-04-01", periods=10)
|
||||
return pd.DataFrame({
|
||||
date_col: dates,
|
||||
"Open": [100.0 + i for i in range(10)],
|
||||
"High": [101.0 + i for i in range(10)],
|
||||
"Low": [99.0 + i for i in range(10)],
|
||||
"Close": [100.5 + i for i in range(10)],
|
||||
"Volume": [1_000_000 + i for i in range(10)],
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEnsureDateColumn:
|
||||
def test_renames_index_column(self):
|
||||
out = su._ensure_date_column(_ohlcv("index"))
|
||||
assert "Date" in out.columns and "index" not in out.columns
|
||||
|
||||
def test_renames_datetime_and_date_variants(self):
|
||||
assert "Date" in su._ensure_date_column(_ohlcv("Datetime")).columns
|
||||
assert "Date" in su._ensure_date_column(_ohlcv("date")).columns
|
||||
|
||||
def test_leaves_existing_date_untouched(self):
|
||||
df = _ohlcv("Date")
|
||||
assert su._ensure_date_column(df) is df # no-op short-circuit
|
||||
|
||||
def test_no_datelike_column_is_left_alone(self):
|
||||
df = pd.DataFrame({"Close": [1, 2, 3]})
|
||||
out = su._ensure_date_column(df)
|
||||
assert "Date" not in out.columns # nothing to rename; caller handles
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCleanDataframeAcrossVersions:
|
||||
def test_clean_handles_index_column(self):
|
||||
"""A frame with `index` instead of `Date` must still clean to a
|
||||
usable, date-parsed frame (was KeyError: 'Date')."""
|
||||
cleaned = su._clean_dataframe(_ohlcv("index"))
|
||||
assert "Date" in cleaned.columns
|
||||
assert pd.api.types.is_datetime64_any_dtype(cleaned["Date"])
|
||||
assert len(cleaned) == 10
|
||||
|
||||
def test_clean_handles_legacy_date_column(self):
|
||||
cleaned = su._clean_dataframe(_ohlcv("Date"))
|
||||
assert len(cleaned) == 10
|
||||
|
||||
def test_indicators_compute_after_index_rename(self):
|
||||
"""stockstats must compute indicators on a frame whose date column
|
||||
arrived as `index`, instead of erroring per indicator."""
|
||||
from stockstats import wrap
|
||||
cleaned = su._clean_dataframe(_ohlcv("index"))
|
||||
df = wrap(cleaned)
|
||||
df["close_5_sma"] # triggers calculation
|
||||
assert "close_5_sma" in df.columns
|
||||
assert df["close_5_sma"].notna().any()
|
||||
77
tests/test_stocktwits_resilience.py
Normal file
77
tests/test_stocktwits_resilience.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""StockTwits fetch: transport-error resilience (#1024) and crypto symbol
|
||||
mapping (#1113).
|
||||
|
||||
StockTwits lists crypto under ``<BASE>.X`` (Yahoo's ``BTC-USD`` 404s), and any
|
||||
transport error must degrade to a placeholder rather than raise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows import stocktwits
|
||||
|
||||
|
||||
def _raise(exc):
|
||||
class _Resp:
|
||||
def __enter__(self_inner):
|
||||
return self_inner
|
||||
|
||||
def __exit__(self_inner, *a):
|
||||
return False
|
||||
|
||||
def read(self_inner):
|
||||
raise exc
|
||||
return _Resp()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStockTwitsResilience:
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
http.client.IncompleteRead(b""),
|
||||
HTTPError("url", 503, "down", {}, None),
|
||||
TimeoutError("slow"),
|
||||
],
|
||||
)
|
||||
def test_transport_errors_return_placeholder(self, exc):
|
||||
with patch.object(stocktwits, "urlopen", return_value=_raise(exc)):
|
||||
out = stocktwits.fetch_stocktwits_messages("NVDA")
|
||||
assert "unavailable" in out.lower()
|
||||
assert out.startswith("<stocktwits unavailable")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStockTwitsCryptoSymbols:
|
||||
@pytest.mark.parametrize(
|
||||
("ticker", "expected"),
|
||||
[
|
||||
("BTC-USD", "BTC.X"),
|
||||
("eth-usd", "ETH.X"),
|
||||
("SOL-USD", "SOL.X"),
|
||||
("BTCUSD", "BTC.X"), # undashed broker form
|
||||
("BTC-USDT", "BTC.X"), # stablecoin quote
|
||||
("AMD", "AMD"),
|
||||
("BRK-B", "BRK-B"), # dashed class share: untouched
|
||||
("GOLD", "GOLD"), # real equity (aliases elsewhere): untouched here
|
||||
("XYZ-USD", "XYZ-USD"), # unknown base: not treated as crypto
|
||||
],
|
||||
)
|
||||
def test_symbol_mapping(self, ticker, expected):
|
||||
assert stocktwits._stocktwits_symbol(ticker) == expected
|
||||
|
||||
def test_crypto_pair_requests_dot_x_endpoint(self):
|
||||
seen = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
seen["url"] = req.full_url
|
||||
raise TimeoutError("stop after capturing the URL")
|
||||
|
||||
with patch.object(stocktwits, "urlopen", side_effect=fake_urlopen):
|
||||
stocktwits.fetch_stocktwits_messages("BTC-USD")
|
||||
assert "/symbol/BTC.X.json" in seen["url"]
|
||||
148
tests/test_structured_agent_prompts.py
Normal file
148
tests/test_structured_agent_prompts.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Agents on the schema-only structured-output path must not invite tool calls (#1130).
|
||||
|
||||
`with_structured_output` binds exactly one tool (the schema). A prompt that
|
||||
primes tool use makes models emit an unknown `web_search` call, which discards
|
||||
the structured attempt and forces a free-text retry — an extra LLM round trip
|
||||
and the loss of typed output.
|
||||
|
||||
These assert the constraint reaches the *rendered* prompt each agent actually
|
||||
sends, not merely that the constant is referenced in the module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.agents.analysts.sentiment_analyst as sentiment
|
||||
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.agents.utils.structured import NO_EXTERNAL_TOOLS
|
||||
|
||||
|
||||
def _capturing_llm(captured: dict, result):
|
||||
"""LLM whose structured binding records the prompt it was handed."""
|
||||
structured = MagicMock()
|
||||
structured.invoke.side_effect = lambda prompt: (
|
||||
captured.__setitem__("prompt", prompt) or result
|
||||
)
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.return_value = structured
|
||||
return llm
|
||||
|
||||
|
||||
def _prompt_text(prompt) -> str:
|
||||
"""Flatten a captured prompt (str, message list, or objects) to text."""
|
||||
if isinstance(prompt, str):
|
||||
return prompt
|
||||
parts = []
|
||||
for m in prompt:
|
||||
parts.append(m.get("content", "") if isinstance(m, dict) else getattr(m, "content", ""))
|
||||
return "\n".join(str(p) for p in parts)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_trader_prompt_states_constraint():
|
||||
from tradingagents.agents.schemas import TraderAction, TraderProposal
|
||||
|
||||
captured = {}
|
||||
llm = _capturing_llm(captured, TraderProposal(action=TraderAction.BUY, reasoning="x"))
|
||||
create_trader(llm)({
|
||||
"company_of_interest": "NVDA",
|
||||
"investment_plan": "**Recommendation**: Buy",
|
||||
"market_report": "Current price $189.5; ATR 4.2.",
|
||||
})
|
||||
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_research_manager_prompt_states_constraint():
|
||||
from tradingagents.agents.schemas import PortfolioRating, ResearchPlan
|
||||
|
||||
captured = {}
|
||||
llm = _capturing_llm(
|
||||
captured,
|
||||
ResearchPlan(
|
||||
recommendation=PortfolioRating.BUY, rationale="x", strategic_actions="y"
|
||||
),
|
||||
)
|
||||
create_research_manager(llm)({
|
||||
"company_of_interest": "NVDA",
|
||||
"investment_debate_state": {
|
||||
"history": "h", "bull_history": "b", "bear_history": "r",
|
||||
"current_response": "", "judge_decision": "", "count": 1,
|
||||
},
|
||||
})
|
||||
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_portfolio_manager_prompt_states_constraint():
|
||||
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
|
||||
|
||||
captured = {}
|
||||
llm = _capturing_llm(
|
||||
captured,
|
||||
PortfolioDecision(
|
||||
rating=PortfolioRating.HOLD,
|
||||
executive_summary="x",
|
||||
investment_thesis="y",
|
||||
),
|
||||
)
|
||||
risk = {
|
||||
"history": "h", "aggressive_history": "a", "conservative_history": "c",
|
||||
"neutral_history": "n", "current_aggressive_response": "",
|
||||
"current_conservative_response": "", "current_neutral_response": "",
|
||||
"latest_speaker": "Neutral", "count": 1,
|
||||
}
|
||||
create_portfolio_manager(llm)({
|
||||
"company_of_interest": "NVDA",
|
||||
"risk_debate_state": risk,
|
||||
"investment_plan": "plan",
|
||||
"trader_investment_plan": "trader plan",
|
||||
})
|
||||
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sentiment_prompt_states_constraint(monkeypatch):
|
||||
from tradingagents.agents.schemas import SentimentBand, SentimentReport
|
||||
|
||||
# Pre-fetched sources are stubbed so the prompt builds without network I/O.
|
||||
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)
|
||||
|
||||
captured = {}
|
||||
llm = _capturing_llm(captured, SentimentReport(
|
||||
overall_band=SentimentBand.BULLISH, overall_score=7.5,
|
||||
confidence="high", narrative="n",
|
||||
))
|
||||
sentiment.create_sentiment_analyst(llm)({
|
||||
"company_of_interest": "NVDA", "trade_date": "2026-01-15",
|
||||
"asset_type": "stock", "messages": [],
|
||||
})
|
||||
text = _prompt_text(captured["prompt"])
|
||||
assert NO_EXTERNAL_TOOLS in text
|
||||
# This agent binds no tools, so tool-range wording must not reappear.
|
||||
assert "tool-call date ranges" not in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tool_using_analysts_keep_their_date_guidance():
|
||||
# The analysts that really do call tools keep the wording that anchors their
|
||||
# tool date ranges (#836) — this fix is scoped to no-tool agents.
|
||||
import tradingagents.agents.analysts.market_analyst as market
|
||||
import tradingagents.agents.analysts.news_analyst as news
|
||||
for module in (market, news):
|
||||
assert "tool-call date ranges" in inspect.getsource(module)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_constraint_text_is_unambiguous():
|
||||
assert "do not call external tools" in NO_EXTERNAL_TOOLS.lower()
|
||||
# No template braces: it is embedded in ChatPromptTemplate strings, where
|
||||
# braces would be parsed as input variables.
|
||||
assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS
|
||||
@@ -1,28 +1,33 @@
|
||||
"""Tests for structured-output agents (Trader and Research Manager).
|
||||
"""Tests for structured-output agents (Trader, Research Manager, Sentiment Analyst).
|
||||
|
||||
The Portfolio Manager has its own coverage in tests/test_memory_log.py
|
||||
(which exercises the full memory-log → PM injection cycle). This file
|
||||
covers the parallel schemas, render functions, and graceful-fallback
|
||||
behavior we added for the Trader and Research Manager so all three
|
||||
decision-making agents share the same shape.
|
||||
behavior we added for the Trader, Research Manager, and Sentiment Analyst
|
||||
so they share the same deterministic output shape.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tradingagents.agents.analysts.sentiment_analyst import create_sentiment_analyst
|
||||
from tradingagents.agents.managers.research_manager import create_research_manager
|
||||
from tradingagents.agents.schemas import (
|
||||
PortfolioDecision,
|
||||
PortfolioRating,
|
||||
ResearchPlan,
|
||||
SentimentBand,
|
||||
SentimentReport,
|
||||
TraderAction,
|
||||
TraderProposal,
|
||||
render_research_plan,
|
||||
render_sentiment_report,
|
||||
render_trader_proposal,
|
||||
)
|
||||
from tradingagents.agents.trader.trader import create_trader
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render functions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -63,6 +68,36 @@ class TestRenderTraderProposal:
|
||||
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNullishFloatCoercion:
|
||||
"""A weak LLM may write "None"/"N/A" into an optional float field (#1058);
|
||||
coerce those to None so the structured call validates instead of erroring."""
|
||||
|
||||
def test_trader_nullish_strings_coerce_to_none(self):
|
||||
for sentinel in ("None", "N/A", "null", "-", "", "TBD"):
|
||||
p = TraderProposal(
|
||||
action=TraderAction.HOLD,
|
||||
reasoning="x",
|
||||
entry_price=sentinel,
|
||||
stop_loss=sentinel,
|
||||
)
|
||||
assert p.entry_price is None
|
||||
assert p.stop_loss is None
|
||||
|
||||
def test_trader_real_numeric_string_still_parses(self):
|
||||
p = TraderProposal(action=TraderAction.BUY, reasoning="x", entry_price="189.5")
|
||||
assert p.entry_price == 189.5
|
||||
|
||||
def test_pm_nullish_price_target_coerces_to_none(self):
|
||||
d = PortfolioDecision(
|
||||
rating=PortfolioRating.OVERWEIGHT,
|
||||
executive_summary="s",
|
||||
investment_thesis="t",
|
||||
price_target="N/A",
|
||||
)
|
||||
assert d.price_target is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRenderResearchPlan:
|
||||
def test_required_fields(self):
|
||||
@@ -96,6 +131,7 @@ def _make_trader_state():
|
||||
return {
|
||||
"company_of_interest": "NVDA",
|
||||
"investment_plan": "**Recommendation**: Buy\n**Rationale**: ...\n**Strategic Actions**: ...",
|
||||
"market_report": "Current price $189.5; 14-day ATR 4.2; support $178, resistance $196.",
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +153,24 @@ def _structured_trader_llm(captured: dict, proposal: TraderProposal | None = Non
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invoke_structured_falls_back_when_result_is_none():
|
||||
# A thinking model can answer in plain text, leaving the parser with None.
|
||||
# That must fall back to free text, not crash on render(None) (#1051).
|
||||
from tradingagents.agents.utils.structured import invoke_structured_or_freetext
|
||||
|
||||
structured = MagicMock()
|
||||
structured.invoke.return_value = None
|
||||
plain = MagicMock()
|
||||
plain.invoke.return_value = MagicMock(content="FREETEXT")
|
||||
|
||||
out = invoke_structured_or_freetext(
|
||||
structured, plain, "prompt", render=lambda r: r.rating, agent_name="t"
|
||||
)
|
||||
assert out == "FREETEXT"
|
||||
plain.invoke.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTraderAgent:
|
||||
def test_structured_path_produces_rendered_markdown(self):
|
||||
@@ -147,6 +201,31 @@ class TestTraderAgent:
|
||||
prompt = captured["prompt"]
|
||||
assert any("Proposed Investment Plan" in m["content"] for m in prompt)
|
||||
|
||||
def test_prompt_includes_market_report_for_price_levels(self):
|
||||
# #1167: the Trader must see the technical market report so entry/stop
|
||||
# levels are grounded in real price structure, not just the digested plan.
|
||||
captured = {}
|
||||
trader = create_trader(_structured_trader_llm(captured))
|
||||
trader(_make_trader_state())
|
||||
user = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "user")
|
||||
system = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "system")
|
||||
assert "Technical Market Report:" in user
|
||||
assert "14-day ATR 4.2" in user # the actual report content reached the Trader
|
||||
assert "support $178, resistance $196" in user
|
||||
assert "Ground concrete price levels" in system
|
||||
|
||||
def test_empty_market_report_omits_the_section_and_grounding(self):
|
||||
# #1167: when the market analyst wasn't selected the report is empty, so
|
||||
# don't tell the Trader to ground levels in a report it doesn't have.
|
||||
captured = {}
|
||||
state = _make_trader_state()
|
||||
state["market_report"] = ""
|
||||
create_trader(_structured_trader_llm(captured))(state)
|
||||
text = " ".join(m["content"] for m in captured["prompt"])
|
||||
assert "Technical Market Report:" not in text
|
||||
assert "Ground concrete price levels" not in text
|
||||
assert "Proposed Investment Plan" in text # still present
|
||||
|
||||
def test_falls_back_to_freetext_when_structured_unavailable(self):
|
||||
plain_response = (
|
||||
"**Action**: Sell\n\nGuidance cut hits margins.\n\n"
|
||||
@@ -230,3 +309,126 @@ class TestResearchManagerAgent:
|
||||
rm = create_research_manager(llm)
|
||||
result = rm(_make_rm_state())
|
||||
assert result["investment_plan"] == plain_response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentiment Analyst: schema, render, structured happy path + fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRenderSentimentReport:
|
||||
def test_header_contains_band_and_score(self):
|
||||
report = SentimentReport(
|
||||
overall_band=SentimentBand.BULLISH,
|
||||
overall_score=7.2,
|
||||
confidence="high",
|
||||
narrative="Source breakdown here.",
|
||||
)
|
||||
md = render_sentiment_report(report)
|
||||
assert "**Overall Sentiment:** **Bullish**" in md
|
||||
assert "(Score: 7.2/10)" in md
|
||||
|
||||
def test_header_contains_confidence(self):
|
||||
report = SentimentReport(
|
||||
overall_band=SentimentBand.NEUTRAL,
|
||||
overall_score=5.0,
|
||||
confidence="low",
|
||||
narrative="Limited data.",
|
||||
)
|
||||
assert "**Confidence:** Low" in render_sentiment_report(report)
|
||||
|
||||
def test_narrative_preserved_in_output(self):
|
||||
narrative = "## Breakdown\n\nStockTwits: 70% bullish.\n\n| Signal | Direction |\n|---|---|\n| News | Neutral |"
|
||||
report = SentimentReport(
|
||||
overall_band=SentimentBand.MILDLY_BULLISH,
|
||||
overall_score=6.0,
|
||||
confidence="medium",
|
||||
narrative=narrative,
|
||||
)
|
||||
assert narrative in render_sentiment_report(report)
|
||||
|
||||
def test_all_six_bands_render(self):
|
||||
for band in SentimentBand:
|
||||
report = SentimentReport(
|
||||
overall_band=band, overall_score=5.0,
|
||||
confidence="medium", narrative="n",
|
||||
)
|
||||
assert band.value in render_sentiment_report(report)
|
||||
|
||||
def test_score_out_of_range_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
SentimentReport(
|
||||
overall_band=SentimentBand.BULLISH, overall_score=11.0,
|
||||
confidence="high", narrative="n",
|
||||
)
|
||||
|
||||
|
||||
def _make_sentiment_state():
|
||||
return {
|
||||
"company_of_interest": "NVDA",
|
||||
"trade_date": "2026-01-15",
|
||||
"asset_type": "stock",
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
|
||||
def _structured_sentiment_llm(captured: dict, report: SentimentReport | None = None):
|
||||
"""MagicMock LLM whose structured binding captures the prompt and returns
|
||||
a real SentimentReport so render_sentiment_report works."""
|
||||
if report is None:
|
||||
report = SentimentReport(
|
||||
overall_band=SentimentBand.BULLISH, overall_score=7.5,
|
||||
confidence="high",
|
||||
narrative="StockTwits 75% bullish. News constructive. Reddit upbeat.",
|
||||
)
|
||||
structured = MagicMock()
|
||||
structured.invoke.side_effect = lambda prompt: (
|
||||
captured.__setitem__("prompt", prompt) or report
|
||||
)
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.return_value = structured
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSentimentAnalystAgent:
|
||||
def test_structured_path_produces_rendered_markdown(self):
|
||||
captured = {}
|
||||
report = SentimentReport(
|
||||
overall_band=SentimentBand.MILDLY_BEARISH, overall_score=4.0,
|
||||
confidence="medium", narrative="Mixed signals across sources.",
|
||||
)
|
||||
analyst = create_sentiment_analyst(_structured_sentiment_llm(captured, report))
|
||||
sr = analyst(_make_sentiment_state())["sentiment_report"]
|
||||
assert "**Overall Sentiment:** **Mildly Bearish**" in sr
|
||||
assert "(Score: 4.0/10)" in sr
|
||||
assert "Mixed signals across sources." in sr
|
||||
|
||||
def test_sentiment_report_also_in_messages(self):
|
||||
captured = {}
|
||||
analyst = create_sentiment_analyst(_structured_sentiment_llm(captured))
|
||||
result = analyst(_make_sentiment_state())
|
||||
assert len(result["messages"]) == 1
|
||||
assert result["sentiment_report"] == result["messages"][0].content
|
||||
|
||||
def test_prompt_contains_ticker(self):
|
||||
captured = {}
|
||||
create_sentiment_analyst(_structured_sentiment_llm(captured))(_make_sentiment_state())
|
||||
assert any("NVDA" in str(m) for m in captured["prompt"])
|
||||
|
||||
def test_falls_back_to_freetext_when_structured_unavailable(self):
|
||||
plain = "**Overall Sentiment:** **Bearish** (Score: 3.0/10)\n**Confidence:** Low\n\nLimited data."
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
|
||||
llm.invoke.return_value = MagicMock(content=plain)
|
||||
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain
|
||||
|
||||
def test_falls_back_to_freetext_when_structured_call_fails(self):
|
||||
plain = "Fallback free-text sentiment."
|
||||
structured = MagicMock()
|
||||
structured.invoke.side_effect = ValueError("bad JSON from model")
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.return_value = structured
|
||||
llm.invoke.return_value = MagicMock(content=plain)
|
||||
assert create_sentiment_analyst(llm)(_make_sentiment_state())["sentiment_report"] == plain
|
||||
|
||||
78
tests/test_symbol_normalization_paths.py
Normal file
78
tests/test_symbol_normalization_paths.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Symbol normalization must apply on every yfinance path, not just price fetch.
|
||||
|
||||
Regression tests for #983 (instrument identity), #984 (reflection returns), and
|
||||
the news path: a broker symbol like XAUUSD must resolve to the same Yahoo symbol
|
||||
(GC=F) that the price path uses, so identity, realized-return, and news lookups
|
||||
hit the right instrument instead of failing/mismatching.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
import tradingagents.agents.utils.agent_utils as au
|
||||
import tradingagents.dataflows.yfinance_news as ynews
|
||||
import tradingagents.graph.trading_graph as tg
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
|
||||
def test_identity_lookup_normalizes_symbol(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
class FakeTicker:
|
||||
def __init__(self, symbol):
|
||||
seen["symbol"] = symbol
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
return {"longName": "Gold Futures", "quoteType": "FUTURE"}
|
||||
|
||||
monkeypatch.setattr(au.yf, "Ticker", FakeTicker)
|
||||
au.resolve_instrument_identity.cache_clear()
|
||||
|
||||
identity = au.resolve_instrument_identity("XAUUSD")
|
||||
|
||||
assert seen["symbol"] == "GC=F" # normalized, not the raw broker symbol
|
||||
assert identity.get("company_name") == "Gold Futures"
|
||||
|
||||
|
||||
def test_fetch_returns_normalizes_symbol(monkeypatch):
|
||||
queried = []
|
||||
|
||||
class FakeTicker:
|
||||
def __init__(self, symbol):
|
||||
queried.append(symbol)
|
||||
|
||||
def history(self, *args, **kwargs):
|
||||
prices = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0]
|
||||
idx = pd.date_range(start="2025-01-02", periods=len(prices), freq="D")
|
||||
return pd.DataFrame({"Close": prices}, index=idx)
|
||||
|
||||
monkeypatch.setattr(tg.yf, "Ticker", FakeTicker)
|
||||
|
||||
# _fetch_returns does not use ``self``; call unbound to avoid building the graph.
|
||||
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(
|
||||
None, "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY"
|
||||
)
|
||||
|
||||
assert queried[0] == "GC=F" # stock symbol normalized (#984)
|
||||
assert queried[1] == "SPY" # benchmark left as the canonical symbol
|
||||
assert raw is not None and days is not None
|
||||
assert resolved == "2025-01-07" # resolution date recorded (#1251)
|
||||
|
||||
|
||||
def test_news_lookup_normalizes_symbol(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
class FakeTicker:
|
||||
def __init__(self, symbol):
|
||||
seen["symbol"] = symbol
|
||||
|
||||
def get_news(self, count):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(ynews.yf, "Ticker", FakeTicker)
|
||||
monkeypatch.setattr(ynews, "yf_retry", lambda fn: fn())
|
||||
|
||||
out = ynews.get_news_yfinance("XAUUSD", "2025-01-01", "2025-01-10")
|
||||
|
||||
assert seen["symbol"] == "GC=F" # news queried with the canonical symbol
|
||||
assert "XAUUSD" in out # the user's ticker stays in the report
|
||||
assert "GC=F" in out # provenance noted
|
||||
102
tests/test_symbol_utils.py
Normal file
102
tests/test_symbol_utils.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for symbol normalization and the no-data routing sentinel."""
|
||||
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.dataflows.symbol_utils import (
|
||||
NoMarketDataError,
|
||||
crypto_base,
|
||||
is_yahoo_safe,
|
||||
normalize_symbol,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeSymbol(unittest.TestCase):
|
||||
def test_plain_equities_unchanged(self):
|
||||
for sym in ("AAPL", "MSFT", "TSM", "BRK.B", "0700.HK", "^GSPC", "GC=F"):
|
||||
self.assertEqual(normalize_symbol(sym), sym)
|
||||
|
||||
def test_lowercases_are_upper(self):
|
||||
self.assertEqual(normalize_symbol("aapl"), "AAPL")
|
||||
self.assertEqual(normalize_symbol(" msft "), "MSFT")
|
||||
|
||||
def test_metal_aliases_map_to_futures(self):
|
||||
self.assertEqual(normalize_symbol("XAUUSD"), "GC=F")
|
||||
self.assertEqual(normalize_symbol("XAUUSD+"), "GC=F") # broker CFD suffix
|
||||
self.assertEqual(normalize_symbol("xauusd+"), "GC=F")
|
||||
self.assertEqual(normalize_symbol("GOLD"), "GC=F")
|
||||
self.assertEqual(normalize_symbol("XAGUSD"), "SI=F")
|
||||
|
||||
def test_energy_and_index_aliases(self):
|
||||
self.assertEqual(normalize_symbol("USOIL"), "CL=F")
|
||||
self.assertEqual(normalize_symbol("SPX500"), "^GSPC")
|
||||
self.assertEqual(normalize_symbol("NAS100"), "^NDX")
|
||||
self.assertEqual(normalize_symbol("US30"), "^DJI")
|
||||
|
||||
def test_forex_pairs_get_x_suffix(self):
|
||||
self.assertEqual(normalize_symbol("EURUSD"), "EURUSD=X")
|
||||
self.assertEqual(normalize_symbol("GBPJPY"), "GBPJPY=X")
|
||||
self.assertEqual(normalize_symbol("eurusd"), "EURUSD=X")
|
||||
|
||||
def test_crypto_pairs_get_dash_usd(self):
|
||||
self.assertEqual(normalize_symbol("BTCUSD"), "BTC-USD")
|
||||
self.assertEqual(normalize_symbol("ETHUSD"), "ETH-USD")
|
||||
|
||||
def test_six_letter_non_currency_left_alone(self):
|
||||
# GOOGLE-style 6-letter tickers that aren't two currency codes
|
||||
# must not be mangled into a fake forex pair.
|
||||
self.assertEqual(normalize_symbol("ABCDEF"), "ABCDEF")
|
||||
|
||||
def test_empty_input_passthrough(self):
|
||||
self.assertEqual(normalize_symbol(""), "")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNoMarketDataError(unittest.TestCase):
|
||||
def test_message_includes_resolution(self):
|
||||
err = NoMarketDataError("XAUUSD+", "GC=F", "no rows")
|
||||
self.assertIn("XAUUSD+", str(err))
|
||||
self.assertIn("GC=F", str(err))
|
||||
self.assertEqual(err.symbol, "XAUUSD+")
|
||||
self.assertEqual(err.canonical, "GC=F")
|
||||
|
||||
def test_canonical_defaults_to_symbol(self):
|
||||
err = NoMarketDataError("FOOBAR")
|
||||
self.assertEqual(err.canonical, "FOOBAR")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIsYahooSafe(unittest.TestCase):
|
||||
def test_accepts_structural_chars(self):
|
||||
for sym in ("AAPL", "GC=F", "^GSPC", "BRK.B", "BTC-USD"):
|
||||
self.assertTrue(is_yahoo_safe(sym))
|
||||
|
||||
def test_rejects_slash_and_space(self):
|
||||
for sym in ("a/b", "AA PL", ""):
|
||||
self.assertFalse(is_yahoo_safe(sym))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCryptoBase(unittest.TestCase):
|
||||
def test_resolves_known_crypto_forms(self):
|
||||
for raw in ("BTC-USD", "BTCUSD", "btc-usdt", "BTC-USDC", "BTCUSD+"):
|
||||
self.assertEqual(crypto_base(raw), "BTC")
|
||||
self.assertEqual(crypto_base("ETH-USD"), "ETH")
|
||||
self.assertEqual(crypto_base("sol-usd"), "SOL")
|
||||
|
||||
def test_non_crypto_returns_none(self):
|
||||
# Plain equities, class shares, and real tickers that alias elsewhere
|
||||
# (GOLD -> gold future on the Yahoo path) must NOT read as crypto.
|
||||
for raw in ("AAPL", "BRK-B", "GOLD", "XYZ-USD", "EURUSD", "", None):
|
||||
self.assertIsNone(crypto_base(raw))
|
||||
|
||||
def test_agrees_with_normalize_symbol(self):
|
||||
# crypto_base is the shared primitive behind the -USD normalization.
|
||||
self.assertEqual(normalize_symbol("BTCUSD"), "BTC-USD")
|
||||
self.assertEqual(crypto_base("BTCUSD"), "BTC")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
83
tests/test_temperature_config.py
Normal file
83
tests/test_temperature_config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for the configurable sampling temperature (#178/#168).
|
||||
|
||||
Temperature is a cross-provider knob: when set it must reach the underlying
|
||||
chat client; when unset the provider keeps its own default.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.factory import create_llm_client
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTemperatureForwarding:
|
||||
@pytest.mark.parametrize(
|
||||
"provider,model",
|
||||
[
|
||||
# gpt-4.1 is intentionally a non-reasoning model: the GPT-5 family
|
||||
# are reasoning models and correctly drop temperature (see
|
||||
# test_openai_reasoning_effort), so forwarding is tested on gpt-4.1.
|
||||
("openai", "gpt-4.1"),
|
||||
("anthropic", "claude-sonnet-5"),
|
||||
("google", "gemini-3.5-flash"),
|
||||
("deepseek", "deepseek-chat"),
|
||||
],
|
||||
)
|
||||
def test_temperature_reaches_client_when_set(self, provider, model):
|
||||
llm = create_llm_client(
|
||||
provider=provider, model=model, temperature=0.0, api_key="placeholder"
|
||||
).get_llm()
|
||||
assert llm.temperature == 0.0
|
||||
|
||||
def test_temperature_omitted_leaves_provider_default(self):
|
||||
# Not passing temperature must not force it to a value.
|
||||
llm = create_llm_client(
|
||||
provider="openai", model="gpt-4.1", api_key="placeholder"
|
||||
).get_llm()
|
||||
# langchain's default is unset/None, not 0.0
|
||||
assert llm.temperature is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTemperatureEnvOverlay:
|
||||
def test_env_sets_temperature(self, monkeypatch):
|
||||
import tradingagents.default_config as dc
|
||||
monkeypatch.setenv("TRADINGAGENTS_TEMPERATURE", "0.2")
|
||||
importlib.reload(dc)
|
||||
# Stored on config (string from env is fine; consumed via float()).
|
||||
assert dc.DEFAULT_CONFIG["temperature"] in ("0.2", 0.2)
|
||||
assert float(dc.DEFAULT_CONFIG["temperature"]) == 0.2
|
||||
monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False)
|
||||
importlib.reload(dc)
|
||||
|
||||
def test_default_temperature_is_none(self, monkeypatch):
|
||||
import tradingagents.default_config as dc
|
||||
monkeypatch.delenv("TRADINGAGENTS_TEMPERATURE", raising=False)
|
||||
importlib.reload(dc)
|
||||
assert dc.DEFAULT_CONFIG["temperature"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestProviderKwargsTemperature:
|
||||
"""_get_provider_kwargs float-coerces and forwards temperature, or omits it."""
|
||||
|
||||
def _kwargs_for(self, temperature):
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
# Call the method without constructing the full graph.
|
||||
graph = TradingAgentsGraph.__new__(TradingAgentsGraph)
|
||||
graph.config = {"llm_provider": "openai", "temperature": temperature}
|
||||
return TradingAgentsGraph._get_provider_kwargs(graph)
|
||||
|
||||
def test_float_string_coerced(self):
|
||||
assert self._kwargs_for("0.3")["temperature"] == 0.3
|
||||
|
||||
def test_float_passthrough(self):
|
||||
assert self._kwargs_for(0.0)["temperature"] == 0.0
|
||||
|
||||
def test_none_omitted(self):
|
||||
assert "temperature" not in self._kwargs_for(None)
|
||||
|
||||
def test_empty_string_omitted(self):
|
||||
assert "temperature" not in self._kwargs_for("")
|
||||
@@ -16,6 +16,14 @@ class TickerSymbolHandlingTests(unittest.TestCase):
|
||||
self.assertIn("7203.T", context)
|
||||
self.assertIn("exchange suffix", context)
|
||||
|
||||
def test_single_get_ticker_no_shadow(self):
|
||||
# Regression: cli/main.py had a duplicate get_ticker with an empty
|
||||
# questionary prompt (rendered as a bare "?") that shadowed the
|
||||
# descriptive one in cli/utils. Keep a single canonical definition.
|
||||
import cli.main
|
||||
import cli.utils
|
||||
self.assertIs(cli.main.get_ticker, cli.utils.get_ticker)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
105
tests/test_vendor_errors.py
Normal file
105
tests/test_vendor_errors.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""The vendor data-error hierarchy: every "vendor couldn't return usable data"
|
||||
condition derives from VendorError, so the router catches base types and any
|
||||
vendor slots in without new handling.
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import interface
|
||||
from tradingagents.dataflows.alpha_vantage_common import (
|
||||
AlphaVantageNotConfiguredError,
|
||||
AlphaVantageRateLimitError,
|
||||
)
|
||||
from tradingagents.dataflows.config import set_config
|
||||
from tradingagents.dataflows.errors import (
|
||||
NoMarketDataError,
|
||||
VendorError,
|
||||
VendorNotConfiguredError,
|
||||
VendorRateLimitError,
|
||||
)
|
||||
from tradingagents.dataflows.fred import FredNotConfiguredError
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class HierarchyTests(unittest.TestCase):
|
||||
def test_all_conditions_derive_from_vendor_error(self):
|
||||
for cls in (NoMarketDataError, VendorRateLimitError, VendorNotConfiguredError):
|
||||
self.assertTrue(issubclass(cls, VendorError))
|
||||
|
||||
def test_not_configured_is_still_a_value_error(self):
|
||||
# Back-compat: existing `except ValueError` callers keep working.
|
||||
self.assertTrue(issubclass(VendorNotConfiguredError, ValueError))
|
||||
|
||||
def test_vendor_named_errors_subclass_the_generic_bases(self):
|
||||
self.assertTrue(issubclass(AlphaVantageRateLimitError, VendorRateLimitError))
|
||||
self.assertTrue(issubclass(AlphaVantageNotConfiguredError, VendorNotConfiguredError))
|
||||
self.assertTrue(issubclass(FredNotConfiguredError, VendorNotConfiguredError))
|
||||
# ... and therefore still ValueErrors
|
||||
self.assertTrue(issubclass(FredNotConfiguredError, ValueError))
|
||||
|
||||
def test_symbol_utils_reexports_no_market_data_error(self):
|
||||
from tradingagents.dataflows.symbol_utils import (
|
||||
NoMarketDataError as ReExported,
|
||||
)
|
||||
self.assertIs(ReExported, NoMarketDataError)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class RouterHandlesBaseTypesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def test_rate_limit_subclass_caught_by_base(self):
|
||||
# A vendor-named rate-limit error skips to the next vendor in the chain.
|
||||
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}})
|
||||
|
||||
def _throttled(*a, **k):
|
||||
raise AlphaVantageRateLimitError("slow down")
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": {"alpha_vantage": _throttled, "yfinance": lambda *a, **k: "YF"}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertEqual(out, "YF")
|
||||
|
||||
def test_not_configured_falls_through_to_next_vendor(self):
|
||||
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage,yfinance"}})
|
||||
|
||||
def _unconfigured(*a, **k):
|
||||
raise AlphaVantageNotConfiguredError("no key")
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": {"alpha_vantage": _unconfigured, "yfinance": lambda *a, **k: "YF"}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertEqual(out, "YF")
|
||||
|
||||
def test_sole_unconfigured_vendor_surfaces_the_error(self):
|
||||
# With no fallback, the not-configured condition must surface (not vanish).
|
||||
set_config({"data_vendors": {"core_stock_apis": "alpha_vantage"}})
|
||||
|
||||
def _unconfigured(*a, **k):
|
||||
raise AlphaVantageNotConfiguredError("no key")
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": {"alpha_vantage": _unconfigured}},
|
||||
clear=False,
|
||||
), self.assertRaises(AlphaVantageNotConfiguredError):
|
||||
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
123
tests/test_vendor_routing.py
Normal file
123
tests/test_vendor_routing.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Vendor router must respect the configured chain and never silently hide a
|
||||
broken primary.
|
||||
|
||||
Regressions for #988 (explicit single-vendor config still fell back to others),
|
||||
#289 (fallback ran for unchosen vendors), and #989 (serious primary failures
|
||||
were swallowed without a trace).
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import interface
|
||||
from tradingagents.dataflows.config import set_config
|
||||
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||
|
||||
|
||||
def _reset_config():
|
||||
# Hard reset: set_config() merges, so empty DEFAULT dicts (e.g. tool_vendors)
|
||||
# don't clear keys leaked by other tests. Replace the global outright.
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
|
||||
def _no_data(symbol, *a, **k):
|
||||
raise NoMarketDataError(symbol, symbol, "no rows")
|
||||
|
||||
|
||||
def _returns(value):
|
||||
def impl(symbol, *a, **k):
|
||||
return value
|
||||
return impl
|
||||
|
||||
|
||||
def _raises(exc):
|
||||
def impl(symbol, *a, **k):
|
||||
raise exc
|
||||
return impl
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class VendorRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
_reset_config()
|
||||
|
||||
def tearDown(self):
|
||||
_reset_config()
|
||||
|
||||
def _route(self, vendors_for_get_stock_data):
|
||||
return mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": vendors_for_get_stock_data},
|
||||
clear=False,
|
||||
)
|
||||
|
||||
def test_explicit_single_vendor_does_not_fall_back(self):
|
||||
# #988: with yfinance pinned, a healthy alpha_vantage must NOT be used.
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
|
||||
av = mock.Mock(side_effect=_returns("AV_DATA"))
|
||||
with self._route({"yfinance": _no_data, "alpha_vantage": av}):
|
||||
result = interface.route_to_vendor("get_stock_data", "FAKE", "2026-01-01", "2026-01-10")
|
||||
self.assertIn("NO_DATA_AVAILABLE", result)
|
||||
av.assert_not_called() # the unchosen vendor was never tried
|
||||
|
||||
def test_explicit_multi_vendor_falls_back_within_chain(self):
|
||||
# Listing both vendors opts in to ordered fallback.
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}})
|
||||
with self._route({"yfinance": _no_data, "alpha_vantage": _returns("AV_DATA")}):
|
||||
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertEqual(result, "AV_DATA")
|
||||
|
||||
def test_primary_error_is_logged_not_masked(self):
|
||||
# #989: primary errors + fallback no-data -> NO_DATA, but the failure
|
||||
# must be visible in logs (broken primary not hidden).
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}})
|
||||
with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \
|
||||
self.assertLogs("tradingagents.dataflows.interface", level="WARNING") as cm:
|
||||
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertIn("NO_DATA_AVAILABLE", result)
|
||||
joined = "\n".join(cm.output)
|
||||
self.assertIn("boom", joined) # the real error surfaced in logs
|
||||
self.assertIn("yfinance", joined)
|
||||
|
||||
def test_unknown_configured_vendor_raises(self):
|
||||
set_config({"data_vendors": {"core_stock_apis": "bogus_vendor"}})
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertIn("bogus_vendor", str(ctx.exception))
|
||||
|
||||
def test_default_sentinel_uses_all_vendors(self):
|
||||
# No explicit choice ("default") keeps the resilient full-chain behavior.
|
||||
set_config({"data_vendors": {"core_stock_apis": "default"}})
|
||||
with self._route({"yfinance": _no_data, "alpha_vantage": _returns("AV_DATA")}):
|
||||
result = interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertEqual(result, "AV_DATA")
|
||||
|
||||
def _route_method(self, method, vendors):
|
||||
return mock.patch.dict(interface.VENDOR_METHODS, {method: vendors}, clear=False)
|
||||
|
||||
def test_optional_category_degrades_instead_of_raising(self):
|
||||
# An optional enrichment vendor (FRED macro) that raises must NOT abort
|
||||
# the run — the router returns a sentinel so the analysis proceeds.
|
||||
set_config({"data_vendors": {"macro_data": "fred"}})
|
||||
with self._route_method(
|
||||
"get_macro_indicators", {"fred": _raises(ValueError("FRED 400: bad series"))}
|
||||
):
|
||||
result = interface.route_to_vendor("get_macro_indicators", "cpi", "2026-01-01")
|
||||
self.assertIn("DATA_UNAVAILABLE", result)
|
||||
self.assertIn("macro_data", result)
|
||||
|
||||
def test_core_category_still_raises_on_error(self):
|
||||
# A core category (single configured vendor) propagates the error so a
|
||||
# broken primary is loud, not silently degraded.
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
|
||||
with self._route({"yfinance": _raises(ValueError("boom"))}), \
|
||||
self.assertRaises(ValueError):
|
||||
interface.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
113
tests/test_yfinance_stale_ohlcv_guard.py
Normal file
113
tests/test_yfinance_stale_ohlcv_guard.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Stale OHLCV guard (#1021): a vendor returning a year-old partial frame must
|
||||
be rejected, not fed into the report as if it were current.
|
||||
|
||||
The guard raises NoMarketDataError with a stale-specific detail, so the router's
|
||||
existing try-next-vendor + single-sentinel handling applies and the sentinel
|
||||
surfaces the reason.
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.dataflows.y_finance as y_finance
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import interface
|
||||
from tradingagents.dataflows.config import set_config
|
||||
from tradingagents.dataflows.stockstats_utils import _assert_ohlcv_not_stale
|
||||
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||
|
||||
|
||||
def _frame(date):
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"Date": [pd.Timestamp(date)],
|
||||
"Open": [330.0],
|
||||
"High": [332.0],
|
||||
"Low": [328.0],
|
||||
"Close": [330.58],
|
||||
"Volume": [1_000_000],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardUnitTests(unittest.TestCase):
|
||||
def test_recent_prior_trading_day_is_accepted(self):
|
||||
# 1 day before curr_date — well within the freshness window.
|
||||
_assert_ohlcv_not_stale(_frame("2026-06-10"), "2026-06-11", "CB")
|
||||
|
||||
def test_year_old_row_is_rejected_with_detail(self):
|
||||
with self.assertRaises(NoMarketDataError) as ctx:
|
||||
_assert_ohlcv_not_stale(_frame("2025-06-11"), "2026-06-11", "CB", "CB")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("2025-06-11", msg)
|
||||
self.assertIn("2026-06-11", msg)
|
||||
self.assertIn("stale", msg)
|
||||
|
||||
def test_empty_frame_is_left_to_caller(self):
|
||||
# Empty is a no-data condition handled elsewhere, not a staleness one.
|
||||
_assert_ohlcv_not_stale(
|
||||
pd.DataFrame(columns=["Date", "Close"]), "2026-06-11", "X"
|
||||
)
|
||||
|
||||
def test_long_holiday_gap_within_threshold_is_accepted(self):
|
||||
_assert_ohlcv_not_stale(_frame("2026-06-02"), "2026-06-11", "X") # 9 days
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardPropagationTests(unittest.TestCase):
|
||||
def test_get_yfin_data_online_raises_on_stale_frame(self):
|
||||
stale = pd.DataFrame(
|
||||
{
|
||||
"Open": [280.0], "High": [286.0], "Low": [278.0],
|
||||
"Close": [284.45], "Volume": [1_000_000],
|
||||
},
|
||||
index=pd.DatetimeIndex([pd.Timestamp("2025-06-11")], name="Date"),
|
||||
)
|
||||
|
||||
class DummyTicker:
|
||||
def __init__(self, symbol):
|
||||
pass
|
||||
|
||||
def history(self, start, end):
|
||||
return stale
|
||||
|
||||
with mock.patch.object(y_finance.yf, "Ticker", DummyTicker), \
|
||||
self.assertRaises(NoMarketDataError):
|
||||
y_finance.get_YFin_data_online("CB", "2026-06-01", "2026-06-11")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def test_router_sentinel_surfaces_stale_reason(self):
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
|
||||
|
||||
def _stale(symbol, *a, **k):
|
||||
raise NoMarketDataError(
|
||||
symbol, symbol, "latest row is 2025-06-11, 365 days before ... (stale)"
|
||||
)
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": {"yfinance": _stale}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor(
|
||||
"get_stock_data", "CB", "2026-06-01", "2026-06-11"
|
||||
)
|
||||
self.assertIn("NO_DATA_AVAILABLE", out)
|
||||
self.assertIn("stale", out) # the typed detail is surfaced to the agent
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
import contextlib
|
||||
import warnings
|
||||
|
||||
# Load .env files at package import so DEFAULT_CONFIG's env-var overlay
|
||||
# (and every llm_clients consumer) sees the user's keys regardless of
|
||||
# which entry point started the process. find_dotenv(usecwd=True) walks
|
||||
# from the CWD, so the installed `tradingagents` console script picks up
|
||||
# the project's .env instead of stepping up from site-packages.
|
||||
# load_dotenv defaults to override=False, so it never clobbers values
|
||||
# the caller has already exported.
|
||||
try:
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
load_dotenv(find_dotenv(usecwd=True))
|
||||
load_dotenv(find_dotenv(".env.enterprise", usecwd=True), override=False)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# langchain-core 1.3.3 calls surface_langchain_deprecation_warnings() in
|
||||
# its own __init__, which prepends default-action filters for its
|
||||
# subclassed warning categories. To suppress a specific warning we must
|
||||
# install our filter AFTER langchain-core has installed its own, so import
|
||||
# it first. The package is a guaranteed transitive dep via langgraph.
|
||||
with contextlib.suppress(ImportError):
|
||||
import langchain_core # noqa: F401
|
||||
|
||||
# langgraph-checkpoint 4.0.3 calls Reviver() at module load without an
|
||||
# explicit allowed_objects, which triggers a noisy pending-deprecation
|
||||
# warning from langchain-core 1.3.3 on every interpreter start. The fix
|
||||
# is already merged upstream (langchain-ai/langgraph#7743, 2026-05-08)
|
||||
# and will arrive in the next langgraph-checkpoint release. Remove this
|
||||
# block (and the langchain_core preload above) when we bump past it.
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"The default value of `allowed_objects`.*",
|
||||
category=PendingDeprecationWarning,
|
||||
)
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
from .utils.agent_utils import create_msg_delete
|
||||
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
|
||||
|
||||
from .analysts.fundamentals_analyst import create_fundamentals_analyst
|
||||
from .analysts.market_analyst import create_market_analyst
|
||||
from .analysts.news_analyst import create_news_analyst
|
||||
from .analysts.social_media_analyst import create_social_media_analyst
|
||||
|
||||
from .analysts.sentiment_analyst import (
|
||||
create_sentiment_analyst,
|
||||
create_social_media_analyst, # deprecated alias kept for back-compat
|
||||
)
|
||||
from .managers.portfolio_manager import create_portfolio_manager
|
||||
from .managers.research_manager import create_research_manager
|
||||
from .researchers.bear_researcher import create_bear_researcher
|
||||
from .researchers.bull_researcher import create_bull_researcher
|
||||
|
||||
from .risk_mgmt.aggressive_debator import create_aggressive_debator
|
||||
from .risk_mgmt.conservative_debator import create_conservative_debator
|
||||
from .risk_mgmt.neutral_debator import create_neutral_debator
|
||||
|
||||
from .managers.research_manager import create_research_manager
|
||||
from .managers.portfolio_manager import create_portfolio_manager
|
||||
|
||||
from .trader.trader import create_trader
|
||||
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
|
||||
from .utils.agent_utils import create_msg_delete
|
||||
|
||||
__all__ = [
|
||||
"AgentState",
|
||||
@@ -33,6 +31,7 @@ __all__ = [
|
||||
"create_aggressive_debator",
|
||||
"create_portfolio_manager",
|
||||
"create_conservative_debator",
|
||||
"create_social_media_analyst",
|
||||
"create_sentiment_analyst",
|
||||
"create_social_media_analyst", # deprecated; will be removed in a future version
|
||||
"create_trader",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_fundamentals,
|
||||
get_income_statement,
|
||||
get_insider_transactions,
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_fundamentals_analyst(llm):
|
||||
def fundamentals_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
tools = [
|
||||
get_fundamentals,
|
||||
@@ -40,8 +39,9 @@ def create_fundamentals_analyst(llm):
|
||||
" 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,"
|
||||
" 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
" 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"
|
||||
"{system_message}",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_indicators,
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
get_stock_data,
|
||||
get_verified_market_snapshot,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_market_analyst(llm):
|
||||
|
||||
def market_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
tools = [
|
||||
get_stock_data,
|
||||
get_indicators,
|
||||
get_verified_market_snapshot,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
@@ -44,7 +46,11 @@ Volatility Indicators:
|
||||
Volume-Based Indicators:
|
||||
- vwma: VWMA: A moving average weighted by volume. Usage: Confirm trends by integrating price action with volume data. Tips: Watch for skewed results from volume spikes; use in combination with other volume analyses.
|
||||
|
||||
- Select indicators that provide diverse and complementary information. Avoid redundancy (e.g., do not select both rsi and stochrsi). Also briefly explain why they are suitable for the given market context. When you tool call, please use the exact name of the indicators provided above as they are defined parameters, otherwise your call will fail. Please make sure to call get_stock_data first to retrieve the CSV that is needed to generate indicators. Then use get_indicators with the specific indicator names. Write a very detailed and nuanced report of the trends you observe. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."""
|
||||
- Select indicators that provide diverse and complementary information. Avoid redundancy (e.g., do not select both rsi and stochrsi). Also briefly explain why they are suitable for the given market context. When you tool call, please use the exact name of the indicators provided above as they are defined parameters, otherwise your call will fail. Please make sure to call get_stock_data first to retrieve the CSV that is needed to generate indicators. Then use get_indicators with the specific indicator names.
|
||||
|
||||
Before writing the final report, call get_verified_market_snapshot for this ticker and the current date, and treat it as the source of truth for any exact OHLCV, price-level, or indicator-value claim. If another tool's output conflicts with the verified snapshot, flag the discrepancy rather than inventing a reconciled number. Do not claim historical validation, support/resistance bounces, or exact percentage moves unless they are directly supported by tool output with concrete dates and prices.
|
||||
|
||||
Write a very detailed and nuanced report of the trends you observe. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."""
|
||||
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
|
||||
+ get_language_instruction()
|
||||
)
|
||||
@@ -59,8 +65,9 @@ Volume-Based Indicators:
|
||||
" 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,"
|
||||
" 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
" 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"
|
||||
"{system_message}",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_global_news,
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
get_macro_indicators,
|
||||
get_news,
|
||||
get_prediction_markets,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_news_analyst(llm):
|
||||
def news_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
asset_type = state.get("asset_type", "stock")
|
||||
asset_label = "company" if asset_type == "stock" else "asset"
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
tools = [
|
||||
get_news,
|
||||
get_global_news,
|
||||
get_macro_indicators,
|
||||
get_prediction_markets,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for company-specific or targeted news searches, and get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
|
||||
f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
|
||||
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
|
||||
+ get_language_instruction()
|
||||
)
|
||||
@@ -34,8 +40,9 @@ def create_news_analyst(llm):
|
||||
" 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,"
|
||||
" 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
" 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"
|
||||
"{system_message}",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
|
||||
214
tradingagents/agents/analysts/sentiment_analyst.py
Normal file
214
tradingagents/agents/analysts/sentiment_analyst.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Sentiment analyst — multi-source sentiment analysis for a target ticker.
|
||||
|
||||
Previously named ``social_media_analyst``. Renamed and redesigned because
|
||||
the old version had a prompt that demanded social-media analysis but the
|
||||
only tool available was Yahoo Finance news — which led LLMs to fabricate
|
||||
Reddit/X/StockTwits content under prompt pressure (verified live).
|
||||
|
||||
The redesigned agent pre-fetches three complementary data sources before
|
||||
the LLM is invoked and injects them into the prompt as structured blocks:
|
||||
|
||||
1. News headlines — Yahoo Finance (institutional framing)
|
||||
2. StockTwits messages — retail-trader posts indexed by cashtag, with
|
||||
user-labeled Bullish/Bearish sentiment tags
|
||||
3. Reddit posts — r/wallstreetbets, r/stocks, r/investing
|
||||
|
||||
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
|
||||
OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic), falling
|
||||
back to free-text generation for providers that lack native support, so
|
||||
the sentiment header (band + score + confidence) is deterministic across
|
||||
runs and providers instead of free-form per-model prose.
|
||||
|
||||
See: https://github.com/TauricResearch/TradingAgents/issues/557
|
||||
See: https://github.com/TauricResearch/TradingAgents/issues/796
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
|
||||
from tradingagents.agents.schemas import SentimentReport, render_sentiment_report
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
get_news,
|
||||
)
|
||||
from tradingagents.agents.utils.structured import (
|
||||
NO_EXTERNAL_TOOLS,
|
||||
bind_structured,
|
||||
invoke_structured_or_freetext,
|
||||
)
|
||||
from tradingagents.dataflows.reddit import fetch_reddit_posts
|
||||
from tradingagents.dataflows.stocktwits import fetch_stocktwits_messages
|
||||
|
||||
|
||||
def _seven_days_back(trade_date: str) -> str:
|
||||
return (datetime.strptime(trade_date, "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def create_sentiment_analyst(llm):
|
||||
"""Create a sentiment analyst node for the trading graph.
|
||||
|
||||
Pre-fetches news + StockTwits + Reddit data, injects them into the
|
||||
prompt as structured blocks, and produces a deterministic sentiment
|
||||
report via structured output (with a free-text fallback for providers
|
||||
that do not support it).
|
||||
"""
|
||||
structured_llm = bind_structured(llm, SentimentReport, "Sentiment Analyst")
|
||||
|
||||
def sentiment_analyst_node(state):
|
||||
ticker = state["company_of_interest"]
|
||||
end_date = state["trade_date"]
|
||||
start_date = _seven_days_back(end_date)
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
# Pre-fetch all three sources. Each fetcher degrades gracefully and
|
||||
# returns a string (no exceptions surface from here), so the LLM
|
||||
# always sees something — either real data or a clear placeholder.
|
||||
news_block = get_news.func(ticker, start_date, end_date)
|
||||
# Pass the analysis window so a historical run trims social posts to it
|
||||
# instead of leaking today's chatter into a backtest (#1220).
|
||||
stocktwits_block = fetch_stocktwits_messages(
|
||||
ticker, limit=30, start_date=start_date, end_date=end_date
|
||||
)
|
||||
reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date)
|
||||
|
||||
system_message = _build_system_message(
|
||||
ticker=ticker,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
news_block=news_block,
|
||||
stocktwits_block=stocktwits_block,
|
||||
reddit_block=reddit_block,
|
||||
)
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"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,"
|
||||
" 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
|
||||
# prompt, so tool-range wording would only invite a
|
||||
# hallucinated tool call (#1130).
|
||||
" Today's date is {current_date}; treat it as 'now' for all analysis. {instrument_context}"
|
||||
" " + NO_EXTERNAL_TOOLS +
|
||||
"\n{system_message}",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
)
|
||||
|
||||
prompt = prompt.partial(system_message=system_message)
|
||||
prompt = prompt.partial(current_date=end_date)
|
||||
prompt = prompt.partial(instrument_context=instrument_context)
|
||||
|
||||
# Format the template into a concrete message list so the structured
|
||||
# and free-text paths receive the same input. No bind_tools — the
|
||||
# data is already in the prompt.
|
||||
formatted_messages = prompt.format_messages(messages=state["messages"])
|
||||
|
||||
report_text = invoke_structured_or_freetext(
|
||||
structured_llm,
|
||||
llm,
|
||||
formatted_messages,
|
||||
render_sentiment_report,
|
||||
"Sentiment Analyst",
|
||||
)
|
||||
|
||||
return {
|
||||
"messages": [AIMessage(content=report_text)],
|
||||
"sentiment_report": report_text,
|
||||
}
|
||||
|
||||
return sentiment_analyst_node
|
||||
|
||||
|
||||
def _build_system_message(
|
||||
*,
|
||||
ticker: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
news_block: str,
|
||||
stocktwits_block: str,
|
||||
reddit_block: str,
|
||||
) -> str:
|
||||
"""Assemble the sentiment-analyst system message with structured data blocks."""
|
||||
return f"""You are a financial market sentiment analyst. Your task is to produce a comprehensive sentiment report for {ticker} covering the period from {start_date} to {end_date}, drawing on three complementary data sources that have already been collected for you.
|
||||
|
||||
## Data sources (pre-fetched, in this prompt)
|
||||
|
||||
### News headlines — Yahoo Finance, past 7 days
|
||||
Institutional framing. Fact-driven, slower-moving signal.
|
||||
|
||||
<start_of_news>
|
||||
{news_block}
|
||||
<end_of_news>
|
||||
|
||||
### StockTwits messages — retail-trader social platform indexed by cashtag
|
||||
Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish / Bearish / no-label) plus the message body.
|
||||
|
||||
<start_of_stocktwits>
|
||||
{stocktwits_block}
|
||||
<end_of_stocktwits>
|
||||
|
||||
### 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).
|
||||
|
||||
<start_of_reddit>
|
||||
{reddit_block}
|
||||
<end_of_reddit>
|
||||
|
||||
## How to analyze this data (best practices)
|
||||
|
||||
1. **Read the StockTwits Bullish/Bearish ratio as a leading retail-sentiment signal.** A 70/30 bullish/bearish split is moderately bullish; ≥90/10 may indicate over-extension and contrarian risk; 50/50 is uncertainty. Sample size matters — base rates on the actual message count, not percentages alone.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
5. **Identify recurring narrative themes.** What topic keeps coming up across sources? That's the dominant narrative driving current sentiment.
|
||||
|
||||
6. **Be honest about data limits.** If StockTwits returned only a handful of messages, or one or more sources returned an "<unavailable>" placeholder, the sentiment read is less robust — flag this explicitly in the `confidence` field and the narrative. If the sources are silent on a given subreddit, say so.
|
||||
|
||||
7. **Identify catalysts and risks** that emerge across sources — news of upcoming earnings, product launches, competitive threats, macro headlines, etc.
|
||||
|
||||
8. **Past sentiment is not predictive.** Frame your conclusions as signal for the trader to weigh alongside fundamentals and technicals, not as a price call.
|
||||
|
||||
## Output fields
|
||||
|
||||
Fill the following fields:
|
||||
|
||||
- **overall_band**: Exactly one of Bullish / Mildly Bullish / Neutral / Mixed / Mildly Bearish / Bearish. Use Mixed when sources point in clearly different directions; Neutral only when all sources are genuinely silent.
|
||||
- **overall_score**: A number from 0 (maximally bearish) to 10 (maximally bullish); 5 is neutral. Keep it consistent with overall_band.
|
||||
- **confidence**: low / medium / high, based on data quality and sample size.
|
||||
- **narrative**: Full source-by-source breakdown, divergences, dominant narrative themes, catalysts and risks, and a markdown summary table of key sentiment signals (direction, source, supporting evidence).
|
||||
|
||||
{get_language_instruction()}"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backwards-compatibility shim
|
||||
# ---------------------------------------------------------------------------
|
||||
def create_social_media_analyst(llm):
|
||||
"""Deprecated alias for :func:`create_sentiment_analyst`.
|
||||
|
||||
Kept so existing code that imports ``create_social_media_analyst``
|
||||
continues to work.
|
||||
|
||||
.. deprecated::
|
||||
Import :func:`create_sentiment_analyst` directly instead.
|
||||
"""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"create_social_media_analyst is deprecated and will be removed in a "
|
||||
"future version. Use create_sentiment_analyst instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return create_sentiment_analyst(llm)
|
||||
@@ -1,57 +1,23 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import build_instrument_context, get_language_instruction, get_news
|
||||
from tradingagents.dataflows.config import get_config
|
||||
"""Backwards-compatibility shim for the renamed module.
|
||||
|
||||
The agent is now ``sentiment_analyst`` and aggregates Yahoo Finance news,
|
||||
StockTwits cashtag streams, and Reddit posts into a single sentiment
|
||||
report. Import from ``tradingagents.agents.analysts.sentiment_analyst``
|
||||
going forward; this module will be removed in a future release.
|
||||
|
||||
def create_social_media_analyst(llm):
|
||||
def social_media_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
See: https://github.com/TauricResearch/TradingAgents/issues/557
|
||||
"""
|
||||
|
||||
tools = [
|
||||
get_news,
|
||||
]
|
||||
import warnings as _warnings
|
||||
|
||||
system_message = (
|
||||
"You are a social media and company specific news researcher/analyst tasked with analyzing social media posts, recent company news, and public sentiment for a specific company over the past week. You will be given a company's name your objective is to write a comprehensive long report detailing your analysis, insights, and implications for traders and investors on this company's current state after looking at social media and what people are saying about that company, analyzing sentiment data of what people feel each day about the company, and looking at recent company news. Use the get_news(query, start_date, end_date) tool to search for company-specific news and social media discussions. Try to look at all sources possible from social media to sentiment to news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
|
||||
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
|
||||
+ get_language_instruction()
|
||||
from tradingagents.agents.analysts.sentiment_analyst import ( # noqa: F401
|
||||
create_sentiment_analyst,
|
||||
create_social_media_analyst,
|
||||
)
|
||||
|
||||
prompt = ChatPromptTemplate.from_messages(
|
||||
[
|
||||
(
|
||||
"system",
|
||||
"You are a helpful AI assistant, collaborating with other assistants."
|
||||
" 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"
|
||||
" 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,"
|
||||
" 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
_warnings.warn(
|
||||
"tradingagents.agents.analysts.social_media_analyst is deprecated. "
|
||||
"Import from tradingagents.agents.analysts.sentiment_analyst instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
prompt = prompt.partial(system_message=system_message)
|
||||
prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools]))
|
||||
prompt = prompt.partial(current_date=current_date)
|
||||
prompt = prompt.partial(instrument_context=instrument_context)
|
||||
|
||||
chain = prompt | llm.bind_tools(tools)
|
||||
|
||||
result = chain.invoke(state["messages"])
|
||||
|
||||
report = ""
|
||||
|
||||
if len(result.tool_calls) == 0:
|
||||
report = result.content
|
||||
|
||||
return {
|
||||
"messages": [result],
|
||||
"sentiment_report": report,
|
||||
}
|
||||
|
||||
return social_media_analyst_node
|
||||
|
||||
@@ -12,10 +12,11 @@ from __future__ import annotations
|
||||
|
||||
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
)
|
||||
from tradingagents.agents.utils.structured import (
|
||||
NO_EXTERNAL_TOOLS,
|
||||
bind_structured,
|
||||
invoke_structured_or_freetext,
|
||||
)
|
||||
@@ -25,7 +26,7 @@ def create_portfolio_manager(llm):
|
||||
structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager")
|
||||
|
||||
def portfolio_manager_node(state) -> dict:
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
history = state["risk_debate_state"]["history"]
|
||||
risk_debate_state = state["risk_debate_state"]
|
||||
@@ -61,7 +62,9 @@ def create_portfolio_manager(llm):
|
||||
|
||||
---
|
||||
|
||||
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}"""
|
||||
Ground every conclusion in specific evidence from the analysts. Commit to a directional call only when the evidence clearly supports one; choose Hold when the case is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure, rather than forcing a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
|
||||
|
||||
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
|
||||
|
||||
final_trade_decision = invoke_structured_or_freetext(
|
||||
structured_llm,
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from tradingagents.agents.schemas import ResearchPlan, render_research_plan
|
||||
from tradingagents.agents.utils.agent_utils import build_instrument_context
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
)
|
||||
from tradingagents.agents.utils.structured import (
|
||||
NO_EXTERNAL_TOOLS,
|
||||
bind_structured,
|
||||
invoke_structured_or_freetext,
|
||||
)
|
||||
@@ -14,7 +18,7 @@ def create_research_manager(llm):
|
||||
structured_llm = bind_structured(llm, ResearchPlan, "Research Manager")
|
||||
|
||||
def research_manager_node(state) -> dict:
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
history = state["investment_debate_state"].get("history", "")
|
||||
|
||||
investment_debate_state = state["investment_debate_state"]
|
||||
@@ -32,12 +36,14 @@ def create_research_manager(llm):
|
||||
- **Underweight**: Cautious view; recommend trimming exposure
|
||||
- **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.
|
||||
Commit to a directional stance only when the debate's strongest arguments clearly warrant one. Choose Hold when the evidence is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure; do not manufacture a direction merely to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
|
||||
|
||||
---
|
||||
|
||||
**Debate History:**
|
||||
{history}"""
|
||||
{history}
|
||||
|
||||
{NO_EXTERNAL_TOOLS}""" + get_language_instruction()
|
||||
|
||||
investment_plan = invoke_structured_or_freetext(
|
||||
structured_llm,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
opponent_argument_or_opening,
|
||||
)
|
||||
|
||||
|
||||
def create_bear_researcher(llm):
|
||||
@@ -6,13 +11,23 @@ def create_bear_researcher(llm):
|
||||
history = investment_debate_state.get("history", "")
|
||||
bear_history = investment_debate_state.get("bear_history", "")
|
||||
|
||||
current_response = investment_debate_state.get("current_response", "")
|
||||
current_response = opponent_argument_or_opening(
|
||||
investment_debate_state.get("current_response", ""), "bull analyst"
|
||||
)
|
||||
market_research_report = state["market_report"]
|
||||
sentiment_report = state["sentiment_report"]
|
||||
news_report = state["news_report"]
|
||||
fundamentals_report = state["fundamentals_report"]
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
asset_type = state.get("asset_type", "stock")
|
||||
target_label = "stock" if asset_type == "stock" else "asset"
|
||||
fundamentals_label = (
|
||||
"Company fundamentals report"
|
||||
if asset_type == "stock"
|
||||
else "Asset fundamentals report (may be unavailable for crypto)"
|
||||
)
|
||||
|
||||
prompt = f"""You are a Bear Analyst making the case against investing in the stock. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
|
||||
prompt = f"""You are a Bear Analyst making the case against investing in the {target_label}. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
|
||||
|
||||
Key points to focus on:
|
||||
|
||||
@@ -24,14 +39,15 @@ Key points to focus on:
|
||||
|
||||
Resources available:
|
||||
|
||||
{instrument_context}
|
||||
Market research report: {market_research_report}
|
||||
Social media sentiment report: {sentiment_report}
|
||||
Latest world affairs news: {news_report}
|
||||
Company fundamentals report: {fundamentals_report}
|
||||
{fundamentals_label}: {fundamentals_report}
|
||||
Conversation history of the debate: {history}
|
||||
Last bull argument: {current_response}
|
||||
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the stock.
|
||||
"""
|
||||
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the {target_label}.
|
||||
""" + get_language_instruction()
|
||||
|
||||
response = llm.invoke(prompt)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
opponent_argument_or_opening,
|
||||
)
|
||||
|
||||
|
||||
def create_bull_researcher(llm):
|
||||
@@ -6,13 +11,23 @@ def create_bull_researcher(llm):
|
||||
history = investment_debate_state.get("history", "")
|
||||
bull_history = investment_debate_state.get("bull_history", "")
|
||||
|
||||
current_response = investment_debate_state.get("current_response", "")
|
||||
current_response = opponent_argument_or_opening(
|
||||
investment_debate_state.get("current_response", ""), "bear analyst"
|
||||
)
|
||||
market_research_report = state["market_report"]
|
||||
sentiment_report = state["sentiment_report"]
|
||||
news_report = state["news_report"]
|
||||
fundamentals_report = state["fundamentals_report"]
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
asset_type = state.get("asset_type", "stock")
|
||||
target_label = "stock" if asset_type == "stock" else "asset"
|
||||
fundamentals_label = (
|
||||
"Company fundamentals report"
|
||||
if asset_type == "stock"
|
||||
else "Asset fundamentals report (may be unavailable for crypto)"
|
||||
)
|
||||
|
||||
prompt = f"""You are a Bull Analyst advocating for investing in the stock. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
|
||||
prompt = f"""You are a Bull Analyst advocating for investing in the {target_label}. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
|
||||
|
||||
Key points to focus on:
|
||||
- Growth Potential: Highlight the company's market opportunities, revenue projections, and scalability.
|
||||
@@ -22,14 +37,15 @@ Key points to focus on:
|
||||
- Engagement: Present your argument in a conversational style, engaging directly with the bear analyst's points and debating effectively rather than just listing data.
|
||||
|
||||
Resources available:
|
||||
{instrument_context}
|
||||
Market research report: {market_research_report}
|
||||
Social media sentiment report: {sentiment_report}
|
||||
Latest world affairs news: {news_report}
|
||||
Company fundamentals report: {fundamentals_report}
|
||||
{fundamentals_label}: {fundamentals_report}
|
||||
Conversation history of the debate: {history}
|
||||
Last bear argument: {current_response}
|
||||
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position.
|
||||
"""
|
||||
""" + get_language_instruction()
|
||||
|
||||
response = llm.invoke(prompt)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
opponent_argument_or_opening,
|
||||
)
|
||||
|
||||
|
||||
def create_aggressive_debator(llm):
|
||||
@@ -6,13 +11,18 @@ def create_aggressive_debator(llm):
|
||||
history = risk_debate_state.get("history", "")
|
||||
aggressive_history = risk_debate_state.get("aggressive_history", "")
|
||||
|
||||
current_conservative_response = risk_debate_state.get("current_conservative_response", "")
|
||||
current_neutral_response = risk_debate_state.get("current_neutral_response", "")
|
||||
current_conservative_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
|
||||
)
|
||||
current_neutral_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
||||
)
|
||||
|
||||
market_research_report = state["market_report"]
|
||||
sentiment_report = state["sentiment_report"]
|
||||
news_report = state["news_report"]
|
||||
fundamentals_report = state["fundamentals_report"]
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
@@ -22,13 +32,14 @@ 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:
|
||||
|
||||
{instrument_context}
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest World Affairs Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Here is the current conversation history: {history} Here are the last arguments from the conservative analyst: {current_conservative_response} Here are the last arguments from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data.
|
||||
|
||||
Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting."""
|
||||
Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction()
|
||||
|
||||
response = llm.invoke(prompt)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
opponent_argument_or_opening,
|
||||
)
|
||||
|
||||
|
||||
def create_conservative_debator(llm):
|
||||
@@ -6,13 +11,18 @@ def create_conservative_debator(llm):
|
||||
history = risk_debate_state.get("history", "")
|
||||
conservative_history = risk_debate_state.get("conservative_history", "")
|
||||
|
||||
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "")
|
||||
current_neutral_response = risk_debate_state.get("current_neutral_response", "")
|
||||
current_aggressive_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst"
|
||||
)
|
||||
current_neutral_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_neutral_response", ""), "neutral analyst"
|
||||
)
|
||||
|
||||
market_research_report = state["market_report"]
|
||||
sentiment_report = state["sentiment_report"]
|
||||
news_report = state["news_report"]
|
||||
fundamentals_report = state["fundamentals_report"]
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
@@ -22,13 +32,14 @@ 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:
|
||||
|
||||
{instrument_context}
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest World Affairs Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data.
|
||||
|
||||
Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting."""
|
||||
Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction()
|
||||
|
||||
response = llm.invoke(prompt)
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
opponent_argument_or_opening,
|
||||
)
|
||||
|
||||
|
||||
def create_neutral_debator(llm):
|
||||
@@ -6,13 +11,18 @@ def create_neutral_debator(llm):
|
||||
history = risk_debate_state.get("history", "")
|
||||
neutral_history = risk_debate_state.get("neutral_history", "")
|
||||
|
||||
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "")
|
||||
current_conservative_response = risk_debate_state.get("current_conservative_response", "")
|
||||
current_aggressive_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst"
|
||||
)
|
||||
current_conservative_response = opponent_argument_or_opening(
|
||||
risk_debate_state.get("current_conservative_response", ""), "conservative analyst"
|
||||
)
|
||||
|
||||
market_research_report = state["market_report"]
|
||||
sentiment_report = state["sentiment_report"]
|
||||
news_report = state["news_report"]
|
||||
fundamentals_report = state["fundamentals_report"]
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
@@ -22,13 +32,14 @@ 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:
|
||||
|
||||
{instrument_context}
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest World Affairs Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the conservative analyst: {current_conservative_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data.
|
||||
|
||||
Engage actively by analyzing both sides critically, addressing weaknesses in the aggressive and conservative arguments to advocate for a more balanced approach. Challenge each of their points to illustrate why a moderate risk strategy might offer the best of both worlds, providing growth potential while safeguarding against extreme volatility. Focus on debating rather than simply presenting data, aiming to show that a balanced view can lead to the most reliable outcomes. Output conversationally as if you are speaking without any special formatting."""
|
||||
Engage actively by analyzing both sides critically, addressing weaknesses in the aggressive and conservative arguments to advocate for a more balanced approach. Challenge each of their points to illustrate why a moderate risk strategy might offer the best of both worlds, providing growth potential while safeguarding against extreme volatility. Focus on debating rather than simply presenting data, aiming to show that a balanced view can lead to the most reliable outcomes. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction()
|
||||
|
||||
response = llm.invoke(prompt)
|
||||
|
||||
|
||||
@@ -19,9 +19,21 @@ so that:
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# LLMs sometimes write a placeholder string ("None", "N/A", ...) into an optional
|
||||
# numeric field instead of omitting it. Coerce those to None so the structured
|
||||
# call validates instead of erroring (#1058). Pydantic still parses real numeric
|
||||
# strings ("189.5") to float.
|
||||
_NULLISH_FLOAT = {"", "none", "n/a", "na", "null", "nil", "-", "tbd", "unknown"}
|
||||
|
||||
|
||||
def _coerce_optional_float(value):
|
||||
if isinstance(value, str) and value.strip().lower() in _NULLISH_FLOAT:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -70,9 +82,11 @@ class ResearchPlan(BaseModel):
|
||||
recommendation: PortfolioRating = Field(
|
||||
description=(
|
||||
"The investment recommendation. Exactly one of Buy / Overweight / "
|
||||
"Hold / Underweight / Sell. Reserve Hold for situations where the "
|
||||
"evidence on both sides is genuinely balanced; otherwise commit to "
|
||||
"the side with the stronger arguments."
|
||||
"Hold / Underweight / Sell. Choose Hold when the evidence is "
|
||||
"balanced, materially conflicting, ambiguous, or insufficient to "
|
||||
"justify changing exposure; otherwise commit to the side with the "
|
||||
"clearly stronger arguments. Do not pick a direction merely to be "
|
||||
"decisive."
|
||||
),
|
||||
)
|
||||
rationale: str = Field(
|
||||
@@ -124,19 +138,24 @@ class TraderProposal(BaseModel):
|
||||
"the research plan. Two to four sentences."
|
||||
),
|
||||
)
|
||||
entry_price: Optional[float] = Field(
|
||||
entry_price: float | None = Field(
|
||||
default=None,
|
||||
description="Optional entry price target in the instrument's quote currency.",
|
||||
)
|
||||
stop_loss: Optional[float] = Field(
|
||||
stop_loss: float | None = Field(
|
||||
default=None,
|
||||
description="Optional stop-loss price in the instrument's quote currency.",
|
||||
)
|
||||
position_sizing: Optional[str] = Field(
|
||||
position_sizing: str | None = Field(
|
||||
default=None,
|
||||
description="Optional sizing guidance, e.g. '5% of portfolio'.",
|
||||
)
|
||||
|
||||
@field_validator("entry_price", "stop_loss", mode="before")
|
||||
@classmethod
|
||||
def _nullish_float_to_none(cls, v):
|
||||
return _coerce_optional_float(v)
|
||||
|
||||
|
||||
def render_trader_proposal(proposal: TraderProposal) -> str:
|
||||
"""Render a TraderProposal to markdown.
|
||||
@@ -180,7 +199,10 @@ class PortfolioDecision(BaseModel):
|
||||
rating: PortfolioRating = Field(
|
||||
description=(
|
||||
"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. Choose "
|
||||
"Hold when the case is balanced, materially conflicting, ambiguous, "
|
||||
"or insufficient to justify changing exposure, rather than forcing a "
|
||||
"direction to appear decisive."
|
||||
),
|
||||
)
|
||||
executive_summary: str = Field(
|
||||
@@ -196,15 +218,20 @@ class PortfolioDecision(BaseModel):
|
||||
"incorporate them; otherwise rely solely on the current analysis."
|
||||
),
|
||||
)
|
||||
price_target: Optional[float] = Field(
|
||||
price_target: float | None = Field(
|
||||
default=None,
|
||||
description="Optional target price in the instrument's quote currency.",
|
||||
)
|
||||
time_horizon: Optional[str] = Field(
|
||||
time_horizon: str | None = Field(
|
||||
default=None,
|
||||
description="Optional recommended holding period, e.g. '3-6 months'.",
|
||||
)
|
||||
|
||||
@field_validator("price_target", mode="before")
|
||||
@classmethod
|
||||
def _nullish_float_to_none(cls, v):
|
||||
return _coerce_optional_float(v)
|
||||
|
||||
|
||||
def render_pm_decision(decision: PortfolioDecision) -> str:
|
||||
"""Render a PortfolioDecision back to the markdown shape the rest of the system expects.
|
||||
@@ -226,3 +253,94 @@ def render_pm_decision(decision: PortfolioDecision) -> str:
|
||||
if decision.time_horizon:
|
||||
parts.extend(["", f"**Time Horizon**: {decision.time_horizon}"])
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sentiment Analyst
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SentimentBand(str, Enum):
|
||||
"""Discrete sentiment direction produced by the Sentiment Analyst.
|
||||
|
||||
Six tiers keep the signal granular enough to be actionable while remaining
|
||||
small enough for every provider to map reliably from its JSON output.
|
||||
"""
|
||||
|
||||
BULLISH = "Bullish"
|
||||
MILDLY_BULLISH = "Mildly Bullish"
|
||||
NEUTRAL = "Neutral"
|
||||
MIXED = "Mixed"
|
||||
MILDLY_BEARISH = "Mildly Bearish"
|
||||
BEARISH = "Bearish"
|
||||
|
||||
|
||||
class SentimentReport(BaseModel):
|
||||
"""Structured sentiment report produced by the Sentiment Analyst.
|
||||
|
||||
Replaces the previous free-form prose output so downstream consumers
|
||||
(dashboards, audit logs, PDF renderers, other agents) can read
|
||||
``overall_band`` and ``overall_score`` without maintaining fragile regex
|
||||
fallbacks that drift with every model release. ``narrative`` preserves the
|
||||
rich source-by-source analysis; ``render_sentiment_report`` prepends a
|
||||
deterministic header so the saved report stays human-readable.
|
||||
"""
|
||||
|
||||
overall_band: SentimentBand = Field(
|
||||
description=(
|
||||
"Overall sentiment direction. Exactly one of: "
|
||||
"Bullish / Mildly Bullish / Neutral / Mixed / Mildly Bearish / Bearish. "
|
||||
"Use Mixed when sources point in clearly different directions. "
|
||||
"Use Neutral only when all sources are genuinely silent or non-committal."
|
||||
),
|
||||
)
|
||||
overall_score: float = Field(
|
||||
ge=0.0,
|
||||
le=10.0,
|
||||
description=(
|
||||
"Numeric sentiment intensity on a 0–10 scale. "
|
||||
"0 = maximally bearish, 5 = neutral, 10 = maximally bullish. "
|
||||
"Guideline for consistency with overall_band: "
|
||||
"Bullish ~6.5–10, Mildly Bullish ~5.5–6.4, Neutral/Mixed ~4.5–5.5, "
|
||||
"Mildly Bearish ~3.5–4.4, Bearish ~0–3.4. "
|
||||
"Only the 0–10 bounds are enforced."
|
||||
),
|
||||
)
|
||||
confidence: Literal["low", "medium", "high"] = Field(
|
||||
description=(
|
||||
"Confidence in the assessment based on data quality and sample size. "
|
||||
"Use 'low' when one or more sources returned a placeholder or fewer "
|
||||
"than 5 data points; 'medium' when data is present but sparse; "
|
||||
"'high' when all three sources returned substantive data."
|
||||
),
|
||||
)
|
||||
narrative: str = Field(
|
||||
description=(
|
||||
"Full sentiment report covering, in order: "
|
||||
"(1) source-by-source breakdown with specific evidence (cite message "
|
||||
"counts, ratios, notable posts); "
|
||||
"(2) cross-source divergences and alignments; "
|
||||
"(3) dominant narrative themes; "
|
||||
"(4) catalysts and risks surfaced by the data; "
|
||||
"(5) a markdown table summarising key sentiment signals, their "
|
||||
"direction, source, and supporting evidence. "
|
||||
"Keep it informative and substantive: develop each section thoroughly "
|
||||
"with concrete evidence so every point adds new signal for the trader."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def render_sentiment_report(report: SentimentReport) -> str:
|
||||
"""Render a SentimentReport to the markdown shape the rest of the system expects.
|
||||
|
||||
The structured header (band + score + confidence) is prepended to the
|
||||
narrative so the saved report is both human-readable and machine-parseable
|
||||
without regex.
|
||||
"""
|
||||
return "\n".join([
|
||||
f"**Overall Sentiment:** **{report.overall_band.value}** "
|
||||
f"(Score: {report.overall_score:.1f}/10)",
|
||||
f"**Confidence:** {report.confidence.capitalize()}",
|
||||
"",
|
||||
report.narrative,
|
||||
])
|
||||
|
||||
@@ -7,8 +7,12 @@ import functools
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from tradingagents.agents.schemas import TraderProposal, render_trader_proposal
|
||||
from tradingagents.agents.utils.agent_utils import build_instrument_context
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
get_instrument_context_from_state,
|
||||
get_language_instruction,
|
||||
)
|
||||
from tradingagents.agents.utils.structured import (
|
||||
NO_EXTERNAL_TOOLS,
|
||||
bind_structured,
|
||||
invoke_structured_or_freetext,
|
||||
)
|
||||
@@ -19,8 +23,25 @@ def create_trader(llm):
|
||||
|
||||
def trader_node(state, name):
|
||||
company_name = state["company_of_interest"]
|
||||
instrument_context = build_instrument_context(company_name)
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
investment_plan = state["investment_plan"]
|
||||
# The research plan digests the debate but loses exact price structure;
|
||||
# give the Trader the technical market report so entry/stop levels are
|
||||
# grounded in real ATR / support-resistance / current price (#1167). The
|
||||
# report is empty when the user did not select the market analyst, so
|
||||
# only offer it (and the grounding instruction) when it has content.
|
||||
market_report = (state["market_report"] or "").strip()
|
||||
|
||||
if market_report:
|
||||
grounding = (
|
||||
"Ground concrete price levels (entry, stop-loss, position sizing) in the technical "
|
||||
"market report's price structure -- current price, support/resistance, ATR, and "
|
||||
"volatility -- and use the research plan for direction and strategy. "
|
||||
)
|
||||
report_section = f"Technical Market Report:\n{market_report}\n\n"
|
||||
else:
|
||||
grounding = ""
|
||||
report_section = ""
|
||||
|
||||
messages = [
|
||||
{
|
||||
@@ -28,18 +49,19 @@ def create_trader(llm):
|
||||
"content": (
|
||||
"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. "
|
||||
"Anchor your reasoning in the analysts' reports and the research plan."
|
||||
+ grounding
|
||||
+ NO_EXTERNAL_TOOLS
|
||||
+ get_language_instruction()
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Based on a comprehensive analysis by a team of analysts, here is an investment "
|
||||
f"plan tailored for {company_name}. {instrument_context} This plan incorporates "
|
||||
f"insights from current technical market trends, macroeconomic indicators, and "
|
||||
f"social media sentiment. Use this plan as a foundation for evaluating your next "
|
||||
f"trading decision.\n\nProposed Investment Plan: {investment_plan}\n\n"
|
||||
f"Leverage these insights to make an informed and strategic decision."
|
||||
f"Here is the research team's investment plan for {company_name}. "
|
||||
f"{instrument_context}\n\n"
|
||||
f"{report_section}"
|
||||
f"Proposed Investment Plan:\n{investment_plan}\n\n"
|
||||
f"Make an informed, strategic trading decision."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Annotated
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import MessagesState
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
# Researcher team state
|
||||
@@ -45,13 +46,15 @@ class RiskDebateState(TypedDict):
|
||||
|
||||
class AgentState(MessagesState):
|
||||
company_of_interest: Annotated[str, "Company that we are interested in trading"]
|
||||
asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"]
|
||||
instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"]
|
||||
trade_date: Annotated[str, "What date we are trading at"]
|
||||
|
||||
sender: Annotated[str, "Agent that sent this message"]
|
||||
|
||||
# research step
|
||||
market_report: Annotated[str, "Report from the Market Analyst"]
|
||||
sentiment_report: Annotated[str, "Report from the Social Media Analyst"]
|
||||
sentiment_report: Annotated[str, "Report from the Sentiment Analyst"]
|
||||
news_report: Annotated[
|
||||
str, "Report from the News Researcher of current world affairs"
|
||||
]
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
import functools
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import yfinance as yf
|
||||
from langchain_core.messages import HumanMessage, RemoveMessage
|
||||
|
||||
# Import tools from separate utility files
|
||||
from tradingagents.agents.utils.core_stock_tools import (
|
||||
get_stock_data
|
||||
)
|
||||
from tradingagents.agents.utils.technical_indicators_tools import (
|
||||
get_indicators
|
||||
)
|
||||
from tradingagents.agents.utils.core_stock_tools import get_stock_data
|
||||
from tradingagents.agents.utils.fundamental_data_tools import (
|
||||
get_fundamentals,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_income_statement
|
||||
get_fundamentals,
|
||||
get_income_statement,
|
||||
)
|
||||
from tradingagents.agents.utils.macro_data_tools import get_macro_indicators
|
||||
from tradingagents.agents.utils.market_data_validation_tools import get_verified_market_snapshot
|
||||
from tradingagents.agents.utils.news_data_tools import (
|
||||
get_news,
|
||||
get_global_news,
|
||||
get_insider_transactions,
|
||||
get_global_news
|
||||
get_news,
|
||||
)
|
||||
from tradingagents.agents.utils.prediction_markets_tools import get_prediction_markets
|
||||
from tradingagents.agents.utils.technical_indicators_tools import get_indicators
|
||||
|
||||
# Public surface: the data tools are imported here so agents and the graph
|
||||
# import them from one place, plus the instrument/language helpers defined below.
|
||||
__all__ = [
|
||||
"get_stock_data",
|
||||
"get_indicators",
|
||||
"get_fundamentals",
|
||||
"get_balance_sheet",
|
||||
"get_cashflow",
|
||||
"get_income_statement",
|
||||
"get_news",
|
||||
"get_global_news",
|
||||
"get_insider_transactions",
|
||||
"get_macro_indicators",
|
||||
"get_prediction_markets",
|
||||
"get_verified_market_snapshot",
|
||||
"build_instrument_context",
|
||||
"resolve_instrument_identity",
|
||||
"get_instrument_context_from_state",
|
||||
"get_language_instruction",
|
||||
"create_msg_delete",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_language_instruction() -> str:
|
||||
"""Return a prompt instruction for the configured output language.
|
||||
|
||||
Returns empty string when English (default), so no extra tokens are used.
|
||||
Only applied to user-facing agents (analysts, portfolio manager).
|
||||
Internal debate agents stay in English for reasoning quality.
|
||||
Applied to every agent whose output reaches the saved report —
|
||||
analysts, researchers, debaters, research manager, trader, and
|
||||
portfolio manager — so a non-English run produces a fully localized
|
||||
report rather than a mix of languages.
|
||||
"""
|
||||
from tradingagents.dataflows.config import get_config
|
||||
lang = get_config().get("output_language", "English")
|
||||
@@ -34,25 +65,164 @@ def get_language_instruction() -> str:
|
||||
return f" Write your entire response in {lang}."
|
||||
|
||||
|
||||
def build_instrument_context(ticker: str) -> str:
|
||||
"""Describe the exact instrument so agents preserve exchange-qualified tickers."""
|
||||
return (
|
||||
f"The instrument to analyze is `{ticker}`. "
|
||||
"Use this exact ticker in every tool call, report, and recommendation, "
|
||||
"preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`)."
|
||||
def opponent_argument_or_opening(text: str, opponent: str) -> str:
|
||||
"""Opponent's latest argument, or an explicit opening marker when empty.
|
||||
|
||||
The first speaker in each debate round receives an empty opponent response;
|
||||
interpolating it into a "refute the opponent" prompt makes the model
|
||||
fabricate the other side's position. Returning a clear "has not spoken yet"
|
||||
marker instead lets it open with its own case (#1176).
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return f"(The {opponent} has not spoken yet — open the debate with your own case.)"
|
||||
|
||||
|
||||
def _clean_identity_value(value: Any) -> str | None:
|
||||
"""Return a trimmed string, or None for empty / placeholder-ish values."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
if not cleaned or cleaned.lower() in {"none", "n/a", "nan", "null"}:
|
||||
return None
|
||||
return cleaned
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def resolve_instrument_identity(ticker: str) -> dict:
|
||||
"""Resolve deterministic identity metadata (company name, sector, …) for a ticker.
|
||||
|
||||
This exists to stop the pipeline from hallucinating a *different* company
|
||||
when a chart pattern suggests a different industry than the real one
|
||||
(#814): without a ground-truth name, the market analyst would pattern-match
|
||||
the price action to a narrative and invent an identity that then cascaded
|
||||
through every downstream agent.
|
||||
|
||||
Best-effort by design: if yfinance is unavailable, rate-limited, or doesn't
|
||||
recognise the ticker, we return ``{}`` and the caller falls back to
|
||||
ticker-only context rather than failing before analysis starts. Cached so
|
||||
the lookup happens at most once per ticker per process.
|
||||
|
||||
The symbol is normalized first (e.g. ``XAUUSD`` -> ``GC=F``) so identity
|
||||
resolves for the same instrument the price path actually fetches (#983).
|
||||
"""
|
||||
from tradingagents.dataflows.symbol_utils import normalize_symbol
|
||||
|
||||
try:
|
||||
info = yf.Ticker(normalize_symbol(ticker)).info or {}
|
||||
except Exception as exc: # noqa: BLE001 — fail open, never block the run
|
||||
logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc)
|
||||
return {}
|
||||
|
||||
identity: dict[str, str] = {}
|
||||
company_name = _clean_identity_value(info.get("longName")) or _clean_identity_value(
|
||||
info.get("shortName")
|
||||
)
|
||||
if company_name:
|
||||
identity["company_name"] = company_name
|
||||
for source_key, target_key in (
|
||||
("sector", "sector"),
|
||||
("industry", "industry"),
|
||||
("exchange", "exchange"),
|
||||
("quoteType", "quote_type"),
|
||||
):
|
||||
value = _clean_identity_value(info.get(source_key))
|
||||
if value:
|
||||
identity[target_key] = value
|
||||
return identity
|
||||
|
||||
|
||||
def build_instrument_context(
|
||||
ticker: str,
|
||||
asset_type: str = "stock",
|
||||
identity: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Describe the exact instrument so agents preserve identity and ticker.
|
||||
|
||||
When ``identity`` is provided (resolved deterministically via
|
||||
:func:`resolve_instrument_identity`), the company name and business
|
||||
classification are injected so agents anchor to the real company rather
|
||||
than pattern-matching the price chart to a wrong one (#814).
|
||||
"""
|
||||
is_crypto = asset_type == "crypto"
|
||||
instrument_label = "asset" if is_crypto else "instrument"
|
||||
context = (
|
||||
f"The {instrument_label} to analyze is `{ticker}`. "
|
||||
"Use this exact ticker in every tool call, report, and recommendation, "
|
||||
"preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)."
|
||||
)
|
||||
|
||||
details = []
|
||||
if identity:
|
||||
name = identity.get("company_name") or identity.get("name")
|
||||
if name:
|
||||
details.append(f"{'Name' if is_crypto else 'Company'}: {name}")
|
||||
sector, industry = identity.get("sector"), identity.get("industry")
|
||||
if sector and industry:
|
||||
details.append(f"Business classification: {sector} / {industry}")
|
||||
elif sector:
|
||||
details.append(f"Sector: {sector}")
|
||||
elif industry:
|
||||
details.append(f"Industry: {industry}")
|
||||
if identity.get("exchange"):
|
||||
details.append(f"Exchange: {identity['exchange']}")
|
||||
|
||||
if details:
|
||||
context += (
|
||||
f" Resolved identity: {'; '.join(details)}. "
|
||||
"Do not substitute a different company or ticker unless a tool "
|
||||
"result explicitly disproves this resolved identity."
|
||||
)
|
||||
|
||||
if is_crypto:
|
||||
context += (
|
||||
" Treat it as a crypto asset rather than a company, and do not "
|
||||
"assume company fundamentals are available."
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def get_instrument_context_from_state(state: Mapping[str, Any]) -> str:
|
||||
"""Return the instrument context for the current run.
|
||||
|
||||
Prefers the identity-resolved context computed once at run start and
|
||||
stored on the state (see ``TradingAgentsGraph.resolve_instrument_context``).
|
||||
Falls back to a ticker-only context — with no network lookup — when the
|
||||
state was constructed without it (bare programmatic states, tests), so a
|
||||
consumer is never forced to make a yfinance call mid-graph.
|
||||
"""
|
||||
context = state.get("instrument_context")
|
||||
if isinstance(context, str) and context.strip():
|
||||
return context
|
||||
return build_instrument_context(
|
||||
str(state["company_of_interest"]),
|
||||
state.get("asset_type", "stock"),
|
||||
)
|
||||
|
||||
|
||||
def create_msg_delete():
|
||||
def delete_messages(state):
|
||||
"""Clear messages and add placeholder for Anthropic compatibility"""
|
||||
messages = state["messages"]
|
||||
"""Clear messages and add a context-anchored placeholder.
|
||||
|
||||
# Remove all messages
|
||||
The placeholder must not be a bare ``"Continue"``: some
|
||||
OpenAI-compatible providers interpret that literally as the user task
|
||||
and produce output about the word "continue" instead of analysing the
|
||||
instrument (#888). Anchoring it to the resolved instrument context and
|
||||
date keeps the next analyst on-task even if the provider treats the
|
||||
placeholder as a standalone request.
|
||||
"""
|
||||
messages = state["messages"]
|
||||
removal_operations = [RemoveMessage(id=m.id) for m in messages]
|
||||
|
||||
# Add a minimal placeholder message
|
||||
placeholder = HumanMessage(content="Continue")
|
||||
|
||||
instrument_context = get_instrument_context_from_state(state)
|
||||
trade_date = state.get("trade_date", "the requested date")
|
||||
placeholder = HumanMessage(
|
||||
content=(
|
||||
f"Proceed with your assigned analysis for this workflow. "
|
||||
f"{instrument_context} The analysis date is {trade_date}."
|
||||
)
|
||||
)
|
||||
return {"messages": removal_operations + [placeholder]}
|
||||
|
||||
return delete_messages
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from tradingagents.dataflows.interface import route_to_vendor
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from tradingagents.dataflows.interface import route_to_vendor
|
||||
|
||||
|
||||
|
||||
36
tradingagents/agents/utils/macro_data_tools.py
Normal file
36
tradingagents/agents/utils/macro_data_tools.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from tradingagents.dataflows.interface import route_to_vendor
|
||||
|
||||
|
||||
@tool
|
||||
def get_macro_indicators(
|
||||
indicator: Annotated[
|
||||
str,
|
||||
"Macro indicator: a friendly alias such as 'cpi', 'core_pce', "
|
||||
"'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve', "
|
||||
"'real_gdp', 'vix', or a raw FRED series ID such as 'CPIAUCSL'.",
|
||||
],
|
||||
curr_date: Annotated[str, "Current date in yyyy-mm-dd format; the end of the window"],
|
||||
look_back_days: Annotated[
|
||||
int | None, "Trailing window length in days; omit for a 1-year window"
|
||||
] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve a macroeconomic indicator time series from FRED (Federal Reserve
|
||||
Economic Data): policy rates, Treasury yields, inflation, labor, and growth.
|
||||
Returns the series title, units, frequency, the latest value, the change
|
||||
over the window, and a recent observation table. Uses the configured
|
||||
macro_data vendor.
|
||||
|
||||
Args:
|
||||
indicator (str): Friendly alias or raw FRED series ID
|
||||
curr_date (str): Current date in yyyy-mm-dd format
|
||||
look_back_days (int): Trailing window length; omit for a 1-year window
|
||||
|
||||
Returns:
|
||||
str: A formatted markdown report of the macro series
|
||||
"""
|
||||
return route_to_vendor("get_macro_indicators", indicator, curr_date, look_back_days)
|
||||
23
tradingagents/agents/utils/market_data_validation_tools.py
Normal file
23
tradingagents/agents/utils/market_data_validation_tools.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import Annotated
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from tradingagents.dataflows.market_data_validator import build_verified_market_snapshot
|
||||
|
||||
|
||||
@tool
|
||||
def get_verified_market_snapshot(
|
||||
symbol: Annotated[str, "ticker symbol of the company"],
|
||||
curr_date: Annotated[str, "the current trading date, YYYY-mm-dd"],
|
||||
look_back_days: Annotated[
|
||||
int, "number of recent trading rows to include for sanity-checking"
|
||||
] = 30,
|
||||
) -> str:
|
||||
"""Deterministic verification snapshot for exact market-data claims.
|
||||
|
||||
Returns the latest OHLCV row on or before curr_date, common technical
|
||||
indicators, and recent closes. Call this before making exact claims about
|
||||
price levels, Bollinger bands, RSI, MACD, moving averages, support /
|
||||
resistance, or historical comparisons, and treat it as the source of truth.
|
||||
"""
|
||||
return build_verified_market_snapshot(symbol, curr_date, look_back_days)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user