commit 8b3317e15b394fdbd00bc3ee515d45d14ed6e87f Author: wehub-resource-sync Date: Mon Jul 13 11:58:51 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cac7101 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.venv +.env +.claude +.idea +.vscode +.DS_Store +__pycache__ +*.egg-info +build +dist +results +eval_results +Dockerfile +docker-compose.yml diff --git a/.env.enterprise.example b/.env.enterprise.example new file mode 100644 index 0000000..4f7bda3 --- /dev/null +++ b/.env.enterprise.example @@ -0,0 +1,5 @@ +# Azure OpenAI +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT_NAME= +# OPENAI_API_VERSION=2024-10-21 # optional, required for non-v1 API diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a812347 --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# LLM Providers (set the one you use) +OPENAI_API_KEY= +GOOGLE_API_KEY= +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 +# 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b72079 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eddc67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,223 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Cache +**/data_cache/ + +# Enterprise env file (secrets) and generated run reports +.env.enterprise +reports/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8e926b0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,442 @@ +# Changelog + +All notable changes to TradingAgents are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Breaking changes within the 0.x line are called out explicitly. + +## [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 `.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 + +- **Structured-output decision agents.** Research Manager, Trader, and Portfolio + Manager now use `llm.with_structured_output(Schema)` on their primary call + and return typed Pydantic instances. Each provider's native structured-output + mode is used (`json_schema` for OpenAI / xAI, `response_schema` for Gemini, + tool-use for Anthropic, function-calling for OpenAI-compatible providers). + Render helpers preserve the existing markdown shape so memory log, CLI + display, and saved reports keep working unchanged. (#434) +- **LangGraph checkpoint resume** — opt-in via `--checkpoint`. State is saved + after each node so crashed or interrupted runs resume from the last + successful step. Per-ticker SQLite databases under + `~/.tradingagents/cache/checkpoints/`. `--clear-checkpoints` resets them. (#594) +- **Persistent decision log** replacing the per-agent BM25 memory. Decisions + are stored automatically at the end of `propagate()`; the next same-ticker + run resolves prior pending entries with realised return, alpha vs SPY, and + a one-paragraph reflection. Override path with `TRADINGAGENTS_MEMORY_LOG_PATH`. + Optional `memory_log_max_entries` config caps resolved entries; pending + entries are never pruned. (#578, #563, #564, #579) +- **DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), and Azure OpenAI** + providers, plus dynamic OpenRouter model selection. +- **Docker support** — multi-stage build with separate dev and runtime images. +- **`scripts/smoke_structured_output.py`** — diagnostic that exercises the + three structured-output agents against any provider so contributors can + verify their setup with one command. +- **5-tier rating scale** (Buy / Overweight / Hold / Underweight / Sell) used + consistently by Research Manager, Portfolio Manager, signal processor, and + the memory log; Trader keeps 3-tier (Buy / Hold / Sell) since transaction + direction is naturally ternary. +- **Pytest fixtures** — lazy LLM client imports plus placeholder API keys so + the test suite runs cleanly without credentials. (#588) + +### Changed + +- **`backend_url` default is now `None`** rather than the OpenAI URL. Each + provider client falls back to its native default. The previous default + leaked the OpenAI URL into non-OpenAI clients (e.g. Gemini), producing + malformed request URLs for Python users who switched providers without + overriding `backend_url`. The CLI flow is unaffected. +- All file I/O passes explicit `encoding="utf-8"` so Windows users no longer + hit `UnicodeEncodeError` with the cp1252 default. (#543, #550, #576) +- Cache and log directories moved to `~/.tradingagents/` to resolve Docker + permission issues. (#519) +- `SignalProcessor` reads the rating from the Portfolio Manager's rendered + markdown via a deterministic heuristic — no extra LLM call. +- OpenAI structured-output calls default to `method="function_calling"` to + avoid noisy `PydanticSerializationUnexpectedValue` warnings emitted by + langchain-openai's Responses-API parse path. Same typed result, no warnings. + +### Fixed + +- Empty memory no longer triggers fabricated past-lessons in agent prompts; + the memory-log redesign makes this structurally impossible since only the + Portfolio Manager consults memory and only when entries exist. (#572) +- Tool-call logging processes every chunk message, not just the last one, and + memory score normalization handles empty score arrays. (#534, #531) + +### Removed + +- `FinancialSituationMemory` (the per-agent BM25 system) and the dead + `reflect_and_remember()` plumbing; subsumed by the persistent decision log. +- Hardcoded Google endpoint that caused 404 when `langchain-google-genai` + changed its API path. (#493, #496) + +### Contributors + +Thanks to everyone who shaped this release through code, design, and reports: + +- [@claytonbrown](https://github.com/claytonbrown) — checkpoint resume (#594), test fixtures (#588), design feedback on cost tracking (#582) and structured validation (#583) +- [@Bcardo](https://github.com/Bcardo) — memory-log redesign (#579), empty-memory hallucination report (#572), encoding fix proposal (#570) +- [@voidborne-d](https://github.com/voidborne-d) — memory persistence design (#564), portfolio manager state fix (#503) +- [@mannubaveja007](https://github.com/mannubaveja007) — structured-output feature request (#434) +- [@kelder66](https://github.com/kelder66) — RAM-only memory issue (#563) +- [@Gujiassh](https://github.com/Gujiassh) — tool-call logging fix (#534), test stub PR (#533) +- [@iuyup](https://github.com/iuyup) — memory score normalization fix (#531) +- [@kaihg](https://github.com/kaihg) — Google base_url fix (#496) +- [@32ryh98yfe](https://github.com/32ryh98yfe) — Gemini 404 report (#493) +- [@uppb](https://github.com/uppb) — OpenRouter dynamic model selection (#482) +- [@guoz14](https://github.com/guoz14) — OpenRouter limited-model report (#337) +- [@samchenku](https://github.com/samchenku) — indicator name normalization (#490) +- [@JasonOA888](https://github.com/JasonOA888) — y_finance pandas import fix (#488) +- [@tiffanychum](https://github.com/tiffanychum) — stale import cleanup (#499) +- [@zaizou](https://github.com/zaizou) — Docker permission issue (#519) +- [@Stosman123](https://github.com/Stosman123), [@mauropuga](https://github.com/mauropuga), [@hotwind2015](https://github.com/hotwind2015) — Windows encoding bug reports (#543, #550, #576) +- [@nnishad](https://github.com/nnishad), [@atharvajoshi01](https://github.com/atharvajoshi01) — encoding fix proposals (#568, #549) + +## [0.2.3] — 2026-03-29 + +### Added + +- **Multi-language output** for analyst reports and final decisions, with a + CLI selector. Internal agent debate stays in English for reasoning quality. (#472) +- **GPT-5.4 family models** in the default catalog, with deep/quick model split. +- **Unified model catalog** as a single source of truth for CLI options and + provider validation. + +### Changed + +- `base_url` is forwarded to Google and Anthropic clients so corporate proxies + work consistently across providers. (#427) +- Standardised the Google `api_key` parameter to the unified `api_key` form. + +### Fixed + +- Backtesting fetchers no longer leak look-ahead data when `curr_date` is in + the middle of a fetched window. (#475) +- Invalid indicator names from the LLM are caught at the tool boundary instead + of crashing the run. (#429) +- yfinance news fetchers respect the same exponential-backoff retry as price + fetchers. (#445) + +### Contributors + +- [@ahmedk20](https://github.com/ahmedk20) — multi-language output (#472) +- [@CadeYu](https://github.com/CadeYu) — model catalog typing (#464) +- [@javierdejesusda](https://github.com/javierdejesusda) — unified Google API key parameter (#453) +- [@voidborne-d](https://github.com/voidborne-d) — yfinance news retry (#445) +- [@kostakost2](https://github.com/kostakost2) — look-ahead bias report (#475) +- [@lu-zhengda](https://github.com/lu-zhengda) — proxy/base_url support request (#427) +- [@VamsiKrishna2021](https://github.com/VamsiKrishna2021) — invalid indicator crash report (#429) + +## [0.2.2] — 2026-03-22 + +### Added + +- **Five-tier rating scale** (Buy / Overweight / Hold / Underweight / Sell) + introduced for the Portfolio Manager. +- **Anthropic effort level** support for Claude models. +- **OpenAI Responses API** path for native OpenAI models. + +### Changed + +- `risk_manager` renamed to `portfolio_manager` to match the role description + shown in the CLI display. +- Exchange-qualified tickers (e.g. `7203.T`, `BRK.B`) preserved across all + agent prompts and tool calls. +- Process-level UTF-8 default attempted for cross-platform consistency + (note: this approach did not actually take effect; replaced in v0.2.4 with + explicit per-call `encoding="utf-8"` arguments). + +### Fixed + +- yfinance rate-limit errors are retried with exponential backoff. (#426) +- HTTP client SSL customisation is supported for environments that need + custom certificate bundles. (#379) +- Report-section writes handle list-of-string content gracefully. + +### Contributors + +- [@CadeYu](https://github.com/CadeYu) — exchange-qualified ticker preservation (#413) +- [@yang1002378395-cmyk](https://github.com/yang1002378395-cmyk) — HTTP client SSL customisation (#379) + +## [0.2.1] — 2026-03-15 + +### Security + +- Patched `langchain-core` vulnerability (LangGrinch). (#335) +- Removed `chainlit` dependency affected by CVE-2026-22218. + +### Added + +- `pyproject.toml` build-system configuration; the project now installs via + modern packaging tooling. + +### Removed + +- `setup.py` — dependencies consolidated to `pyproject.toml`. + +### Fixed + +- Risk manager reads the correct fundamental report source. (#341) +- All `open()` calls receive an explicit UTF-8 encoding (initial pass). +- `get_indicators` tool handles comma-separated indicator names from the LLM. (#368) +- `Propagation` initialises every debate-state field so risk debaters never + see missing keys. +- Stock data parsing tolerates malformed CSVs and NaN values. +- Conditional debate logic respects the configured round count. (#361) + +### Contributors + +- [@RinZ27](https://github.com/RinZ27) — `langchain-core` security patch (#335) +- [@Ljx-007](https://github.com/Ljx-007) — risk manager fundamental-report fix (#341) +- [@makk9](https://github.com/makk9) — debate-rounds config issue (#361) + +## [0.2.0] — 2026-02-04 + +This is the largest release since the initial public version. The framework +moved from single-provider to a multi-provider architecture and grew several +production-ready surfaces. + +### Added + +- **Multi-provider LLM support** (OpenAI, Google, Anthropic, xAI, OpenRouter, + Ollama) via a factory pattern, with provider-specific thinking configurations. +- **Alpha Vantage** integration as a configurable primary data provider, with + yfinance as a community-stability fallback. +- **Footer statistics** in the CLI: real-time tracking of LLM calls, tool + calls, and token usage via LangChain callbacks. +- **Post-analysis report saving** — the framework writes per-section markdown + files (analyst reports, debate transcripts, final decision) when a run + completes. +- **Announcements panel** — fetches updates from `api.tauric.ai/v1/announcements` + for the CLI welcome screen. +- **Tool fallbacks** so a single vendor outage does not stop the pipeline. + +### Changed + +- Risky / Safe risk debaters renamed to **Aggressive / Conservative** for + consistency with the displayed agent labels. +- Default data vendor switched to balance reliability and quota across + community deployments. +- Ollama and OpenRouter model lists updated; default endpoints clarified. + +### Fixed + +- Analyst status tracking and message deduplication in the live display. +- Infinite-loop guard in the agent loop; reflection and logging hardened. +- Various data-vendor implementation bugs and tool-signature mismatches. + +### Contributors + +This release is the first with substantial outside contributions; many community +PRs from late 2025 also landed here. + +- [@luohy15](https://github.com/luohy15) — Alpha Vantage data-vendor integration (#235) +- [@EdwardoSunny](https://github.com/EdwardoSunny) — yfinance fetching optimisations (#245) +- [@Mirza-Samad-Ahmed-Baig](https://github.com/Mirza-Samad-Ahmed-Baig) — infinite-loop guard, reflection, and logging fixes (#89) +- [@ZeroAct](https://github.com/ZeroAct) — saved results path support (#29) +- [@Zhongyi-Lu](https://github.com/Zhongyi-Lu) — `.env` gitignore (#49) +- [@csoboy](https://github.com/csoboy) — local Ollama setup (#53) +- [@chauhang](https://github.com/chauhang) — initial Docker support attempt (#47, later reverted; the merged Docker support shipped in v0.2.4) + +## [0.1.1] — 2025-06-07 + +### Removed + +- Static site assets that had been bundled with v0.1.0; the public site now + lives separately. + +## [0.1.0] — 2025-06-05 + +### Added + +- **Initial public release** of the TradingAgents multi-agent trading + framework: market / sentiment / news / fundamentals analysts; bull and bear + researchers; trader; aggressive, conservative, and neutral risk debaters; + portfolio manager. LangGraph orchestration, yfinance data, per-agent + BM25 memory, single-provider OpenAI integration, interactive CLI. + +[0.2.4]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/TauricResearch/TradingAgents/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/TauricResearch/TradingAgents/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/TauricResearch/TradingAgents/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/TauricResearch/TradingAgents/releases/tag/v0.1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..024c7c7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /build +COPY . . +RUN pip install --no-cache-dir . + +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +RUN useradd --create-home appuser \ + && install -d -m 0755 -o appuser -g appuser /home/appuser/.tradingagents +USER appuser +WORKDIR /home/appuser/app + +COPY --from=builder --chown=appuser:appuser /build . + +ENTRYPOINT ["tradingagents"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e57a267 --- /dev/null +++ b/README.md @@ -0,0 +1,312 @@ +

+ +

+ +
+ arXiv + Discord + WeChat + X Follow +
+ Community +
+ +
+ + Deutsch | + Español | + français | + 日本語 | + 한국어 | + Português | + Русский | + 中文 +
+ +--- + +# TradingAgents: Multi-Agents LLM Financial Trading Framework + +## News +- [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. See [CHANGELOG.md](CHANGELOG.md) for the full list. +- [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. + +
+ + + + + TradingAgents Star History + + +
+ +> 🎉 **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! + +
+ +🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation) + +
+ +## TradingAgents 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. + +

+ +

+ +> 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. + +### Analyst Team +- Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags. +- 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. + +

+ +

+ +### Researcher Team +- Comprises both bullish and bearish researchers who critically assess the insights provided by the Analyst Team. Through structured debates, they balance potential gains against inherent risks. + +

+ +

+ +### Trader Agent +- Composes reports from the analysts and researchers to make informed trading decisions, determining the timing and magnitude of trades. + +

+ +

+ +### Risk Management and Portfolio Manager +- Continuously evaluates portfolio risk by assessing market volatility, liquidity, and other risk factors. The risk management team evaluates and adjusts trading strategies, providing assessment reports to the Portfolio Manager for final decision. +- The Portfolio Manager approves/rejects the transaction proposal. If approved, the order will be sent to the simulated exchange and executed. + +

+ +

+ +## Installation and CLI + +### Installation + +Clone TradingAgents: +```bash +git clone https://github.com/TauricResearch/TradingAgents.git +cd TradingAgents +``` + +Create a virtual environment in any of your favorite environment managers: +```bash +conda create -n tradingagents python=3.12 +conda activate tradingagents +``` + +Install the package and its dependencies: +```bash +pip install . +``` + +### Docker + +Alternatively, run with Docker: +```bash +cp .env.example .env # add your API keys +docker compose run --rm tradingagents +``` + +For local models with Ollama: +```bash +docker compose --profile ollama run --rm tradingagents-ollama +``` + +### Required APIs + +TradingAgents supports multiple LLM providers. Set the API key for your chosen provider: + +```bash +export OPENAI_API_KEY=... # OpenAI (GPT) +export GOOGLE_API_KEY=... # Google (Gemini) +export ANTHROPIC_API_KEY=... # Anthropic (Claude) +export XAI_API_KEY=... # xAI (Grok) +export DEEPSEEK_API_KEY=... # DeepSeek +export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com) +export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com) +export 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 Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. + +For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-4-8-v1:0`. + +For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull `, and pick "Custom model ID" in the CLI for any model not listed by default. + +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 +cp .env.example .env +``` + +### CLI Usage + +Launch the interactive CLI: +```bash +tradingagents # installed command +python -m cli.main # alternative: run directly from source +``` +You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. + +### 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` + +

+ +

+ +An interface will appear showing results as they load, letting you track the agent's progress as it runs. + +

+ +

+ +

+ +

+ +## TradingAgents Package + +### 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, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. + +### Python Usage + +To use TradingAgents inside your code, you can import the `tradingagents` module and initialize a `TradingAgentsGraph()` object. The `.propagate()` function will return a decision. You can run `main.py`, here's also a quick example: + +```python +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG + +ta = TradingAgentsGraph(debug=True, config=DEFAULT_CONFIG.copy()) + +# forward propagate +_, decision = ta.propagate("NVDA", "2026-01-15") +print(decision) +``` + +You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc. + +```python +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.default_config import DEFAULT_CONFIG + +config = DEFAULT_CONFIG.copy() +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.5" # Model for complex reasoning +config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks +config["max_debate_rounds"] = 2 + +ta = TradingAgentsGraph(debug=True, config=config) +_, decision = ta.propagate("NVDA", "2026-01-15") +print(decision) +``` + +See `tradingagents/default_config.py` for all configuration options. + +## Persistence and Recovery + +TradingAgents persists two kinds of state across runs. + +### Decision log + +The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw and alpha vs SPY), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. + +Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`. + +### Checkpoint resume + +Checkpoint resume is opt-in via `--checkpoint`. When enabled, LangGraph saves state after each node so a crashed or interrupted run resumes from the last successful step instead of starting over. On a resume run you will see `Resuming from step N for on ` in the logs; on a new run you will see `Starting fresh`. Checkpoints are cleared automatically on successful completion. + +Per-ticker SQLite databases live at `~/.tradingagents/cache/checkpoints/.db` (override the base with `TRADINGAGENTS_CACHE_DIR`). Use `--clear-checkpoints` to reset all of them before a run. + +```bash +tradingagents analyze --checkpoint # enable for this run +tradingagents analyze --clear-checkpoints # reset before running +``` + +```python +config = DEFAULT_CONFIG.copy() +config["checkpoint_enabled"] = True +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 + +Contributions are welcome: bug fixes, documentation, and feature ideas; past contributions are credited per release in [`CHANGELOG.md`](CHANGELOG.md). + +## Citation + +Please reference our work if you find *TradingAgents* provides you with some help :) + +``` +@misc{xiao2025tradingagentsmultiagentsllmfinancial, + title={TradingAgents: Multi-Agents LLM Financial Trading Framework}, + author={Yijia Xiao and Edward Sun and Di Luo and Wei Wang}, + year={2025}, + eprint={2412.20138}, + archivePrefix={arXiv}, + primaryClass={q-fin.TR}, + url={https://arxiv.org/abs/2412.20138}, +} +``` diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..55482db --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`TauricResearch/TradingAgents` +- 原始仓库:https://github.com/TauricResearch/TradingAgents +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/assets/TauricResearch.png b/assets/TauricResearch.png new file mode 100644 index 0000000..3e88994 Binary files /dev/null and b/assets/TauricResearch.png differ diff --git a/assets/analyst.png b/assets/analyst.png new file mode 100644 index 0000000..9c89a6e Binary files /dev/null and b/assets/analyst.png differ diff --git a/assets/cli/cli_init.png b/assets/cli/cli_init.png new file mode 100644 index 0000000..eeb2b63 Binary files /dev/null and b/assets/cli/cli_init.png differ diff --git a/assets/cli/cli_news.png b/assets/cli/cli_news.png new file mode 100644 index 0000000..b5d33be Binary files /dev/null and b/assets/cli/cli_news.png differ diff --git a/assets/cli/cli_technical.png b/assets/cli/cli_technical.png new file mode 100644 index 0000000..5fd363a Binary files /dev/null and b/assets/cli/cli_technical.png differ diff --git a/assets/cli/cli_transaction.png b/assets/cli/cli_transaction.png new file mode 100644 index 0000000..ab36d2b Binary files /dev/null and b/assets/cli/cli_transaction.png differ diff --git a/assets/researcher.png b/assets/researcher.png new file mode 100644 index 0000000..8301534 Binary files /dev/null and b/assets/researcher.png differ diff --git a/assets/risk.png b/assets/risk.png new file mode 100644 index 0000000..5708dc4 Binary files /dev/null and b/assets/risk.png differ diff --git a/assets/schema.png b/assets/schema.png new file mode 100644 index 0000000..549f8c5 Binary files /dev/null and b/assets/schema.png differ diff --git a/assets/trader.png b/assets/trader.png new file mode 100644 index 0000000..2bd7f51 Binary files /dev/null and b/assets/trader.png differ diff --git a/assets/wechat.png b/assets/wechat.png new file mode 100644 index 0000000..9435568 Binary files /dev/null and b/assets/wechat.png differ diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/announcements.py b/cli/announcements.py new file mode 100644 index 0000000..3ff7015 --- /dev/null +++ b/cli/announcements.py @@ -0,0 +1,52 @@ +import getpass + +import requests +from rich.console import Console +from rich.panel import Panel + +from cli.config import CLI_CONFIG + + +def fetch_announcements(url: str = None, timeout: float = None) -> dict: + """Fetch announcements from endpoint. Returns dict with announcements and settings.""" + endpoint = url or CLI_CONFIG["announcements_url"] + timeout = timeout or CLI_CONFIG["announcements_timeout"] + fallback = CLI_CONFIG["announcements_fallback"] + + try: + response = requests.get(endpoint, timeout=timeout) + response.raise_for_status() + data = response.json() + return { + "announcements": data.get("announcements", [fallback]), + "require_attention": data.get("require_attention", False), + } + except Exception: + return { + "announcements": [fallback], + "require_attention": False, + } + + +def display_announcements(console: Console, data: dict) -> None: + """Display announcements panel. Prompts for Enter if require_attention is True.""" + announcements = data.get("announcements", []) + require_attention = data.get("require_attention", False) + + if not announcements: + return + + content = "\n".join(announcements) + + panel = Panel( + content, + border_style="cyan", + padding=(1, 2), + title="Announcements", + ) + console.print(panel) + + if require_attention: + getpass.getpass("Press Enter to continue...") + else: + console.print() diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000..08483f4 --- /dev/null +++ b/cli/config.py @@ -0,0 +1,6 @@ +CLI_CONFIG = { + # Announcements + "announcements_url": "https://api.tauric.ai/v1/announcements", + "announcements_timeout": 1.0, + "announcements_fallback": "[cyan]For more information, please visit[/cyan] [link=https://github.com/TauricResearch]https://github.com/TauricResearch[/link]", +} diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..c14f538 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,1292 @@ +import datetime +import os +import time +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 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() + +app = typer.Typer( + name="TradingAgents", + help="TradingAgents CLI: Multi-Agents LLM Financial Trading Framework", + add_completion=True, # Enable shell completion +) + + +# Create a deque to store recent messages with a maximum length +class MessageBuffer: + # Fixed teams that always run (not user-selectable) + FIXED_AGENTS = { + "Research Team": ["Bull Researcher", "Bear Researcher", "Research Manager"], + "Trading Team": ["Trader"], + "Risk Management": ["Aggressive Analyst", "Neutral Analyst", "Conservative Analyst"], + "Portfolio Management": ["Portfolio Manager"], + } + + # Analyst name mapping + ANALYST_MAPPING = { + "market": "Market Analyst", + "social": "Sentiment Analyst", + "news": "News Analyst", + "fundamentals": "Fundamentals Analyst", + } + + # Report section mapping: section -> (analyst_key for filtering, finalizing_agent) + # analyst_key: which analyst selection controls this section (None = always included) + # finalizing_agent: which agent must be "completed" for this report to count as done + REPORT_SECTIONS = { + "market_report": ("market", "Market Analyst"), + "sentiment_report": ("social", "Sentiment Analyst"), + "news_report": ("news", "News Analyst"), + "fundamentals_report": ("fundamentals", "Fundamentals Analyst"), + "investment_plan": (None, "Research Manager"), + "trader_investment_plan": (None, "Trader"), + "final_trade_decision": (None, "Portfolio Manager"), + } + + def __init__(self, max_length=100): + self.messages = deque(maxlen=max_length) + self.tool_calls = deque(maxlen=max_length) + self.current_report = None + self.final_report = None # Store the complete final report + self.agent_status = {} + self.current_agent = None + self.report_sections = {} + self.selected_analysts = [] + self._processed_message_ids = set() + + def init_for_analysis(self, selected_analysts): + """Initialize agent status and report sections based on selected analysts. + + Args: + selected_analysts: List of analyst type strings (e.g., ["market", "news"]) + """ + self.selected_analysts = [a.lower() for a in selected_analysts] + + # Build agent_status dynamically + self.agent_status = {} + + # Add selected analysts + for analyst_key in self.selected_analysts: + if analyst_key in self.ANALYST_MAPPING: + self.agent_status[self.ANALYST_MAPPING[analyst_key]] = "pending" + + # Add fixed teams + for team_agents in self.FIXED_AGENTS.values(): + for agent in team_agents: + self.agent_status[agent] = "pending" + + # Build report_sections dynamically + self.report_sections = {} + for section, (analyst_key, _) in self.REPORT_SECTIONS.items(): + if analyst_key is None or analyst_key in self.selected_analysts: + self.report_sections[section] = None + + # Reset other state + self.current_report = None + self.final_report = None + self.current_agent = None + self.messages.clear() + self.tool_calls.clear() + self._processed_message_ids.clear() + + def get_completed_reports_count(self): + """Count reports that are finalized (their finalizing agent is completed). + + A report is considered complete when: + 1. The report section has content (not None), AND + 2. The agent responsible for finalizing that report has status "completed" + + This prevents interim updates (like debate rounds) from counting as completed. + """ + count = 0 + for section in self.report_sections: + if section not in self.REPORT_SECTIONS: + continue + _, finalizing_agent = self.REPORT_SECTIONS[section] + # Report is complete if it has content AND its finalizing agent is done + has_content = self.report_sections.get(section) is not None + agent_done = self.agent_status.get(finalizing_agent) == "completed" + if has_content and agent_done: + count += 1 + return count + + def add_message(self, message_type, content): + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + self.messages.append((timestamp, message_type, content)) + + def add_tool_call(self, tool_name, args): + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + self.tool_calls.append((timestamp, tool_name, args)) + + def update_agent_status(self, agent, status): + if agent in self.agent_status: + self.agent_status[agent] = status + self.current_agent = agent + + def update_report_section(self, section_name, content): + if section_name in self.report_sections: + self.report_sections[section_name] = content + self._update_current_report() + + def _update_current_report(self): + # For the panel display, only show the most recently updated section + latest_section = None + latest_content = None + + # Find the most recently updated section + for section, content in self.report_sections.items(): + if content is not None: + latest_section = section + latest_content = content + + if latest_section and latest_content: + # Format the current section for display + section_titles = { + "market_report": "Market Analysis", + "sentiment_report": "Social Sentiment", + "news_report": "News Analysis", + "fundamentals_report": "Fundamentals Analysis", + "investment_plan": "Research Team Decision", + "trader_investment_plan": "Trading Team Plan", + "final_trade_decision": "Portfolio Management Decision", + } + self.current_report = ( + f"### {section_titles[latest_section]}\n{latest_content}" + ) + + # Update the final complete report + self._update_final_report() + + def _update_final_report(self): + report_parts = [] + + # Analyst Team Reports - use .get() to handle missing sections + analyst_sections = ["market_report", "sentiment_report", "news_report", "fundamentals_report"] + if any(self.report_sections.get(section) for section in analyst_sections): + report_parts.append("## Analyst Team Reports") + if self.report_sections.get("market_report"): + report_parts.append( + f"### Market Analysis\n{self.report_sections['market_report']}" + ) + if self.report_sections.get("sentiment_report"): + report_parts.append( + f"### Social Sentiment\n{self.report_sections['sentiment_report']}" + ) + if self.report_sections.get("news_report"): + report_parts.append( + f"### News Analysis\n{self.report_sections['news_report']}" + ) + if self.report_sections.get("fundamentals_report"): + report_parts.append( + f"### Fundamentals Analysis\n{self.report_sections['fundamentals_report']}" + ) + + # Research Team Reports + if self.report_sections.get("investment_plan"): + report_parts.append("## Research Team Decision") + report_parts.append(f"{self.report_sections['investment_plan']}") + + # Trading Team Reports + if self.report_sections.get("trader_investment_plan"): + report_parts.append("## Trading Team Plan") + report_parts.append(f"{self.report_sections['trader_investment_plan']}") + + # Portfolio Management Decision + if self.report_sections.get("final_trade_decision"): + report_parts.append("## Portfolio Management Decision") + report_parts.append(f"{self.report_sections['final_trade_decision']}") + + self.final_report = "\n\n".join(report_parts) if report_parts else None + + +message_buffer = MessageBuffer() + + +def create_layout(): + layout = Layout() + layout.split_column( + Layout(name="header", size=3), + Layout(name="main"), + Layout(name="footer", size=3), + ) + layout["main"].split_column( + Layout(name="upper", ratio=3), Layout(name="analysis", ratio=5) + ) + layout["upper"].split_row( + Layout(name="progress", ratio=2), Layout(name="messages", ratio=3) + ) + return layout + + +def format_tokens(n): + """Format token count for display.""" + if n >= 1000: + return f"{n/1000:.1f}k" + return str(n) + + +def update_display(layout, spinner_text=None, stats_handler=None, start_time=None): + # Header with welcome message + layout["header"].update( + Panel( + "[bold green]Welcome to TradingAgents CLI[/bold green]\n" + "[dim]© [Tauric Research](https://github.com/TauricResearch)[/dim]", + title="Welcome to TradingAgents", + border_style="green", + padding=(1, 2), + expand=True, + ) + ) + + # Progress panel showing agent status + progress_table = Table( + show_header=True, + header_style="bold magenta", + show_footer=False, + box=box.SIMPLE_HEAD, # Use simple header with horizontal lines + title=None, # Remove the redundant Progress title + padding=(0, 2), # Add horizontal padding + expand=True, # Make table expand to fill available space + ) + progress_table.add_column("Team", style="cyan", justify="center", width=20) + progress_table.add_column("Agent", style="green", justify="center", width=20) + progress_table.add_column("Status", style="yellow", justify="center", width=20) + + # Group agents by team - filter to only include agents in agent_status + all_teams = { + "Analyst Team": [ + "Market Analyst", + "Sentiment Analyst", + "News Analyst", + "Fundamentals Analyst", + ], + "Research Team": ["Bull Researcher", "Bear Researcher", "Research Manager"], + "Trading Team": ["Trader"], + "Risk Management": ["Aggressive Analyst", "Neutral Analyst", "Conservative Analyst"], + "Portfolio Management": ["Portfolio Manager"], + } + + # Filter teams to only include agents that are in agent_status + teams = {} + for team, agents in all_teams.items(): + active_agents = [a for a in agents if a in message_buffer.agent_status] + if active_agents: + teams[team] = active_agents + + for team, agents in teams.items(): + # Add first agent with team name + first_agent = agents[0] + status = message_buffer.agent_status.get(first_agent, "pending") + if status == "in_progress": + spinner = Spinner( + "dots", text="[blue]in_progress[/blue]", style="bold cyan" + ) + status_cell = spinner + else: + status_color = { + "pending": "yellow", + "completed": "green", + "error": "red", + }.get(status, "white") + status_cell = f"[{status_color}]{status}[/{status_color}]" + progress_table.add_row(team, first_agent, status_cell) + + # Add remaining agents in team + for agent in agents[1:]: + status = message_buffer.agent_status.get(agent, "pending") + if status == "in_progress": + spinner = Spinner( + "dots", text="[blue]in_progress[/blue]", style="bold cyan" + ) + status_cell = spinner + else: + status_color = { + "pending": "yellow", + "completed": "green", + "error": "red", + }.get(status, "white") + status_cell = f"[{status_color}]{status}[/{status_color}]" + progress_table.add_row("", agent, status_cell) + + # Add horizontal line after each team + progress_table.add_row("─" * 20, "─" * 20, "─" * 20, style="dim") + + layout["progress"].update( + Panel(progress_table, title="Progress", border_style="cyan", padding=(1, 2)) + ) + + # Messages panel showing recent messages and tool calls + messages_table = Table( + show_header=True, + header_style="bold magenta", + show_footer=False, + expand=True, # Make table expand to fill available space + box=box.MINIMAL, # Use minimal box style for a lighter look + show_lines=True, # Keep horizontal lines + padding=(0, 1), # Add some padding between columns + ) + messages_table.add_column("Time", style="cyan", width=8, justify="center") + messages_table.add_column("Type", style="green", width=10, justify="center") + messages_table.add_column( + "Content", style="white", no_wrap=False, ratio=1 + ) # Make content column expand + + # Combine tool calls and messages + all_messages = [] + + # Add tool calls + for timestamp, tool_name, args in message_buffer.tool_calls: + formatted_args = format_tool_args(args) + all_messages.append((timestamp, "Tool", f"{tool_name}: {formatted_args}")) + + # Add regular messages + for timestamp, msg_type, content in message_buffer.messages: + content_str = str(content) if content else "" + if len(content_str) > 200: + content_str = content_str[:197] + "..." + all_messages.append((timestamp, msg_type, content_str)) + + # Sort by timestamp descending (newest first) + all_messages.sort(key=lambda x: x[0], reverse=True) + + # Calculate how many messages we can show based on available space + max_messages = 12 + + # Get the first N messages (newest ones) + recent_messages = all_messages[:max_messages] + + # Add messages to table (already in newest-first order) + for timestamp, msg_type, content in recent_messages: + # Format content with word wrapping + wrapped_content = Text(content, overflow="fold") + messages_table.add_row(timestamp, msg_type, wrapped_content) + + layout["messages"].update( + Panel( + messages_table, + title="Messages & Tools", + border_style="blue", + padding=(1, 2), + ) + ) + + # Analysis panel showing current report + if message_buffer.current_report: + layout["analysis"].update( + Panel( + Markdown(message_buffer.current_report), + title="Current Report", + border_style="green", + padding=(1, 2), + ) + ) + else: + layout["analysis"].update( + Panel( + "[italic]Waiting for analysis report...[/italic]", + title="Current Report", + border_style="green", + padding=(1, 2), + ) + ) + + # Footer with statistics + # Agent progress - derived from agent_status dict + agents_completed = sum( + 1 for status in message_buffer.agent_status.values() if status == "completed" + ) + agents_total = len(message_buffer.agent_status) + + # Report progress - based on agent completion (not just content existence) + reports_completed = message_buffer.get_completed_reports_count() + reports_total = len(message_buffer.report_sections) + + # Build stats parts + stats_parts = [f"Agents: {agents_completed}/{agents_total}"] + + # LLM and tool stats from callback handler + if stats_handler: + stats = stats_handler.get_stats() + stats_parts.append(f"LLM: {stats['llm_calls']}") + stats_parts.append(f"Tools: {stats['tool_calls']}") + + # Token display with graceful fallback + if stats["tokens_in"] > 0 or stats["tokens_out"] > 0: + tokens_str = f"Tokens: {format_tokens(stats['tokens_in'])}\u2191 {format_tokens(stats['tokens_out'])}\u2193" + else: + tokens_str = "Tokens: --" + stats_parts.append(tokens_str) + + stats_parts.append(f"Reports: {reports_completed}/{reports_total}") + + # Elapsed time + if start_time: + elapsed = time.time() - start_time + elapsed_str = f"\u23f1 {int(elapsed // 60):02d}:{int(elapsed % 60):02d}" + stats_parts.append(elapsed_str) + + stats_table = Table(show_header=False, box=None, padding=(0, 2), expand=True) + stats_table.add_column("Stats", justify="center") + stats_table.add_row(" | ".join(stats_parts)) + + layout["footer"].update(Panel(stats_table, border_style="grey50")) + + +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", encoding="utf-8") as f: + welcome_ascii = f.read() + + # Create welcome box content + welcome_content = f"{welcome_ascii}\n" + welcome_content += "[bold green]TradingAgents: Multi-Agents LLM Financial Trading Framework - CLI[/bold green]\n\n" + welcome_content += "[bold]Workflow Steps:[/bold]\n" + welcome_content += "I. Analyst Team → II. Research Team → III. Trader → IV. Risk Management → V. Portfolio Management\n\n" + welcome_content += ( + "[dim]Built by [Tauric Research](https://github.com/TauricResearch)[/dim]" + ) + + # Create and center the welcome box + welcome_box = Panel( + welcome_content, + border_style="green", + padding=(1, 2), + title="Welcome to TradingAgents", + subtitle="Multi-Agents LLM Financial Trading Framework", + ) + console.print(Align.center(welcome_box)) + console.print() + console.print() # Add vertical space before announcements + + # Fetch and display announcements (silent on failure) + announcements = fetch_announcements() + display_announcements(console, announcements) + + # Create a boxed questionnaire for each step + def create_question_box(title, prompt, default=None): + box_content = f"[bold]{title}[/bold]\n" + box_content += f"[dim]{prompt}[/dim]" + if default: + 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 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") + console.print( + create_question_box( + "Step 2: Analysis Date", + "Enter the analysis date (YYYY-MM-DD)", + default_date, + ) + ) + analysis_date = get_analysis_date() + + # 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", + "Select the language for analyst reports and final decision" + ) + ) + output_language = ask_output_language() + + # Step 4: Select analysts + console.print( + create_question_box( + "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" + ) + ) + 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 (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" + ) + ) + selected_research_depth = select_research_depth() + + # 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" + ) + ) + selected_llm_provider, backend_url = select_llm_provider() + + # 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" + ) + ) + selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) + selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) + + # 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_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, + ) + elif provider_lower == "openai": + 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, + ) + elif provider_lower == "anthropic": + anthropic_effort = thinking_value_or_prompt( + "TRADINGAGENTS_ANTHROPIC_EFFORT", "anthropic_effort", + "Claude effort", "Step 8: Effort Level", + "Configure Claude effort level", ask_anthropic_effort, + ) + + return { + "ticker": selected_ticker, + "asset_type": asset_type.value, + "analysis_date": analysis_date, + "analysts": selected_analysts, + "research_depth": selected_research_depth, + "llm_provider": selected_llm_provider.lower(), + "backend_url": backend_url, + "shallow_thinker": selected_shallow_thinker, + "deep_thinker": selected_deep_thinker, + "google_thinking_level": thinking_level, + "openai_reasoning_effort": reasoning_effort, + "anthropic_effort": anthropic_effort, + "output_language": output_language, + } + + +def get_analysis_date(): + """Get the analysis date from user input.""" + while True: + date_str = typer.prompt( + "", default=datetime.datetime.now().strftime("%Y-%m-%d") + ) + try: + # Validate date format and ensure it's not in the future + analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") + if analysis_date.date() > datetime.datetime.now().date(): + console.print("[red]Error: Analysis date cannot be in the future[/red]") + continue + return date_str + except ValueError: + console.print( + "[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]" + ) + + +def save_report_to_disk(final_state, ticker: str, save_path: Path): + """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): + """Display the complete analysis report sequentially (avoids truncation).""" + console.print() + console.print(Rule("Complete Analysis Report", style="bold green")) + + # I. Analyst Team Reports + analysts = [] + if final_state.get("market_report"): + analysts.append(("Market Analyst", final_state["market_report"])) + if final_state.get("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"): + analysts.append(("Fundamentals Analyst", final_state["fundamentals_report"])) + if analysts: + console.print(Panel("[bold]I. Analyst Team Reports[/bold]", border_style="cyan")) + for title, content in analysts: + console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) + + # II. Research Team Reports + if final_state.get("investment_debate_state"): + debate = final_state["investment_debate_state"] + research = [] + if debate.get("bull_history"): + research.append(("Bull Researcher", debate["bull_history"])) + if debate.get("bear_history"): + research.append(("Bear Researcher", debate["bear_history"])) + if debate.get("judge_decision"): + research.append(("Research Manager", debate["judge_decision"])) + if research: + console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta")) + for title, content in research: + console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) + + # III. Trading Team + if final_state.get("trader_investment_plan"): + console.print(Panel("[bold]III. Trading Team Plan[/bold]", border_style="yellow")) + console.print(Panel(Markdown(final_state["trader_investment_plan"]), title="Trader", border_style="blue", padding=(1, 2))) + + # IV. Risk Management Team + if final_state.get("risk_debate_state"): + risk = final_state["risk_debate_state"] + risk_reports = [] + if risk.get("aggressive_history"): + risk_reports.append(("Aggressive Analyst", risk["aggressive_history"])) + if risk.get("conservative_history"): + risk_reports.append(("Conservative Analyst", risk["conservative_history"])) + if risk.get("neutral_history"): + risk_reports.append(("Neutral Analyst", risk["neutral_history"])) + if risk_reports: + console.print(Panel("[bold]IV. Risk Management Team Decision[/bold]", border_style="red")) + for title, content in risk_reports: + console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) + + # V. Portfolio Manager Decision + if risk.get("judge_decision"): + console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) + console.print(Panel(Markdown(risk["judge_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) + + +def update_research_team_status(status): + """Update status for research team members (not Trader).""" + research_team = ["Bull Researcher", "Bear Researcher", "Research Manager"] + for agent in research_team: + message_buffer.update_agent_status(agent, status) + + +# Ordered list of analysts for status transitions +ANALYST_ORDER = ["market", "social", "news", "fundamentals"] +ANALYST_AGENT_NAMES = { + "market": "Market Analyst", + "social": "Sentiment Analyst", + "news": "News Analyst", + "fundamentals": "Fundamentals Analyst", +} +ANALYST_REPORT_MAP = { + "market": "market_report", + "social": "sentiment_report", + "news": "news_report", + "fundamentals": "fundamentals_report", +} + + +def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): + """Update analyst statuses based on accumulated report state. + + Logic: + - Store new report content from the current chunk if present + - Check accumulated report_sections (not just current chunk) for status + - Analysts with reports = completed + - First analyst without report = in_progress + - Remaining analysts without reports = pending + - When all analysts done, set Bull Researcher to in_progress + """ + 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 + + agent_name = ANALYST_AGENT_NAMES[analyst_key] + report_key = ANALYST_REPORT_MAP[analyst_key] + + # Capture new report content from current chunk + if chunk.get(report_key): + message_buffer.update_report_section(report_key, chunk[report_key]) + + # Determine status from accumulated sections, not just current chunk + has_report = bool(message_buffer.report_sections.get(report_key)) + + if has_report: + message_buffer.update_agent_status(agent_name, "completed") + elif not found_active: + message_buffer.update_agent_status(agent_name, "in_progress") + found_active = True + else: + message_buffer.update_agent_status(agent_name, "pending") + + # When all analysts complete, transition research team to in_progress + 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): + """Extract string content from various message formats. + Returns None if no meaningful text content is found. + """ + import ast + + def is_empty(val): + """Check if value is empty using Python's truthiness.""" + if val is None or val == '': + return True + if isinstance(val, str): + s = val.strip() + if not s: + return True + try: + return not bool(ast.literal_eval(s)) + except (ValueError, SyntaxError): + return False # Can't parse = real text + return not bool(val) + + if is_empty(content): + return None + + if isinstance(content, str): + return content.strip() + + if isinstance(content, dict): + text = content.get('text', '') + return text.strip() if not is_empty(text) else None + + if isinstance(content, list): + text_parts = [ + item.get('text', '').strip() if isinstance(item, dict) and item.get('type') == 'text' + else (item.strip() if isinstance(item, str) else '') + for item in content + ] + result = ' '.join(t for t in text_parts if t and not is_empty(t)) + return result if result else None + + return str(content).strip() if not is_empty(content) else None + + +def classify_message_type(message) -> tuple[str, str | None]: + """Classify LangChain message into display type and extract content. + + Returns: + (type, content) - type is one of: User, Agent, Data, Control + - content is extracted string or None + """ + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + content = extract_content_string(getattr(message, 'content', None)) + + if isinstance(message, HumanMessage): + if content and content.strip() == "Continue": + return ("Control", content) + return ("User", content) + + if isinstance(message, ToolMessage): + return ("Data", content) + + if isinstance(message, AIMessage): + return ("Agent", content) + + # Fallback for unknown types + return ("System", content) + + +def format_tool_args(args, max_length=80) -> str: + """Format tool arguments for terminal display.""" + result = str(args) + if len(result) > max_length: + return result[:max_length - 3] + "..." + return result + +def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: + """Assemble the run config from interactive selections, honoring env precedence. + + 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"] + config["backend_url"] = selections["backend_url"] + config["llm_provider"] = selections["llm_provider"].lower() + # Provider-specific thinking configuration + config["google_thinking_level"] = selections.get("google_thinking_level") + 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() + + # 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( + selected_analyst_keys, + config=config, + debug=True, + callbacks=[stats_handler], + ) + + # Initialize message buffer with selected analysts + message_buffer.init_for_analysis(selected_analyst_keys) + + # Track start time for elapsed display + start_time = time.time() + + # Create result directory + results_dir = Path(config["results_dir"]) / selections["ticker"] / selections["analysis_date"] + results_dir.mkdir(parents=True, exist_ok=True) + report_dir = results_dir / "reports" + report_dir.mkdir(parents=True, exist_ok=True) + log_file = results_dir / "message_tool.log" + log_file.touch(exist_ok=True) + + def save_message_decorator(obj, func_name): + func = getattr(obj, func_name) + @wraps(func) + def wrapper(*args, **kwargs): + func(*args, **kwargs) + timestamp, message_type, content = obj.messages[-1] + content = content.replace("\n", " ") # Replace newlines with spaces + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"{timestamp} [{message_type}] {content}\n") + return wrapper + + def save_tool_call_decorator(obj, func_name): + func = getattr(obj, func_name) + @wraps(func) + def wrapper(*args, **kwargs): + func(*args, **kwargs) + timestamp, tool_name, args = obj.tool_calls[-1] + args_str = ", ".join(f"{k}={v}" for k, v in args.items()) + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"{timestamp} [Tool Call] {tool_name}({args_str})\n") + return wrapper + + def save_report_section_decorator(obj, func_name): + func = getattr(obj, func_name) + @wraps(func) + def wrapper(section_name, content): + func(section_name, content) + if section_name in obj.report_sections and obj.report_sections[section_name] is not None: + content = obj.report_sections[section_name] + if content: + file_name = f"{section_name}.md" + text = "\n".join(str(item) for item in content) if isinstance(content, list) else content + with open(report_dir / file_name, "w", encoding="utf-8") as f: + f.write(text) + return wrapper + + message_buffer.add_message = save_message_decorator(message_buffer, "add_message") + message_buffer.add_tool_call = save_tool_call_decorator(message_buffer, "add_tool_call") + message_buffer.update_report_section = save_report_section_decorator(message_buffer, "update_report_section") + + # Now start the display layout + layout = create_layout() + + 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']}" + ) + message_buffer.add_message( + "System", + f"Selected analysts: {', '.join(analyst.value for analyst in selections['analysts'])}", + ) + update_display(layout, stats_handler=stats_handler, start_time=start_time) + + # Update agent status to in_progress for the first 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 + spinner_text = ( + f"Analyzing {selections['ticker']} on {selections['analysis_date']}..." + ) + update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) + + # Initialize state and get graph args with callbacks. + # Resolve the instrument identity once here so all agents anchor to + # the real company (#814); the CLI builds state directly rather than + # going through propagate(), so this must happen on the CLI path too. + instrument_context = graph.resolve_instrument_context( + selections["ticker"], selections["asset_type"] + ) + init_agent_state = graph.propagator.create_initial_state( + selections["ticker"], + selections["analysis_date"], + asset_type=selections["asset_type"], + instrument_context=instrument_context, + ) + # 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 + trace = [] + for chunk in graph.graph.stream(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) + if msg_id is not None: + if msg_id in message_buffer._processed_message_ids: + continue + message_buffer._processed_message_ids.add(msg_id) + + msg_type, content = classify_message_type(message) + if content and content.strip(): + message_buffer.add_message(msg_type, content) + + if hasattr(message, "tool_calls") and message.tool_calls: + for tool_call in message.tool_calls: + if isinstance(tool_call, dict): + message_buffer.add_tool_call(tool_call["name"], tool_call["args"]) + else: + 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, + wall_time_tracker=analyst_wall_time_tracker, + ) + + # Research Team - Handle Investment Debate State + if chunk.get("investment_debate_state"): + debate_state = chunk["investment_debate_state"] + bull_hist = debate_state.get("bull_history", "").strip() + bear_hist = debate_state.get("bear_history", "").strip() + judge = debate_state.get("judge_decision", "").strip() + + # Only update status when there's actual content + if bull_hist or bear_hist: + update_research_team_status("in_progress") + if bull_hist: + message_buffer.update_report_section( + "investment_plan", f"### Bull Researcher Analysis\n{bull_hist}" + ) + if bear_hist: + message_buffer.update_report_section( + "investment_plan", f"### Bear Researcher Analysis\n{bear_hist}" + ) + if judge: + message_buffer.update_report_section( + "investment_plan", f"### Research Manager Decision\n{judge}" + ) + update_research_team_status("completed") + message_buffer.update_agent_status("Trader", "in_progress") + + # Trading Team + if chunk.get("trader_investment_plan"): + message_buffer.update_report_section( + "trader_investment_plan", chunk["trader_investment_plan"] + ) + if message_buffer.agent_status.get("Trader") != "completed": + message_buffer.update_agent_status("Trader", "completed") + message_buffer.update_agent_status("Aggressive Analyst", "in_progress") + + # Risk Management Team - Handle Risk Debate State + if chunk.get("risk_debate_state"): + risk_state = chunk["risk_debate_state"] + agg_hist = risk_state.get("aggressive_history", "").strip() + con_hist = risk_state.get("conservative_history", "").strip() + neu_hist = risk_state.get("neutral_history", "").strip() + judge = risk_state.get("judge_decision", "").strip() + + if agg_hist: + if message_buffer.agent_status.get("Aggressive Analyst") != "completed": + message_buffer.update_agent_status("Aggressive Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}" + ) + if con_hist: + if message_buffer.agent_status.get("Conservative Analyst") != "completed": + message_buffer.update_agent_status("Conservative Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}" + ) + if neu_hist: + if message_buffer.agent_status.get("Neutral Analyst") != "completed": + message_buffer.update_agent_status("Neutral Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}" + ) + 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}" + ) + message_buffer.update_agent_status("Aggressive Analyst", "completed") + message_buffer.update_agent_status("Conservative Analyst", "completed") + message_buffer.update_agent_status("Neutral Analyst", "completed") + message_buffer.update_agent_status("Portfolio Manager", "completed") + + # Update the display + update_display(layout, stats_handler=stats_handler, start_time=start_time) + + trace.append(chunk) + + # Streamed chunks are per-node deltas, not full state. Merge them + # so every report field populated across the run is present. + final_state = {} + for chunk in trace: + final_state.update(chunk) + + # Update all agent statuses to completed + for agent in message_buffer.agent_status: + message_buffer.update_agent_status(agent, "completed") + + 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: + if section in final_state: + message_buffer.update_report_section(section, final_state[section]) + + update_display(layout, stats_handler=stats_handler, start_time=start_time) + + # 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() + if save_choice in ("Y", "YES", ""): + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + default_path = Path.cwd() / "reports" / f"{selections['ticker']}_{timestamp}" + save_path_str = typer.prompt( + "Save path (press Enter for default)", + default=str(default_path) + ).strip() + save_path = Path(save_path_str) + try: + report_file = save_report_to_disk(final_state, selections["ticker"], save_path) + console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") + console.print(f" [dim]Complete report:[/dim] {report_file.name}") + except Exception as e: + console.print(f"[red]Error saving report: {e}[/red]") + + # Prompt to display full report + display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() + if display_choice in ("Y", "YES", ""): + display_complete_report(final_state) + + +@app.command() +def analyze( + 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, + "--clear-checkpoints", + help="Delete all saved checkpoints before running (force fresh start).", + ), +): + if clear_checkpoints: + from tradingagents.graph.checkpointer import clear_all_checkpoints + n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"]) + console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]") + run_analysis(checkpoint=checkpoint) + + +if __name__ == "__main__": + app() diff --git a/cli/models.py b/cli/models.py new file mode 100644 index 0000000..1eef2b7 --- /dev/null +++ b/cli/models.py @@ -0,0 +1,15 @@ +from enum import Enum + + +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" diff --git a/cli/static/welcome.txt b/cli/static/welcome.txt new file mode 100644 index 0000000..f2cf641 --- /dev/null +++ b/cli/static/welcome.txt @@ -0,0 +1,7 @@ + + ______ ___ ___ __ + /_ __/________ _____/ (_)___ ____ _/ | ____ ____ ____ / /______ + / / / ___/ __ `/ __ / / __ \/ __ `/ /| |/ __ `/ _ \/ __ \/ __/ ___/ + / / / / / /_/ / /_/ / / / / / /_/ / ___ / /_/ / __/ / / / /_(__ ) +/_/ /_/ \__,_/\__,_/_/_/ /_/\__, /_/ |_\__, /\___/_/ /_/\__/____/ + /____/ /____/ diff --git a/cli/stats_handler.py b/cli/stats_handler.py new file mode 100644 index 0000000..8e2e83b --- /dev/null +++ b/cli/stats_handler.py @@ -0,0 +1,76 @@ +import threading +from typing import Any + +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.messages import AIMessage +from langchain_core.outputs import LLMResult + + +class StatsCallbackHandler(BaseCallbackHandler): + """Callback handler that tracks LLM calls, tool calls, and token usage.""" + + def __init__(self) -> None: + super().__init__() + self._lock = threading.Lock() + self.llm_calls = 0 + self.tool_calls = 0 + self.tokens_in = 0 + self.tokens_out = 0 + + def on_llm_start( + self, + serialized: dict[str, Any], + prompts: list[str], + **kwargs: Any, + ) -> None: + """Increment LLM call counter when an LLM starts.""" + with self._lock: + self.llm_calls += 1 + + def on_chat_model_start( + self, + serialized: dict[str, Any], + messages: list[list[Any]], + **kwargs: Any, + ) -> None: + """Increment LLM call counter when a chat model starts.""" + with self._lock: + self.llm_calls += 1 + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Extract token usage from LLM response.""" + try: + generation = response.generations[0][0] + except (IndexError, TypeError): + return + + usage_metadata = None + if hasattr(generation, "message"): + message = generation.message + if isinstance(message, AIMessage) and hasattr(message, "usage_metadata"): + usage_metadata = message.usage_metadata + + if usage_metadata: + with self._lock: + self.tokens_in += usage_metadata.get("input_tokens", 0) + self.tokens_out += usage_metadata.get("output_tokens", 0) + + def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + """Increment tool call counter when a tool starts.""" + with self._lock: + self.tool_calls += 1 + + def get_stats(self) -> dict[str, Any]: + """Return current statistics.""" + with self._lock: + return { + "llm_calls": self.llm_calls, + "tool_calls": self.tool_calls, + "tokens_in": self.tokens_in, + "tokens_out": self.tokens_out, + } diff --git a/cli/utils.py b/cli/utils.py new file mode 100644 index 0000000..da8524d --- /dev/null +++ b/cli/utils.py @@ -0,0 +1,688 @@ +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, 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 = "SPY, 0700.HK, BTC-USD" + +ANALYST_ORDER = [ + ("Market Analyst", AnalystType.MARKET), + ("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, 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 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"), + ("highlighted", "noinherit"), + ] + ), + ).ask() + + if ticker is None: + console.print("\n[red]No ticker symbol provided. Exiting...[/red]") + exit(1) + + return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY" + + +def normalize_ticker_symbol(ticker: str) -> str: + """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 + from datetime import datetime + + def validate_date(date_str: str) -> bool: + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + return False + try: + datetime.strptime(date_str, "%Y-%m-%d") + return True + except ValueError: + return False + + date = questionary.text( + "Enter the analysis date (YYYY-MM-DD):", + validate=lambda x: validate_date(x.strip()) + or "Please enter a valid date in YYYY-MM-DD format.", + style=questionary.Style( + [ + ("text", "fg:green"), + ("highlighted", "noinherit"), + ] + ), + ).ask() + + if not date: + console.print("\n[red]No date provided. Exiting...[/red]") + exit(1) + + return date.strip() + + +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 + 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.", + style=questionary.Style( + [ + ("checkbox-selected", "fg:green"), + ("selected", "fg:green noinherit"), + ("highlighted", "noinherit"), + ("pointer", "noinherit"), + ] + ), + ).ask() + + if not choices: + console.print("\n[red]No analysts selected. Exiting...[/red]") + exit(1) + + return choices + + +def select_research_depth() -> int: + """Select research depth using an interactive selection.""" + + # Define research depth options with their corresponding values + DEPTH_OPTIONS = [ + ("Shallow - Quick research, few debate and strategy discussion rounds", 1), + ("Medium - Middle ground, moderate debate rounds and strategy discussion", 3), + ("Deep - Comprehensive research, in depth debate and strategy discussion", 5), + ] + + choice = questionary.select( + "Select Your [Research Depth]:", + choices=[ + questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS + ], + instruction="\n- Use arrow keys to navigate\n- Press Enter to select", + style=questionary.Style( + [ + ("selected", "fg:yellow noinherit"), + ("highlighted", "fg:yellow noinherit"), + ("pointer", "fg:yellow noinherit"), + ] + ), + ).ask() + + if choice is None: + console.print("\n[red]No research depth selected. Exiting...[/red]") + exit(1) + + return choice + + +# 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 _require_text(message: str, hint: str) -> str: + """Prompt for a required value; exit cleanly if the user cancels. + + ``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( + 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([ + ("selected", "fg:magenta noinherit"), + ("highlighted", "fg:magenta noinherit"), + ("pointer", "fg:magenta noinherit"), + ]), + ).ask() + + 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):", + "Please enter a model ID.", + ) + return choice + + +def _prompt_custom_model_id() -> str: + """Prompt user to type a custom model ID.""" + 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(mode) + + if provider.lower() == "azure": + return _require_text( + f"Enter Azure deployment name ({mode}-thinking):", + "Please enter a deployment name.", + ) + + choice = questionary.select( + f"Select Your [{mode.title()}-Thinking LLM Engine]:", + choices=[ + questionary.Choice(display, value=value) + for display, value in get_model_options(provider, mode) + ], + instruction="\n- Use arrow keys to navigate\n- Press Enter to select", + style=questionary.Style( + [ + ("selected", "fg:magenta noinherit"), + ("highlighted", "fg:magenta noinherit"), + ("pointer", "fg:magenta noinherit"), + ] + ), + ).ask() + + if choice is None: + console.print(f"\n[red]No {mode} thinking llm engine selected. Exiting...[/red]") + exit(1) + + if choice == "custom": + return _prompt_custom_model_id() + + return choice + + +def select_shallow_thinking_agent(provider) -> str: + """Select shallow thinking llm engine using an interactive selection.""" + return _select_model(provider, "quick") + + +def select_deep_thinking_agent(provider) -> str: + """Select deep thinking llm engine using an interactive selection.""" + return _select_model(provider, "deep") + +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-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), + ("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=[ + questionary.Choice(display, value=(provider_key, url)) + for display, provider_key, url in PROVIDERS + ], + instruction="\n- Use arrow keys to navigate\n- Press Enter to select", + style=questionary.Style( + [ + ("selected", "fg:magenta noinherit"), + ("highlighted", "fg:magenta noinherit"), + ("pointer", "fg:magenta noinherit"), + ] + ), + ).ask() + + if choice is None: + console.print("\n[red]No LLM provider selected. Exiting...[/red]") + exit(1) + + provider, url = choice + return provider, url + + +def ask_openai_reasoning_effort() -> str: + """Ask for OpenAI reasoning effort level.""" + choices = [ + questionary.Choice("Medium (Default)", "medium"), + questionary.Choice("High (More thorough)", "high"), + questionary.Choice("Low (Faster)", "low"), + ] + return questionary.select( + "Select Reasoning Effort:", + choices=choices, + style=questionary.Style([ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ]), + ).ask() + + +def ask_anthropic_effort() -> str | None: + """Ask for Anthropic effort level. + + 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:", + choices=[ + questionary.Choice("High (recommended)", "high"), + questionary.Choice("Medium (balanced)", "medium"), + questionary.Choice("Low (faster, cheaper)", "low"), + ], + style=questionary.Style([ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ]), + ).ask() + + +def ask_gemini_thinking_config() -> str | None: + """Ask for Gemini thinking configuration. + + Returns thinking_level: "high" or "minimal". + Client maps to appropriate API param based on model series. + """ + return questionary.select( + "Select Thinking Mode:", + choices=[ + questionary.Choice("Enable Thinking (recommended)", "high"), + questionary.Choice("Minimal/Disable Thinking", "minimal"), + ], + style=questionary.Style([ + ("selected", "fg:green noinherit"), + ("highlighted", "fg:green noinherit"), + ("pointer", "fg:green noinherit"), + ]), + ).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://: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( + "Select Output Language:", + choices=[ + questionary.Choice("English (default)", "English"), + questionary.Choice("Chinese (中文)", "Chinese"), + questionary.Choice("Japanese (日本語)", "Japanese"), + questionary.Choice("Korean (한국어)", "Korean"), + questionary.Choice("Hindi (हिन्दी)", "Hindi"), + questionary.Choice("Spanish (Español)", "Spanish"), + questionary.Choice("Portuguese (Português)", "Portuguese"), + questionary.Choice("French (Français)", "French"), + questionary.Choice("German (Deutsch)", "German"), + questionary.Choice("Arabic (العربية)", "Arabic"), + questionary.Choice("Russian (Русский)", "Russian"), + questionary.Choice("Custom language", "custom"), + ], + style=questionary.Style([ + ("selected", "fg:yellow noinherit"), + ("highlighted", "fg:yellow noinherit"), + ("pointer", "fg:yellow noinherit"), + ]), + ).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( + "Enter language name (e.g. Turkish, Vietnamese, Thai, Indonesian):", + validate=lambda x: len(x.strip()) > 0 or "Please enter a language name.", + ).ask() or "").strip() or "English" + + return choice diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..411c989 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + tradingagents: + build: . + env_file: + - .env + volumes: + - tradingagents_data:/home/appuser/.tradingagents + tty: true + stdin_open: true + + ollama: + image: ollama/ollama:latest + volumes: + - ollama_data:/root/.ollama + profiles: + - ollama + + tradingagents-ollama: + build: . + env_file: + - .env + environment: + - TRADINGAGENTS_LLM_PROVIDER=ollama + - OLLAMA_BASE_URL=http://ollama:11434/v1 + volumes: + - tradingagents_data:/home/appuser/.tradingagents + depends_on: + - ollama + tty: true + stdin_open: true + profiles: + - ollama + +volumes: + tradingagents_data: + ollama_data: diff --git a/main.py b/main.py new file mode 100644 index 0000000..c9d3ab0 --- /dev/null +++ b/main.py @@ -0,0 +1,19 @@ +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.graph.trading_graph import TradingAgentsGraph + +# 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() + +# Initialize with custom config +ta = TradingAgentsGraph(debug=True, config=config) + +# forward propagate +_, decision = ta.propagate("NVDA", "2024-05-10") +print(decision) + +# Memorize mistakes and reflect +# ta.reflect_and_remember(1000) # parameter is the position returns diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3021a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,88 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "tradingagents" +version = "0.3.1" +description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "langchain-core>=0.3.81", + "backtrader>=1.9.78.123", + "langchain-anthropic>=0.3.15", + "langchain-experimental>=0.3.4", + "langchain-google-genai>=4.0.0", + "langchain-openai>=0.3.23", + "langgraph>=0.4.8", + "langgraph-checkpoint-sqlite>=2.0.0", + "pandas>=2.3.0", + "parsel>=1.10.0", + "python-dotenv>=1.0.0", + "pytz>=2025.2", + "questionary>=2.1.0", + "redis>=6.2.0", + "requests>=2.32.4", + "rich>=14.0.0", + "typer>=0.21.0", + "setuptools>=80.9.0", + "stockstats>=0.6.5", + "tqdm>=4.67.1", + "typing-extensions>=4.14.0", + "yfinance>=1.4.1", +] + +[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] +tradingagents = "cli.main:app" + +[tool.setuptools.packages.find] +include = ["tradingagents*", "cli*"] + +[tool.setuptools.package-data] +cli = ["static/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra --strict-markers" +markers = [ + "unit: fast isolated unit tests", + "integration: tests requiring external services", + "smoke: quick sanity-check tests", +] +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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9c558e3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +. diff --git a/scripts/smoke_structured_output.py b/scripts/smoke_structured_output.py new file mode 100644 index 0000000..f225381 --- /dev/null +++ b/scripts/smoke_structured_output.py @@ -0,0 +1,174 @@ +"""End-to-end smoke for structured-output agents against a real LLM provider. + +Runs the three decision-making agents (Research Manager, Trader, Portfolio +Manager) directly with their structured-output bindings and prints the +typed Pydantic instance + the rendered markdown for each. Use this to +verify a provider's native structured-output mode (json_schema for +OpenAI / xAI / DeepSeek / Qwen / GLM, response_schema for Gemini, tool-use +for Anthropic) returns clean instances on the schemas we ship. + +Usage: + OPENAI_API_KEY=... python scripts/smoke_structured_output.py openai + GOOGLE_API_KEY=... python scripts/smoke_structured_output.py google + ANTHROPIC_API_KEY=... python scripts/smoke_structured_output.py anthropic + DEEPSEEK_API_KEY=... python scripts/smoke_structured_output.py deepseek + +The script does NOT call propagate(), to keep the surface tight and the +cost low — it exercises only the three structured-output calls we just +added, plus the heuristic SignalProcessor. +""" + +from __future__ import annotations + +import argparse +import sys + +from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager +from tradingagents.agents.managers.research_manager import create_research_manager +from tradingagents.agents.trader.trader import create_trader +from tradingagents.graph.signal_processing import SignalProcessor +from tradingagents.llm_clients import create_llm_client + +PROVIDER_DEFAULTS = { + "openai": ("gpt-5.4-mini", None), + "google": ("gemini-3.5-flash", None), + "anthropic": ("claude-sonnet-4-6", None), + "deepseek": ("deepseek-v4-flash", None), + "qwen": ("qwen3.7-plus", None), + "glm": ("glm-5", None), + "xai": ("grok-4.3", None), +} + + +# Minimal but realistic state for the three agents. +DEBATE_HISTORY = """ +Bull Analyst: NVDA's data-center revenue grew 60% YoY last quarter, driven by +Blackwell ramp; sovereign AI deals with multiple governments add a $40B+ +multi-year tailwind. Margins remain above peer average. + +Bear Analyst: Concentration risk is real — top three customers are >40% of +revenue. Any pause in hyperscaler capex would compress the multiple. China +export restrictions still cap a meaningful portion of demand. +""" + + +def _make_rm_state(): + return { + "company_of_interest": "NVDA", + "investment_debate_state": { + "history": DEBATE_HISTORY, + "bull_history": "Bull Analyst: NVDA's data-center revenue grew 60% YoY...", + "bear_history": "Bear Analyst: Concentration risk is real...", + "current_response": "", + "judge_decision": "", + "count": 1, + }, + } + + +def _make_trader_state(investment_plan: str): + return { + "company_of_interest": "NVDA", + "investment_plan": investment_plan, + } + + +def _make_pm_state(investment_plan: str, trader_plan: str): + return { + "company_of_interest": "NVDA", + "past_context": "", + "risk_debate_state": { + "history": "Aggressive: lean in. Conservative: trim. Neutral: balanced sizing.", + "aggressive_history": "Aggressive: ...", + "conservative_history": "Conservative: ...", + "neutral_history": "Neutral: ...", + "judge_decision": "", + "current_aggressive_response": "", + "current_conservative_response": "", + "current_neutral_response": "", + "count": 1, + }, + "market_report": "Market report.", + "sentiment_report": "Sentiment report.", + "news_report": "News report.", + "fundamentals_report": "Fundamentals report.", + "investment_plan": investment_plan, + "trader_investment_plan": trader_plan, + } + + +def _print_section(title: str, content: str) -> None: + bar = "=" * 70 + print(f"\n{bar}\n{title}\n{bar}\n{content}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("provider", choices=list(PROVIDER_DEFAULTS.keys())) + parser.add_argument("--deep-model", default=None, help="Override deep_think_llm") + parser.add_argument("--quick-model", default=None, help="Override quick_think_llm") + args = parser.parse_args() + + default_model, _ = PROVIDER_DEFAULTS[args.provider] + deep_model = args.deep_model or default_model + quick_model = args.quick_model or default_model + + print(f"Provider: {args.provider}") + print(f"Deep model: {deep_model}") + print(f"Quick model: {quick_model}") + + # Build the LLM clients via the framework's factory. + deep_client = create_llm_client(provider=args.provider, model=deep_model) + quick_client = create_llm_client(provider=args.provider, model=quick_model) + deep_llm = deep_client.get_llm() + quick_llm = quick_client.get_llm() + + # 1) Research Manager + rm = create_research_manager(deep_llm) + rm_result = rm(_make_rm_state()) + investment_plan = rm_result["investment_plan"] + _print_section("[1] Research Manager — investment_plan", investment_plan) + + # 2) Trader (consumes RM's plan) + trader = create_trader(quick_llm) + trader_result = trader(_make_trader_state(investment_plan)) + trader_plan = trader_result["trader_investment_plan"] + _print_section("[2] Trader — trader_investment_plan", trader_plan) + + # 3) Portfolio Manager (consumes both) + pm = create_portfolio_manager(deep_llm) + pm_result = pm(_make_pm_state(investment_plan, trader_plan)) + final_decision = pm_result["final_trade_decision"] + _print_section("[3] Portfolio Manager — final_trade_decision", final_decision) + + # 4) SignalProcessor extracts the rating with zero LLM calls. + sp = SignalProcessor() + rating = sp.process_signal(final_decision) + _print_section("[4] SignalProcessor → rating", rating) + + # 5) Lightweight checks: each rendered output should carry the expected + # section headers so downstream consumers (memory log, CLI display, + # saved reports) keep working. + checks = [ + ("Research Manager", investment_plan, ["**Recommendation**:"]), + ("Trader", trader_plan, ["**Action**:", "FINAL TRANSACTION PROPOSAL:"]), + ("Portfolio Manager", final_decision, ["**Rating**:", "**Executive Summary**:", "**Investment Thesis**:"]), + ] + print("\n" + "=" * 70 + "\nStructure checks\n" + "=" * 70) + failures = 0 + for name, text, required in checks: + for marker in required: + ok = marker in text + print(f" {'PASS' if ok else 'FAIL'} {name}: contains {marker!r}") + failures += int(not ok) + + print() + if failures: + print(f"Smoke FAILED: {failures} structure check(s) missing.") + return 1 + print("Smoke PASSED: structured output → rendered markdown chain works for", args.provider) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test.py b/test.py new file mode 100644 index 0000000..f0b9318 --- /dev/null +++ b/test.py @@ -0,0 +1,14 @@ +import time + +from tradingagents.dataflows.y_finance import ( + get_stock_stats_indicators_window, +) + +print("Testing optimized implementation with 30-day lookback:") +start_time = time.time() +result = get_stock_stats_indicators_window("AAPL", "macd", "2024-11-01", 30) +end_time = time.time() + +print(f"Execution time: {end_time - start_time:.2f} seconds") +print(f"Result length: {len(result)} characters") +print(result) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5b86595 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared pytest fixtures that prevent CI hangs when API keys are absent.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + + +def pytest_configure(config): + for marker in ("unit", "integration", "smoke"): + config.addinivalue_line("markers", f"{marker}: {marker}-level tests") + + +_API_KEY_ENV_VARS = ( + "OPENAI_API_KEY", + "GOOGLE_API_KEY", + "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", + "AZURE_OPENAI_API_KEY", + "ALPHA_VANTAGE_API_KEY", +) + + +@pytest.fixture(autouse=True) +def _dummy_api_keys(monkeypatch): + for env_var in _API_KEY_ENV_VARS: + # `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() +def mock_llm_client(): + client = MagicMock() + client.get_llm.return_value = MagicMock() + with patch( + "tradingagents.llm_clients.factory.create_llm_client", + return_value=client, + ): + yield client diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py new file mode 100644 index 0000000..d82ea8d --- /dev/null +++ b/tests/test_alpha_vantage_hardening.py @@ -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" diff --git a/tests/test_analyst_execution.py b/tests/test_analyst_execution.py new file mode 100644 index 0000000..807caf6 --- /dev/null +++ b/tests/test_analyst_execution.py @@ -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}, + ) diff --git a/tests/test_anthropic_effort.py b/tests/test_anthropic_effort.py new file mode 100644 index 0000000..04db66d --- /dev/null +++ b/tests/test_anthropic_effort.py @@ -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"] diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py new file mode 100644 index 0000000..7361ea7 --- /dev/null +++ b/tests/test_api_key_env.py @@ -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 diff --git a/tests/test_bedrock_provider.py b/tests/test_bedrock_provider.py new file mode 100644 index 0000000..12a3c95 --- /dev/null +++ b/tests/test_bedrock_provider.py @@ -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" diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 0000000..debbb75 --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,124 @@ +"""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 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 +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] diff --git a/tests/test_checkpoint_resume.py b/tests/test_checkpoint_resume.py new file mode 100644 index 0000000..6c5be50 --- /dev/null +++ b/tests/test_checkpoint_resume.py @@ -0,0 +1,218 @@ +"""Test checkpoint resume: crash mid-analysis, re-run resumes from last node.""" + +import tempfile +import unittest +from typing import TypedDict + +from langgraph.graph import END, StateGraph + +from tradingagents.graph.checkpointer import ( + checkpoint_step, + clear_checkpoint, + get_checkpointer, + has_checkpoint, + thread_id, +) + +# Mutable flag to simulate crash on first run +_should_crash = False + + +class _SimpleState(TypedDict): + count: int + + +def _node_a(state: _SimpleState) -> dict: + return {"count": state["count"] + 1} + + +def _node_b(state: _SimpleState) -> dict: + if _should_crash: + raise RuntimeError("simulated mid-analysis crash") + return {"count": state["count"] + 10} + + +def _build_graph() -> StateGraph: + builder = StateGraph(_SimpleState) + builder.add_node("analyst", _node_a) + builder.add_node("trader", _node_b) + builder.set_entry_point("analyst") + builder.add_edge("analyst", "trader") + builder.add_edge("trader", END) + return builder + + +class TestCheckpointResume(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.ticker = "TEST" + self.date = "2026-04-20" + + def test_crash_and_resume(self): + """Crash at 'trader' node, then resume from checkpoint.""" + global _should_crash + builder = _build_graph() + tid = thread_id(self.ticker, self.date) + cfg = {"configurable": {"thread_id": tid}} + + # Run 1: crash at trader node + _should_crash = True + with get_checkpointer(self.tmpdir, self.ticker) as saver: + graph = builder.compile(checkpointer=saver) + with self.assertRaises(RuntimeError): + graph.invoke({"count": 0}, config=cfg) + + # Checkpoint should exist at step 1 (analyst completed) + self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date)) + step = checkpoint_step(self.tmpdir, self.ticker, self.date) + self.assertEqual(step, 1) + + # Run 2: resume — trader succeeds this time + _should_crash = False + with get_checkpointer(self.tmpdir, self.ticker) as saver: + graph = builder.compile(checkpointer=saver) + result = graph.invoke(None, config=cfg) + + # analyst added 1, trader added 10 → 11 + self.assertEqual(result["count"], 11) + + def test_clear_checkpoint_allows_fresh_start(self): + """After clearing, the graph starts from scratch.""" + global _should_crash + builder = _build_graph() + tid = thread_id(self.ticker, self.date) + cfg = {"configurable": {"thread_id": tid}} + + # Create a checkpoint by crashing + _should_crash = True + with get_checkpointer(self.tmpdir, self.ticker) as saver: + graph = builder.compile(checkpointer=saver) + with self.assertRaises(RuntimeError): + graph.invoke({"count": 0}, config=cfg) + + self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date)) + + # Clear it + clear_checkpoint(self.tmpdir, self.ticker, self.date) + self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date)) + + # Fresh run succeeds from scratch + _should_crash = False + with get_checkpointer(self.tmpdir, self.ticker) as saver: + graph = builder.compile(checkpointer=saver) + result = graph.invoke({"count": 0}, config=cfg) + + self.assertEqual(result["count"], 11) + + + def test_different_date_starts_fresh(self): + """A different date must NOT resume from an existing checkpoint.""" + global _should_crash + builder = _build_graph() + date2 = "2026-04-21" + + # Run with date1 — crash to leave a checkpoint + _should_crash = True + tid1 = thread_id(self.ticker, self.date) + 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)) + + # date2 should have no checkpoint + self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, date2)) + + # Run with date2 — should start fresh and succeed + _should_crash = False + tid2 = thread_id(self.ticker, date2) + 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}}) + + # Fresh run: analyst +1, trader +10 = 11 + self.assertEqual(result["count"], 11) + + # Original date checkpoint still exists (untouched) + 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() diff --git a/tests/test_cli_config_precedence.py b/tests/test_cli_config_precedence.py new file mode 100644 index 0000000..e2ba90d --- /dev/null +++ b/tests/test_cli_config_precedence.py @@ -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 diff --git a/tests/test_cli_env_skip.py b/tests/test_cli_env_skip.py new file mode 100644 index 0000000..c98c245 --- /dev/null +++ b/tests/test_cli_env_skip.py @@ -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() diff --git a/tests/test_cli_symbol_handling.py b/tests/test_cli_symbol_handling.py new file mode 100644 index 0000000..e599a4e --- /dev/null +++ b/tests/test_cli_symbol_handling.py @@ -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) diff --git a/tests/test_crypto_asset_mode.py b/tests/test_crypto_asset_mode.py new file mode 100644 index 0000000..d9b5497 --- /dev/null +++ b/tests/test_crypto_asset_mode.py @@ -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() diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py new file mode 100644 index 0000000..ab0800e --- /dev/null +++ b/tests/test_dataflows_config.py @@ -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") diff --git a/tests/test_date_boundaries.py b/tests/test_date_boundaries.py new file mode 100644 index 0000000..263a8cb --- /dev/null +++ b/tests/test_date_boundaries.py @@ -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) diff --git a/tests/test_deepseek_reasoning.py b/tests/test_deepseek_reasoning.py new file mode 100644 index 0000000..5ba719e --- /dev/null +++ b/tests/test_deepseek_reasoning.py @@ -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 + ) diff --git a/tests/test_env_overrides.py b/tests/test_env_overrides.py new file mode 100644 index 0000000..b9fd59c --- /dev/null +++ b/tests/test_env_overrides.py @@ -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.5" + assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.4-mini" + 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 diff --git a/tests/test_fred.py b/tests/test_fred.py new file mode 100644 index 0000000..367c28f --- /dev/null +++ b/tests/test_fred.py @@ -0,0 +1,194 @@ +"""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 + + +@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() diff --git a/tests/test_google_api_key.py b/tests/test_google_api_key.py new file mode 100644 index 0000000..9bad87c --- /dev/null +++ b/tests/test_google_api_key.py @@ -0,0 +1,31 @@ +import unittest +from unittest.mock import patch + +import pytest + +from tradingagents.llm_clients.google_client import GoogleClient + + +@pytest.mark.unit +class TestGoogleApiKeyStandardization(unittest.TestCase): + """Verify GoogleClient accepts unified api_key parameter.""" + + @patch("tradingagents.llm_clients.google_client.NormalizedChatGoogleGenerativeAI") + def test_api_key_handling(self, mock_chat): + test_cases = [ + ("unified api_key is mapped", {"api_key": "test-key-123"}, "test-key-123"), + ("legacy google_api_key still works", {"google_api_key": "legacy-key-456"}, "legacy-key-456"), + ("unified api_key takes precedence", {"api_key": "unified", "google_api_key": "legacy"}, "unified"), + ] + + for msg, kwargs, expected_key in test_cases: + with self.subTest(msg=msg): + mock_chat.reset_mock() + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_google_thinking_level.py b/tests/test_google_thinking_level.py new file mode 100644 index 0000000..f24ceff --- /dev/null +++ b/tests/test_google_thinking_level.py @@ -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 diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py new file mode 100644 index 0000000..67ca08b --- /dev/null +++ b/tests/test_i18n_coverage.py @@ -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)." + ) diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py new file mode 100644 index 0000000..7e50878 --- /dev/null +++ b/tests/test_instrument_identity.py @@ -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() diff --git a/tests/test_llm_max_retries.py b/tests/test_llm_max_retries.py new file mode 100644 index 0000000..632060a --- /dev/null +++ b/tests/test_llm_max_retries.py @@ -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 diff --git a/tests/test_market_data_validator.py b/tests/test_market_data_validator.py new file mode 100644 index 0000000..40b6349 --- /dev/null +++ b/tests/test_market_data_validator.py @@ -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 diff --git a/tests/test_market_toolnode.py b/tests/test_market_toolnode.py new file mode 100644 index 0000000..4c4a811 --- /dev/null +++ b/tests/test_market_toolnode.py @@ -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 diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py new file mode 100644 index 0000000..69fdb80 --- /dev/null +++ b/tests/test_memory_log.py @@ -0,0 +1,871 @@ +"""Tests for TradingMemoryLog — storage, deferred reflection, PM injection, legacy removal.""" + +from unittest.mock import MagicMock, patch + +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 + +_SEP = TradingMemoryLog._SEPARATOR + +DECISION_BUY = "Rating: Buy\nEnter at $189-192, 6% portfolio cap." +DECISION_OVERWEIGHT = ( + "Rating: Overweight\n" + "Executive Summary: Moderate position, await confirmation.\n" + "Investment Thesis: Strong fundamentals but near-term headwinds." +) +DECISION_SELL = "Rating: Sell\nExit position immediately." +DECISION_NO_RATING = ( + "Executive Summary: Complex situation with multiple competing factors.\n" + "Investment Thesis: No clear directional signal at this time." +) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def make_log(tmp_path, filename="trading_memory.md"): + config = {"memory_log_path": str(tmp_path / filename)} + return TradingMemoryLog(config) + + +def _seed_completed(tmp_path, ticker, date, decision_text, reflection_text, filename="trading_memory.md"): + """Write a completed entry directly to file, bypassing the API.""" + entry = ( + f"[{date} | {ticker} | Buy | +1.0% | +0.5% | 5d]\n\n" + f"DECISION:\n{decision_text}\n\n" + f"REFLECTION:\n{reflection_text}" + + _SEP + ) + with open(tmp_path / filename, "a", encoding="utf-8") as f: + f.write(entry) + + +def _resolve_entry(log, ticker, date, decision, reflection="Good call."): + """Store a decision then immediately resolve it via the API.""" + log.store_decision(ticker, date, decision) + 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 _make_pm_state(past_context=""): + """Minimal AgentState dict for portfolio_manager_node.""" + return { + "company_of_interest": "NVDA", + "past_context": past_context, + "risk_debate_state": { + "history": "Risk debate history.", + "aggressive_history": "", + "conservative_history": "", + "neutral_history": "", + "judge_decision": "", + "current_aggressive_response": "", + "current_conservative_response": "", + "current_neutral_response": "", + "count": 1, + }, + "market_report": "Market report.", + "sentiment_report": "Sentiment report.", + "news_report": "News report.", + "fundamentals_report": "Fundamentals report.", + "investment_plan": "Research plan.", + "trader_investment_plan": "Trader plan.", + } + + +def _structured_pm_llm(captured: dict, decision: PortfolioDecision | None = None): + """Build a MagicMock LLM whose with_structured_output binding captures the + prompt and returns a real PortfolioDecision (so render_pm_decision works). + """ + if decision is None: + decision = PortfolioDecision( + rating=PortfolioRating.HOLD, + executive_summary="Hold the position; await catalyst.", + investment_thesis="Balanced view; neither side carried the debate.", + ) + structured = MagicMock() + structured.invoke.side_effect = lambda prompt: ( + captured.__setitem__("prompt", prompt) or decision + ) + llm = MagicMock() + llm.with_structured_output.return_value = structured + return llm + + +# --------------------------------------------------------------------------- +# Core: storage and read path +# --------------------------------------------------------------------------- + +class TestTradingMemoryLogCore: + + def test_store_creates_file(self, tmp_path): + log = make_log(tmp_path) + assert not (tmp_path / "trading_memory.md").exists() + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert (tmp_path / "trading_memory.md").exists() + + def test_store_appends_not_overwrites(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT) + entries = log.load_entries() + assert len(entries) == 2 + assert entries[0]["ticker"] == "NVDA" + assert entries[1]["ticker"] == "AAPL" + + def test_store_decision_idempotent(self, tmp_path): + """Calling store_decision twice with same (ticker, date) stores only one entry.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert len(log.load_entries()) == 1 + + def test_batch_update_resolves_multiple_entries(self, tmp_path): + """batch_update_with_outcomes resolves multiple pending entries in one write.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-05", DECISION_BUY) + log.store_decision("NVDA", "2026-01-12", DECISION_SELL) + + updates = [ + {"ticker": "NVDA", "trade_date": "2026-01-05", + "raw_return": 0.05, "alpha_return": 0.02, "holding_days": 5, + "reflection": "First correct."}, + {"ticker": "NVDA", "trade_date": "2026-01-12", + "raw_return": -0.03, "alpha_return": -0.01, "holding_days": 5, + "reflection": "Second correct."}, + ] + log.batch_update_with_outcomes(updates) + + entries = log.load_entries() + assert len(entries) == 2 + assert all(not e["pending"] for e in entries) + assert entries[0]["reflection"] == "First correct." + assert entries[1]["reflection"] == "Second correct." + + def test_pending_tag_format(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8") + assert "[2026-01-10 | NVDA | Buy | pending]" in text + + # Rating parsing + + def test_rating_parsed_buy(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert log.load_entries()[0]["rating"] == "Buy" + + def test_rating_parsed_overweight(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT) + assert log.load_entries()[0]["rating"] == "Overweight" + + def test_rating_fallback_hold(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING) + assert log.load_entries()[0]["rating"] == "Hold" + + def test_rating_priority_over_prose(self, tmp_path): + """'Rating: X' label wins even when an opposing rating word appears earlier in prose.""" + decision = ( + "The sell thesis is weak. The hold case is marginal.\n\n" + "Rating: Buy\n\n" + "Executive Summary: Strong fundamentals support the position." + ) + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + assert log.load_entries()[0]["rating"] == "Buy" + + # Delimiter robustness + + def test_decision_with_markdown_separator(self, tmp_path): + """LLM decision containing '---' must not corrupt the entry.""" + decision = "Rating: Buy\n\n---\n\nRisk: elevated volatility." + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + entries = log.load_entries() + assert len(entries) == 1 + assert "Risk: elevated volatility" in entries[0]["decision"] + + # load_entries + + def test_load_entries_empty_file(self, tmp_path): + log = make_log(tmp_path) + assert log.load_entries() == [] + + def test_load_entries_single(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + entries = log.load_entries() + assert len(entries) == 1 + e = entries[0] + assert e["date"] == "2026-01-10" + assert e["ticker"] == "NVDA" + assert e["rating"] == "Buy" + assert e["pending"] is True + assert e["raw"] is None + + def test_load_entries_multiple(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.store_decision("AAPL", "2026-01-11", DECISION_OVERWEIGHT) + log.store_decision("MSFT", "2026-01-12", DECISION_NO_RATING) + entries = log.load_entries() + assert len(entries) == 3 + assert [e["ticker"] for e in entries] == ["NVDA", "AAPL", "MSFT"] + + def test_decision_content_preserved(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert log.load_entries()[0]["decision"] == DECISION_BUY.strip() + + # get_pending_entries + + def test_get_pending_returns_pending_only(self, tmp_path): + log = make_log(tmp_path) + _seed_completed(tmp_path, "NVDA", "2026-01-05", "Buy NVDA.", "Correct.") + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + pending = log.get_pending_entries() + assert len(pending) == 1 + assert pending[0]["ticker"] == "NVDA" + assert pending[0]["date"] == "2026-01-10" + + # get_past_context + + def test_get_past_context_empty(self, tmp_path): + log = make_log(tmp_path) + assert log.get_past_context("NVDA") == "" + + def test_get_past_context_pending_excluded(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert log.get_past_context("NVDA") == "" + + def test_get_past_context_same_ticker(self, tmp_path): + log = make_log(tmp_path) + _seed_completed(tmp_path, "NVDA", "2026-01-05", "Buy NVDA — AI capex thesis intact.", "Directionally correct.") + ctx = log.get_past_context("NVDA") + assert "Past analyses of NVDA" in ctx + assert "Buy NVDA" in ctx + + def test_get_past_context_cross_ticker(self, tmp_path): + log = make_log(tmp_path) + _seed_completed(tmp_path, "AAPL", "2026-01-05", "Buy AAPL — Services growth.", "Correct.") + ctx = log.get_past_context("NVDA") + assert "Recent cross-ticker lessons" in ctx + assert "Past analyses of NVDA" not in ctx + + def test_n_same_limit_respected(self, tmp_path): + """Only the n_same most recent same-ticker entries are included.""" + log = make_log(tmp_path) + for i in range(6): + _seed_completed(tmp_path, "NVDA", f"2026-01-{i+1:02d}", f"Buy entry {i}.", "Correct.") + ctx = log.get_past_context("NVDA", n_same=5) + assert "Buy entry 0" not in ctx + assert "Buy entry 5" in ctx + + def test_n_cross_limit_respected(self, tmp_path): + """Only the n_cross most recent cross-ticker entries are included.""" + log = make_log(tmp_path) + for i, ticker in enumerate(["AAPL", "MSFT", "GOOG", "META"]): + _seed_completed(tmp_path, ticker, f"2026-01-{i+1:02d}", f"Buy {ticker}.", "Correct.") + ctx = log.get_past_context("NVDA", n_cross=3) + assert "AAPL" not in ctx + assert "META" in ctx + + # No-op when config is None + + def test_no_log_path_is_noop(self): + log = TradingMemoryLog(config=None) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + assert log.load_entries() == [] + assert log.get_past_context("NVDA") == "" + + # Rotation: opt-in cap on resolved entries + + def test_rotation_disabled_by_default(self, tmp_path): + """Without max_entries, all resolved entries are kept.""" + log = make_log(tmp_path) + for i in range(7): + _resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.") + assert len(log.load_entries()) == 7 + + def test_rotation_prunes_oldest_resolved(self, tmp_path): + """When max_entries is set and exceeded, oldest resolved entries are pruned.""" + log = TradingMemoryLog({ + "memory_log_path": str(tmp_path / "trading_memory.md"), + "memory_log_max_entries": 3, + }) + # Resolve 5 entries; rotation should keep only the 3 most recent. + for i in range(5): + _resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.") + entries = log.load_entries() + assert len(entries) == 3 + # Confirm the OLDEST were dropped, not the newest. + dates = [e["date"] for e in entries] + assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"] + + def test_rotation_never_prunes_pending(self, tmp_path): + """Pending entries (unresolved) are kept regardless of the cap.""" + log = TradingMemoryLog({ + "memory_log_path": str(tmp_path / "trading_memory.md"), + "memory_log_max_entries": 2, + }) + # 3 resolved + 2 pending. With cap=2, only 2 resolved survive; both pending stay. + for i in range(3): + _resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Resolved {i}.") + log.store_decision("NVDA", "2026-02-01", DECISION_BUY) + log.store_decision("NVDA", "2026-02-02", DECISION_OVERWEIGHT) + # Trigger rotation by resolving one more entry — pending entries must stay. + _resolve_entry(log, "NVDA", "2026-01-04", DECISION_BUY, "Resolved 3.") + entries = log.load_entries() + pending = [e for e in entries if e["pending"]] + resolved = [e for e in entries if not e["pending"]] + assert len(pending) == 2, "pending entries must never be pruned" + assert len(resolved) == 2, f"expected 2 resolved after rotation, got {len(resolved)}" + + def test_rotation_under_cap_is_noop(self, tmp_path): + """No rotation when resolved count <= max_entries.""" + log = TradingMemoryLog({ + "memory_log_path": str(tmp_path / "trading_memory.md"), + "memory_log_max_entries": 10, + }) + for i in range(3): + _resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.") + assert len(log.load_entries()) == 3 + + # Rating parsing: markdown bold and numbered list formats + + def test_rating_parsed_from_bold_markdown(self, tmp_path): + """**Rating**: Buy — markdown bold around the label must not prevent parsing.""" + decision = "**Rating**: Buy\nEnter at $190." + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + assert log.load_entries()[0]["rating"] == "Buy" + + def test_rating_parsed_from_bold_value(self, tmp_path): + """Rating: **Sell** — markdown bold around the value must not prevent parsing.""" + decision = "Rating: **Sell**\nExit immediately." + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + assert log.load_entries()[0]["rating"] == "Sell" + + def test_rating_label_wins_over_prose_with_markdown(self, tmp_path): + """Rating: **Sell** must win even when prose contains a conflicting rating word.""" + decision = ( + "The buy thesis is weakened by guidance.\n" + "Rating: **Sell**\n" + "Exit before earnings." + ) + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + assert log.load_entries()[0]["rating"] == "Sell" + + def test_rating_parsed_from_numbered_list(self, tmp_path): + """1. Rating: Buy — numbered list prefix must not prevent parsing.""" + decision = "1. Rating: Buy\nEnter at $190." + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", decision) + assert log.load_entries()[0]["rating"] == "Buy" + + +# --------------------------------------------------------------------------- +# Deferred reflection: update_with_outcome, Reflector, _fetch_returns +# --------------------------------------------------------------------------- + +class TestDeferredReflection: + + # update_with_outcome + + def test_update_replaces_pending_tag(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.") + text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8") + assert "[2026-01-10 | NVDA | Buy | pending]" not in text + assert "+4.2%" in text + assert "+2.1%" in text + assert "5d" in text + + def test_update_appends_reflection(self, tmp_path): + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.") + entries = log.load_entries() + assert len(entries) == 1 + e = entries[0] + assert e["pending"] is False + assert e["reflection"] == "Momentum confirmed." + assert e["decision"] == DECISION_BUY.strip() + + def test_update_preserves_other_entries(self, tmp_path): + """Only the matching entry is modified; all other entries remain unchanged.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.store_decision("AAPL", "2026-01-11", "Rating: Hold\nHold AAPL.") + log.store_decision("MSFT", "2026-01-12", DECISION_SELL) + log.update_with_outcome("AAPL", "2026-01-11", 0.01, -0.01, 5, "Neutral result.") + entries = log.load_entries() + assert len(entries) == 3 + nvda, aapl, msft = entries + assert nvda["ticker"] == "NVDA" and nvda["pending"] is True + assert aapl["ticker"] == "AAPL" and aapl["pending"] is False + assert aapl["reflection"] == "Neutral result." + assert msft["ticker"] == "MSFT" and msft["pending"] is True + + def test_update_atomic_write(self, tmp_path): + """A pre-existing .tmp file is overwritten; the log is correctly updated.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + stale_tmp = tmp_path / "trading_memory.tmp" + stale_tmp.write_text("GARBAGE CONTENT — should be overwritten", encoding="utf-8") + log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Correct.") + assert not stale_tmp.exists() + entries = log.load_entries() + assert len(entries) == 1 + assert entries[0]["reflection"] == "Correct." + assert entries[0]["pending"] is False + + def test_update_noop_when_no_log_path(self): + log = TradingMemoryLog(config=None) + log.update_with_outcome("NVDA", "2026-01-10", 0.05, 0.02, 5, "Reflection") + + def test_formatting_roundtrip_after_update(self, tmp_path): + """All fields intact and blank line between tag and DECISION preserved after update.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-10", DECISION_BUY) + log.update_with_outcome("NVDA", "2026-01-10", 0.042, 0.021, 5, "Momentum confirmed.") + entries = log.load_entries() + assert len(entries) == 1 + e = entries[0] + assert e["pending"] is False + assert e["decision"] == DECISION_BUY.strip() + assert e["reflection"] == "Momentum confirmed." + assert e["raw"] == "+4.2%" + assert e["alpha"] == "+2.1%" + assert e["holding"] == "5d" + raw_text = (tmp_path / "trading_memory.md").read_text(encoding="utf-8") + assert "[2026-01-10 | NVDA | Buy | +4.2% | +2.1% | 5d]\n\nDECISION:" in raw_text + + # Reflector.reflect_on_final_decision + + def test_reflect_on_final_decision_returns_llm_output(self): + mock_llm = MagicMock() + mock_llm.invoke.return_value.content = "Directionally correct. Thesis confirmed." + reflector = Reflector(mock_llm) + result = reflector.reflect_on_final_decision( + final_decision=DECISION_BUY, raw_return=0.042, alpha_return=0.021 + ) + assert result == "Directionally correct. Thesis confirmed." + mock_llm.invoke.assert_called_once() + + def test_reflect_on_final_decision_includes_returns_in_prompt(self): + """Return figures are present in the human message sent to the LLM.""" + mock_llm = MagicMock() + mock_llm.invoke.return_value.content = "Incorrect call." + reflector = Reflector(mock_llm) + reflector.reflect_on_final_decision( + final_decision=DECISION_SELL, raw_return=-0.08, alpha_return=-0.05 + ) + messages = mock_llm.invoke.call_args[0][0] + human_content = next(content for role, content in messages if role == "human") + assert "-8.0%" in human_content + assert "-5.0%" in human_content + assert "Exit position immediately." in human_content + + # TradingAgentsGraph._fetch_returns + + def test_fetch_returns_valid_ticker(self): + stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] + spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.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 + 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 isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int) + assert days == 5 + + def test_fetch_returns_too_recent(self): + """Only 1 data point available → returns (None, None, 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 + + def test_fetch_returns_delisted(self): + """Empty DataFrame → returns (None, None, 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 + + 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] + 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 + 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 + + # 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 + + def test_resolve_skips_other_tickers(self, tmp_path): + """Pending AAPL entry is not resolved when the run is for NVDA.""" + log = make_log(tmp_path) + 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)) + TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") + mock_graph._fetch_returns.assert_not_called() + assert len(log.get_pending_entries()) == 1 + + def test_resolve_marks_entry_completed(self, tmp_path): + """After resolve, get_pending_entries() is empty and the entry has a REFLECTION.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-05", DECISION_BUY) + mock_reflector = MagicMock() + mock_reflector.reflect_on_final_decision.return_value = "Momentum confirmed." + 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)) + TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") + assert log.get_pending_entries() == [] + entries = log.load_entries() + assert len(entries) == 1 + assert entries[0]["pending"] is False + assert entries[0]["reflection"] == "Momentum confirmed." + assert "+5.0%" in entries[0]["raw"] + assert "+2.0%" in entries[0]["alpha"] + + +# --------------------------------------------------------------------------- +# Portfolio Manager injection: past_context in state and prompt +# --------------------------------------------------------------------------- + +class TestPortfolioManagerInjection: + + # past_context in initial state + + def test_past_context_in_initial_state(self): + propagator = Propagator() + state = propagator.create_initial_state("NVDA", "2026-01-10", past_context="some context") + assert "past_context" in state + assert state["past_context"] == "some context" + + def test_past_context_defaults_to_empty(self): + propagator = Propagator() + state = propagator.create_initial_state("NVDA", "2026-01-10") + assert state["past_context"] == "" + + # PM prompt + + def test_pm_prompt_includes_past_context(self): + captured = {} + llm = _structured_pm_llm(captured) + pm_node = create_portfolio_manager(llm) + state = _make_pm_state(past_context="[2026-01-05 | NVDA | Buy | +5.0% | +2.0% | 5d]\nGreat call.") + pm_node(state) + assert "Lessons from prior decisions and outcomes" in captured["prompt"] + assert "Great call." in captured["prompt"] + + def test_pm_no_past_context_no_section(self): + """PM prompt omits the lessons section entirely when past_context is empty.""" + captured = {} + llm = _structured_pm_llm(captured) + pm_node = create_portfolio_manager(llm) + state = _make_pm_state(past_context="") + pm_node(state) + assert "Lessons from prior decisions" not in captured["prompt"] + + def test_pm_returns_rendered_markdown_with_rating(self): + """The structured PortfolioDecision is rendered to markdown that + downstream consumers (memory log, signal processor, CLI display) + can parse without any extra LLM call.""" + captured = {} + decision = PortfolioDecision( + rating=PortfolioRating.OVERWEIGHT, + executive_summary="Build position gradually over the next two weeks.", + investment_thesis="AI capex cycle remains intact; institutional flows constructive.", + price_target=215.0, + time_horizon="3-6 months", + ) + llm = _structured_pm_llm(captured, decision) + pm_node = create_portfolio_manager(llm) + result = pm_node(_make_pm_state()) + md = result["final_trade_decision"] + assert "**Rating**: Overweight" in md + assert "**Executive Summary**: Build position gradually" in md + assert "**Investment Thesis**: AI capex cycle" in md + assert "**Price Target**: 215.0" in md + assert "**Time Horizon**: 3-6 months" in md + + def test_pm_falls_back_to_freetext_when_structured_unavailable(self): + """If a provider does not support with_structured_output, the agent + falls back to a plain invoke and returns whatever prose the model + produced, so the pipeline never blocks.""" + plain_response = "**Rating**: Sell\n\nExit ahead of guidance." + llm = MagicMock() + llm.with_structured_output.side_effect = NotImplementedError("provider unsupported") + llm.invoke.return_value = MagicMock(content=plain_response) + pm_node = create_portfolio_manager(llm) + result = pm_node(_make_pm_state()) + assert result["final_trade_decision"] == plain_response + + # get_past_context ordering and limits + + def test_same_ticker_prioritised(self, tmp_path): + """Same-ticker entries in same-ticker section; cross-ticker entries in cross-ticker section.""" + log = make_log(tmp_path) + _resolve_entry(log, "NVDA", "2026-01-05", DECISION_BUY, "Momentum confirmed.") + _resolve_entry(log, "AAPL", "2026-01-06", DECISION_SELL, "Overvalued.") + result = log.get_past_context("NVDA") + assert "Past analyses of NVDA" in result + assert "Recent cross-ticker lessons" in result + same_block, cross_block = result.split("Recent cross-ticker lessons") + assert "NVDA" in same_block + assert "AAPL" in cross_block + + def test_cross_ticker_reflection_only(self, tmp_path): + """Cross-ticker entries show only the REFLECTION text, not the full DECISION.""" + log = make_log(tmp_path) + _resolve_entry(log, "AAPL", "2026-01-06", DECISION_SELL, "Overvalued correction.") + result = log.get_past_context("NVDA") + assert "Overvalued correction." in result + assert "Exit position immediately." not in result + + def test_n_same_limit_respected(self, tmp_path): + """More than 5 same-ticker completed entries → only 5 injected.""" + log = make_log(tmp_path) + for i in range(7): + _resolve_entry(log, "NVDA", f"2026-01-{i+1:02d}", DECISION_BUY, f"Lesson {i}.") + result = log.get_past_context("NVDA", n_same=5) + lessons_present = sum(1 for i in range(7) if f"Lesson {i}." in result) + assert lessons_present == 5 + + def test_n_cross_limit_respected(self, tmp_path): + """More than 3 cross-ticker completed entries → only 3 injected.""" + log = make_log(tmp_path) + tickers = ["AAPL", "MSFT", "TSLA", "AMZN", "GOOG"] + for i, ticker in enumerate(tickers): + _resolve_entry(log, ticker, f"2026-01-{i+1:02d}", DECISION_BUY, f"{ticker} lesson.") + result = log.get_past_context("NVDA", n_cross=3) + cross_count = sum(result.count(f"{t} lesson.") for t in tickers) + assert cross_count == 3 + + # Full A→B→C integration cycle + + def test_full_cycle_store_resolve_inject(self, tmp_path): + """store pending → resolve with outcome → past_context non-empty for PM.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-05", DECISION_BUY) + assert len(log.get_pending_entries()) == 1 + assert log.get_past_context("NVDA") == "" + log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "Correct call.") + assert log.get_pending_entries() == [] + past_ctx = log.get_past_context("NVDA") + assert past_ctx != "" + assert "NVDA" in past_ctx + assert "Correct call." in past_ctx + assert "DECISION:" in past_ctx + assert "REFLECTION:" in past_ctx + + +# --------------------------------------------------------------------------- +# Legacy removal: BM25 / FinancialSituationMemory fully gone +# --------------------------------------------------------------------------- + +class TestLegacyRemoval: + + def test_financial_situation_memory_removed(self): + """FinancialSituationMemory must not be importable from the memory module.""" + import tradingagents.agents.utils.memory as m + assert not hasattr(m, "FinancialSituationMemory") + + def test_bm25_not_imported(self): + """rank_bm25 must not be present in the memory module namespace.""" + import tradingagents.agents.utils.memory as m + assert not hasattr(m, "BM25Okapi") + + def test_reflect_and_remember_removed(self): + """TradingAgentsGraph must not expose reflect_and_remember.""" + assert not hasattr(TradingAgentsGraph, "reflect_and_remember") + + def test_portfolio_manager_no_memory_param(self): + """create_portfolio_manager accepts only llm; passing memory= raises TypeError.""" + mock_llm = MagicMock() + create_portfolio_manager(mock_llm) + with pytest.raises(TypeError): + create_portfolio_manager(mock_llm, memory=MagicMock()) + + def test_full_pipeline_no_regression(self, tmp_path): + """propagate() completes and stores the decision after the redesign.""" + import functools + + fake_state = { + "final_trade_decision": "Rating: Buy\nBuy NVDA.", + "company_of_interest": "NVDA", + "trade_date": "2026-01-10", + "market_report": "", + "sentiment_report": "", + "news_report": "", + "fundamentals_report": "", + "investment_debate_state": { + "bull_history": "", "bear_history": "", "history": "", + "current_response": "", "judge_decision": "", + }, + "investment_plan": "", + "trader_investment_plan": "", + "risk_debate_state": { + "aggressive_history": "", "conservative_history": "", + "neutral_history": "", "history": "", "judge_decision": "", + "current_aggressive_response": "", "current_conservative_response": "", + "current_neutral_response": "", "count": 1, "latest_speaker": "", + }, + } + mock_graph = MagicMock() + mock_graph.memory_log = TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")}) + mock_graph.log_states_dict = {} + mock_graph.debug = False + mock_graph.config = {"results_dir": str(tmp_path)} + mock_graph.graph.invoke.return_value = fake_state + mock_graph.propagator.create_initial_state.return_value = fake_state + mock_graph.propagator.get_graph_args.return_value = {} + mock_graph.signal_processor.process_signal.return_value = "Buy" + # Bind the real _run_graph so propagate's call to self._run_graph executes + # the actual write path instead of the auto-MagicMock. + mock_graph._run_graph = functools.partial( + TradingAgentsGraph._run_graph, mock_graph + ) + TradingAgentsGraph.propagate(mock_graph, "NVDA", "2026-01-10") + entries = mock_graph.memory_log.load_entries() + assert len(entries) == 1 + assert entries[0]["ticker"] == "NVDA" + assert entries[0]["pending"] is True diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 0000000..62f5072 --- /dev/null +++ b/tests/test_minimax.py @@ -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 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}" diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py new file mode 100644 index 0000000..5392d7c --- /dev/null +++ b/tests/test_model_validation.py @@ -0,0 +1,55 @@ +import unittest +import warnings + +import pytest + +from tradingagents.llm_clients.base_client import BaseLLMClient +from tradingagents.llm_clients.model_catalog import get_known_models +from tradingagents.llm_clients.validators import validate_model + + +class DummyLLMClient(BaseLLMClient): + def __init__(self, provider: str, model: str): + self.provider = provider + super().__init__(model) + + def get_llm(self): + self.warn_if_unknown_model() + return object() + + def validate_model(self) -> bool: + return validate_model(self.provider, self.model) + + +@pytest.mark.unit +class ModelValidationTests(unittest.TestCase): + def test_cli_catalog_models_are_all_validator_approved(self): + for provider, models in get_known_models().items(): + if provider in ("ollama", "openrouter"): + continue + + for model in models: + with self.subTest(provider=provider, model=model): + self.assertTrue(validate_model(provider, model)) + + def test_unknown_model_emits_warning_for_strict_provider(self): + client = DummyLLMClient("openai", "not-a-real-openai-model") + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.get_llm() + + self.assertEqual(len(caught), 1) + self.assertIn("not-a-real-openai-model", str(caught[0].message)) + self.assertIn("openai", str(caught[0].message)) + + def test_openrouter_and_ollama_accept_custom_models_without_warning(self): + for provider in ("openrouter", "ollama"): + client = DummyLLMClient(provider, "custom-model-name") + + with self.subTest(provider=provider): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.get_llm() + + self.assertEqual(caught, []) diff --git a/tests/test_news_analyst_prompt.py b/tests/test_news_analyst_prompt.py new file mode 100644 index 0000000..bc29ae9 --- /dev/null +++ b/tests/test_news_analyst_prompt.py @@ -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 diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py new file mode 100644 index 0000000..97dbc1c --- /dev/null +++ b/tests/test_news_lookahead.py @@ -0,0 +1,79 @@ +"""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). +""" +import time +from datetime import datetime + +import pytest + +import tradingagents.dataflows.yfinance_news as ynews + + +def _epoch(date_str): + return int(time.mktime(datetime.strptime(date_str, "%Y-%m-%d").timetuple())) + + +@pytest.mark.unit +def test_flat_article_publish_time_is_parsed(): + # #992: flat articles now carry a pub_date (was always None -> unfilterable). + 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"].strftime("%Y-%m-%d") == "2025-05-09" + + +@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 ynews._in_news_window(inside, start, end) is True + assert ynews._in_news_window(future, start, end) is False # look-ahead blocked + assert ynews._in_news_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. + start = datetime.now() + end = datetime.now() + assert ynews._in_news_window(None, 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 diff --git a/tests/test_no_data_handling.py b/tests/test_no_data_handling.py new file mode 100644 index 0000000..e4bc784 --- /dev/null +++ b/tests/test_no_data_handling.py @@ -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() diff --git a/tests/test_ollama_base_url.py b/tests/test_ollama_base_url.py new file mode 100644 index 0000000..5dd0f85 --- /dev/null +++ b/tests/test_ollama_base_url.py @@ -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://: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}" diff --git a/tests/test_openai_compatible_provider.py b/tests/test_openai_compatible_provider.py new file mode 100644 index 0000000..57c3439 --- /dev/null +++ b/tests/test_openai_compatible_provider.py @@ -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 diff --git a/tests/test_openai_reasoning_effort.py b/tests/test_openai_reasoning_effort.py new file mode 100644 index 0000000..f58a4e2 --- /dev/null +++ b/tests/test_openai_reasoning_effort.py @@ -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 diff --git a/tests/test_openai_responses_base_url.py b/tests/test_openai_responses_base_url.py new file mode 100644 index 0000000..f527afd --- /dev/null +++ b/tests/test_openai_responses_base_url.py @@ -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 diff --git a/tests/test_openrouter_model_select.py b/tests/test_openrouter_model_select.py new file mode 100644 index 0000000..7ca10f0 --- /dev/null +++ b/tests/test_openrouter_model_select.py @@ -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" diff --git a/tests/test_polymarket.py b/tests/test_polymarket.py new file mode 100644 index 0000000..e1b1852 --- /dev/null +++ b/tests/test_polymarket.py @@ -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() diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py new file mode 100644 index 0000000..596108a --- /dev/null +++ b/tests/test_provider_registry.py @@ -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" diff --git a/tests/test_reddit_fallback.py b/tests/test_reddit_fallback.py new file mode 100644 index 0000000..dece01c --- /dev/null +++ b/tests/test_reddit_fallback.py @@ -0,0 +1,214 @@ +"""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 = """ + + + NVDA earnings beat, stock pops + 2026-05-20T14:30:00+00:00 + <!-- SC_OFF --><div class="md"><p>Great <b>quarter</b> for NVDA&#39;s datacenter unit.</p></div><!-- SC_ON --> + + + Is NVDA overvalued? + 2026-05-19T09:00:00Z + <p>Forward P/E discussion</p> + + +""" + + +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): + return read_fn() + 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 = "

Great quarter & more

" + 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"<>")): + 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) + + +@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() + + +@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" diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..04a1d5a --- /dev/null +++ b/tests/test_reporting.py @@ -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_/... + assert out.parent.name.startswith("AAPL_") diff --git a/tests/test_risk_router_path_map.py b/tests/test_risk_router_path_map.py new file mode 100644 index 0000000..328816c --- /dev/null +++ b/tests/test_risk_router_path_map.py @@ -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 diff --git a/tests/test_safe_ticker_component.py b/tests/test_safe_ticker_component.py new file mode 100644 index 0000000..5af6017 --- /dev/null +++ b/tests/test_safe_ticker_component.py @@ -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() diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py new file mode 100644 index 0000000..92520a8 --- /dev/null +++ b/tests/test_signal_processing.py @@ -0,0 +1,89 @@ +"""Tests for the shared rating heuristic and the SignalProcessor adapter. + +The Portfolio Manager produces a typed PortfolioDecision via structured +output and renders it to markdown that always contains a ``**Rating**: X`` +header. The deterministic heuristic in ``tradingagents.agents.utils.rating`` +is therefore sufficient to extract the rating downstream — no second LLM +call is needed — and SignalProcessor is now a thin adapter that delegates +to it. +""" + +import pytest + +from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating +from tradingagents.graph.signal_processing import SignalProcessor + +# --------------------------------------------------------------------------- +# Heuristic parser +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestParseRating: + def test_explicit_label_buy(self): + assert parse_rating("Rating: Buy\nReasoning here.") == "Buy" + + def test_explicit_label_overweight(self): + assert parse_rating("Rating: Overweight\nDetails.") == "Overweight" + + def test_explicit_label_with_markdown_bold_value(self): + # Regression: Rating: **Sell** — markdown around the value. + assert parse_rating("Rating: **Sell**\nExit immediately.") == "Sell" + + def test_explicit_label_with_markdown_bold_label(self): + assert parse_rating("**Rating**: Underweight\nTrim exposure.") == "Underweight" + + def test_rendered_pm_markdown_shape(self): + # The exact shape produced by render_pm_decision must always parse. + text = ( + "**Rating**: Buy\n\n" + "**Executive Summary**: Enter at $189-192, 6% portfolio cap.\n\n" + "**Investment Thesis**: AI capex cycle intact; institutional flows constructive." + ) + assert parse_rating(text) == "Buy" + + def test_explicit_label_wins_over_prose_with_markdown(self): + text = ( + "The buy thesis is weakened by guidance.\n" + "Rating: **Sell**\n" + "Exit before earnings." + ) + assert parse_rating(text) == "Sell" + + def test_no_rating_returns_default(self): + assert parse_rating("No clear directional signal at this time.") == "Hold" + + def test_no_rating_custom_default(self): + assert parse_rating("Plain prose.", default="Underweight") == "Underweight" + + def test_all_five_tiers_recognised(self): + for r in RATINGS_5_TIER: + assert parse_rating(f"Rating: {r}") == r + + +# --------------------------------------------------------------------------- +# SignalProcessor: thin adapter over the heuristic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSignalProcessor: + def test_returns_rating_from_pm_markdown(self): + sp = SignalProcessor() + md = "**Rating**: Overweight\n\n**Executive Summary**: Build gradually." + assert sp.process_signal(md) == "Overweight" + + def test_makes_no_llm_calls(self): + """SignalProcessor must not invoke the LLM it was constructed with — + the rating is parseable from the rendered PM markdown directly.""" + from unittest.mock import MagicMock + + llm = MagicMock() + sp = SignalProcessor(llm) + sp.process_signal("Rating: Buy\nDetails.") + llm.invoke.assert_not_called() + llm.with_structured_output.assert_not_called() + + def test_default_when_no_rating_present(self): + sp = SignalProcessor() + assert sp.process_signal("Plain prose without a recommendation.") == "Hold" diff --git a/tests/test_stockstats_date_column.py b/tests/test_stockstats_date_column.py new file mode 100644 index 0000000..e55a5c7 --- /dev/null +++ b/tests/test_stockstats_date_column.py @@ -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() diff --git a/tests/test_stocktwits_resilience.py b/tests/test_stocktwits_resilience.py new file mode 100644 index 0000000..8e963f5 --- /dev/null +++ b/tests/test_stocktwits_resilience.py @@ -0,0 +1,77 @@ +"""StockTwits fetch: transport-error resilience (#1024) and crypto symbol +mapping (#1113). + +StockTwits lists crypto under ``.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(" 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() diff --git a/tests/test_temperature_config.py b/tests/test_temperature_config.py new file mode 100644 index 0000000..758f808 --- /dev/null +++ b/tests/test_temperature_config.py @@ -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("") diff --git a/tests/test_ticker_symbol_handling.py b/tests/test_ticker_symbol_handling.py new file mode 100644 index 0000000..99db0b1 --- /dev/null +++ b/tests/test_ticker_symbol_handling.py @@ -0,0 +1,29 @@ +import unittest + +import pytest + +from cli.utils import normalize_ticker_symbol +from tradingagents.agents.utils.agent_utils import build_instrument_context + + +@pytest.mark.unit +class TickerSymbolHandlingTests(unittest.TestCase): + def test_normalize_ticker_symbol_preserves_exchange_suffix(self): + self.assertEqual(normalize_ticker_symbol(" cnc.to "), "CNC.TO") + + def test_build_instrument_context_mentions_exact_symbol(self): + context = build_instrument_context("7203.T") + 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() diff --git a/tests/test_vendor_errors.py b/tests/test_vendor_errors.py new file mode 100644 index 0000000..9df641e --- /dev/null +++ b/tests/test_vendor_errors.py @@ -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() diff --git a/tests/test_vendor_routing.py b/tests/test_vendor_routing.py new file mode 100644 index 0000000..757fc28 --- /dev/null +++ b/tests/test_vendor_routing.py @@ -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() diff --git a/tests/test_yfinance_stale_ohlcv_guard.py b/tests/test_yfinance_stale_ohlcv_guard.py new file mode 100644 index 0000000..4ab425f --- /dev/null +++ b/tests/test_yfinance_stale_ohlcv_guard.py @@ -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() diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py new file mode 100644 index 0000000..87e37b3 --- /dev/null +++ b/tradingagents/__init__.py @@ -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, +) diff --git a/tradingagents/agents/__init__.py b/tradingagents/agents/__init__.py new file mode 100644 index 0000000..b78803a --- /dev/null +++ b/tradingagents/agents/__init__.py @@ -0,0 +1,37 @@ +from .analysts.fundamentals_analyst import create_fundamentals_analyst +from .analysts.market_analyst import create_market_analyst +from .analysts.news_analyst import create_news_analyst +from .analysts.sentiment_analyst import ( + create_sentiment_analyst, + 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 .trader.trader import create_trader +from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState +from .utils.agent_utils import create_msg_delete + +__all__ = [ + "AgentState", + "create_msg_delete", + "InvestDebateState", + "RiskDebateState", + "create_bear_researcher", + "create_bull_researcher", + "create_research_manager", + "create_fundamentals_analyst", + "create_market_analyst", + "create_neutral_debator", + "create_news_analyst", + "create_aggressive_debator", + "create_portfolio_manager", + "create_conservative_debator", + "create_sentiment_analyst", + "create_social_media_analyst", # deprecated; will be removed in a future version + "create_trader", +] diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py new file mode 100644 index 0000000..76e6bed --- /dev/null +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -0,0 +1,69 @@ +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +from tradingagents.agents.utils.agent_utils import ( + get_balance_sheet, + get_cashflow, + get_fundamentals, + get_income_statement, + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_fundamentals_analyst(llm): + def fundamentals_analyst_node(state): + current_date = state["trade_date"] + instrument_context = get_instrument_context_from_state(state) + + tools = [ + get_fundamentals, + get_balance_sheet, + get_cashflow, + get_income_statement, + ] + + system_message = ( + "You are a researcher tasked with analyzing fundamental information over the past week about a company. Please write a comprehensive report of the company's fundamental information such as financial documents, company profile, basic company financials, and company financial history to gain a full view of the company's fundamental information to inform traders. Make sure to include as much detail as possible. Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + + " Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read." + + " Use the available tools: `get_fundamentals` for comprehensive company analysis, `get_balance_sheet`, `get_cashflow`, and `get_income_statement` for specific financial statements." + + get_language_instruction(), + ) + + 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}." + " 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"), + ] + ) + + 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], + "fundamentals_report": report, + } + + return fundamentals_analyst_node diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py new file mode 100644 index 0000000..a8f1ecb --- /dev/null +++ b/tradingagents/agents/analysts/market_analyst.py @@ -0,0 +1,95 @@ +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +from tradingagents.agents.utils.agent_utils import ( + get_indicators, + get_instrument_context_from_state, + get_language_instruction, + get_stock_data, + get_verified_market_snapshot, +) + + +def create_market_analyst(llm): + + def market_analyst_node(state): + current_date = state["trade_date"] + instrument_context = get_instrument_context_from_state(state) + + tools = [ + get_stock_data, + get_indicators, + get_verified_market_snapshot, + ] + + system_message = ( + """You are a trading assistant tasked with analyzing financial markets. Your role is to select the **most relevant indicators** for a given market condition or trading strategy from the following list. The goal is to choose up to **8 indicators** that provide complementary insights without redundancy. Categories and each category's indicators are: + +Moving Averages: +- close_50_sma: 50 SMA: A medium-term trend indicator. Usage: Identify trend direction and serve as dynamic support/resistance. Tips: It lags price; combine with faster indicators for timely signals. +- close_200_sma: 200 SMA: A long-term trend benchmark. Usage: Confirm overall market trend and identify golden/death cross setups. Tips: It reacts slowly; best for strategic trend confirmation rather than frequent trading entries. +- close_10_ema: 10 EMA: A responsive short-term average. Usage: Capture quick shifts in momentum and potential entry points. Tips: Prone to noise in choppy markets; use alongside longer averages for filtering false signals. + +MACD Related: +- macd: MACD: Computes momentum via differences of EMAs. Usage: Look for crossovers and divergence as signals of trend changes. Tips: Confirm with other indicators in low-volatility or sideways markets. +- macds: MACD Signal: An EMA smoothing of the MACD line. Usage: Use crossovers with the MACD line to trigger trades. Tips: Should be part of a broader strategy to avoid false positives. +- macdh: MACD Histogram: Shows the gap between the MACD line and its signal. Usage: Visualize momentum strength and spot divergence early. Tips: Can be volatile; complement with additional filters in fast-moving markets. + +Momentum Indicators: +- rsi: RSI: Measures momentum to flag overbought/oversold conditions. Usage: Apply 70/30 thresholds and watch for divergence to signal reversals. Tips: In strong trends, RSI may remain extreme; always cross-check with trend analysis. + +Volatility Indicators: +- boll: Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. Usage: Acts as a dynamic benchmark for price movement. Tips: Combine with the upper and lower bands to effectively spot breakouts or reversals. +- boll_ub: Bollinger Upper Band: Typically 2 standard deviations above the middle line. Usage: Signals potential overbought conditions and breakout zones. Tips: Confirm signals with other tools; prices may ride the band in strong trends. +- boll_lb: Bollinger Lower Band: Typically 2 standard deviations below the middle line. Usage: Indicates potential oversold conditions. Tips: Use additional analysis to avoid false reversal signals. +- atr: ATR: Averages true range to measure volatility. Usage: Set stop-loss levels and adjust position sizes based on current market volatility. Tips: It's a reactive measure, so use it as part of a broader risk management strategy. + +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. + +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() + ) + + 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}." + " 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"), + ] + ) + + 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], + "market_report": report, + } + + return market_analyst_node diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py new file mode 100644 index 0000000..c2fe20c --- /dev/null +++ b/tradingagents/agents/analysts/news_analyst.py @@ -0,0 +1,69 @@ +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +from tradingagents.agents.utils.agent_utils import ( + get_global_news, + get_instrument_context_from_state, + get_language_instruction, + get_macro_indicators, + get_news, + get_prediction_markets, +) + + +def create_news_analyst(llm): + def news_analyst_node(state): + current_date = state["trade_date"] + 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 = ( + 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() + ) + + 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}." + " 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"), + ] + ) + + 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], + "news_report": report, + } + + return news_analyst_node diff --git a/tradingagents/agents/analysts/sentiment_analyst.py b/tradingagents/agents/analysts/sentiment_analyst.py new file mode 100644 index 0000000..68bb8c5 --- /dev/null +++ b/tradingagents/agents/analysts/sentiment_analyst.py @@ -0,0 +1,205 @@ +"""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 ( + 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) + stocktwits_block = fetch_stocktwits_messages(ticker, limit=30) + reddit_block = fetch_reddit_posts(ticker) + + 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." + " 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"), + ] + ) + + 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. + + +{news_block} + + +### 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. + + +{stocktwits_block} + + +### 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). + + +{reddit_block} + + +## 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 "" 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) diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py new file mode 100644 index 0000000..8c72df0 --- /dev/null +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -0,0 +1,23 @@ +"""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. + +See: https://github.com/TauricResearch/TradingAgents/issues/557 +""" + +import warnings as _warnings + +from tradingagents.agents.analysts.sentiment_analyst import ( # noqa: F401 + create_sentiment_analyst, + create_social_media_analyst, +) + +_warnings.warn( + "tradingagents.agents.analysts.social_media_analyst is deprecated. " + "Import from tradingagents.agents.analysts.sentiment_analyst instead.", + DeprecationWarning, + stacklevel=2, +) diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py new file mode 100644 index 0000000..36cdef5 --- /dev/null +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -0,0 +1,92 @@ +"""Portfolio Manager: synthesises the risk-analyst debate into the final decision. + +Uses LangChain's ``with_structured_output`` so the LLM produces a typed +``PortfolioDecision`` directly, in a single call. The result is rendered +back to markdown for storage in ``final_trade_decision`` so memory log, +CLI display, and saved reports continue to consume the same shape they do +today. When a provider does not expose structured output, the agent falls +back gracefully to free-text generation. +""" + +from __future__ import annotations + +from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) +from tradingagents.agents.utils.structured import ( + bind_structured, + invoke_structured_or_freetext, +) + + +def create_portfolio_manager(llm): + structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager") + + def portfolio_manager_node(state) -> dict: + instrument_context = get_instrument_context_from_state(state) + + history = state["risk_debate_state"]["history"] + risk_debate_state = state["risk_debate_state"] + research_plan = state["investment_plan"] + trader_plan = state["trader_investment_plan"] + + past_context = state.get("past_context", "") + lessons_line = ( + f"- Lessons from prior decisions and outcomes:\n{past_context}\n" + if past_context + else "" + ) + + prompt = f"""As the Portfolio Manager, synthesize the risk analysts' debate and deliver the final trading decision. + +{instrument_context} + +--- + +**Rating Scale** (use exactly one): +- **Buy**: Strong conviction to enter or add to position +- **Overweight**: Favorable outlook, gradually increase exposure +- **Hold**: Maintain current position, no action needed +- **Underweight**: Reduce exposure, take partial profits +- **Sell**: Exit position or avoid entry + +**Context:** +- Research Manager's investment plan: **{research_plan}** +- Trader's transaction proposal: **{trader_plan}** +{lessons_line} +**Risk Analysts Debate History:** +{history} + +--- + +Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}""" + + final_trade_decision = invoke_structured_or_freetext( + structured_llm, + llm, + prompt, + render_pm_decision, + "Portfolio Manager", + ) + + new_risk_debate_state = { + "judge_decision": final_trade_decision, + "history": risk_debate_state["history"], + "aggressive_history": risk_debate_state["aggressive_history"], + "conservative_history": risk_debate_state["conservative_history"], + "neutral_history": risk_debate_state["neutral_history"], + "latest_speaker": "Judge", + "current_aggressive_response": risk_debate_state["current_aggressive_response"], + "current_conservative_response": risk_debate_state["current_conservative_response"], + "current_neutral_response": risk_debate_state["current_neutral_response"], + "count": risk_debate_state["count"], + } + + return { + "risk_debate_state": new_risk_debate_state, + "final_trade_decision": final_trade_decision, + } + + return portfolio_manager_node diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py new file mode 100644 index 0000000..7d39ed0 --- /dev/null +++ b/tradingagents/agents/managers/research_manager.py @@ -0,0 +1,67 @@ +"""Research Manager: turns the bull/bear debate into a structured investment plan for the trader.""" + +from __future__ import annotations + +from tradingagents.agents.schemas import ResearchPlan, render_research_plan +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) +from tradingagents.agents.utils.structured import ( + bind_structured, + invoke_structured_or_freetext, +) + + +def create_research_manager(llm): + structured_llm = bind_structured(llm, ResearchPlan, "Research Manager") + + def research_manager_node(state) -> dict: + instrument_context = get_instrument_context_from_state(state) + history = state["investment_debate_state"].get("history", "") + + investment_debate_state = state["investment_debate_state"] + + prompt = f"""As the Research Manager and debate facilitator, your role is to critically evaluate this round of debate and deliver a clear, actionable investment plan for the trader. + +{instrument_context} + +--- + +**Rating Scale** (use exactly one): +- **Buy**: Strong conviction in the bull thesis; recommend taking or growing the position +- **Overweight**: Constructive view; recommend gradually increasing exposure +- **Hold**: Balanced view; recommend maintaining the current position +- **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. + +--- + +**Debate History:** +{history}""" + get_language_instruction() + + investment_plan = invoke_structured_or_freetext( + structured_llm, + llm, + prompt, + render_research_plan, + "Research Manager", + ) + + new_investment_debate_state = { + "judge_decision": investment_plan, + "history": investment_debate_state.get("history", ""), + "bear_history": investment_debate_state.get("bear_history", ""), + "bull_history": investment_debate_state.get("bull_history", ""), + "current_response": investment_plan, + "count": investment_debate_state["count"], + } + + return { + "investment_debate_state": new_investment_debate_state, + "investment_plan": investment_plan, + } + + return research_manager_node diff --git a/tradingagents/agents/researchers/bear_researcher.py b/tradingagents/agents/researchers/bear_researcher.py new file mode 100644 index 0000000..860ae4b --- /dev/null +++ b/tradingagents/agents/researchers/bear_researcher.py @@ -0,0 +1,63 @@ +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_bear_researcher(llm): + def bear_node(state) -> dict: + investment_debate_state = state["investment_debate_state"] + history = investment_debate_state.get("history", "") + bear_history = investment_debate_state.get("bear_history", "") + + current_response = investment_debate_state.get("current_response", "") + 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 {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: + +- Risks and Challenges: Highlight factors like market saturation, financial instability, or macroeconomic threats that could hinder the stock's performance. +- Competitive Weaknesses: Emphasize vulnerabilities such as weaker market positioning, declining innovation, or threats from competitors. +- Negative Indicators: Use evidence from financial data, market trends, or recent adverse news to support your position. +- Bull Counterpoints: Critically analyze the bull argument with specific data and sound reasoning, exposing weaknesses or over-optimistic assumptions. +- Engagement: Present your argument in a conversational style, directly engaging with the bull analyst's points and debating effectively rather than simply listing facts. + +Resources available: + +{instrument_context} +Market research report: {market_research_report} +Social media sentiment report: {sentiment_report} +Latest world affairs news: {news_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 {target_label}. +""" + get_language_instruction() + + response = llm.invoke(prompt) + + argument = f"Bear Analyst: {response.content}" + + new_investment_debate_state = { + "history": history + "\n" + argument, + "bear_history": bear_history + "\n" + argument, + "bull_history": investment_debate_state.get("bull_history", ""), + "current_response": argument, + "count": investment_debate_state["count"] + 1, + } + + return {"investment_debate_state": new_investment_debate_state} + + return bear_node diff --git a/tradingagents/agents/researchers/bull_researcher.py b/tradingagents/agents/researchers/bull_researcher.py new file mode 100644 index 0000000..5f939ed --- /dev/null +++ b/tradingagents/agents/researchers/bull_researcher.py @@ -0,0 +1,61 @@ +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_bull_researcher(llm): + def bull_node(state) -> dict: + investment_debate_state = state["investment_debate_state"] + history = investment_debate_state.get("history", "") + bull_history = investment_debate_state.get("bull_history", "") + + current_response = investment_debate_state.get("current_response", "") + 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 {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. +- Competitive Advantages: Emphasize factors like unique products, strong branding, or dominant market positioning. +- Positive Indicators: Use financial health, industry trends, and recent positive news as evidence. +- Bear Counterpoints: Critically analyze the bear argument with specific data and sound reasoning, addressing concerns thoroughly and showing why the bull perspective holds stronger merit. +- 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} +{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) + + argument = f"Bull Analyst: {response.content}" + + new_investment_debate_state = { + "history": history + "\n" + argument, + "bull_history": bull_history + "\n" + argument, + "bear_history": investment_debate_state.get("bear_history", ""), + "current_response": argument, + "count": investment_debate_state["count"] + 1, + } + + return {"investment_debate_state": new_investment_debate_state} + + return bull_node diff --git a/tradingagents/agents/risk_mgmt/aggressive_debator.py b/tradingagents/agents/risk_mgmt/aggressive_debator.py new file mode 100644 index 0000000..3fd0e12 --- /dev/null +++ b/tradingagents/agents/risk_mgmt/aggressive_debator.py @@ -0,0 +1,59 @@ +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_aggressive_debator(llm): + def aggressive_node(state) -> dict: + risk_debate_state = state["risk_debate_state"] + 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", "") + + 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"] + + prompt = f"""As the Aggressive Risk Analyst, your role is to actively champion high-reward, high-risk opportunities, emphasizing bold strategies and competitive advantages. When evaluating the trader's decision or plan, focus intently on the potential upside, growth potential, and innovative benefits—even when these come with elevated risk. Use the provided market data and sentiment analysis to strengthen your arguments and challenge the opposing views. Specifically, respond directly to each point made by the conservative and neutral analysts, countering with data-driven rebuttals and persuasive reasoning. Highlight where their caution might miss critical opportunities or where their assumptions may be overly conservative. Here is the trader's decision: + +{trader_decision} + +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.""" + get_language_instruction() + + response = llm.invoke(prompt) + + argument = f"Aggressive Analyst: {response.content}" + + new_risk_debate_state = { + "history": history + "\n" + argument, + "aggressive_history": aggressive_history + "\n" + argument, + "conservative_history": risk_debate_state.get("conservative_history", ""), + "neutral_history": risk_debate_state.get("neutral_history", ""), + "latest_speaker": "Aggressive", + "current_aggressive_response": argument, + "current_conservative_response": risk_debate_state.get("current_conservative_response", ""), + "current_neutral_response": risk_debate_state.get( + "current_neutral_response", "" + ), + "count": risk_debate_state["count"] + 1, + } + + return {"risk_debate_state": new_risk_debate_state} + + return aggressive_node diff --git a/tradingagents/agents/risk_mgmt/conservative_debator.py b/tradingagents/agents/risk_mgmt/conservative_debator.py new file mode 100644 index 0000000..b84add0 --- /dev/null +++ b/tradingagents/agents/risk_mgmt/conservative_debator.py @@ -0,0 +1,61 @@ +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_conservative_debator(llm): + def conservative_node(state) -> dict: + risk_debate_state = state["risk_debate_state"] + 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", "") + + 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"] + + prompt = f"""As the Conservative Risk Analyst, your primary objective is to protect assets, minimize volatility, and ensure steady, reliable growth. You prioritize stability, security, and risk mitigation, carefully assessing potential losses, economic downturns, and market volatility. When evaluating the trader's decision or plan, critically examine high-risk elements, pointing out where the decision may expose the firm to undue risk and where more cautious alternatives could secure long-term gains. Here is the trader's decision: + +{trader_decision} + +Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision: + +{instrument_context} +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.""" + get_language_instruction() + + response = llm.invoke(prompt) + + argument = f"Conservative Analyst: {response.content}" + + new_risk_debate_state = { + "history": history + "\n" + argument, + "aggressive_history": risk_debate_state.get("aggressive_history", ""), + "conservative_history": conservative_history + "\n" + argument, + "neutral_history": risk_debate_state.get("neutral_history", ""), + "latest_speaker": "Conservative", + "current_aggressive_response": risk_debate_state.get( + "current_aggressive_response", "" + ), + "current_conservative_response": argument, + "current_neutral_response": risk_debate_state.get( + "current_neutral_response", "" + ), + "count": risk_debate_state["count"] + 1, + } + + return {"risk_debate_state": new_risk_debate_state} + + return conservative_node diff --git a/tradingagents/agents/risk_mgmt/neutral_debator.py b/tradingagents/agents/risk_mgmt/neutral_debator.py new file mode 100644 index 0000000..b28ade2 --- /dev/null +++ b/tradingagents/agents/risk_mgmt/neutral_debator.py @@ -0,0 +1,59 @@ +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) + + +def create_neutral_debator(llm): + def neutral_node(state) -> dict: + risk_debate_state = state["risk_debate_state"] + 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", "") + + 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"] + + prompt = f"""As the Neutral Risk Analyst, your role is to provide a balanced perspective, weighing both the potential benefits and risks of the trader's decision or plan. You prioritize a well-rounded approach, evaluating the upsides and downsides while factoring in broader market trends, potential economic shifts, and diversification strategies.Here is the trader's decision: + +{trader_decision} + +Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision: + +{instrument_context} +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.""" + get_language_instruction() + + response = llm.invoke(prompt) + + argument = f"Neutral Analyst: {response.content}" + + new_risk_debate_state = { + "history": history + "\n" + argument, + "aggressive_history": risk_debate_state.get("aggressive_history", ""), + "conservative_history": risk_debate_state.get("conservative_history", ""), + "neutral_history": neutral_history + "\n" + argument, + "latest_speaker": "Neutral", + "current_aggressive_response": risk_debate_state.get( + "current_aggressive_response", "" + ), + "current_conservative_response": risk_debate_state.get("current_conservative_response", ""), + "current_neutral_response": argument, + "count": risk_debate_state["count"] + 1, + } + + return {"risk_debate_state": new_risk_debate_state} + + return neutral_node diff --git a/tradingagents/agents/schemas.py b/tradingagents/agents/schemas.py new file mode 100644 index 0000000..63ff221 --- /dev/null +++ b/tradingagents/agents/schemas.py @@ -0,0 +1,341 @@ +"""Pydantic schemas used by agents that produce structured output. + +The framework's primary artifact is still prose: each agent's natural-language +reasoning is what users read in the saved markdown reports and what the +downstream agents read as context. Structured output is layered onto the +three decision-making agents (Research Manager, Trader, Portfolio Manager) +so that: + +- Their outputs follow consistent section headers across runs and providers +- Each provider's native structured-output mode is used (json_schema for + OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic) +- Schema field descriptions become the model's output instructions, freeing + the prompt body to focus on context and the rating-scale guidance +- A render helper turns the parsed Pydantic instance back into the same + markdown shape the rest of the system already consumes, so display, + memory log, and saved reports keep working unchanged +""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +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 + + +# --------------------------------------------------------------------------- +# Shared rating types +# --------------------------------------------------------------------------- + + +class PortfolioRating(str, Enum): + """5-tier rating used by the Research Manager and Portfolio Manager.""" + + BUY = "Buy" + OVERWEIGHT = "Overweight" + HOLD = "Hold" + UNDERWEIGHT = "Underweight" + SELL = "Sell" + + +class TraderAction(str, Enum): + """3-tier transaction direction used by the Trader. + + The Trader's job is to translate the Research Manager's investment plan + into a concrete transaction proposal: should the desk execute a Buy, a + Sell, or sit on Hold this round. Position sizing and the nuanced + Overweight / Underweight calls happen later at the Portfolio Manager. + """ + + BUY = "Buy" + HOLD = "Hold" + SELL = "Sell" + + +# --------------------------------------------------------------------------- +# Research Manager +# --------------------------------------------------------------------------- + + +class ResearchPlan(BaseModel): + """Structured investment plan produced by the Research Manager. + + Hand-off to the Trader: the recommendation pins the directional view, + the rationale captures which side of the bull/bear debate carried the + argument, and the strategic actions translate that into concrete + instructions the trader can execute against. + """ + + 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." + ), + ) + rationale: str = Field( + description=( + "Conversational summary of the key points from both sides of the " + "debate, ending with which arguments led to the recommendation. " + "Speak naturally, as if to a teammate." + ), + ) + strategic_actions: str = Field( + description=( + "Concrete steps for the trader to implement the recommendation, " + "including position sizing guidance consistent with the rating." + ), + ) + + +def render_research_plan(plan: ResearchPlan) -> str: + """Render a ResearchPlan to markdown for storage and the trader's prompt context.""" + return "\n".join([ + f"**Recommendation**: {plan.recommendation.value}", + "", + f"**Rationale**: {plan.rationale}", + "", + f"**Strategic Actions**: {plan.strategic_actions}", + ]) + + +# --------------------------------------------------------------------------- +# Trader +# --------------------------------------------------------------------------- + + +class TraderProposal(BaseModel): + """Structured transaction proposal produced by the Trader. + + The trader reads the Research Manager's investment plan and the analyst + reports, then turns them into a concrete transaction: what action to + take, the reasoning that justifies it, and the practical levels for + entry, stop-loss, and sizing. + """ + + action: TraderAction = Field( + description="The transaction direction. Exactly one of Buy / Hold / Sell.", + ) + reasoning: str = Field( + description=( + "The case for this action, anchored in the analysts' reports and " + "the research plan. Two to four sentences." + ), + ) + entry_price: float | None = Field( + default=None, + description="Optional entry price target in the instrument's quote currency.", + ) + stop_loss: float | None = Field( + default=None, + description="Optional stop-loss price in the instrument's quote currency.", + ) + 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. + + The trailing ``FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL**`` line is + preserved for backward compatibility with the analyst stop-signal text + and any external code that greps for it. + """ + parts = [ + f"**Action**: {proposal.action.value}", + "", + f"**Reasoning**: {proposal.reasoning}", + ] + if proposal.entry_price is not None: + parts.extend(["", f"**Entry Price**: {proposal.entry_price}"]) + if proposal.stop_loss is not None: + parts.extend(["", f"**Stop Loss**: {proposal.stop_loss}"]) + if proposal.position_sizing: + parts.extend(["", f"**Position Sizing**: {proposal.position_sizing}"]) + parts.extend([ + "", + f"FINAL TRANSACTION PROPOSAL: **{proposal.action.value.upper()}**", + ]) + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Portfolio Manager +# --------------------------------------------------------------------------- + + +class PortfolioDecision(BaseModel): + """Structured output produced by the Portfolio Manager. + + The model fills every field as part of its primary LLM call; no separate + extraction pass is required. Field descriptions double as the model's + output instructions, so the prompt body only needs to convey context and + the rating-scale guidance. + """ + + rating: PortfolioRating = Field( + description=( + "The final position rating. Exactly one of Buy / Overweight / Hold / " + "Underweight / Sell, picked based on the analysts' debate." + ), + ) + executive_summary: str = Field( + description=( + "A concise action plan covering entry strategy, position sizing, " + "key risk levels, and time horizon. Two to four sentences." + ), + ) + investment_thesis: str = Field( + description=( + "Detailed reasoning anchored in specific evidence from the analysts' " + "debate. If prior lessons are referenced in the prompt context, " + "incorporate them; otherwise rely solely on the current analysis." + ), + ) + price_target: float | None = Field( + default=None, + description="Optional target price in the instrument's quote currency.", + ) + 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. + + Memory log, CLI display, and saved report files all read this markdown, + so the rendered output preserves the exact section headers (``**Rating**``, + ``**Executive Summary**``, ``**Investment Thesis**``) that downstream + parsers and the report writers already handle. + """ + parts = [ + f"**Rating**: {decision.rating.value}", + "", + f"**Executive Summary**: {decision.executive_summary}", + "", + f"**Investment Thesis**: {decision.investment_thesis}", + ] + if decision.price_target is not None: + parts.extend(["", f"**Price Target**: {decision.price_target}"]) + 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, + ]) diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py new file mode 100644 index 0000000..403a6a5 --- /dev/null +++ b/tradingagents/agents/trader/trader.py @@ -0,0 +1,65 @@ +"""Trader: turns the Research Manager's investment plan into a concrete transaction proposal.""" + +from __future__ import annotations + +import functools + +from langchain_core.messages import AIMessage + +from tradingagents.agents.schemas import TraderProposal, render_trader_proposal +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) +from tradingagents.agents.utils.structured import ( + bind_structured, + invoke_structured_or_freetext, +) + + +def create_trader(llm): + structured_llm = bind_structured(llm, TraderProposal, "Trader") + + def trader_node(state, name): + company_name = state["company_of_interest"] + instrument_context = get_instrument_context_from_state(state) + investment_plan = state["investment_plan"] + + messages = [ + { + "role": "system", + "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." + + 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." + ), + }, + ] + + trader_plan = invoke_structured_or_freetext( + structured_llm, + llm, + messages, + render_trader_proposal, + "Trader", + ) + + return { + "messages": [AIMessage(content=trader_plan)], + "trader_investment_plan": trader_plan, + "sender": name, + } + + return functools.partial(trader_node, name="Trader") diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py new file mode 100644 index 0000000..82504ca --- /dev/null +++ b/tradingagents/agents/utils/agent_states.py @@ -0,0 +1,76 @@ +from typing import Annotated + +from langgraph.graph import MessagesState +from typing_extensions import TypedDict + + +# Researcher team state +class InvestDebateState(TypedDict): + bull_history: Annotated[ + str, "Bullish Conversation history" + ] # Bullish Conversation history + bear_history: Annotated[ + str, "Bearish Conversation history" + ] # Bullish Conversation history + history: Annotated[str, "Conversation history"] # Conversation history + current_response: Annotated[str, "Latest response"] # Last response + judge_decision: Annotated[str, "Final judge decision"] # Last response + count: Annotated[int, "Length of the current conversation"] # Conversation length + + +# Risk management team state +class RiskDebateState(TypedDict): + aggressive_history: Annotated[ + str, "Aggressive Agent's Conversation history" + ] # Conversation history + conservative_history: Annotated[ + str, "Conservative Agent's Conversation history" + ] # Conversation history + neutral_history: Annotated[ + str, "Neutral Agent's Conversation history" + ] # Conversation history + history: Annotated[str, "Conversation history"] # Conversation history + latest_speaker: Annotated[str, "Analyst that spoke last"] + current_aggressive_response: Annotated[ + str, "Latest response by the aggressive analyst" + ] # Last response + current_conservative_response: Annotated[ + str, "Latest response by the conservative analyst" + ] # Last response + current_neutral_response: Annotated[ + str, "Latest response by the neutral analyst" + ] # Last response + judge_decision: Annotated[str, "Judge's decision"] + count: Annotated[int, "Length of the current conversation"] # Conversation length + + +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 Sentiment Analyst"] + news_report: Annotated[ + str, "Report from the News Researcher of current world affairs" + ] + fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"] + + # researcher team discussion step + investment_debate_state: Annotated[ + InvestDebateState, "Current state of the debate on if to invest or not" + ] + investment_plan: Annotated[str, "Plan generated by the Analyst"] + + trader_investment_plan: Annotated[str, "Plan generated by the Trader"] + + # risk management team discussion step + risk_debate_state: Annotated[ + RiskDebateState, "Current state of the debate on evaluating risk" + ] + final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] + past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py new file mode 100644 index 0000000..16f45c1 --- /dev/null +++ b/tradingagents/agents/utils/agent_utils.py @@ -0,0 +1,217 @@ +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.fundamental_data_tools import ( + get_balance_sheet, + get_cashflow, + 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_global_news, + get_insider_transactions, + 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. + 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") + if lang.strip().lower() == "english": + return "" + return f" Write your entire response in {lang}." + + +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 a context-anchored placeholder. + + The placeholder must not be a bare ``"Continue"``: some + OpenAI-compatible providers interpret that literally as the user task + and produce output about the word "continue" instead of analysing the + instrument (#888). Anchoring it to the resolved instrument context and + date keeps the next analyst on-task even if the provider treats the + placeholder as a standalone request. + """ + messages = state["messages"] + removal_operations = [RemoveMessage(id=m.id) for m in messages] + + instrument_context = get_instrument_context_from_state(state) + trade_date = state.get("trade_date", "the requested date") + placeholder = HumanMessage( + content=( + f"Proceed with your assigned analysis for this workflow. " + f"{instrument_context} The analysis date is {trade_date}." + ) + ) + return {"messages": removal_operations + [placeholder]} + + return delete_messages + + + diff --git a/tradingagents/agents/utils/core_stock_tools.py b/tradingagents/agents/utils/core_stock_tools.py new file mode 100644 index 0000000..bd5cabf --- /dev/null +++ b/tradingagents/agents/utils/core_stock_tools.py @@ -0,0 +1,24 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_stock_data( + symbol: Annotated[str, "ticker symbol of the company"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +) -> str: + """ + Retrieve stock price data (OHLCV) for a given ticker symbol. + Uses the configured core_stock_apis vendor. + Args: + symbol (str): Ticker symbol of the company, e.g. AAPL, TSM + start_date (str): Start date in yyyy-mm-dd format + end_date (str): End date in yyyy-mm-dd format + Returns: + str: A formatted dataframe containing the stock price data for the specified ticker symbol in the specified date range. + """ + return route_to_vendor("get_stock_data", symbol, start_date, end_date) diff --git a/tradingagents/agents/utils/fundamental_data_tools.py b/tradingagents/agents/utils/fundamental_data_tools.py new file mode 100644 index 0000000..aefa0de --- /dev/null +++ b/tradingagents/agents/utils/fundamental_data_tools.py @@ -0,0 +1,79 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_fundamentals( + ticker: Annotated[str, "ticker symbol"], + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"], +) -> str: + """ + Retrieve comprehensive fundamental data for a given ticker symbol. + Uses the configured fundamental_data vendor. + Args: + ticker (str): Ticker symbol of the company + curr_date (str): Current date you are trading at, yyyy-mm-dd + Returns: + str: A formatted report containing comprehensive fundamental data + """ + return route_to_vendor("get_fundamentals", ticker, curr_date) + + +@tool +def get_balance_sheet( + ticker: Annotated[str, "ticker symbol"], + freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, +) -> str: + """ + Retrieve balance sheet data for a given ticker symbol. + Uses the configured fundamental_data vendor. + Args: + ticker (str): Ticker symbol of the company + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd + Returns: + str: A formatted report containing balance sheet data + """ + return route_to_vendor("get_balance_sheet", ticker, freq, curr_date) + + +@tool +def get_cashflow( + ticker: Annotated[str, "ticker symbol"], + freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, +) -> str: + """ + Retrieve cash flow statement data for a given ticker symbol. + Uses the configured fundamental_data vendor. + Args: + ticker (str): Ticker symbol of the company + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd + Returns: + str: A formatted report containing cash flow statement data + """ + return route_to_vendor("get_cashflow", ticker, freq, curr_date) + + +@tool +def get_income_statement( + ticker: Annotated[str, "ticker symbol"], + freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly", + curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None, +) -> str: + """ + Retrieve income statement data for a given ticker symbol. + Uses the configured fundamental_data vendor. + Args: + ticker (str): Ticker symbol of the company + freq (str): Reporting frequency: annual/quarterly (default quarterly) + curr_date (str): Current date you are trading at, yyyy-mm-dd + Returns: + str: A formatted report containing income statement data + """ + return route_to_vendor("get_income_statement", ticker, freq, curr_date) diff --git a/tradingagents/agents/utils/macro_data_tools.py b/tradingagents/agents/utils/macro_data_tools.py new file mode 100644 index 0000000..3d56754 --- /dev/null +++ b/tradingagents/agents/utils/macro_data_tools.py @@ -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) diff --git a/tradingagents/agents/utils/market_data_validation_tools.py b/tradingagents/agents/utils/market_data_validation_tools.py new file mode 100644 index 0000000..356a4ac --- /dev/null +++ b/tradingagents/agents/utils/market_data_validation_tools.py @@ -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) diff --git a/tradingagents/agents/utils/memory.py b/tradingagents/agents/utils/memory.py new file mode 100644 index 0000000..ff9e945 --- /dev/null +++ b/tradingagents/agents/utils/memory.py @@ -0,0 +1,299 @@ +"""Append-only markdown decision log for TradingAgents.""" + +import re +from pathlib import Path + +from tradingagents.agents.utils.rating import parse_rating + + +class TradingMemoryLog: + """Append-only markdown log of trading decisions and reflections.""" + + # HTML comment: cannot appear in LLM prose output, safe as a hard delimiter + _SEPARATOR = "\n\n\n\n" + # Precompiled patterns — avoids re-compilation on every load_entries() call + _DECISION_RE = re.compile(r"DECISION:\n(.*?)(?=\nREFLECTION:|\Z)", re.DOTALL) + _REFLECTION_RE = re.compile(r"REFLECTION:\n(.*?)$", re.DOTALL) + + def __init__(self, config: dict = None): + cfg = config or {} + self._log_path = None + path = cfg.get("memory_log_path") + if path: + self._log_path = Path(path).expanduser() + self._log_path.parent.mkdir(parents=True, exist_ok=True) + # Optional cap on resolved entries. None disables rotation. + self._max_entries = cfg.get("memory_log_max_entries") + + # --- Write path (Phase A) --- + + def store_decision( + self, + ticker: str, + trade_date: str, + final_trade_decision: str, + ) -> None: + """Append pending entry at end of propagate(). No LLM call.""" + if not self._log_path: + return + # Idempotency guard: fast raw-text scan instead of full parse + if self._log_path.exists(): + raw = self._log_path.read_text(encoding="utf-8") + for line in raw.splitlines(): + if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("| pending]"): + return + rating = parse_rating(final_trade_decision) + tag = f"[{trade_date} | {ticker} | {rating} | pending]" + entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" + with open(self._log_path, "a", encoding="utf-8") as f: + f.write(entry) + + # --- Read path (Phase A) --- + + def load_entries(self) -> list[dict]: + """Parse all entries from log. Returns list of dicts.""" + if not self._log_path or not self._log_path.exists(): + return [] + text = self._log_path.read_text(encoding="utf-8") + raw_entries = [e.strip() for e in text.split(self._SEPARATOR) if e.strip()] + entries = [] + for raw in raw_entries: + parsed = self._parse_entry(raw) + if parsed: + entries.append(parsed) + return entries + + def get_pending_entries(self) -> list[dict]: + """Return entries with outcome:pending (for Phase B).""" + return [e for e in self.load_entries() if e.get("pending")] + + def get_past_context(self, ticker: str, n_same: int = 5, n_cross: int = 3) -> str: + """Return formatted past context string for agent prompt injection.""" + entries = [e for e in self.load_entries() if not e.get("pending")] + if not entries: + return "" + + same, cross = [], [] + for e in reversed(entries): + if len(same) >= n_same and len(cross) >= n_cross: + break + if e["ticker"] == ticker and len(same) < n_same: + same.append(e) + elif e["ticker"] != ticker and len(cross) < n_cross: + cross.append(e) + + if not same and not cross: + return "" + + parts = [] + if same: + parts.append(f"Past analyses of {ticker} (most recent first):") + parts.extend(self._format_full(e) for e in same) + if cross: + parts.append("Recent cross-ticker lessons:") + parts.extend(self._format_reflection_only(e) for e in cross) + return "\n\n".join(parts) + + # --- Update path (Phase B) --- + + def update_with_outcome( + self, + ticker: str, + trade_date: str, + raw_return: float, + alpha_return: float, + holding_days: int, + reflection: str, + ) -> None: + """Replace pending tag and append REFLECTION section using atomic write. + + Finds the first pending entry matching (trade_date, ticker), updates + its tag with return figures, and appends a REFLECTION section. Uses + a temp-file + os.replace() so a crash mid-write never corrupts the log. + """ + if not self._log_path or not self._log_path.exists(): + return + + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) + + pending_prefix = f"[{trade_date} | {ticker} |" + raw_pct = f"{raw_return:+.1%}" + alpha_pct = f"{alpha_return:+.1%}" + + updated = False + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue + + lines = stripped.splitlines() + tag_line = lines[0].strip() + + if ( + not updated + and tag_line.startswith(pending_prefix) + and tag_line.endswith("| pending]") + ): + # Parse rating from the existing pending tag + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + new_tag = ( + f"[{trade_date} | {ticker} | {rating}" + f" | {raw_pct} | {alpha_pct} | {holding_days}d]" + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{reflection}" + ) + updated = True + else: + new_blocks.append(block) + + if not updated: + return + + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) + + def batch_update_with_outcomes(self, updates: list[dict]) -> None: + """Apply multiple outcome updates in a single read + atomic write. + + Each element of updates must have keys: ticker, trade_date, + raw_return, alpha_return, holding_days, reflection. + """ + if not self._log_path or not self._log_path.exists() or not updates: + return + + text = self._log_path.read_text(encoding="utf-8") + blocks = text.split(self._SEPARATOR) + + # Build lookup keyed by (trade_date, ticker) for O(1) dispatch + update_map = {(u["trade_date"], u["ticker"]): u for u in updates} + + new_blocks = [] + for block in blocks: + stripped = block.strip() + if not stripped: + new_blocks.append(block) + continue + + lines = stripped.splitlines() + tag_line = lines[0].strip() + + matched = False + for (trade_date, ticker), upd in list(update_map.items()): + pending_prefix = f"[{trade_date} | {ticker} |" + if tag_line.startswith(pending_prefix) and tag_line.endswith("| pending]"): + fields = [f.strip() for f in tag_line[1:-1].split("|")] + rating = fields[2] + raw_pct = f"{upd['raw_return']:+.1%}" + alpha_pct = f"{upd['alpha_return']:+.1%}" + new_tag = ( + f"[{trade_date} | {ticker} | {rating}" + f" | {raw_pct} | {alpha_pct} | {upd['holding_days']}d]" + ) + rest = "\n".join(lines[1:]) + new_blocks.append( + f"{new_tag}\n\n{rest.lstrip()}\n\nREFLECTION:\n{upd['reflection']}" + ) + del update_map[(trade_date, ticker)] + matched = True + break + + if not matched: + new_blocks.append(block) + + new_blocks = self._apply_rotation(new_blocks) + new_text = self._SEPARATOR.join(new_blocks) + tmp_path = self._log_path.with_suffix(".tmp") + tmp_path.write_text(new_text, encoding="utf-8") + tmp_path.replace(self._log_path) + + # --- Helpers --- + + def _apply_rotation(self, blocks: list[str]) -> list[str]: + """Drop oldest resolved blocks when their count exceeds max_entries. + + Pending blocks are always kept (they represent unprocessed work). + Returns ``blocks`` unchanged when rotation is disabled or under cap. + """ + if not self._max_entries or self._max_entries <= 0: + return blocks + + # Tag each block with (kept, is_resolved) by parsing tag-line markers. + decisions = [] + for block in blocks: + stripped = block.strip() + if not stripped: + decisions.append((block, False)) + continue + tag_line = stripped.splitlines()[0].strip() + is_resolved = ( + tag_line.startswith("[") + and tag_line.endswith("]") + and not tag_line.endswith("| pending]") + ) + decisions.append((block, is_resolved)) + + resolved_count = sum(1 for _, r in decisions if r) + if resolved_count <= self._max_entries: + return blocks + + to_drop = resolved_count - self._max_entries + kept: list[str] = [] + for block, is_resolved in decisions: + if is_resolved and to_drop > 0: + to_drop -= 1 + continue + kept.append(block) + return kept + + def _parse_entry(self, raw: str) -> dict | None: + lines = raw.strip().splitlines() + if not lines: + return None + tag_line = lines[0].strip() + if not (tag_line.startswith("[") and tag_line.endswith("]")): + return None + fields = [f.strip() for f in tag_line[1:-1].split("|")] + if len(fields) < 4: + return None + entry = { + "date": fields[0], + "ticker": fields[1], + "rating": fields[2], + "pending": fields[3] == "pending", + "raw": fields[3] if fields[3] != "pending" else None, + "alpha": fields[4] if len(fields) > 4 else None, + "holding": fields[5] if len(fields) > 5 else None, + } + body = "\n".join(lines[1:]).strip() + decision_match = self._DECISION_RE.search(body) + reflection_match = self._REFLECTION_RE.search(body) + entry["decision"] = decision_match.group(1).strip() if decision_match else "" + entry["reflection"] = reflection_match.group(1).strip() if reflection_match else "" + return entry + + def _format_full(self, e: dict) -> str: + raw = e["raw"] or "n/a" + alpha = e["alpha"] or "n/a" + holding = e["holding"] or "n/a" + tag = f"[{e['date']} | {e['ticker']} | {e['rating']} | {raw} | {alpha} | {holding}]" + parts = [tag, f"DECISION:\n{e['decision']}"] + if e["reflection"]: + parts.append(f"REFLECTION:\n{e['reflection']}") + return "\n\n".join(parts) + + def _format_reflection_only(self, e: dict) -> str: + tag = f"[{e['date']} | {e['ticker']} | {e['rating']} | {e['raw'] or 'n/a'}]" + if e["reflection"]: + return f"{tag}\n{e['reflection']}" + text = e["decision"][:300] + suffix = "..." if len(e["decision"]) > 300 else "" + return f"{tag}\n{text}{suffix}" diff --git a/tradingagents/agents/utils/news_data_tools.py b/tradingagents/agents/utils/news_data_tools.py new file mode 100644 index 0000000..6c6f535 --- /dev/null +++ b/tradingagents/agents/utils/news_data_tools.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_news( + ticker: Annotated[str, "Ticker symbol"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +) -> str: + """ + Retrieve news data for a given ticker symbol. + Uses the configured news_data vendor. + Args: + ticker (str): Ticker symbol + start_date (str): Start date in yyyy-mm-dd format + end_date (str): End date in yyyy-mm-dd format + Returns: + str: A formatted string containing news data + """ + return route_to_vendor("get_news", ticker, start_date, end_date) + +@tool +def get_global_news( + curr_date: Annotated[str, "Current date in yyyy-mm-dd format"], + look_back_days: Annotated[int | None, "Days to look back; omit to use the configured default"] = None, + limit: Annotated[int | None, "Max articles to return; omit to use the configured default"] = None, +) -> str: + """ + Retrieve global news data. + Uses the configured news_data vendor. Defaults for look_back_days and + limit come from DEFAULT_CONFIG (global_news_lookback_days, + global_news_article_limit); pass explicit values to override. + + Args: + curr_date (str): Current date in yyyy-mm-dd format + look_back_days (int): Number of days to look back; omit to inherit config + limit (int): Maximum number of articles to return; omit to inherit config + + Returns: + str: A formatted string containing global news data + """ + return route_to_vendor("get_global_news", curr_date, look_back_days, limit) + +@tool +def get_insider_transactions( + ticker: Annotated[str, "ticker symbol"], +) -> str: + """ + Retrieve insider transaction information about a company. + Uses the configured news_data vendor. + Args: + ticker (str): Ticker symbol of the company + Returns: + str: A report of insider transaction data + """ + return route_to_vendor("get_insider_transactions", ticker) diff --git a/tradingagents/agents/utils/prediction_markets_tools.py b/tradingagents/agents/utils/prediction_markets_tools.py new file mode 100644 index 0000000..843c9a4 --- /dev/null +++ b/tradingagents/agents/utils/prediction_markets_tools.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_prediction_markets( + topic: Annotated[ + str, + "Event topic/keyword, e.g. 'Fed rate cut', 'recession 2026', " + "'US election', or a sector/company event.", + ], + limit: Annotated[int | None, "Max markets to return; omit for a default of 6"] = None, +) -> str: + """ + Retrieve live, market-implied probabilities for forward-looking events from + prediction markets (Polymarket): Fed decisions, recession, elections, + geopolitics, crypto. Returns the most-traded open markets matching the + topic, each with its implied probability, traded volume, resolution date, + and recent move. Uses the configured prediction_markets vendor. + + Args: + topic (str): Event keyword(s) to search + limit (int): Max markets to return; omit for a default of 6 + + Returns: + str: A formatted markdown report of matching prediction markets + """ + return route_to_vendor("get_prediction_markets", topic, limit) diff --git a/tradingagents/agents/utils/rating.py b/tradingagents/agents/utils/rating.py new file mode 100644 index 0000000..234bc56 --- /dev/null +++ b/tradingagents/agents/utils/rating.py @@ -0,0 +1,48 @@ +"""Shared 5-tier rating vocabulary and a deterministic heuristic parser. + +The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: +- The Research Manager (investment plan recommendation) +- The Portfolio Manager (final position decision) +- The signal processor (rating extracted for downstream consumers) +- The memory log (rating tag stored alongside each decision entry) + +Centralising it here avoids drift between those call sites. +""" + +from __future__ import annotations + +import re + +# Canonical, ordered 5-tier scale (most bullish to most bearish). +RATINGS_5_TIER: tuple[str, ...] = ( + "Buy", "Overweight", "Hold", "Underweight", "Sell", +) + +_RATING_SET = {r.lower() for r in RATINGS_5_TIER} + +# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown +# bold wrappers and either a colon or hyphen separator. +_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE) + + +def parse_rating(text: str, default: str = "Hold") -> str: + """Heuristically extract a 5-tier rating from prose text. + + Two-pass strategy: + 1. Look for an explicit "Rating: X" label (tolerant of markdown bold). + 2. Fall back to the first 5-tier rating word found anywhere in the text. + + Returns a Title-cased rating string, or ``default`` if no rating word appears. + """ + for line in text.splitlines(): + m = _RATING_LABEL_RE.search(line) + if m and m.group(1).lower() in _RATING_SET: + return m.group(1).capitalize() + + for line in text.splitlines(): + for word in line.lower().split(): + clean = word.strip("*:.,") + if clean in _RATING_SET: + return clean.capitalize() + + return default diff --git a/tradingagents/agents/utils/structured.py b/tradingagents/agents/utils/structured.py new file mode 100644 index 0000000..56019bc --- /dev/null +++ b/tradingagents/agents/utils/structured.py @@ -0,0 +1,79 @@ +"""Shared helpers for invoking an agent with structured output and a graceful fallback. + +The Portfolio Manager, Trader, and Research Manager all follow the same +canonical pattern: + +1. At agent creation, wrap the LLM with ``with_structured_output(Schema)`` + so the model returns a typed Pydantic instance. If the provider does + not support structured output (rare; mostly older Ollama models), the + wrap is skipped and the agent uses free-text generation instead. +2. At invocation, run the structured call and render the result back to + markdown. If the structured call itself fails for any reason + (malformed JSON from a weak model, transient provider issue), fall + back to a plain ``llm.invoke`` so the pipeline never blocks. + +Centralising the pattern here keeps the agent factories small and ensures +all three agents log the same warnings when fallback fires. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any, TypeVar + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +T = TypeVar("T", bound=BaseModel) + + +def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None: + """Return ``llm.with_structured_output(schema)`` or ``None`` if unsupported. + + Logs a warning when the binding fails so the user understands the agent + will use free-text generation for every call instead of one-shot fallback. + """ + try: + return llm.with_structured_output(schema) + except (NotImplementedError, AttributeError) as exc: + logger.warning( + "%s: provider does not support with_structured_output (%s); " + "falling back to free-text generation", + agent_name, exc, + ) + return None + + +def invoke_structured_or_freetext( + structured_llm: Any | None, + plain_llm: Any, + prompt: Any, + render: Callable[[T], str], + agent_name: str, +) -> str: + """Run the structured call and render to markdown; fall back to free-text on any failure. + + ``prompt`` is whatever the underlying LLM accepts (a string for chat + invocations, a list of message dicts for chat models that take that + shape). The same value is forwarded to the free-text path so the + fallback sees the same input the structured call did. + """ + if structured_llm is not None: + try: + result = structured_llm.invoke(prompt) + if result is None: + # A thinking model can answer in plain text instead of calling + # the tool, leaving the parser with nothing to return. Treat it + # as a structured miss and fall back, with a clear reason. + raise ValueError("structured output returned no parsed result") + return render(result) + except Exception as exc: + logger.warning( + "%s: structured-output invocation failed (%s); retrying once as free text", + agent_name, exc, + ) + + response = plain_llm.invoke(prompt) + return response.content diff --git a/tradingagents/agents/utils/technical_indicators_tools.py b/tradingagents/agents/utils/technical_indicators_tools.py new file mode 100644 index 0000000..c1c5ad9 --- /dev/null +++ b/tradingagents/agents/utils/technical_indicators_tools.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_indicators( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to get the analysis and report of"], + curr_date: Annotated[str, "The current trading date you are trading on, YYYY-mm-dd"], + look_back_days: Annotated[int, "how many days to look back"] = 30, +) -> str: + """ + Retrieve a single technical indicator for a given ticker symbol. + Uses the configured technical_indicators vendor. + Args: + symbol (str): Ticker symbol of the company, e.g. AAPL, TSM + indicator (str): A single technical indicator name, e.g. 'rsi', 'macd'. Call this tool once per indicator. + curr_date (str): The current trading date you are trading on, YYYY-mm-dd + look_back_days (int): How many days to look back, default is 30 + Returns: + str: A formatted dataframe containing the technical indicators for the specified ticker symbol and indicator. + """ + # LLMs sometimes pass multiple indicators as a comma-separated string; + # split and process each individually. + indicators = [i.strip().lower() for i in indicator.split(",") if i.strip()] + results = [] + for ind in indicators: + try: + results.append(route_to_vendor("get_indicators", symbol, ind, curr_date, look_back_days)) + except ValueError as e: + results.append(str(e)) + return "\n\n".join(results) diff --git a/tradingagents/dataflows/__init__.py b/tradingagents/dataflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tradingagents/dataflows/alpha_vantage.py b/tradingagents/dataflows/alpha_vantage.py new file mode 100644 index 0000000..90032a7 --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage.py @@ -0,0 +1,23 @@ +# Aggregates the per-category Alpha Vantage implementations into one module the +# vendor router imports from; the imports below are the public surface. +from .alpha_vantage_fundamentals import ( + get_balance_sheet, + get_cashflow, + get_fundamentals, + get_income_statement, +) +from .alpha_vantage_indicator import get_indicator +from .alpha_vantage_news import get_global_news, get_insider_transactions, get_news +from .alpha_vantage_stock import get_stock + +__all__ = [ + "get_balance_sheet", + "get_cashflow", + "get_fundamentals", + "get_income_statement", + "get_indicator", + "get_global_news", + "get_insider_transactions", + "get_news", + "get_stock", +] diff --git a/tradingagents/dataflows/alpha_vantage_common.py b/tradingagents/dataflows/alpha_vantage_common.py new file mode 100644 index 0000000..237ff54 --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage_common.py @@ -0,0 +1,151 @@ +import json +import os +from datetime import datetime +from io import StringIO + +import pandas as pd +import requests + +from .errors import VendorNotConfiguredError, VendorRateLimitError + +API_BASE_URL = "https://www.alphavantage.co/query" + +# Network timeout (seconds) so a stalled Alpha Vantage request can't hang the +# CLI/agents indefinitely (#990). +REQUEST_TIMEOUT = 30 + + +class AlphaVantageNotConfiguredError(VendorNotConfiguredError): + """Raised when Alpha Vantage is selected but no API key is configured. + + A VendorNotConfiguredError (and thus still a ValueError), so the routing + layer's "vendor unavailable" handling and existing ValueError callers both + keep working. + """ + pass + + +def get_api_key() -> str: + """Retrieve the API key for Alpha Vantage from environment variables.""" + api_key = os.getenv("ALPHA_VANTAGE_API_KEY") + if not api_key: + raise AlphaVantageNotConfiguredError( + "ALPHA_VANTAGE_API_KEY environment variable is not set." + ) + return api_key + +def format_datetime_for_api(date_input) -> str: + """Convert various date formats to YYYYMMDDTHHMM format required by Alpha Vantage API.""" + if isinstance(date_input, str): + # If already in correct format, return as-is + if len(date_input) == 13 and 'T' in date_input: + return date_input + # Try to parse common date formats + try: + dt = datetime.strptime(date_input, "%Y-%m-%d") + return dt.strftime("%Y%m%dT0000") + except ValueError: + try: + dt = datetime.strptime(date_input, "%Y-%m-%d %H:%M") + return dt.strftime("%Y%m%dT%H%M") + except ValueError: + raise ValueError(f"Unsupported date format: {date_input}") from None + elif isinstance(date_input, datetime): + return date_input.strftime("%Y%m%dT%H%M") + else: + raise ValueError(f"Date must be string or datetime object, got {type(date_input)}") + +class AlphaVantageRateLimitError(VendorRateLimitError): + """Raised when the Alpha Vantage API rate limit is exceeded.""" + pass + +def _make_api_request(function_name: str, params: dict) -> dict | str: + """Helper function to make API requests and handle responses. + + Raises: + AlphaVantageRateLimitError: When API rate limit is exceeded + """ + # Create a copy of params to avoid modifying the original + api_params = params.copy() + api_params.update({ + "function": function_name, + "apikey": get_api_key(), + "source": "trading_agents", + }) + + # Handle entitlement parameter if present in params or global variable + current_entitlement = globals().get('_current_entitlement') + entitlement = api_params.get("entitlement") or current_entitlement + + if entitlement: + api_params["entitlement"] = entitlement + elif "entitlement" in api_params: + # Remove entitlement if it's None or empty + api_params.pop("entitlement", None) + + response = requests.get(API_BASE_URL, params=api_params, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + + response_text = response.text + + # Error responses are JSON; data responses are usually CSV (or data-keyed + # JSON). A non-JSON body is normal data. + try: + response_json = json.loads(response_text) + except json.JSONDecodeError: + return response_text + + # Alpha Vantage reports problems via "Information" / "Note". Classify so a + # genuine rate limit and an invalid/missing key aren't conflated (#991): + # rate-limit phrasing is checked first because those notices also mention + # "API key" ("your API key ... 25 requests per day"). + notice = response_json.get("Information") or response_json.get("Note") + if notice: + low = notice.lower() + if any(m in low for m in ("rate limit", "requests per day", "call frequency", "premium")): + raise AlphaVantageRateLimitError(f"Alpha Vantage rate limit exceeded: {notice}") + if "api key" in low or "apikey" in low: + # Reuse the existing "not configured" error so a bad key surfaces as + # a real, actionable failure rather than a mislabeled rate limit (#991). + raise AlphaVantageNotConfiguredError(f"Alpha Vantage API key invalid or missing: {notice}") + + return response_text + + + +def _filter_csv_by_date_range(csv_data: str, start_date: str, end_date: str) -> str: + """ + Filter CSV data to include only rows within the specified date range. + + Args: + csv_data: CSV string from Alpha Vantage API + start_date: Start date in yyyy-mm-dd format + end_date: End date in yyyy-mm-dd format + + Returns: + Filtered CSV string + """ + if not csv_data or csv_data.strip() == "": + return csv_data + + try: + # Parse CSV data + df = pd.read_csv(StringIO(csv_data)) + + # Assume the first column is the date column (timestamp) + date_col = df.columns[0] + df[date_col] = pd.to_datetime(df[date_col]) + + # Filter by date range + start_dt = pd.to_datetime(start_date) + end_dt = pd.to_datetime(end_date) + + filtered_df = df[(df[date_col] >= start_dt) & (df[date_col] <= end_dt)] + + # Convert back to CSV string + return filtered_df.to_csv(index=False) + + except Exception as e: + # If filtering fails, return original data with a warning + print(f"Warning: Failed to filter CSV data by date range: {e}") + return csv_data diff --git a/tradingagents/dataflows/alpha_vantage_fundamentals.py b/tradingagents/dataflows/alpha_vantage_fundamentals.py new file mode 100644 index 0000000..90c89f2 --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage_fundamentals.py @@ -0,0 +1,64 @@ +import json + +from .alpha_vantage_common import _make_api_request + + +def _filter_reports_by_date(result, curr_date: str): + """Drop annual/quarterly reports dated after curr_date to prevent look-ahead. + + ``_make_api_request`` returns the fundamentals payload as a JSON string, so + parse, filter, and re-serialize. A non-JSON body or an unset ``curr_date`` is + returned unchanged. + """ + if not curr_date or not isinstance(result, str): + return result + try: + payload = json.loads(result) + except json.JSONDecodeError: + return result + if not isinstance(payload, dict): + return result + for key in ("annualReports", "quarterlyReports"): + if isinstance(payload.get(key), list): + payload[key] = [ + r for r in payload[key] + if r.get("fiscalDateEnding", "") <= curr_date + ] + return json.dumps(payload) + + +def get_fundamentals(ticker: str, curr_date: str = None) -> str: + """ + Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. + + Args: + ticker (str): Ticker symbol of the company + curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + + Returns: + str: Company overview data including financial ratios and key metrics + """ + params = { + "symbol": ticker, + } + + return _make_api_request("OVERVIEW", params) + + +def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str = None): + """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" + result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) + return _filter_reports_by_date(result, curr_date) + + +def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str = None): + """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" + result = _make_api_request("CASH_FLOW", {"symbol": ticker}) + return _filter_reports_by_date(result, curr_date) + + +def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str = None): + """Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" + result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) + return _filter_reports_by_date(result, curr_date) + diff --git a/tradingagents/dataflows/alpha_vantage_indicator.py b/tradingagents/dataflows/alpha_vantage_indicator.py new file mode 100644 index 0000000..dcfd17a --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage_indicator.py @@ -0,0 +1,215 @@ +from .alpha_vantage_common import AlphaVantageNotConfiguredError, _make_api_request + + +def get_indicator( + symbol: str, + indicator: str, + curr_date: str, + look_back_days: int, + interval: str = "daily", + time_period: int = 14, + series_type: str = "close" +) -> str: + """ + Returns Alpha Vantage technical indicator values over a time window. + + Args: + symbol: ticker symbol of the company + indicator: technical indicator to get the analysis and report of + curr_date: The current trading date you are trading on, YYYY-mm-dd + look_back_days: how many days to look back + interval: Time interval (daily, weekly, monthly) + time_period: Number of data points for calculation + series_type: The desired price type (close, open, high, low) + + Returns: + String containing indicator values and description + """ + from datetime import datetime + + from dateutil.relativedelta import relativedelta + + supported_indicators = { + "close_50_sma": ("50 SMA", "close"), + "close_200_sma": ("200 SMA", "close"), + "close_10_ema": ("10 EMA", "close"), + "macd": ("MACD", "close"), + "macds": ("MACD Signal", "close"), + "macdh": ("MACD Histogram", "close"), + "rsi": ("RSI", "close"), + "boll": ("Bollinger Middle", "close"), + "boll_ub": ("Bollinger Upper Band", "close"), + "boll_lb": ("Bollinger Lower Band", "close"), + "atr": ("ATR", None), + "vwma": ("VWMA", "close") + } + + indicator_descriptions = { + "close_50_sma": "50 SMA: A medium-term trend indicator. Usage: Identify trend direction and serve as dynamic support/resistance. Tips: It lags price; combine with faster indicators for timely signals.", + "close_200_sma": "200 SMA: A long-term trend benchmark. Usage: Confirm overall market trend and identify golden/death cross setups. Tips: It reacts slowly; best for strategic trend confirmation rather than frequent trading entries.", + "close_10_ema": "10 EMA: A responsive short-term average. Usage: Capture quick shifts in momentum and potential entry points. Tips: Prone to noise in choppy markets; use alongside longer averages for filtering false signals.", + "macd": "MACD: Computes momentum via differences of EMAs. Usage: Look for crossovers and divergence as signals of trend changes. Tips: Confirm with other indicators in low-volatility or sideways markets.", + "macds": "MACD Signal: An EMA smoothing of the MACD line. Usage: Use crossovers with the MACD line to trigger trades. Tips: Should be part of a broader strategy to avoid false positives.", + "macdh": "MACD Histogram: Shows the gap between the MACD line and its signal. Usage: Visualize momentum strength and spot divergence early. Tips: Can be volatile; complement with additional filters in fast-moving markets.", + "rsi": "RSI: Measures momentum to flag overbought/oversold conditions. Usage: Apply 70/30 thresholds and watch for divergence to signal reversals. Tips: In strong trends, RSI may remain extreme; always cross-check with trend analysis.", + "boll": "Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. Usage: Acts as a dynamic benchmark for price movement. Tips: Combine with the upper and lower bands to effectively spot breakouts or reversals.", + "boll_ub": "Bollinger Upper Band: Typically 2 standard deviations above the middle line. Usage: Signals potential overbought conditions and breakout zones. Tips: Confirm signals with other tools; prices may ride the band in strong trends.", + "boll_lb": "Bollinger Lower Band: Typically 2 standard deviations below the middle line. Usage: Indicates potential oversold conditions. Tips: Use additional analysis to avoid false reversal signals.", + "atr": "ATR: Averages true range to measure volatility. Usage: Set stop-loss levels and adjust position sizes based on current market volatility. Tips: It's a reactive measure, so use it as part of a broader risk management strategy.", + "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." + } + + if indicator not in supported_indicators: + raise ValueError( + f"Indicator {indicator} is not supported. Please choose from: {list(supported_indicators.keys())}" + ) + + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + before = curr_date_dt - relativedelta(days=look_back_days) + + # Get the full data for the period instead of making individual calls + _, required_series_type = supported_indicators[indicator] + + # Use the provided series_type or fall back to the required one + if required_series_type: + series_type = required_series_type + + try: + # Get indicator data for the period + if indicator == "close_50_sma": + data = _make_api_request("SMA", { + "symbol": symbol, + "interval": interval, + "time_period": "50", + "series_type": series_type, + "datatype": "csv" + }) + elif indicator == "close_200_sma": + data = _make_api_request("SMA", { + "symbol": symbol, + "interval": interval, + "time_period": "200", + "series_type": series_type, + "datatype": "csv" + }) + elif indicator == "close_10_ema": + data = _make_api_request("EMA", { + "symbol": symbol, + "interval": interval, + "time_period": "10", + "series_type": series_type, + "datatype": "csv" + }) + elif indicator == "macd" or indicator == "macds" or indicator == "macdh": + data = _make_api_request("MACD", { + "symbol": symbol, + "interval": interval, + "series_type": series_type, + "datatype": "csv" + }) + elif indicator == "rsi": + data = _make_api_request("RSI", { + "symbol": symbol, + "interval": interval, + "time_period": str(time_period), + "series_type": series_type, + "datatype": "csv" + }) + elif indicator in ["boll", "boll_ub", "boll_lb"]: + data = _make_api_request("BBANDS", { + "symbol": symbol, + "interval": interval, + "time_period": "20", + "series_type": series_type, + "datatype": "csv" + }) + elif indicator == "atr": + data = _make_api_request("ATR", { + "symbol": symbol, + "interval": interval, + "time_period": str(time_period), + "datatype": "csv" + }) + elif indicator == "vwma": + # Alpha Vantage doesn't have direct VWMA, so we'll return an informative message + # In a real implementation, this would need to be calculated from OHLCV data + return f"## VWMA (Volume Weighted Moving Average) for {symbol}:\n\nVWMA calculation requires OHLCV data and is not directly available from Alpha Vantage API.\nThis indicator would need to be calculated from the raw stock data using volume-weighted price averaging.\n\n{indicator_descriptions.get('vwma', 'No description available.')}" + else: + return f"Error: Indicator {indicator} not implemented yet." + + # Parse CSV data and extract values for the date range + lines = data.strip().split('\n') + if len(lines) < 2: + return f"Error: No data returned for {indicator}" + + # Parse header and data + header = [col.strip() for col in lines[0].split(',')] + try: + date_col_idx = header.index('time') + except ValueError: + return f"Error: 'time' column not found in data for {indicator}. Available columns: {header}" + + # Map internal indicator names to expected CSV column names from Alpha Vantage + col_name_map = { + "macd": "MACD", "macds": "MACD_Signal", "macdh": "MACD_Hist", + "boll": "Real Middle Band", "boll_ub": "Real Upper Band", "boll_lb": "Real Lower Band", + "rsi": "RSI", "atr": "ATR", "close_10_ema": "EMA", + "close_50_sma": "SMA", "close_200_sma": "SMA" + } + + target_col_name = col_name_map.get(indicator) + + if not target_col_name: + # Default to the second column if no specific mapping exists + value_col_idx = 1 + else: + try: + value_col_idx = header.index(target_col_name) + except ValueError: + return f"Error: Column '{target_col_name}' not found for indicator '{indicator}'. Available columns: {header}" + + result_data = [] + for line in lines[1:]: + if not line.strip(): + continue + values = line.split(',') + if len(values) > value_col_idx: + try: + date_str = values[date_col_idx].strip() + # Parse the date + date_dt = datetime.strptime(date_str, "%Y-%m-%d") + + # Check if date is in our range + if before <= date_dt <= curr_date_dt: + value = values[value_col_idx].strip() + result_data.append((date_dt, value)) + except (ValueError, IndexError): + continue + + # Sort by date and format output + result_data.sort(key=lambda x: x[0]) + + ind_string = "" + for date_dt, value in result_data: + ind_string += f"{date_dt.strftime('%Y-%m-%d')}: {value}\n" + + if not ind_string: + ind_string = "No data available for the specified date range.\n" + + result_str = ( + f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" + + ind_string + + "\n\n" + + indicator_descriptions.get(indicator, "No description available.") + ) + + return result_str + + except AlphaVantageNotConfiguredError: + # Vendor unavailable (no API key). Let it propagate so the router can + # fall back / emit the no-data sentinel instead of returning this as a + # successful-looking error string. + raise + except Exception as e: + print(f"Error getting Alpha Vantage indicator data for {indicator}: {e}") + return f"Error retrieving {indicator} data: {str(e)}" diff --git a/tradingagents/dataflows/alpha_vantage_news.py b/tradingagents/dataflows/alpha_vantage_news.py new file mode 100644 index 0000000..f9c7cfc --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage_news.py @@ -0,0 +1,72 @@ +from .alpha_vantage_common import _make_api_request, format_datetime_for_api + + +def get_news(ticker, start_date, end_date) -> dict[str, str] | str: + """Returns live and historical market news & sentiment data from premier news outlets worldwide. + + Covers stocks, cryptocurrencies, forex, and topics like fiscal policy, mergers & acquisitions, IPOs. + + Args: + ticker: Stock symbol for news articles. + start_date: Start date for news search. + end_date: End date for news search. + + Returns: + Dictionary containing news sentiment data or JSON string. + """ + + params = { + "tickers": ticker, + "time_from": format_datetime_for_api(start_date), + "time_to": format_datetime_for_api(end_date), + } + + return _make_api_request("NEWS_SENTIMENT", params) + +def get_global_news(curr_date, look_back_days: int = 7, limit: int = 50) -> dict[str, str] | str: + """Returns global market news & sentiment data without ticker-specific filtering. + + Covers broad market topics like financial markets, economy, and more. + + Args: + curr_date: Current date in yyyy-mm-dd format. + look_back_days: Number of days to look back (default 7). + limit: Maximum number of articles (default 50). + + Returns: + Dictionary containing global news sentiment data or JSON string. + """ + from datetime import datetime, timedelta + + # Calculate start date + curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + start_dt = curr_dt - timedelta(days=look_back_days) + start_date = start_dt.strftime("%Y-%m-%d") + + params = { + "topics": "financial_markets,economy_macro,economy_monetary", + "time_from": format_datetime_for_api(start_date), + "time_to": format_datetime_for_api(curr_date), + "limit": str(limit), + } + + return _make_api_request("NEWS_SENTIMENT", params) + + +def get_insider_transactions(symbol: str) -> dict[str, str] | str: + """Returns latest and historical insider transactions by key stakeholders. + + Covers transactions by founders, executives, board members, etc. + + Args: + symbol: Ticker symbol. Example: "IBM". + + Returns: + Dictionary containing insider transaction data or JSON string. + """ + + params = { + "symbol": symbol, + } + + return _make_api_request("INSIDER_TRANSACTIONS", params) diff --git a/tradingagents/dataflows/alpha_vantage_stock.py b/tradingagents/dataflows/alpha_vantage_stock.py new file mode 100644 index 0000000..43d693b --- /dev/null +++ b/tradingagents/dataflows/alpha_vantage_stock.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from .alpha_vantage_common import _filter_csv_by_date_range, _make_api_request + + +def get_stock( + symbol: str, + start_date: str, + end_date: str +) -> str: + """ + Returns raw daily OHLCV values, adjusted close values, and historical split/dividend events + filtered to the specified date range. + + Args: + symbol: The name of the equity. For example: symbol=IBM + start_date: Start date in yyyy-mm-dd format + end_date: End date in yyyy-mm-dd format + + Returns: + CSV string containing the daily adjusted time series data filtered to the date range. + """ + # Parse dates to determine the range + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + today = datetime.now() + + # Choose outputsize based on whether the requested range is within the latest 100 days + # Compact returns latest 100 data points, so check if start_date is recent enough + days_from_today_to_start = (today - start_dt).days + outputsize = "compact" if days_from_today_to_start < 100 else "full" + + params = { + "symbol": symbol, + "outputsize": outputsize, + "datatype": "csv", + } + + response = _make_api_request("TIME_SERIES_DAILY_ADJUSTED", params) + + return _filter_csv_by_date_range(response, start_date, end_date) diff --git a/tradingagents/dataflows/config.py b/tradingagents/dataflows/config.py new file mode 100644 index 0000000..c4790e8 --- /dev/null +++ b/tradingagents/dataflows/config.py @@ -0,0 +1,41 @@ +from copy import deepcopy + +import tradingagents.default_config as default_config + +# Use default config but allow it to be overridden +_config: dict | None = None + + +def initialize_config(): + """Initialize the configuration with default values.""" + global _config + if _config is None: + _config = deepcopy(default_config.DEFAULT_CONFIG) + + +def set_config(config: dict): + """Update the configuration with custom values. + + Dict-valued keys (e.g. ``data_vendors``) are merged one level deep so a + partial update like ``{"data_vendors": {"core_stock_apis": "alpha_vantage"}}`` + keeps the other nested keys from the default; scalar keys are replaced. + """ + global _config + initialize_config() + incoming = deepcopy(config) + for key, value in incoming.items(): + if isinstance(value, dict) and isinstance(_config.get(key), dict): + _config[key].update(value) + else: + _config[key] = value + + +def get_config() -> dict: + """Get the current configuration.""" + if _config is None: + initialize_config() + return deepcopy(_config) + + +# Initialize with default config +initialize_config() diff --git a/tradingagents/dataflows/errors.py b/tradingagents/dataflows/errors.py new file mode 100644 index 0000000..2ce6a0c --- /dev/null +++ b/tradingagents/dataflows/errors.py @@ -0,0 +1,55 @@ +"""Vendor data-error taxonomy. + +A single hierarchy so the routing layer reacts by *behavior*, not by vendor: +every condition where a vendor cannot return usable data derives from +``VendorError``, and the router catches the base types. A new vendor raises +these (or a thin vendor-named subclass) and needs no new ``except`` clause. + + VendorError + ├── NoMarketDataError no usable rows (empty result OR stale data) + ├── VendorRateLimitError transient throttle -> skip to next vendor + └── VendorNotConfiguredError missing API key/config -> vendor unavailable + +The number of types is the number of distinct router reactions, not the number +of human-describable causes: empty and stale data get identical handling, so +they share ``NoMarketDataError`` and differ only in the free-text ``detail``. +""" + +from __future__ import annotations + + +class VendorError(Exception): + """Base for any condition where a vendor could not return usable data.""" + + +class NoMarketDataError(VendorError): + """A vendor returned no usable rows for a symbol (empty result or stale data). + + Carries both the symbol the user requested and the canonical symbol the + vendor was actually queried with, plus a free-text ``detail``, so callers + can build a clear message instead of emitting a vendor-specific empty + string into the data channel. + """ + + def __init__(self, symbol: str, canonical: str | None = None, detail: str = ""): + self.symbol = symbol + self.canonical = canonical or symbol + self.detail = detail + msg = f"No market data for {symbol!r}" + if canonical and canonical != symbol: + msg += f" (queried as {canonical!r})" + if detail: + msg += f": {detail}" + super().__init__(msg) + + +class VendorRateLimitError(VendorError): + """A vendor throttled the request; the router skips to the next vendor.""" + + +class VendorNotConfiguredError(VendorError, ValueError): + """A vendor was selected but its API key/configuration is missing. + + Also a ``ValueError`` so existing callers that catch ``ValueError`` keep + working while the routing layer can treat it as "vendor unavailable". + """ diff --git a/tradingagents/dataflows/fred.py b/tradingagents/dataflows/fred.py new file mode 100644 index 0000000..eb3856e --- /dev/null +++ b/tradingagents/dataflows/fred.py @@ -0,0 +1,237 @@ +"""FRED (Federal Reserve Economic Data) macro vendor. + +Fetches macroeconomic time series — policy rates, Treasury yields, inflation, +labor, growth — from the St. Louis Fed's free API. Used by the news analyst to +ground macro commentary in actual numbers rather than headlines alone. + +A free API key (https://fred.stlouisfed.org/docs/api/api_key.html) is read from +``FRED_API_KEY``; if it is unset the vendor raises ``FredNotConfiguredError`` so +the routing layer treats it as "unavailable" rather than a hard crash. +""" +import logging +import os +from datetime import datetime, timedelta + +import requests + +from .errors import VendorNotConfiguredError + +logger = logging.getLogger(__name__) + +FRED_API_BASE = "https://api.stlouisfed.org/fred" + +# Network timeout (seconds) so a stalled request can't hang the agents, +# mirroring the Alpha Vantage client. +REQUEST_TIMEOUT = 30 + +# Default trailing window when the caller does not specify one. A year captures +# the trend and the year-over-year base for most monthly/quarterly series. +DEFAULT_LOOKBACK_DAYS = 365 + +# Rows cap for the rendered table: recent values matter most for a decision, and +# daily series (yields, VIX) over a long window would otherwise flood context. +MAX_ROWS = 40 + +# Curated human-friendly aliases -> FRED series IDs. Anything not listed is used +# verbatim as a raw FRED series ID, so power users are never limited to this set. +MACRO_SERIES = { + # Policy rate & Treasury yields + "fed_funds_rate": "FEDFUNDS", + "federal_funds_rate": "FEDFUNDS", + "fed_funds": "FEDFUNDS", + "2y_treasury": "DGS2", + "10y_treasury": "DGS10", + "30y_treasury": "DGS30", + "10y_2y_spread": "T10Y2Y", + "yield_curve": "T10Y2Y", + # Inflation + "cpi": "CPIAUCSL", + "core_cpi": "CPILFESL", + "pce": "PCEPI", + "core_pce": "PCEPILFE", + "inflation_expectations": "T10YIE", + # Growth & output + "real_gdp": "GDPC1", + "gdp": "GDP", + "industrial_production": "INDPRO", + # Labor + "unemployment_rate": "UNRATE", + "unemployment": "UNRATE", + "nonfarm_payrolls": "PAYEMS", + "payrolls": "PAYEMS", + "initial_claims": "ICSA", + # Money & markets + "m2": "M2SL", + "money_supply": "M2SL", + "vix": "VIXCLS", + "dollar_index": "DTWEXBGS", + # Sentiment & housing + "consumer_sentiment": "UMCSENT", + "housing_starts": "HOUST", + "retail_sales": "RSAFS", +} + + +class FredNotConfiguredError(VendorNotConfiguredError): + """Raised when FRED is selected but no API key is configured. + + A VendorNotConfiguredError (and thus still a ValueError), so the routing + layer's "vendor unavailable" handling and existing ValueError callers both + keep working. + """ + + +def get_api_key() -> str: + """Retrieve the FRED API key from the environment.""" + api_key = os.getenv("FRED_API_KEY") + if not api_key: + raise FredNotConfiguredError( + "FRED_API_KEY environment variable is not set. Get a free key at " + "https://fred.stlouisfed.org/docs/api/api_key.html." + ) + return api_key + + +def _resolve_series_id(indicator: str) -> str: + """Map a friendly alias to a FRED series ID, or pass a raw ID through. + + Raises ``ValueError`` when the input is neither a known alias nor a plausible + series ID — typically a descriptive phrase the LLM passed instead (e.g. + "bank of japan rate"). FRED IDs are short and alphanumeric, so this rejects + it up front with guidance rather than letting it 400 the API. + """ + key = indicator.strip().lower().replace(" ", "_").replace("-", "_") + if key in MACRO_SERIES: + return MACRO_SERIES[key] + candidate = indicator.strip().upper() + # FRED series IDs never contain whitespace and are short; reject anything + # else (a descriptive phrase the LLM passed) rather than 400ing the API. + if not candidate or len(candidate) > 30 or any(c.isspace() for c in candidate): + raise ValueError( + f"'{indicator}' is not a known macro alias or a valid FRED series ID. " + f"Use an alias (e.g. 'cpi', 'unemployment', '10y_treasury') or a raw " + f"FRED series ID (e.g. 'CPIAUCSL')." + ) + return candidate + + +def _request(path: str, params: dict) -> dict: + """GET a FRED endpoint, surfacing FRED's JSON error body on a bad request.""" + api_params = {**params, "api_key": get_api_key(), "file_type": "json"} + response = requests.get( + f"{FRED_API_BASE}/{path}", params=api_params, timeout=REQUEST_TIMEOUT + ) + # FRED returns 400 with a JSON {"error_message": ...} for unknown series IDs + # or malformed params; turn that into a clear, actionable error. + if response.status_code == 400: + try: + message = response.json().get("error_message", response.text) + except ValueError: + message = response.text + raise ValueError(f"FRED request failed: {message}") + response.raise_for_status() + return response.json() + + +def get_macro_data( + indicator: str, + curr_date: str, + look_back_days: int | None = None, +) -> str: + """Fetch a FRED macroeconomic series as a formatted markdown report. + + Args: + indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") + or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). + curr_date: End of the window (yyyy-mm-dd); no later observations are + returned, so a past date never leaks future data. + look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS. + + Returns: + A markdown report with the series title, units, frequency, the latest + value, the change over the window, and a recent observation table. + """ + if look_back_days is None: + look_back_days = DEFAULT_LOOKBACK_DAYS + + end_dt = datetime.strptime(curr_date, "%Y-%m-%d") + start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") + + # Invalid LLM-supplied indicator: return guidance rather than raising, so a + # bad argument doesn't abort the run (the routing layer also degrades macro + # data, but a specific message is more useful to the analyst). + try: + series_id = _resolve_series_id(indicator) + except ValueError as e: + return f"FRED: {e}" + + meta = _request("series", {"series_id": series_id}).get("seriess") or [] + if not meta: + return ( + f"FRED series '{series_id}' not found. Pass a known alias " + f"(e.g. 'cpi', 'unemployment') or a valid FRED series ID." + ) + info = meta[0] + title = info.get("title", series_id) + units = info.get("units_short") or info.get("units", "") + frequency = info.get("frequency", "") + seasonal = info.get("seasonal_adjustment_short", "") + + observations = _request( + "series/observations", + { + "series_id": series_id, + "observation_start": start_date, + "observation_end": curr_date, + "sort_order": "asc", + }, + ).get("observations", []) + + # FRED encodes a missing observation as ".". + points = [ + (o["date"], o["value"]) + for o in observations + if o.get("value") not in (".", None, "") + ] + + header = ( + f"## FRED: {title} ({series_id})\n" + f"- Units: {units}\n" + f"- Frequency: {frequency}" + f"{f' ({seasonal})' if seasonal else ''}\n" + f"- Window: {start_date} to {curr_date}\n" + ) + + if not points: + return header + ( + f"\nNo observations for {series_id} in this window. The series may " + f"report less frequently than the window length; widen look_back_days." + ) + + first_date, first_val = points[0] + last_date, last_val = points[-1] + try: + delta = float(last_val) - float(first_val) + base = float(first_val) + pct = f" ({delta / base * 100:+.2f}%)" if base != 0 else "" + summary = ( + f"\n**Latest:** {last_val} ({last_date}) | " + f"**Change over window:** {delta:+.2f}{pct} " + f"from {first_val} ({first_date})\n" + ) + except ValueError: + summary = f"\n**Latest:** {last_val} ({last_date})\n" + + shown = points + note = "" + if len(points) > MAX_ROWS: + shown = points[-MAX_ROWS:] + note = f"\n_(showing the most recent {MAX_ROWS} of {len(points)} observations)_\n" + + table = ( + "\n| Date | Value |\n| --- | --- |\n" + + "\n".join(f"| {d} | {v} |" for d, v in shown) + + "\n" + ) + + return header + summary + note + table diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py new file mode 100644 index 0000000..281c837 --- /dev/null +++ b/tradingagents/dataflows/interface.py @@ -0,0 +1,262 @@ +import logging + +from .alpha_vantage import ( + get_balance_sheet as get_alpha_vantage_balance_sheet, + get_cashflow as get_alpha_vantage_cashflow, + get_fundamentals as get_alpha_vantage_fundamentals, + get_global_news as get_alpha_vantage_global_news, + get_income_statement as get_alpha_vantage_income_statement, + get_indicator as get_alpha_vantage_indicator, + get_insider_transactions as get_alpha_vantage_insider_transactions, + get_news as get_alpha_vantage_news, + get_stock as get_alpha_vantage_stock, +) +from .config import get_config +from .errors import ( + NoMarketDataError, + VendorNotConfiguredError, + VendorRateLimitError, +) +from .fred import get_macro_data as get_fred_macro_data +from .polymarket import get_prediction_markets as get_polymarket_prediction_markets +from .y_finance import ( + get_balance_sheet as get_yfinance_balance_sheet, + get_cashflow as get_yfinance_cashflow, + get_fundamentals as get_yfinance_fundamentals, + get_income_statement as get_yfinance_income_statement, + get_insider_transactions as get_yfinance_insider_transactions, + get_stock_stats_indicators_window, + get_YFin_data_online, +) +from .yfinance_news import get_global_news_yfinance, get_news_yfinance + +logger = logging.getLogger(__name__) + +# Tools organized by category +TOOLS_CATEGORIES = { + "core_stock_apis": { + "description": "OHLCV stock price data", + "tools": [ + "get_stock_data" + ] + }, + "technical_indicators": { + "description": "Technical analysis indicators", + "tools": [ + "get_indicators" + ] + }, + "fundamental_data": { + "description": "Company fundamentals", + "tools": [ + "get_fundamentals", + "get_balance_sheet", + "get_cashflow", + "get_income_statement" + ] + }, + "news_data": { + "description": "News and insider data", + "tools": [ + "get_news", + "get_global_news", + "get_insider_transactions", + ] + }, + "macro_data": { + "description": "Macroeconomic indicators (rates, inflation, labor, growth)", + "tools": [ + "get_macro_indicators", + ] + }, + "prediction_markets": { + "description": "Market-implied probabilities for forward-looking events", + "tools": [ + "get_prediction_markets", + ] + } +} + +VENDOR_LIST = [ + "yfinance", + "fred", + "polymarket", + "alpha_vantage", +] + +# Optional enrichment categories. These add macro/event context to the news +# analyst but are not core to a decision, so a vendor failure here degrades to a +# sentinel instead of aborting the run (a bad LLM-supplied indicator, a missing +# key, or a network blip should not crash an analysis over flavour data). Core +# categories (prices, fundamentals, news) still raise so a broken primary is loud. +OPTIONAL_CATEGORIES = {"macro_data", "prediction_markets"} + +# Mapping of methods to their vendor-specific implementations +VENDOR_METHODS = { + # core_stock_apis + "get_stock_data": { + "alpha_vantage": get_alpha_vantage_stock, + "yfinance": get_YFin_data_online, + }, + # technical_indicators + "get_indicators": { + "alpha_vantage": get_alpha_vantage_indicator, + "yfinance": get_stock_stats_indicators_window, + }, + # fundamental_data + "get_fundamentals": { + "alpha_vantage": get_alpha_vantage_fundamentals, + "yfinance": get_yfinance_fundamentals, + }, + "get_balance_sheet": { + "alpha_vantage": get_alpha_vantage_balance_sheet, + "yfinance": get_yfinance_balance_sheet, + }, + "get_cashflow": { + "alpha_vantage": get_alpha_vantage_cashflow, + "yfinance": get_yfinance_cashflow, + }, + "get_income_statement": { + "alpha_vantage": get_alpha_vantage_income_statement, + "yfinance": get_yfinance_income_statement, + }, + # news_data + "get_news": { + "alpha_vantage": get_alpha_vantage_news, + "yfinance": get_news_yfinance, + }, + "get_global_news": { + "yfinance": get_global_news_yfinance, + "alpha_vantage": get_alpha_vantage_global_news, + }, + "get_insider_transactions": { + "alpha_vantage": get_alpha_vantage_insider_transactions, + "yfinance": get_yfinance_insider_transactions, + }, + # macro_data + "get_macro_indicators": { + "fred": get_fred_macro_data, + }, + # prediction_markets + "get_prediction_markets": { + "polymarket": get_polymarket_prediction_markets, + }, +} + +def get_category_for_method(method: str) -> str: + """Get the category that contains the specified method.""" + for category, info in TOOLS_CATEGORIES.items(): + if method in info["tools"]: + return category + raise ValueError(f"Method '{method}' not found in any category") + +def get_vendor(category: str, method: str = None) -> str: + """Get the configured vendor for a data category or specific tool method. + Tool-level configuration takes precedence over category-level. + """ + config = get_config() + + # Check tool-level configuration first (if method provided) + if method: + tool_vendors = config.get("tool_vendors", {}) + if method in tool_vendors: + return tool_vendors[method] + + # Fall back to category-level configuration + return config.get("data_vendors", {}).get(category, "default") + +def route_to_vendor(method: str, *args, **kwargs): + """Route method calls to appropriate vendor implementation with fallback support.""" + category = get_category_for_method(method) + vendor_config = get_vendor(category, method) + primary_vendors = [v.strip() for v in vendor_config.split(',')] + + if method not in VENDOR_METHODS: + raise ValueError(f"Method '{method}' not supported") + + all_available_vendors = list(VENDOR_METHODS[method].keys()) + + # The configured vendor list IS the chain: we do NOT silently fall back to + # vendors the user did not choose (#988/#289) — that returned data from an + # unexpected source and caused cross-vendor inconsistencies. For multi-vendor + # fallback, list them in order, e.g. data_vendors="yfinance,alpha_vantage". + # The "default" sentinel (no explicit config) uses all available vendors. + explicit = [v for v in primary_vendors if v and v != "default"] + if explicit: + vendor_chain = [v for v in explicit if v in VENDOR_METHODS[method]] + if not vendor_chain: + raise ValueError( + f"Configured vendor(s) {explicit} not available for '{method}'. " + f"Available: {all_available_vendors}." + ) + else: + vendor_chain = all_available_vendors + + last_no_data: NoMarketDataError | None = None + first_error: Exception | None = None + for vendor in vendor_chain: + vendor_impl = VENDOR_METHODS[method][vendor] + impl_func = vendor_impl[0] if isinstance(vendor_impl, list) else vendor_impl + + try: + return impl_func(*args, **kwargs) + except VendorRateLimitError: + logger.warning("Vendor %r rate-limited for %s; trying next vendor.", vendor, method) + continue + except VendorNotConfiguredError as e: + logger.warning("Vendor %r not configured for %s; trying next vendor.", vendor, method) + if first_error is None: + first_error = e # Surface it if no other vendor can serve the call. + continue + except NoMarketDataError as e: + last_no_data = e # No data here; another configured vendor may have it + continue + except Exception as e: + # Don't let one vendor's failure crash the call when another can + # serve it, but never swallow silently: a broken primary must be + # visible in the logs (#989), not hidden behind a fallback's verdict. + logger.warning("Vendor %r failed for %s: %s", vendor, method, e) + if first_error is None: + first_error = e + continue + + # If any vendor reported "no data", the symbol is genuinely unavailable. + # Return one explicit, instructive sentinel rather than a vendor-specific + # empty string, so the agent reports "unavailable" instead of inventing a + # value. This takes precedence over incidental fallback errors. + if last_no_data is not None: + if first_error is not None: + # A vendor also hit a real error; surface it in logs so the no-data + # verdict can't hide a broken primary (network/auth/etc.). + logger.warning( + "Returning NO_DATA for %s, but a vendor errored earlier: %s", + method, first_error, + ) + sym = last_no_data.symbol + canonical = last_no_data.canonical + resolved = "" if canonical == sym else f" (resolved to '{canonical}')" + # Surface the typed error's detail (e.g. "latest row is 2025-06-11 ... + # stale") so the agent sees the specific reason — invalid symbol, no + # coverage, or stale data — not just a generic "unavailable". + reason = f" ({last_no_data.detail})" if last_no_data.detail else "" + return ( + f"NO_DATA_AVAILABLE: No usable market data for '{sym}'{resolved} from " + f"any configured vendor{reason}. The symbol may be invalid, delisted, " + f"not covered, or the vendor returned stale data. Do not estimate or " + f"fabricate values — report that data is unavailable for this symbol." + ) + + # No vendor returned data and none reported clean "no data" — surface the + # first real error (e.g. the primary vendor's network failure). Optional + # enrichment categories degrade to a sentinel instead, so flavour data can't + # abort the run. + if first_error is not None: + if category in OPTIONAL_CATEGORIES: + logger.warning("Optional %s unavailable for %s: %s", category, method, first_error) + return ( + f"DATA_UNAVAILABLE: optional {category} could not be retrieved " + f"({first_error}). Proceed without it; do not fabricate values." + ) + raise first_error + + raise RuntimeError(f"No available vendor for '{method}'") diff --git a/tradingagents/dataflows/market_data_validator.py b/tradingagents/dataflows/market_data_validator.py new file mode 100644 index 0000000..baa5efe --- /dev/null +++ b/tradingagents/dataflows/market_data_validator.py @@ -0,0 +1,123 @@ +"""Deterministic market-data verification snapshot. + +The market analyst is an LLM that can confabulate exact numbers — citing a +Bollinger band or a "historically validated bounce" that the underlying data +doesn't support (#830). This module computes a ground-truth snapshot (latest +OHLCV row on or before the analysis date, common indicators, recent closes) +the analyst is told to treat as the source of truth for any exact numeric +claim. Deterministic, no LLM involved. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import pandas as pd +from stockstats import wrap + +from tradingagents.dataflows.stockstats_utils import load_ohlcv + +# A fixed, common indicator set so the snapshot is the same shape every run. +DEFAULT_SNAPSHOT_INDICATORS: tuple[str, ...] = ( + "close_10_ema", "close_50_sma", "close_200_sma", + "rsi", "boll", "boll_ub", "boll_lb", + "macd", "macds", "macdh", "atr", +) + + +def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: + """OHLCV on or before curr_date, date-sorted. Raises if nothing usable. + + ``load_ohlcv`` already normalizes the Date column and filters out + look-ahead rows, but we re-apply the cutoff defensively — this is a + verification path, so it must not trust its input to be pre-filtered. + """ + data = load_ohlcv(symbol, curr_date) + if data is None or data.empty: + raise ValueError(f"No OHLCV data available for {symbol}.") + + df = data.copy() + df["Date"] = pd.to_datetime(df["Date"], errors="coerce") + df = df.dropna(subset=["Date"]) + df = df[df["Date"] <= pd.to_datetime(curr_date)].sort_values("Date") + if df.empty: + raise ValueError(f"No OHLCV rows on or before {curr_date} for {symbol}.") + return df + + +def _fmt(value) -> str: + if value is None or pd.isna(value): + return "N/A" + if isinstance(value, pd.Timestamp): + return value.strftime("%Y-%m-%d") + if isinstance(value, bool): + return str(value) + if isinstance(value, (int,)): + return str(value) + if isinstance(value, float): + return f"{value:.2f}" + return str(value) + + +def build_verified_market_snapshot( + symbol: str, + curr_date: str, + look_back_days: int = 30, + indicators: Iterable[str] | None = None, +) -> str: + """Render a ground-truth snapshot: latest OHLCV row, indicators, recent closes.""" + # `df` keeps the original capitalized OHLCV columns (Open/High/Low/Close/ + # Volume); stockstats `wrap()` lowercases columns and adds indicator + # columns, so read raw prices from `df` and indicators from `stock_df`. + df = _verified_rows(symbol, curr_date) + stock_df = wrap(df.copy()) + + selected = tuple(indicators or DEFAULT_SNAPSHOT_INDICATORS) + indicator_values: dict[str, str] = {} + for name in selected: + try: + stock_df[name] # triggers stockstats calculation + indicator_values[name] = _fmt(stock_df.iloc[-1][name]) + except Exception as exc: # noqa: BLE001 — one bad indicator shouldn't sink the snapshot + indicator_values[name] = f"N/A ({type(exc).__name__})" + + latest = df.iloc[-1] + latest_date = _fmt(latest["Date"]) + window = max(1, min(int(look_back_days), 30)) + recent = df.tail(window) + + lines = [ + f"## Verified market data snapshot for {symbol.upper()}", + "", + f"- Requested analysis date: {curr_date}", + f"- Latest trading row used: {latest_date}", + "- Rows after the requested analysis date are excluded before verification.", + "", + "### Latest verified OHLCV row", + "", + "| Field | Value |", + "|---|---:|", + ] + for field in ("Open", "High", "Low", "Close", "Volume"): + lines.append(f"| {field} | {_fmt(latest.get(field))} |") + + lines += ["", "### Verified technical indicators (latest row)", "", + "| Indicator | Value |", "|---|---:|"] + for name, value in indicator_values.items(): + lines.append(f"| {name} | {value} |") + + lines += ["", f"### Recent verified closes (last {len(recent)} rows)", "", + "| Date | Close |", "|---|---:|"] + for _, row in recent.iterrows(): + lines.append(f"| {_fmt(row['Date'])} | {_fmt(row.get('Close'))} |") + + lines += [ + "", + "Use this snapshot as the source of truth for exact OHLCV, price-level, " + "and indicator-value claims. If another tool output conflicts with it, " + "flag the discrepancy rather than inventing a reconciled number. Do not " + "claim historical validation, support/resistance bounces, or exact " + "percentage moves unless directly supported by tool output with concrete " + "dates and prices.", + ] + return "\n".join(lines) diff --git a/tradingagents/dataflows/polymarket.py b/tradingagents/dataflows/polymarket.py new file mode 100644 index 0000000..b76dfe7 --- /dev/null +++ b/tradingagents/dataflows/polymarket.py @@ -0,0 +1,139 @@ +"""Polymarket prediction-market vendor. + +Surfaces live, market-implied probabilities for forward-looking events (Fed +decisions, recession, elections, geopolitics, crypto) to the news analyst, as a +complement to news (what happened) and FRED macro data (where things stand): +what the crowd actually prices to happen next. + +Uses Polymarket's public Gamma API (https://gamma-api.polymarket.com) — no key, +no auth. Each market's ``outcomePrices`` are the implied probabilities of its +outcomes (a "Yes" at 0.76 means the market prices a 76% chance). +""" +import json +import logging +from datetime import datetime, timezone + +import requests + +logger = logging.getLogger(__name__) + +GAMMA_BASE = "https://gamma-api.polymarket.com" + +# Network timeout (seconds), consistent with the other vendors. +REQUEST_TIMEOUT = 30 + +# Default number of markets to return, ranked by traded volume. +DEFAULT_LIMIT = 6 + + +def _request(path: str, params: dict) -> dict: + response = requests.get( + f"{GAMMA_BASE}/{path}", params=params, timeout=REQUEST_TIMEOUT + ) + response.raise_for_status() + return response.json() + + +def _parse_json_list(value) -> list: + """Gamma encodes ``outcomes``/``outcomePrices`` as JSON-string arrays.""" + if isinstance(value, list): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return [] + + +def _is_forward_looking(market: dict, now: datetime) -> bool: + """Keep only open markets that resolve in the future. + + ``closed`` is the reliable resolved flag (``active`` stays True even for + settled markets), and a past ``endDate`` means the event already resolved — + either way it is not a forward-looking signal. + """ + if market.get("closed"): + return False + end_date = market.get("endDate") + if end_date: + try: + if datetime.fromisoformat(end_date.replace("Z", "+00:00")) < now: + return False + except ValueError: + pass + return bool(_parse_json_list(market.get("outcomePrices"))) and bool( + _parse_json_list(market.get("outcomes")) + ) + + +def get_prediction_markets(topic: str, limit: int | None = None) -> str: + """Return live prediction-market probabilities for an event topic. + + Args: + topic: Event keyword(s), e.g. "Fed rate cut", "recession 2026", + "US election", or a sector/company event. + limit: Max markets to return (ranked by traded volume); ``None`` uses + DEFAULT_LIMIT. + + Returns: + A markdown report of the most-traded open markets matching the topic, + each with its implied probability, traded volume, resolution date, and + recent (1-week) move. + """ + if limit is None: + limit = DEFAULT_LIMIT + + try: + data = _request("public-search", {"q": topic, "limit_per_type": 20}) + except requests.RequestException as e: + logger.warning("Polymarket search failed for %r: %s", topic, e) + return ( + f"Polymarket data is currently unavailable (network error: {e}). " + f"Proceed without prediction-market signal for '{topic}'." + ) + + now = datetime.now(timezone.utc) + candidates = [ + m + for event in data.get("events", []) + for m in event.get("markets", []) + if _is_forward_looking(m, now) + ] + candidates.sort(key=lambda m: m.get("volumeNum") or 0, reverse=True) + + header = ( + f'## Polymarket prediction markets: "{topic}"\n' + f"Live, market-implied probabilities (higher traded volume = deeper, " + f"more reliable). A probability is the crowd's priced odds of the event, " + f"not a forecast you should take as certain.\n\n" + ) + + if not candidates: + return header + ( + f"No open prediction markets matched '{topic}'. Polymarket coverage " + f"is concentrated in macro, political, geopolitical, and crypto " + f"events; a specific equity may have none." + ) + + lines = [] + for m in candidates[:limit]: + prices = _parse_json_list(m.get("outcomePrices")) + outcomes = _parse_json_list(m.get("outcomes")) + try: + prob = float(prices[0]) + except (ValueError, IndexError): + continue + label = outcomes[0] if outcomes else "Yes" + volume = m.get("volumeNum") or 0 + end_date = (m.get("endDate") or "")[:10] + wk = m.get("oneWeekPriceChange") + wk_str = ( + f", 1-week {wk * 100:+.1f}pp" + if isinstance(wk, (int, float)) and wk + else "" + ) + lines.append( + f"- **{m.get('question')}** — {label} {prob:.0%} " + f"(${volume:,.0f} volume, resolves {end_date}{wk_str})" + ) + + return header + "\n".join(lines) + "\n" diff --git a/tradingagents/dataflows/reddit.py b/tradingagents/dataflows/reddit.py new file mode 100644 index 0000000..edb4aed --- /dev/null +++ b/tradingagents/dataflows/reddit.py @@ -0,0 +1,250 @@ +"""Reddit search fetcher for ticker-specific discussion posts. + +Default path is Reddit's public Atom/RSS search feed +(``reddit.com/r/{sub}/search.rss``). The richer JSON search endpoint +(``/search.json``) is reliably WAF-blocked (``HTTP 403``) for public clients +(issue #862), and probing it on every call only doubled our request volume +against Reddit's per-IP rate limit — tripping ``429`` on the RSS fallback — so +it is kept (``_fetch_subreddit_json``) but not used by default. On a 429 we back +off once (honouring ``Retry-After``). RSS lacks score / comment counts, so those +posts are marked and the formatter omits the metrics rather than printing fake +zeros. + +No API key required. Returns formatted plaintext blocks ready for prompt +injection and degrades gracefully — returns a placeholder string rather than +raising, so callers never special-case missing data. +""" + +from __future__ import annotations + +import html +import http.client +import json +import logging +import re +import time +import xml.etree.ElementTree as ET +from collections.abc import Iterable +from datetime import datetime +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from .symbol_utils import crypto_base + +logger = logging.getLogger(__name__) + +_API = "https://www.reddit.com/r/{sub}/search.json?{qs}" +_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}" +# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit +# blocks generic/anonymous tokens like bare "Mozilla/5.0" or "curl/…" but +# serves this one on both endpoints; the RSS feed accepts it even when the +# JSON search endpoint 403s, so no browser-spoofing is needed. +_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" +_ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"} + +# Default subreddits ordered roughly by signal density for ticker-specific +# discussion. wallstreetbets has the most volume but most noise; stocks / +# investing trend more measured. Caller can override. +DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing") + + +def _search_qs(ticker: str, limit: int) -> str: + return urlencode({ + "q": ticker, + "restrict_sr": "on", + "sort": "new", + "t": "week", # last 7 days + "limit": limit, + }) + + +def _iso_to_timestamp(iso_str: str | None) -> float | None: + """Parse an Atom ``published`` timestamp to a UTC epoch, or None.""" + if not iso_str: + return None + try: + normalized = iso_str[:-1] + "+00:00" if iso_str.endswith("Z") else iso_str + return datetime.fromisoformat(normalized).timestamp() + except (ValueError, TypeError): + return None + + +def _strip_html(content: str) -> str: + """Reduce the HTML body Reddit embeds in an Atom entry to plain text.""" + if not content: + return "" + # Reddit wraps the real selftext between SC_OFF / SC_ON markers. + if "" in content and "" in content: + content = content.split("")[1].split("")[0] + text = re.sub(r"<[^>]+>", " ", content) + return " ".join(html.unescape(text).split()) + + +def _retry_after_seconds(exc: HTTPError) -> float | None: + """Seconds to wait from a 429's ``Retry-After`` header, capped at 30s.""" + try: + val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None + return min(float(val), 30.0) if val else None + except (ValueError, TypeError, AttributeError): + return None + + +def _fetch_subreddit_rss( + ticker: str, + sub: str, + limit: int, + timeout: float, + _retry: bool = True, +) -> list[dict]: + """Default path: parse the public Atom search feed for a subreddit. + + Carries no score / comment counts, so those fields are left None and the + post is tagged ``source="rss"`` for honest display. On a 429 (Reddit's + per-IP rate limit) we back off once — honouring ``Retry-After`` when + present — before giving up, so a transient burst doesn't blank the feed. + """ + url = _RSS.format(sub=sub, qs=_search_qs(ticker, limit)) + req = Request(url, headers={"User-Agent": _UA}) + try: + with urlopen(req, timeout=timeout) as resp: + root = ET.fromstring(resp.read()) + except HTTPError as exc: + if exc.code == 429 and _retry: + wait = _retry_after_seconds(exc) or 5.0 + logger.warning( + "Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once", + sub, ticker, wait, + ) + time.sleep(wait) + return _fetch_subreddit_rss(ticker, sub, limit, timeout, _retry=False) + logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc) + return [] + except (OSError, http.client.HTTPException, ET.ParseError) as exc: + # OSError covers URLError/TimeoutError/connection resets; HTTPException + # covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024). + logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc) + return [] + + posts = [] + for entry in root.findall("atom:entry", _ATOM_NS)[:limit]: + title_el = entry.find("atom:title", _ATOM_NS) + published_el = entry.find("atom:published", _ATOM_NS) + content_el = entry.find("atom:content", _ATOM_NS) + posts.append({ + "title": (title_el.text if title_el is not None else "") or "", + "score": None, + "num_comments": None, + "created_utc": _iso_to_timestamp( + published_el.text if published_el is not None else None + ), + "selftext": _strip_html(content_el.text if content_el is not None else ""), + "source": "rss", + }) + return posts + + +def _fetch_subreddit_json( + ticker: str, + sub: str, + limit: int, + timeout: float, +) -> list[dict]: + """Richer JSON search path (carries score / comment counts). + + Reddit's WAF currently returns ``403 Blocked`` on this endpoint for + non-OAuth clients (issue #862), so it is NOT used by default — calling it on + every request only doubled our volume against the per-IP rate limit and + triggered 429s on the RSS fallback. Kept for the day the WAF relaxes or an + OAuth token is wired in; degrades to RSS on failure. + """ + url = _API.format(sub=sub, qs=_search_qs(ticker, limit)) + req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) + try: + with urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read()) + children = (payload.get("data") or {}).get("children") or [] + return [c.get("data", {}) for c in children if isinstance(c, dict)] + except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc: + logger.warning( + "Reddit JSON fetch failed for r/%s · %s: %s — falling back to RSS feed.", + sub, ticker, exc, + ) + return _fetch_subreddit_rss(ticker, sub, limit, timeout) + + +def _fetch_subreddit( + ticker: str, + sub: str, + limit: int, + timeout: float, +) -> list[dict]: + """Fetch one subreddit, RSS-first. + + The JSON search endpoint is reliably WAF-blocked (403) for public clients, + so we go straight to the RSS feed — which serves our identified User-Agent + reliably — halving our request volume against Reddit's per-IP rate limit. + """ + return _fetch_subreddit_rss(ticker, sub, limit, timeout) + + +def fetch_reddit_posts( + ticker: str, + subreddits: Iterable[str] = DEFAULT_SUBREDDITS, + limit_per_sub: int = 5, + timeout: float = 10.0, + inter_request_delay: float = 1.0, +) -> str: + """Fetch recent Reddit posts mentioning ``ticker`` across finance + subreddits and return them as a formatted plaintext block. + + ``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to + stay under Reddit's public per-IP rate limit; combined with the RSS-first + path it makes 429s rare even when several analyses run back-to-back. + """ + # Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base + # ("BTC") so the query actually matches discussion instead of near-nothing. + ticker = crypto_base(ticker) or ticker + blocks = [] + total_posts = 0 + for i, sub in enumerate(subreddits): + if i > 0: + time.sleep(inter_request_delay) + posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout) + total_posts += len(posts) + if not posts: + blocks.append(f"r/{sub}: ") + continue + + via_rss = any(p.get("source") == "rss" for p in posts) + header = f"r/{sub} — {len(posts)} recent posts mentioning {ticker.upper()}" + header += " (via RSS feed; scores/comments unavailable):" if via_rss else ":" + lines = [header] + for p in posts: + title = (p.get("title") or "").replace("\n", " ").strip() + score = p.get("score") + comments = p.get("num_comments") + created = p.get("created_utc") + created_str = ( + time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?" + ) + # Score / comment counts are absent on the RSS fallback path — + # show them only when present rather than printing fake zeros. + meta = created_str + if score is not None and comments is not None: + meta += f" · {score:>4}↑ · {comments:>3}c" + selftext = (p.get("selftext") or "").replace("\n", " ").strip() + if len(selftext) > 240: + selftext = selftext[:240] + "…" + lines.append( + f" [{meta}] {title}" + + (f"\n body excerpt: {selftext}" if selftext else "") + ) + blocks.append("\n".join(lines)) + + if total_posts == 0: + return ( + f"" + ) + return "\n\n".join(blocks) diff --git a/tradingagents/dataflows/stockstats_utils.py b/tradingagents/dataflows/stockstats_utils.py new file mode 100644 index 0000000..db880b0 --- /dev/null +++ b/tradingagents/dataflows/stockstats_utils.py @@ -0,0 +1,232 @@ +import logging +import os +import time +from typing import Annotated + +import pandas as pd +import yfinance as yf +from stockstats import wrap +from yfinance.exceptions import YFRateLimitError + +from .config import get_config +from .symbol_utils import NoMarketDataError, normalize_symbol +from .utils import safe_ticker_component + +logger = logging.getLogger(__name__) + +# A vendor's latest OHLCV row this many calendar days before the requested date +# is treated as stale. Generous enough to span long holiday weekends, tight +# enough to catch the year-old frames yfinance occasionally returns (#1021). +MAX_OHLCV_STALE_DAYS = 10 + + +def yf_retry(func, max_retries=3, base_delay=2.0): + """Execute a yfinance call with exponential backoff on rate limits. + + yfinance raises YFRateLimitError on HTTP 429 responses but does not + retry them internally. This wrapper adds retry logic specifically + for rate limits. Other exceptions propagate immediately. + """ + for attempt in range(max_retries + 1): + try: + return func() + except YFRateLimitError: + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") + time.sleep(delay) + else: + raise + + +def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: + """Normalize the date column to ``Date``. + + Some yfinance builds leave the index unnamed (so ``reset_index()`` yields + ``index``) or use ``Datetime`` for intraday data. Rename the first + date-like column so indicators don't silently drop when it isn't ``Date``. + """ + if "Date" in data.columns: + return data + for candidate in ("index", "Datetime", "date"): + if candidate in data.columns: + return data.rename(columns={candidate: "Date"}) + return data + + +def _clean_dataframe(data: pd.DataFrame) -> pd.DataFrame: + """Normalize a stock DataFrame for stockstats: parse dates, drop invalid rows, fill price gaps.""" + data = _ensure_date_column(data) + data["Date"] = pd.to_datetime(data["Date"], errors="coerce") + data = data.dropna(subset=["Date"]) + + price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns] + data[price_cols] = data[price_cols].apply(pd.to_numeric, errors="coerce") + data = data.dropna(subset=["Close"]) + data[price_cols] = data[price_cols].ffill().bfill() + + return data + + +def _coerce_ohlcv_dates(data: pd.DataFrame) -> pd.Series: + """Return parsed dates from an OHLCV frame, whether Date is a column or the index.""" + if "Date" in data.columns: + return pd.to_datetime(data["Date"], errors="coerce").dropna() + # yfinance keeps the dates in the index (a DatetimeIndex, sometimes unnamed). + if isinstance(data.index, pd.DatetimeIndex): + return pd.Series(pd.to_datetime(data.index, errors="coerce")).dropna() + # Fallback: expose the index and look for any date-like column. + df = data.reset_index() + for col in ("Date", "Datetime", "date", "index"): + if col in df.columns: + parsed = pd.to_datetime(df[col], errors="coerce").dropna() + if not parsed.empty: + return parsed + return pd.Series(dtype="datetime64[ns]") + + +def _assert_ohlcv_not_stale( + data: pd.DataFrame, + curr_date: str, + symbol: str, + canonical: str | None = None, + *, + max_stale_days: int = MAX_OHLCV_STALE_DAYS, +) -> None: + """Reject OHLCV whose latest row is far older than curr_date. + + Raises NoMarketDataError (with a stale-specific detail) so the router treats + it like any other "no usable data from this vendor" — try the next vendor, + then emit one clear unavailable signal. Empty frames are left to the + caller's existing no-data handling; this guards only the dangerous case of + present-but-stale rows (a vendor returning a year-old frame that would + otherwise feed wrong prices to the agent, #1021). + """ + if data is None or data.empty: + return + requested = pd.to_datetime(curr_date, errors="coerce") + if pd.isna(requested): + return + requested = requested.normalize() + dates = _coerce_ohlcv_dates(data) + if dates.empty: + return + latest = dates.max().normalize() + stale_days = (requested - latest).days + if stale_days > max_stale_days: + raise NoMarketDataError( + symbol, + canonical, + f"latest row is {latest.date()}, {stale_days} days before the " + f"requested {requested.date()} (stale) — refusing to use it", + ) + + +def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame: + """Fetch OHLCV data with caching, filtered to prevent look-ahead bias. + + Downloads 5 years of data up to today and caches per symbol. On + subsequent calls the cache is reused. Rows after curr_date are + filtered out so backtests never see future prices. + """ + # Resolve broker/forex symbols (XAUUSD+ -> GC=F) to Yahoo's convention, + # then reject values that would escape the cache directory when + # interpolated into the cache filename (e.g. ``../../tmp/x``). + canonical = normalize_symbol(symbol) + safe_symbol = safe_ticker_component(canonical) + + config = get_config() + curr_date_dt = pd.to_datetime(curr_date) + + # Cache uses a fixed window (5y to today) so one file per symbol. + today_date = pd.Timestamp.today() + start_date = today_date - pd.DateOffset(years=5) + start_str = start_date.strftime("%Y-%m-%d") + # yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included + # when curr_date is the current day (#986). Look-ahead is still prevented by + # the curr_date filter below. + end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d") + + os.makedirs(config["data_cache_dir"], exist_ok=True) + data_file = os.path.join( + config["data_cache_dir"], + f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv", + ) + + # A cached file may be empty if a prior fetch failed (unknown symbol, + # transient rate limit). Treat an empty/columnless cache as a miss and + # re-fetch rather than serving the poisoned file forever. + data = None + if os.path.exists(data_file): + cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8") + if not cached.empty and "Close" in cached.columns: + data = cached + + if data is None: + downloaded = yf_retry(lambda: yf.download( + canonical, + start=start_str, + end=end_str, + multi_level_index=False, + progress=False, + auto_adjust=True, + )) + downloaded = _ensure_date_column(downloaded.reset_index()) + # Only cache real data — never persist an empty frame. + if downloaded.empty or "Close" not in downloaded.columns: + raise NoMarketDataError( + symbol, canonical, "Yahoo Finance returned no rows" + ) + downloaded.to_csv(data_file, index=False, encoding="utf-8") + data = downloaded + + data = _clean_dataframe(data) + + # Filter to curr_date to prevent look-ahead bias in backtesting + data = data[data["Date"] <= curr_date_dt] + + # Reject a stale frame (latest row far older than curr_date) rather than + # feeding year-old prices into indicators (#1021). + _assert_ohlcv_not_stale(data, curr_date, symbol, canonical) + + return data + + +def filter_financials_by_date(data: pd.DataFrame, curr_date: str) -> pd.DataFrame: + """Drop financial statement columns (fiscal period timestamps) after curr_date. + + yfinance financial statements use fiscal period end dates as columns. + Columns after curr_date represent future data and are removed to + prevent look-ahead bias. + """ + if not curr_date or data.empty: + return data + cutoff = pd.Timestamp(curr_date) + mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff + return data.loc[:, mask] + + +class StockstatsUtils: + @staticmethod + def get_stock_stats( + symbol: Annotated[str, "ticker symbol for the company"], + indicator: Annotated[ + str, "quantitative indicators based off of the stock data for the company" + ], + curr_date: Annotated[ + str, "curr date for retrieving stock price data, YYYY-mm-dd" + ], + ): + data = load_ohlcv(symbol, curr_date) + df = wrap(data) + df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") + curr_date_str = pd.to_datetime(curr_date).strftime("%Y-%m-%d") + + df[indicator] # trigger stockstats to calculate the indicator + matching_rows = df[df["Date"].str.startswith(curr_date_str)] + + if not matching_rows.empty: + indicator_value = matching_rows[indicator].values[0] + return indicator_value + else: + return "N/A: Not a trading day (weekend or holiday)" diff --git a/tradingagents/dataflows/stocktwits.py b/tradingagents/dataflows/stocktwits.py new file mode 100644 index 0000000..1c2fd02 --- /dev/null +++ b/tradingagents/dataflows/stocktwits.py @@ -0,0 +1,96 @@ +"""StockTwits public symbol-stream fetcher. + +StockTwits exposes a per-symbol message stream at +``api.stocktwits.com/api/2/streams/symbol/{ticker}.json`` that requires no +API key, no OAuth, and no registration. Each message includes a +user-labeled sentiment field (``Bullish``/``Bearish``/null), the message +body, timestamp, and posting user. + +The function is deliberately self-contained: short timeout, graceful +degradation on any HTTP or parse failure, and a string return type so +the calling agent gets a uniform interface regardless of whether the +network call succeeded. +""" + +from __future__ import annotations + +import http.client +import json +import logging +from urllib.request import Request, urlopen + +from .symbol_utils import crypto_base + +logger = logging.getLogger(__name__) + +_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json" +_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" + + +def _stocktwits_symbol(ticker: str) -> str: + """Map a crypto pair to StockTwits' ``.X`` convention. + + StockTwits lists crypto as ``BTC.X`` (Yahoo's ``BTC-USD`` form 404s), so any + crypto symbol resolves to its base plus ``.X``; other symbols pass through + upper-cased. + """ + base = crypto_base(ticker) + return f"{base}.X" if base else ticker.strip().upper() + + +def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str: + """Fetch recent StockTwits messages for ``ticker`` and return them as a + formatted plaintext block ready for prompt injection. + + Returns a placeholder string when the endpoint is unreachable, the + symbol has no messages, or the response shape is unexpected — the + caller never has to special-case None or exceptions. + """ + url = _API.format(ticker=_stocktwits_symbol(ticker)) + req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) + try: + with urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read()) + except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc: + # OSError covers URLError/TimeoutError/connection resets; HTTPException + # covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024). + logger.warning("StockTwits fetch failed for %s: %s", ticker, exc) + return f"" + + messages = data.get("messages", []) if isinstance(data, dict) else [] + if not messages: + return f"" + + lines = [] + bullish = bearish = unlabeled = 0 + for m in messages[:limit]: + created = m.get("created_at", "") + user = (m.get("user") or {}).get("username", "?") + entities = m.get("entities") or {} + sentiment_obj = entities.get("sentiment") or {} + sentiment = sentiment_obj.get("basic") if isinstance(sentiment_obj, dict) else None + body = (m.get("body") or "").replace("\n", " ").strip() + if len(body) > 280: + body = body[:280] + "…" + + if sentiment == "Bullish": + bullish += 1 + tag = "Bullish" + elif sentiment == "Bearish": + bearish += 1 + tag = "Bearish" + else: + unlabeled += 1 + tag = "no-label" + lines.append(f"[{created} · @{user} · {tag}] {body}") + + total = bullish + bearish + unlabeled + bull_pct = round(100 * bullish / total) if total else 0 + bear_pct = round(100 * bearish / total) if total else 0 + summary = ( + f"Bullish: {bullish} ({bull_pct}%) · " + f"Bearish: {bearish} ({bear_pct}%) · " + f"Unlabeled: {unlabeled} · " + f"Total: {total} most-recent messages" + ) + return summary + "\n\n" + "\n".join(lines) diff --git a/tradingagents/dataflows/symbol_utils.py b/tradingagents/dataflows/symbol_utils.py new file mode 100644 index 0000000..f91b4e1 --- /dev/null +++ b/tradingagents/dataflows/symbol_utils.py @@ -0,0 +1,143 @@ +"""Symbol normalization and market-data error types for vendor calls. + +Yahoo Finance (the default vendor) uses specific ticker conventions that +differ from the broker / TradingView / MT5 style symbols users often type: + + user types Yahoo wants why + --------------- --------------- ----------------------------------- + XAUUSD, XAUUSD+ GC=F gold has no forex pair on Yahoo; + it is quoted as a COMEX future + EURUSD EURUSD=X spot forex pairs take a ``=X`` suffix + BTCUSD BTC-USD crypto pairs use a ``-`` separator + SPX500, US500 ^GSPC index CFDs map to Yahoo index symbols + +Passing the raw broker symbol to Yahoo returns an empty result, which the +agents previously received as free text and could hallucinate a price +around (see issue #781). Centralizing the mapping here means every yfinance +entry point resolves symbols the same way, and new instruments are added by +appending a table row rather than editing call sites. +""" + +from __future__ import annotations + +import logging +import re + +# NoMarketDataError lives in the vendor-error taxonomy (errors.py); re-exported +# here for the many call sites that import it alongside normalize_symbol. +from .errors import NoMarketDataError as NoMarketDataError + +logger = logging.getLogger(__name__) + + +# ISO-4217 codes common enough to appear in retail forex pairs. A bare +# six-letter symbol whose halves are BOTH in this set is treated as a spot +# forex pair and given Yahoo's ``=X`` suffix. +_FOREX_CURRENCIES = frozenset( + { + "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", + "CNY", "CNH", "HKD", "SGD", "SEK", "NOK", "DKK", "PLN", + "MXN", "ZAR", "TRY", "INR", "KRW", "BRL", "RUB", "THB", + } +) + +# Crypto bases that brokers quote against USD without a separator. +_CRYPTO_BASES = frozenset( + {"BTC", "ETH", "SOL", "XRP", "ADA", "DOGE", "LTC", "BCH", "DOT", "AVAX", "LINK"} +) + +# Explicit aliases for instruments whose broker symbol does not map to a +# Yahoo symbol by rule. Metals/energy resolve to their front-month future; +# index CFD names resolve to the underlying Yahoo index symbol. Extend by +# adding rows — no call site changes required. +_ALIASES = { + # Precious metals (spot names -> COMEX/NYMEX futures) + "XAUUSD": "GC=F", "XAU": "GC=F", "GOLD": "GC=F", + "XAGUSD": "SI=F", "XAG": "SI=F", "SILVER": "SI=F", + "XPTUSD": "PL=F", "XPDUSD": "PA=F", + # Energy + "WTICOUSD": "CL=F", "USOIL": "CL=F", "WTI": "CL=F", + "BCOUSD": "BZ=F", "UKOIL": "BZ=F", "BRENT": "BZ=F", + "NATGAS": "NG=F", "XNGUSD": "NG=F", + "COPPER": "HG=F", "XCUUSD": "HG=F", + # Index CFDs -> Yahoo index symbols + "SPX500": "^GSPC", "US500": "^GSPC", "SPX": "^GSPC", + "NAS100": "^NDX", "US100": "^NDX", "USTEC": "^NDX", + "US30": "^DJI", "DJI30": "^DJI", "WS30": "^DJI", + "GER40": "^GDAXI", "GER30": "^GDAXI", "DE40": "^GDAXI", + "UK100": "^FTSE", "JP225": "^N225", "JPN225": "^N225", + "FRA40": "^FCHI", "EU50": "^STOXX50E", "HK50": "^HSI", +} + +# Yahoo symbols may contain letters, digits, and these structural characters. +_YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$") + + +# Crypto quote currencies that all map to Yahoo's USD pair. Yahoo lists only +# ``-USD`` (not the USDT/USDC stablecoin pairs), so a broker symbol quoted +# in any of these resolves to ``-USD`` (#982). Longest first so ``USDT``/``USDC`` +# match before the ``USD`` substring. +_CRYPTO_QUOTES = ("USDT", "USDC", "USD") + + +def crypto_base(raw: str) -> str | None: + """Return the crypto base (e.g. ``BTC``) for a known USD/USDT/USDC-quoted + crypto symbol in any form the pipeline may hold — ``BTC-USD``, ``BTCUSD``, + ``BTC-USDT`` — or None for non-crypto symbols. Purely syntactic. + """ + if not isinstance(raw, str): + return None + compact = raw.strip().upper().rstrip("+").replace("-", "") + for quote in _CRYPTO_QUOTES: + if compact.endswith(quote): + base = compact[: -len(quote)] + return base if base in _CRYPTO_BASES else None + return None + + +def _normalize_crypto(s: str) -> str | None: + """Return ``-USD`` for a known USD/USDT/USDC-quoted crypto, else None.""" + base = crypto_base(s) + return f"{base}-USD" if base else None + + +def normalize_symbol(raw: str) -> str: + """Map a user/broker symbol to its canonical Yahoo Finance symbol. + + Resolution order (first match wins): + 1. Explicit alias table (metals, energy, index CFDs). + 2. Crypto rule: a known crypto base quoted in USD/USDT/USDC (dashed or + not) -> ``BASE-USD``. + 3. Forex rule: six letters that are two ISO currency codes -> ``PAIR=X``. + 4. Otherwise the upper-cased symbol is returned unchanged (plain + equities, ETFs, Yahoo-native symbols like ``GC=F`` or ``^GSPC``). + + A trailing ``+`` (broker CFD marker, e.g. ``XAUUSD+``) is stripped before + matching. The function is purely syntactic — it performs no network + calls — so it is safe to apply on every request. + """ + if not isinstance(raw, str) or not raw.strip(): + return raw + + s = raw.strip().upper() + # Broker CFD/qualifier suffixes Yahoo never uses. + s = s.rstrip("+") + + crypto = _normalize_crypto(s) + if s in _ALIASES: + canonical = _ALIASES[s] + elif crypto is not None: + canonical = crypto + elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES: + canonical = f"{s}=X" + else: + canonical = s + + if canonical != raw.strip().upper(): + logger.info("Resolved symbol %r to Yahoo symbol %r", raw, canonical) + return canonical + + +def is_yahoo_safe(symbol: str) -> bool: + """True when ``symbol`` only contains characters Yahoo symbols use.""" + return bool(symbol) and _YAHOO_SAFE.fullmatch(symbol) is not None diff --git a/tradingagents/dataflows/utils.py b/tradingagents/dataflows/utils.py new file mode 100644 index 0000000..b5d48d0 --- /dev/null +++ b/tradingagents/dataflows/utils.py @@ -0,0 +1,75 @@ +import re +from datetime import date, datetime, timedelta +from typing import Annotated + +import pandas as pd + +SavePathType = Annotated[str, "File path to save data. If None, data is not saved."] + +# Tickers can contain letters, digits, dot, dash, underscore, caret +# (index symbols like ^GSPC), equals (futures like GC=F), and plus +# (forex/CFD symbols like XAUUSD+). None of these enable directory +# traversal, so the value never escapes a containing directory when +# interpolated into a path. Anything else is rejected. +_TICKER_PATH_RE = re.compile(r"^[A-Za-z0-9._\-\^=+]+$") + + +def safe_ticker_component(value: str, *, max_len: int = 32) -> str: + """Validate ``value`` is safe to interpolate into a filesystem path. + + Tickers come from user CLI input or from LLM tool calls, both of which + can be influenced by attacker-controlled content (e.g. prompt injection + embedded in fetched news). Without validation, a value like + ``"../../../etc/foo"`` flows into ``os.path.join`` / ``Path /`` and + escapes the configured cache, checkpoint, or results directory. + + Returns ``value`` unchanged when it matches the allowed pattern; raises + ``ValueError`` otherwise. + """ + if not isinstance(value, str) or not value: + raise ValueError(f"ticker must be a non-empty string, got {value!r}") + if len(value) > max_len: + raise ValueError(f"ticker exceeds {max_len} chars: {value!r}") + if not _TICKER_PATH_RE.fullmatch(value): + raise ValueError( + f"ticker contains characters not allowed in a filesystem path: {value!r}" + ) + # The regex above allows '.', so values like '.', '..', '...' would pass, + # and as a path component they traverse the parent directory. Reject any + # value that's only dots. + if set(value) == {"."}: + raise ValueError(f"ticker cannot consist solely of dots: {value!r}") + return value + + +def save_output(data: pd.DataFrame, tag: str, save_path: SavePathType = None) -> None: + if save_path: + data.to_csv(save_path, encoding="utf-8") + print(f"{tag} saved to {save_path}") + + +def get_current_date(): + return date.today().strftime("%Y-%m-%d") + + +def decorate_all_methods(decorator): + def class_decorator(cls): + for attr_name, attr_value in cls.__dict__.items(): + if callable(attr_value): + setattr(cls, attr_name, decorator(attr_value)) + return cls + + return class_decorator + + +def get_next_weekday(date): + + if not isinstance(date, datetime): + date = datetime.strptime(date, "%Y-%m-%d") + + if date.weekday() >= 5: + days_to_add = 7 - date.weekday() + next_weekday = date + timedelta(days=days_to_add) + return next_weekday + else: + return date diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py new file mode 100644 index 0000000..fdb49d5 --- /dev/null +++ b/tradingagents/dataflows/y_finance.py @@ -0,0 +1,470 @@ +from datetime import datetime +from typing import Annotated + +import pandas as pd +import yfinance as yf +from dateutil.relativedelta import relativedelta + +from .stockstats_utils import ( + StockstatsUtils, + _assert_ohlcv_not_stale, + filter_financials_by_date, + load_ohlcv, + yf_retry, +) +from .symbol_utils import NoMarketDataError, normalize_symbol + + +def get_YFin_data_online( + symbol: Annotated[str, "ticker symbol of the company"], + start_date: Annotated[str, "Start date in yyyy-mm-dd format"], + end_date: Annotated[str, "End date in yyyy-mm-dd format"], +): + + datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + + # Resolve broker/forex symbols to Yahoo's convention (XAUUSD+ -> GC=F). + canonical = normalize_symbol(symbol) + ticker = yf.Ticker(canonical) + + # yfinance treats ``end`` as EXCLUSIVE, so it would drop the requested + # end_date row (and the current day when end_date is today). Request one day + # past end_date so the requested range is actually inclusive (#986/#987). + end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d") + data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive)) + + # Empty result means the symbol is unknown/delisted. Raise a typed error + # instead of returning prose: the routing layer turns it into a single + # unambiguous "no data" signal so the agent never fabricates a price. + if data.empty: + raise NoMarketDataError( + symbol, canonical, f"no rows between {start_date} and {end_date}" + ) + + # Remove timezone info from index for cleaner output + if data.index.tz is not None: + data.index = data.index.tz_localize(None) + + # Reject a stale frame (e.g. a year-old partial response) before it is + # formatted into the report. Raises NoMarketDataError, which the router + # turns into one clear unavailable signal (#1021). + _assert_ohlcv_not_stale(data, end_date, symbol, canonical) + + # Round numerical values to 2 decimal places for cleaner display + numeric_columns = ["Open", "High", "Low", "Close", "Adj Close"] + for col in numeric_columns: + if col in data.columns: + data[col] = data[col].round(2) + + # Convert DataFrame to CSV string + csv_string = data.to_csv() + + # Add header information; note the resolved symbol when it differs so the + # agent (and user) can see which instrument was actually priced. + label = canonical if canonical == symbol.upper() else f"{canonical} (from {symbol})" + header = f"# Stock data for {label} from {start_date} to {end_date}\n" + header += f"# Total records: {len(data)}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + +def get_stock_stats_indicators_window( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to get the analysis and report of"], + curr_date: Annotated[ + str, "The current trading date you are trading on, YYYY-mm-dd" + ], + look_back_days: Annotated[int, "how many days to look back"], +) -> str: + + best_ind_params = { + # Moving Averages + "close_50_sma": ( + "50 SMA: A medium-term trend indicator. " + "Usage: Identify trend direction and serve as dynamic support/resistance. " + "Tips: It lags price; combine with faster indicators for timely signals." + ), + "close_200_sma": ( + "200 SMA: A long-term trend benchmark. " + "Usage: Confirm overall market trend and identify golden/death cross setups. " + "Tips: It reacts slowly; best for strategic trend confirmation rather than frequent trading entries." + ), + "close_10_ema": ( + "10 EMA: A responsive short-term average. " + "Usage: Capture quick shifts in momentum and potential entry points. " + "Tips: Prone to noise in choppy markets; use alongside longer averages for filtering false signals." + ), + # MACD Related + "macd": ( + "MACD: Computes momentum via differences of EMAs. " + "Usage: Look for crossovers and divergence as signals of trend changes. " + "Tips: Confirm with other indicators in low-volatility or sideways markets." + ), + "macds": ( + "MACD Signal: An EMA smoothing of the MACD line. " + "Usage: Use crossovers with the MACD line to trigger trades. " + "Tips: Should be part of a broader strategy to avoid false positives." + ), + "macdh": ( + "MACD Histogram: Shows the gap between the MACD line and its signal. " + "Usage: Visualize momentum strength and spot divergence early. " + "Tips: Can be volatile; complement with additional filters in fast-moving markets." + ), + # Momentum Indicators + "rsi": ( + "RSI: Measures momentum to flag overbought/oversold conditions. " + "Usage: Apply 70/30 thresholds and watch for divergence to signal reversals. " + "Tips: In strong trends, RSI may remain extreme; always cross-check with trend analysis." + ), + # Volatility Indicators + "boll": ( + "Bollinger Middle: A 20 SMA serving as the basis for Bollinger Bands. " + "Usage: Acts as a dynamic benchmark for price movement. " + "Tips: Combine with the upper and lower bands to effectively spot breakouts or reversals." + ), + "boll_ub": ( + "Bollinger Upper Band: Typically 2 standard deviations above the middle line. " + "Usage: Signals potential overbought conditions and breakout zones. " + "Tips: Confirm signals with other tools; prices may ride the band in strong trends." + ), + "boll_lb": ( + "Bollinger Lower Band: Typically 2 standard deviations below the middle line. " + "Usage: Indicates potential oversold conditions. " + "Tips: Use additional analysis to avoid false reversal signals." + ), + "atr": ( + "ATR: Averages true range to measure volatility. " + "Usage: Set stop-loss levels and adjust position sizes based on current market volatility. " + "Tips: It's a reactive measure, so use it as part of a broader risk management strategy." + ), + # 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." + ), + "mfi": ( + "MFI: The Money Flow Index is a momentum indicator that uses both price and volume to measure buying and selling pressure. " + "Usage: Identify overbought (>80) or oversold (<20) conditions and confirm the strength of trends or reversals. " + "Tips: Use alongside RSI or MACD to confirm signals; divergence between price and MFI can indicate potential reversals." + ), + } + + if indicator not in best_ind_params: + raise ValueError( + f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" + ) + + end_date = curr_date + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + before = curr_date_dt - relativedelta(days=look_back_days) + + # Optimized: Get stock data once and calculate indicators for all dates + try: + indicator_data = _get_stock_stats_bulk(symbol, indicator, curr_date) + + # Generate the date range we need + current_dt = curr_date_dt + date_values = [] + + while current_dt >= before: + date_str = current_dt.strftime('%Y-%m-%d') + + # Look up the indicator value for this date + if date_str in indicator_data: + indicator_value = indicator_data[date_str] + else: + indicator_value = "N/A: Not a trading day (weekend or holiday)" + + date_values.append((date_str, indicator_value)) + current_dt = current_dt - relativedelta(days=1) + + # Build the result string + ind_string = "" + for date_str, value in date_values: + ind_string += f"{date_str}: {value}\n" + + except NoMarketDataError: + raise # Unknown/delisted symbol — let the router emit the sentinel + except Exception as e: + print(f"Error getting bulk stockstats data: {e}") + # Fallback to original implementation if bulk method fails + ind_string = "" + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + while curr_date_dt >= before: + indicator_value = get_stockstats_indicator( + symbol, indicator, curr_date_dt.strftime("%Y-%m-%d") + ) + ind_string += f"{curr_date_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" + curr_date_dt = curr_date_dt - relativedelta(days=1) + + result_str = ( + f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n" + + ind_string + + "\n\n" + + best_ind_params.get(indicator, "No description available.") + ) + + return result_str + + +def _get_stock_stats_bulk( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to calculate"], + curr_date: Annotated[str, "current date for reference"] +) -> dict: + """ + Optimized bulk calculation of stock stats indicators. + Fetches data once and calculates indicator for all available dates. + Returns dict mapping date strings to indicator values. + """ + from stockstats import wrap + + data = load_ohlcv(symbol, curr_date) + df = wrap(data) + df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") + + # Calculate the indicator for all rows at once + df[indicator] # This triggers stockstats to calculate the indicator + + # Create a dictionary mapping date strings to indicator values + result_dict = {} + for _, row in df.iterrows(): + date_str = row["Date"] + indicator_value = row[indicator] + + # Handle NaN/None values + if pd.isna(indicator_value): + result_dict[date_str] = "N/A" + else: + result_dict[date_str] = str(indicator_value) + + return result_dict + + +def get_stockstats_indicator( + symbol: Annotated[str, "ticker symbol of the company"], + indicator: Annotated[str, "technical indicator to get the analysis and report of"], + curr_date: Annotated[ + str, "The current trading date you are trading on, YYYY-mm-dd" + ], +) -> str: + + curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") + curr_date = curr_date_dt.strftime("%Y-%m-%d") + + try: + indicator_value = StockstatsUtils.get_stock_stats( + symbol, + indicator, + curr_date, + ) + except NoMarketDataError: + raise # Unknown/delisted symbol — let the router emit the sentinel + except Exception as e: + print( + f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}" + ) + return "" + + return str(indicator_value) + + +def get_fundamentals( + ticker: Annotated[str, "ticker symbol of the company"], + curr_date: Annotated[str, "current date (not used for yfinance)"] = None +): + """Get company fundamentals overview from yfinance.""" + canonical = normalize_symbol(ticker) + try: + ticker_obj = yf.Ticker(canonical) + info = yf_retry(lambda: ticker_obj.info) + + if not info: + raise NoMarketDataError(ticker, canonical, "no fundamentals returned") + + fields = [ + ("Name", info.get("longName")), + ("Sector", info.get("sector")), + ("Industry", info.get("industry")), + ("Market Cap", info.get("marketCap")), + ("PE Ratio (TTM)", info.get("trailingPE")), + ("Forward PE", info.get("forwardPE")), + ("PEG Ratio", info.get("pegRatio")), + ("Price to Book", info.get("priceToBook")), + ("EPS (TTM)", info.get("trailingEps")), + ("Forward EPS", info.get("forwardEps")), + ("Dividend Yield", info.get("dividendYield")), + ("Beta", info.get("beta")), + ("52 Week High", info.get("fiftyTwoWeekHigh")), + ("52 Week Low", info.get("fiftyTwoWeekLow")), + ("50 Day Average", info.get("fiftyDayAverage")), + ("200 Day Average", info.get("twoHundredDayAverage")), + ("Revenue (TTM)", info.get("totalRevenue")), + ("Gross Profit", info.get("grossProfits")), + ("EBITDA", info.get("ebitda")), + ("Net Income", info.get("netIncomeToCommon")), + ("Profit Margin", info.get("profitMargins")), + ("Operating Margin", info.get("operatingMargins")), + ("Return on Equity", info.get("returnOnEquity")), + ("Return on Assets", info.get("returnOnAssets")), + ("Debt to Equity", info.get("debtToEquity")), + ("Current Ratio", info.get("currentRatio")), + ("Book Value", info.get("bookValue")), + ("Free Cash Flow", info.get("freeCashflow")), + ] + + lines = [] + for label, value in fields: + if value is not None: + lines.append(f"{label}: {value}") + + # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for + # unknown symbols, so `info` is truthy but every field is empty. Treat + # "no usable fields" as no data rather than emitting a bare header the + # agent might fabricate around. + if not lines: + raise NoMarketDataError(ticker, canonical, "no fundamental fields returned") + + header = f"# Company Fundamentals for {canonical}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + "\n".join(lines) + + except NoMarketDataError: + raise + except Exception as e: + return f"Error retrieving fundamentals for {ticker}: {str(e)}" + + +def get_balance_sheet( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None +): + """Get balance sheet data from yfinance.""" + canonical = normalize_symbol(ticker) + try: + ticker_obj = yf.Ticker(canonical) + + if freq.lower() == "quarterly": + data = yf_retry(lambda: ticker_obj.quarterly_balance_sheet) + else: + data = yf_retry(lambda: ticker_obj.balance_sheet) + + data = filter_financials_by_date(data, curr_date) + + if data.empty: + raise NoMarketDataError(ticker, canonical, "no balance sheet data") + + # Convert to CSV string for consistency with other functions + csv_string = data.to_csv() + + # Add header information + header = f"# Balance Sheet data for {canonical} ({freq})\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except NoMarketDataError: + raise + except Exception as e: + return f"Error retrieving balance sheet for {ticker}: {str(e)}" + + +def get_cashflow( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None +): + """Get cash flow data from yfinance.""" + canonical = normalize_symbol(ticker) + try: + ticker_obj = yf.Ticker(canonical) + + if freq.lower() == "quarterly": + data = yf_retry(lambda: ticker_obj.quarterly_cashflow) + else: + data = yf_retry(lambda: ticker_obj.cashflow) + + data = filter_financials_by_date(data, curr_date) + + if data.empty: + raise NoMarketDataError(ticker, canonical, "no cash flow data") + + # Convert to CSV string for consistency with other functions + csv_string = data.to_csv() + + # Add header information + header = f"# Cash Flow data for {canonical} ({freq})\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except NoMarketDataError: + raise + except Exception as e: + return f"Error retrieving cash flow for {ticker}: {str(e)}" + + +def get_income_statement( + ticker: Annotated[str, "ticker symbol of the company"], + freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", + curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None +): + """Get income statement data from yfinance.""" + canonical = normalize_symbol(ticker) + try: + ticker_obj = yf.Ticker(canonical) + + if freq.lower() == "quarterly": + data = yf_retry(lambda: ticker_obj.quarterly_income_stmt) + else: + data = yf_retry(lambda: ticker_obj.income_stmt) + + data = filter_financials_by_date(data, curr_date) + + if data.empty: + raise NoMarketDataError(ticker, canonical, "no income statement data") + + # Convert to CSV string for consistency with other functions + csv_string = data.to_csv() + + # Add header information + header = f"# Income Statement data for {canonical} ({freq})\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except NoMarketDataError: + raise + except Exception as e: + return f"Error retrieving income statement for {ticker}: {str(e)}" + + +def get_insider_transactions( + ticker: Annotated[str, "ticker symbol of the company"] +): + """Get insider transactions data from yfinance.""" + canonical = normalize_symbol(ticker) + try: + ticker_obj = yf.Ticker(canonical) + data = yf_retry(lambda: ticker_obj.insider_transactions) + + # Empty is normal here (many valid symbols have no insider filings), + # so report it plainly rather than treating the symbol as invalid. + if data is None or data.empty: + return f"No insider transactions reported for symbol '{canonical}'" + + # Convert to CSV string for consistency with other functions + csv_string = data.to_csv() + + # Add header information + header = f"# Insider Transactions data for {canonical}\n" + header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + + return header + csv_string + + except Exception as e: + return f"Error retrieving insider transactions for {ticker}: {str(e)}" diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py new file mode 100644 index 0000000..832e399 --- /dev/null +++ b/tradingagents/dataflows/yfinance_news.py @@ -0,0 +1,219 @@ +"""yfinance-based news data fetching functions.""" + +import contextlib +from datetime import datetime + +import yfinance as yf +from dateutil.relativedelta import relativedelta + +from .config import get_config +from .stockstats_utils import yf_retry +from .symbol_utils import normalize_symbol + + +def _extract_article_data(article: dict) -> dict: + """Extract article data from yfinance news format (handles nested 'content' structure).""" + # Handle nested content structure + if "content" in article: + content = article["content"] + title = content.get("title", "No title") + summary = content.get("summary", "") + provider = content.get("provider", {}) + publisher = provider.get("displayName", "Unknown") + + # Get URL from canonicalUrl or clickThroughUrl + url_obj = content.get("canonicalUrl") or content.get("clickThroughUrl") or {} + link = url_obj.get("url", "") + + # Get publish date + pub_date_str = content.get("pubDate", "") + pub_date = None + if pub_date_str: + with contextlib.suppress(ValueError, AttributeError): + pub_date = datetime.fromisoformat(pub_date_str.replace("Z", "+00:00")) + + return { + "title": title, + "summary": summary, + "publisher": publisher, + "link": link, + "pub_date": pub_date, + } + else: + # Fallback for flat structure. Parse the epoch publish time so flat + # articles are date-filterable too (otherwise they bypass the + # historical window and leak future news, #992/#1007). + pub_date = None + ts = article.get("providerPublishTime") + if ts: + with contextlib.suppress(ValueError, OSError, TypeError): + pub_date = datetime.fromtimestamp(ts) + return { + "title": article.get("title", "No title"), + "summary": article.get("summary", ""), + "publisher": article.get("publisher", "Unknown"), + "link": article.get("link", ""), + "pub_date": pub_date, + } + + +def _in_news_window(pub_date, start_dt, end_dt) -> bool: + """Whether an article belongs in the [start_dt, end_dt] window. + + Dated articles are kept only if they fall in the window. An undated article + is kept only when the window reaches the present (live run) — in a + historical/backtest window it's excluded, since we can't prove it isn't + future news (look-ahead safety, #992/#1007). + """ + if pub_date is not None: + naive = pub_date.replace(tzinfo=None) if hasattr(pub_date, "replace") else pub_date + return start_dt <= naive <= end_dt + relativedelta(days=1) + return end_dt >= datetime.now() - relativedelta(days=1) + + +def get_news_yfinance( + ticker: str, + start_date: str, + end_date: str, +) -> str: + """ + Retrieve news for a specific stock ticker using yfinance. + + Args: + ticker: Stock ticker symbol (e.g., "AAPL") + start_date: Start date in yyyy-mm-dd format + end_date: End date in yyyy-mm-dd format + + Returns: + Formatted string containing news articles + """ + article_limit = get_config()["news_article_limit"] + # Query Yahoo with the canonical symbol, like every other yfinance path — + # a raw broker/forex/crypto alias (XAUUSD, BTCUSD) otherwise silently + # returns no news. Keep the user's ticker in the report header. + canonical = normalize_symbol(ticker) + resolved = "" if canonical == ticker else f" (resolved to {canonical})" + try: + stock = yf.Ticker(canonical) + news = yf_retry(lambda: stock.get_news(count=article_limit)) + + if not news: + return f"No news found for {ticker}{resolved}" + + # Parse date range for filtering + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + + news_str = "" + filtered_count = 0 + + for article in news: + data = _extract_article_data(article) + + # Keep only articles within the requested window (look-ahead safe). + if not _in_news_window(data["pub_date"], start_dt, end_dt): + continue + + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" + filtered_count += 1 + + if filtered_count == 0: + return f"No news found for {ticker}{resolved} between {start_date} and {end_date}" + + return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" + + except Exception as e: + return f"Error fetching news for {ticker}: {str(e)}" + + +def get_global_news_yfinance( + curr_date: str, + look_back_days: int | None = None, + limit: int | None = None, +) -> str: + """ + Retrieve global/macro economic news using yfinance Search. + + Args: + curr_date: Current date in yyyy-mm-dd format + look_back_days: Number of days to look back. ``None`` falls back to + ``global_news_lookback_days`` from the active config. + limit: Maximum number of articles to return. ``None`` falls back to + ``global_news_article_limit`` from the active config. + + Returns: + Formatted string containing global news articles + """ + config = get_config() + if look_back_days is None: + look_back_days = config["global_news_lookback_days"] + if limit is None: + limit = config["global_news_article_limit"] + search_queries = config["global_news_queries"] + + all_news = [] + seen_titles = set() + + try: + for query in search_queries: + search = yf_retry(lambda q=query: yf.Search( + query=q, + news_count=limit, + enable_fuzzy_query=True, + )) + + if search.news: + for article in search.news: + # Handle both flat and nested structures + if "content" in article: + data = _extract_article_data(article) + title = data["title"] + else: + title = article.get("title", "") + + # Deduplicate by title + if title and title not in seen_titles: + seen_titles.add(title) + all_news.append(article) + + if len(all_news) >= limit: + break + + if not all_news: + return f"No global news found for {curr_date}" + + # Calculate date range + curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") + start_dt = curr_dt - relativedelta(days=look_back_days) + start_date = start_dt.strftime("%Y-%m-%d") + + news_str = "" + kept = 0 + for article in all_news[:limit]: + # Extract uniformly (flat + nested) and apply the same look-ahead-safe + # window filter, so flat articles can't leak future news (#1007). + data = _extract_article_data(article) + if not _in_news_window(data["pub_date"], start_dt, curr_dt): + continue + news_str += f"### {data['title']} (source: {data['publisher']})\n" + if data["summary"]: + news_str += f"{data['summary']}\n" + if data["link"]: + news_str += f"Link: {data['link']}\n" + news_str += "\n" + kept += 1 + + # All candidates fell outside the window -> say so rather than return an + # empty-bodied report (#993). + if kept == 0: + return f"No global news found between {start_date} and {curr_date}" + + return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" + + except Exception as e: + return f"Error fetching global news: {str(e)}" diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py new file mode 100644 index 0000000..ab75f75 --- /dev/null +++ b/tradingagents/default_config.py @@ -0,0 +1,164 @@ +import os + +_TRADINGAGENTS_HOME = os.path.join(os.path.expanduser("~"), ".tradingagents") + +# Single source of truth for env-var → config-key overrides. To expose +# a new config key for environment-based override, add a row here — no +# entry-point script changes required. Coercion is driven by the type +# of the existing default, so users can keep writing plain strings in +# their .env file. +_ENV_OVERRIDES = { + "TRADINGAGENTS_LLM_PROVIDER": "llm_provider", + "TRADINGAGENTS_DEEP_THINK_LLM": "deep_think_llm", + "TRADINGAGENTS_QUICK_THINK_LLM": "quick_think_llm", + "TRADINGAGENTS_LLM_BACKEND_URL": "backend_url", + "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "max_risk_discuss_rounds", + "TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled", + "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", + "TRADINGAGENTS_TEMPERATURE": "temperature", + "TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries", + # Provider-specific reasoning/thinking knobs (None = each provider's own + # default). Settable here for non-interactive runs; the CLI also offers an + # interactive choice, which is skipped when the matching var is set. + "TRADINGAGENTS_GOOGLE_THINKING_LEVEL": "google_thinking_level", + "TRADINGAGENTS_OPENAI_REASONING_EFFORT": "openai_reasoning_effort", + "TRADINGAGENTS_ANTHROPIC_EFFORT": "anthropic_effort", +} + + +_BOOL_TRUE = ("true", "1", "yes", "on") +_BOOL_FALSE = ("false", "0", "no", "off") + + +def _coerce(value: str, reference): + """Coerce env-var string to the type of the existing default value. + + Invalid values raise ``ValueError`` rather than silently falling back to a + default — a misspelled boolean (e.g. ``treu``) or non-numeric int should fail + loudly at startup, not quietly misconfigure an unattended run. + """ + if isinstance(reference, bool): + normalized = value.strip().lower() + if normalized in _BOOL_TRUE: + return True + if normalized in _BOOL_FALSE: + return False + raise ValueError( + f"expected a boolean ({'/'.join(_BOOL_TRUE + _BOOL_FALSE)}), got {value!r}" + ) + if isinstance(reference, int) and not isinstance(reference, bool): + return int(value) + if isinstance(reference, float): + return float(value) + return value + + +def _apply_env_overrides(config: dict) -> dict: + """Apply TRADINGAGENTS_* env vars to the config dict in-place.""" + for env_var, key in _ENV_OVERRIDES.items(): + raw = os.environ.get(env_var) + if raw is None or raw == "": + continue + try: + config[key] = _coerce(raw, config.get(key)) + except ValueError as exc: + raise ValueError(f"Invalid value for {env_var}: {exc}") from exc + return config + + +DEFAULT_CONFIG = _apply_env_overrides({ + "project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), + "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", os.path.join(_TRADINGAGENTS_HOME, "logs")), + "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR", os.path.join(_TRADINGAGENTS_HOME, "cache")), + "memory_log_path": os.getenv("TRADINGAGENTS_MEMORY_LOG_PATH", os.path.join(_TRADINGAGENTS_HOME, "memory", "trading_memory.md")), + # Optional cap on the number of resolved memory log entries. When set, + # the oldest resolved entries are pruned once this limit is exceeded. + # Pending entries are never pruned. None disables rotation entirely. + "memory_log_max_entries": None, + # LLM settings + "llm_provider": "openai", + "deep_think_llm": "gpt-5.5", + "quick_think_llm": "gpt-5.4-mini", + # When None, each provider's client falls back to its own default endpoint + # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). + # The CLI overrides this per provider when the user picks one. Keeping a + # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously + # being forwarded to Gemini, producing malformed request URLs). + "backend_url": None, + # Provider-specific thinking configuration + "google_thinking_level": None, # "high", "minimal", etc. + "openai_reasoning_effort": None, # "medium", "high", "low" + "anthropic_effort": None, # "high", "medium", "low" + # Sampling temperature, forwarded to every provider when set. None leaves + # each provider at its own default. Lower values reduce run-to-run + # variation on models that honor it; reasoning models largely ignore it + # and no setting makes LLM output bit-identical across runs (see README). + "temperature": None, + # SDK retry budget forwarded to every provider chat client. None leaves each + # provider/SDK at its own default (usually 2). Raise it to ride out bursty + # 429 throttling on rate-limited deployments instead of aborting a run (#1091). + "llm_max_retries": None, + # Checkpoint/resume: when True, LangGraph saves state after each node + # so a crashed run can resume from the last successful step. + "checkpoint_enabled": False, + # Output language for analyst reports and final decision + # Internal agent debate stays in English for reasoning quality + "output_language": "English", + # Debate and discussion settings + "max_debate_rounds": 1, + "max_risk_discuss_rounds": 1, + "max_recur_limit": 100, + # News / data fetching parameters + # Increase for longer lookback strategies or to broaden macro coverage; + # decrease to reduce token usage in agent prompts. + "news_article_limit": 20, # max articles per ticker (ticker-news) + "global_news_article_limit": 10, # max articles for global/macro news + "global_news_lookback_days": 7, # macro news lookback window + # Search queries used by get_global_news for macro headlines. Extend or + # replace to broaden geographic / sector coverage. + "global_news_queries": [ + "Federal Reserve interest rates inflation", + "S&P 500 earnings GDP economic outlook", + "geopolitical risk trade war sanctions", + "ECB Bank of England BOJ central bank policy", + "oil commodities supply chain energy", + ], + # Data vendor configuration + # Category-level configuration (default for all tools in category). + # The configured value is the exact vendor chain — requests are NOT silently + # routed to vendors you didn't choose. For ordered fallback, list several, + # e.g. "yfinance,alpha_vantage". "default" uses all available vendors. + "data_vendors": { + "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance + "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance + "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance + "news_data": "yfinance", # Options: alpha_vantage, yfinance + "macro_data": "fred", # Options: fred (needs FRED_API_KEY) + "prediction_markets": "polymarket", # Options: polymarket (keyless) + }, + # Tool-level configuration (takes precedence over category-level) + "tool_vendors": { + # Example: "get_stock_data": "alpha_vantage", # Override category default + }, + # Benchmark for alpha calculation in the reflection layer. + # ``benchmark_ticker`` (when set) overrides the suffix map for all + # tickers; leave it None to use ``benchmark_map`` for auto-detection + # based on the ticker's exchange suffix. SPY remains the US default + # so the reflection label keeps reading "Alpha vs SPY" for US tickers + # while non-US tickers get their regional index automatically. + "benchmark_ticker": None, + "benchmark_map": { + ".NS": "^NSEI", # NSE India (Nifty 50) + ".BO": "^BSESN", # BSE India (Sensex) + ".T": "^N225", # Tokyo (Nikkei 225) + ".HK": "^HSI", # Hong Kong (Hang Seng) + ".L": "^FTSE", # London (FTSE 100) + ".TO": "^GSPTSE", # Toronto (TSX Composite) + ".AX": "^AXJO", # Australia (ASX 200) + ".SS": "000001.SS", # Shanghai (SSE Composite) + ".SZ": "399001.SZ", # Shenzhen (SZSE Component) + "": "SPY", # default for US-listed tickers (no suffix) + }, +}) diff --git a/tradingagents/graph/__init__.py b/tradingagents/graph/__init__.py new file mode 100644 index 0000000..901eddd --- /dev/null +++ b/tradingagents/graph/__init__.py @@ -0,0 +1,17 @@ +# TradingAgents/graph/__init__.py + +from .conditional_logic import ConditionalLogic +from .propagation import Propagator +from .reflection import Reflector +from .setup import GraphSetup +from .signal_processing import SignalProcessor +from .trading_graph import TradingAgentsGraph + +__all__ = [ + "TradingAgentsGraph", + "ConditionalLogic", + "GraphSetup", + "Propagator", + "Reflector", + "SignalProcessor", +] diff --git a/tradingagents/graph/analyst_execution.py b/tradingagents/graph/analyst_execution.py new file mode 100644 index 0000000..0e65374 --- /dev/null +++ b/tradingagents/graph/analyst_execution.py @@ -0,0 +1,135 @@ +from collections.abc import Iterable +from dataclasses import dataclass +from time import monotonic + + +@dataclass(frozen=True) +class AnalystNodeSpec: + key: str + agent_node: str + clear_node: str + tool_node: str + report_key: str + + +@dataclass(frozen=True) +class AnalystExecutionPlan: + specs: list[AnalystNodeSpec] + + +ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = { + "market": AnalystNodeSpec( + key="market", + agent_node="Market Analyst", + clear_node="Msg Clear Market", + tool_node="tools_market", + report_key="market_report", + ), + "social": AnalystNodeSpec( + # Wire key stays "social" for saved-config back-compat; the + # user-facing label is "Sentiment Analyst" to match the rename + # that landed in v0.2.5 (sentiment_analyst now ingests news + + # StockTwits + Reddit, not just social media). + key="social", + agent_node="Sentiment Analyst", + clear_node="Msg Clear Sentiment", + tool_node="tools_social", + report_key="sentiment_report", + ), + "news": AnalystNodeSpec( + key="news", + agent_node="News Analyst", + clear_node="Msg Clear News", + tool_node="tools_news", + report_key="news_report", + ), + "fundamentals": AnalystNodeSpec( + key="fundamentals", + agent_node="Fundamentals Analyst", + clear_node="Msg Clear Fundamentals", + tool_node="tools_fundamentals", + report_key="fundamentals_report", + ), +} + + +def build_analyst_execution_plan( + selected_analysts: Iterable[str], +) -> AnalystExecutionPlan: + specs: list[AnalystNodeSpec] = [] + for analyst_key in selected_analysts: + spec = ANALYST_NODE_SPECS.get(analyst_key) + if spec is None: + raise ValueError(f"unknown analyst key: {analyst_key}") + specs.append(spec) + + if not specs: + raise ValueError("at least one analyst must be selected") + + return AnalystExecutionPlan(specs=specs) + + +def get_initial_analyst_node(plan: AnalystExecutionPlan) -> str: + return plan.specs[0].agent_node + + +class AnalystWallTimeTracker: + def __init__(self, plan: AnalystExecutionPlan): + self.plan = plan + self._started_at: dict[str, float] = {} + self._wall_times: dict[str, float] = {} + + def mark_started(self, analyst_key: str, started_at: float | None = None) -> None: + if analyst_key not in ANALYST_NODE_SPECS: + raise ValueError(f"unknown analyst key: {analyst_key}") + self._started_at.setdefault(analyst_key, monotonic() if started_at is None else started_at) + + def mark_completed( + self, + analyst_key: str, + completed_at: float | None = None, + ) -> None: + if analyst_key not in ANALYST_NODE_SPECS: + raise ValueError(f"unknown analyst key: {analyst_key}") + if analyst_key in self._wall_times: + return + started_at = self._started_at.get(analyst_key) + if started_at is None: + return + finished_at = monotonic() if completed_at is None else completed_at + self._wall_times[analyst_key] = max(0.0, finished_at - started_at) + + def get_wall_times(self) -> dict[str, float]: + return dict(self._wall_times) + + def format_summary(self) -> str: + parts = [] + for spec in self.plan.specs: + duration = self._wall_times.get(spec.key) + if duration is not None: + label = spec.agent_node.removesuffix(" Analyst") + parts.append(f"{label} {duration:.2f}s") + if not parts: + return "Analyst wall time: pending" + return "Analyst wall time: " + " | ".join(parts) + + +def sync_analyst_tracker_from_chunk( + tracker: AnalystWallTimeTracker, + chunk: dict[str, str], + now: float | None = None, +) -> None: + current_time = monotonic() if now is None else now + active_found = False + + for spec in tracker.plan.specs: + has_report = bool(chunk.get(spec.report_key)) + + if has_report: + tracker.mark_started(spec.key, started_at=current_time) + tracker.mark_completed(spec.key, completed_at=current_time) + continue + + if not active_found: + tracker.mark_started(spec.key, started_at=current_time) + active_found = True diff --git a/tradingagents/graph/checkpointer.py b/tradingagents/graph/checkpointer.py new file mode 100644 index 0000000..750d8d1 --- /dev/null +++ b/tradingagents/graph/checkpointer.py @@ -0,0 +1,98 @@ +"""LangGraph checkpoint support for resumable analysis runs. + +Per-ticker SQLite databases so concurrent tickers don't contend. +""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + +from langgraph.checkpoint.sqlite import SqliteSaver + +from tradingagents.dataflows.utils import safe_ticker_component + + +def _db_path(data_dir: str | Path, ticker: str) -> Path: + """Return the SQLite checkpoint DB path for a ticker.""" + # Reject ticker values that would escape the checkpoints directory. + safe = safe_ticker_component(ticker).upper() + p = Path(data_dir) / "checkpoints" + p.mkdir(parents=True, exist_ok=True) + return p / f"{safe}.db" + + +def thread_id(ticker: str, date: str, signature: str = "") -> str: + """Deterministic thread ID for a ticker+date pair. + + ``signature`` folds in graph-shape-affecting run choices so a resume under a + different graph can't reuse this checkpoint (#1089); omitting it keeps the + legacy ID. + """ + base = f"{ticker.upper()}:{date}" + if signature: + base = f"{base}:{signature}" + return hashlib.sha256(base.encode()).hexdigest()[:16] + + +@contextmanager +def get_checkpointer(data_dir: str | Path, ticker: str) -> Generator[SqliteSaver, None, None]: + """Context manager yielding a SqliteSaver backed by a per-ticker DB.""" + db = _db_path(data_dir, ticker) + conn = sqlite3.connect(str(db), check_same_thread=False) + try: + saver = SqliteSaver(conn) + saver.setup() + yield saver + finally: + conn.close() + + +def has_checkpoint(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> bool: + """Check whether a resumable checkpoint exists for ticker+date.""" + return checkpoint_step(data_dir, ticker, date, signature) is not None + + +def checkpoint_step(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> int | None: + """Return the step number of the latest checkpoint, or None if none exists.""" + db = _db_path(data_dir, ticker) + if not db.exists(): + return None + tid = thread_id(ticker, date, signature) + with get_checkpointer(data_dir, ticker) as saver: + config = {"configurable": {"thread_id": tid}} + cp = saver.get_tuple(config) + if cp is None: + return None + return cp.metadata.get("step") + + +def clear_all_checkpoints(data_dir: str | Path) -> int: + """Remove all checkpoint DBs. Returns number of files deleted.""" + cp_dir = Path(data_dir) / "checkpoints" + if not cp_dir.exists(): + return 0 + dbs = list(cp_dir.glob("*.db")) + for db in dbs: + db.unlink() + return len(dbs) + + +def clear_checkpoint(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> None: + """Remove checkpoint for a specific ticker+date by deleting the thread's rows.""" + db = _db_path(data_dir, ticker) + if not db.exists(): + return + tid = thread_id(ticker, date, signature) + conn = sqlite3.connect(str(db)) + try: + for table in ("writes", "checkpoints"): + conn.execute(f"DELETE FROM {table} WHERE thread_id = ?", (tid,)) + conn.commit() + except sqlite3.OperationalError: + pass + finally: + conn.close() diff --git a/tradingagents/graph/conditional_logic.py b/tradingagents/graph/conditional_logic.py new file mode 100644 index 0000000..b032735 --- /dev/null +++ b/tradingagents/graph/conditional_logic.py @@ -0,0 +1,73 @@ +# TradingAgents/graph/conditional_logic.py + +from tradingagents.agents.utils.agent_states import AgentState + + +class ConditionalLogic: + """Handles conditional logic for determining graph flow.""" + + def __init__(self, max_debate_rounds=1, max_risk_discuss_rounds=1): + """Initialize with configuration parameters.""" + self.max_debate_rounds = max_debate_rounds + self.max_risk_discuss_rounds = max_risk_discuss_rounds + + def should_continue_market(self, state: AgentState): + """Determine if market analysis should continue.""" + messages = state["messages"] + last_message = messages[-1] + if last_message.tool_calls: + return "tools_market" + return "Msg Clear Market" + + def should_continue_social(self, state: AgentState): + """Determine if sentiment-analyst tool round should continue. + + Method name keeps the legacy ``social`` suffix to match the + ``AnalystType.SOCIAL = "social"`` wire value (saved-config + back-compat); the returned ``clear_node`` label uses the v0.2.5 + rename so it matches the node registered by the execution plan. + """ + messages = state["messages"] + last_message = messages[-1] + if last_message.tool_calls: + return "tools_social" + return "Msg Clear Sentiment" + + def should_continue_news(self, state: AgentState): + """Determine if news analysis should continue.""" + messages = state["messages"] + last_message = messages[-1] + if last_message.tool_calls: + return "tools_news" + return "Msg Clear News" + + def should_continue_fundamentals(self, state: AgentState): + """Determine if fundamentals analysis should continue.""" + messages = state["messages"] + last_message = messages[-1] + if last_message.tool_calls: + return "tools_fundamentals" + return "Msg Clear Fundamentals" + + def should_continue_debate(self, state: AgentState) -> str: + """Determine if debate should continue.""" + + if ( + state["investment_debate_state"]["count"] >= 2 * self.max_debate_rounds + ): # 3 rounds of back-and-forth between 2 agents + return "Research Manager" + if state["investment_debate_state"]["current_response"].startswith("Bull"): + return "Bear Researcher" + return "Bull Researcher" + + def should_continue_risk_analysis(self, state: AgentState) -> str: + """Determine if risk analysis should continue.""" + if ( + state["risk_debate_state"]["count"] >= 3 * self.max_risk_discuss_rounds + ): # 3 rounds of back-and-forth between 3 agents + return "Portfolio Manager" + if state["risk_debate_state"]["latest_speaker"].startswith("Aggressive"): + return "Conservative Analyst" + if state["risk_debate_state"]["latest_speaker"].startswith("Conservative"): + return "Neutral Analyst" + return "Aggressive Analyst" diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py new file mode 100644 index 0000000..2a6aedd --- /dev/null +++ b/tradingagents/graph/propagation.py @@ -0,0 +1,84 @@ +# TradingAgents/graph/propagation.py + +from typing import Any + +from tradingagents.agents.utils.agent_states import ( + InvestDebateState, + RiskDebateState, +) + + +class Propagator: + """Handles state initialization and propagation through the graph.""" + + def __init__(self, max_recur_limit=100): + """Initialize with configuration parameters.""" + self.max_recur_limit = max_recur_limit + + def create_initial_state( + self, + company_name: str, + trade_date: str, + asset_type: str = "stock", + past_context: str = "", + instrument_context: str = "", + ) -> dict[str, Any]: + """Create the initial state for the agent graph. + + ``instrument_context`` is the deterministic ticker-identity string + resolved once at run start (see + ``TradingAgentsGraph.resolve_instrument_context``). When empty, agents + fall back to ticker-only context via + ``get_instrument_context_from_state``. + """ + return { + "messages": [("human", company_name)], + "company_of_interest": company_name, + "asset_type": asset_type, + "instrument_context": instrument_context, + "trade_date": str(trade_date), + "past_context": past_context, + "investment_debate_state": InvestDebateState( + { + "bull_history": "", + "bear_history": "", + "history": "", + "current_response": "", + "judge_decision": "", + "count": 0, + } + ), + "risk_debate_state": RiskDebateState( + { + "aggressive_history": "", + "conservative_history": "", + "neutral_history": "", + "history": "", + "latest_speaker": "", + "current_aggressive_response": "", + "current_conservative_response": "", + "current_neutral_response": "", + "judge_decision": "", + "count": 0, + } + ), + "market_report": "", + "fundamentals_report": "", + "sentiment_report": "", + "news_report": "", + } + + def get_graph_args(self, callbacks: list | None = None) -> dict[str, Any]: + """Get arguments for the graph invocation. + + Args: + callbacks: Optional list of callback handlers for tool execution tracking. + Note: LLM callbacks are handled separately via LLM constructor. + """ + config = {"recursion_limit": self.max_recur_limit} + if callbacks: + config["callbacks"] = callbacks + return { + "stream_mode": "values", + "config": config, + } diff --git a/tradingagents/graph/reflection.py b/tradingagents/graph/reflection.py new file mode 100644 index 0000000..0685941 --- /dev/null +++ b/tradingagents/graph/reflection.py @@ -0,0 +1,57 @@ +# TradingAgents/graph/reflection.py + +from typing import Any + + +class Reflector: + """Handles reflection on trading decisions.""" + + def __init__(self, quick_thinking_llm: Any): + """Initialize the reflector with an LLM.""" + self.quick_thinking_llm = quick_thinking_llm + self.log_reflection_prompt = self._get_log_reflection_prompt() + + def _get_log_reflection_prompt(self) -> str: + """Concise prompt for reflect_on_final_decision (Phase B log entries). + + Produces 2-4 sentences of plain prose — compact enough to be re-injected + into future agent prompts without bloating the context window. + """ + return ( + "You are a trading analyst reviewing your own past decision now that the outcome is known.\n" + "Write exactly 2-4 sentences of plain prose (no bullets, no headers, no markdown).\n\n" + "Cover in order:\n" + "1. Was the directional call correct? (cite the alpha figure)\n" + "2. Which part of the investment thesis held or failed?\n" + "3. One concrete lesson to apply to the next similar analysis.\n\n" + "Be specific and terse. Your output will be stored verbatim in a decision log " + "and re-read by future analysts, so every word must earn its place." + ) + + def reflect_on_final_decision( + self, + final_decision: str, + raw_return: float, + alpha_return: float, + benchmark_name: str = "SPY", + ) -> str: + """Single reflection call on the final trade decision with outcome context. + + Used by Phase B deferred reflection. The final_trade_decision already + synthesises all analyst insights, so no separate market context is needed. + ``benchmark_name`` is the label used for the alpha line (e.g. ``"SPY"`` + for US tickers, ``"^N225"`` for ``.T`` listings); defaults to SPY for + callers that haven't been updated to thread the benchmark through. + """ + messages = [ + ("system", self.log_reflection_prompt), + ( + "human", + ( + f"Raw return: {raw_return:+.1%}\n" + f"Alpha vs {benchmark_name}: {alpha_return:+.1%}\n\n" + f"Final Decision:\n{final_decision}" + ), + ), + ] + return self.quick_thinking_llm.invoke(messages).content diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py new file mode 100644 index 0000000..171b656 --- /dev/null +++ b/tradingagents/graph/setup.py @@ -0,0 +1,156 @@ +# TradingAgents/graph/setup.py + +from typing import Any + +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode + +from tradingagents.agents import ( + create_aggressive_debator, + create_bear_researcher, + create_bull_researcher, + create_conservative_debator, + create_fundamentals_analyst, + create_market_analyst, + create_msg_delete, + create_neutral_debator, + create_news_analyst, + create_portfolio_manager, + create_research_manager, + create_sentiment_analyst, + create_trader, +) +from tradingagents.agents.utils.agent_states import AgentState + +from .analyst_execution import build_analyst_execution_plan +from .conditional_logic import ConditionalLogic + +# Every target a shared conditional router can return. Each edge driven by the +# router maps all of them, so a fall-through return (e.g. under prompt/i18n/ +# refactor drift in the speaker labels) can never hit a missing path_map entry +# and crash LangGraph mid-run (#1088). +DEBATE_PATH_MAP = { + "Bull Researcher": "Bull Researcher", + "Bear Researcher": "Bear Researcher", + "Research Manager": "Research Manager", +} +RISK_ANALYSIS_PATH_MAP = { + "Aggressive Analyst": "Aggressive Analyst", + "Conservative Analyst": "Conservative Analyst", + "Neutral Analyst": "Neutral Analyst", + "Portfolio Manager": "Portfolio Manager", +} + + +class GraphSetup: + """Handles the setup and configuration of the agent graph.""" + + def __init__( + self, + quick_thinking_llm: Any, + deep_thinking_llm: Any, + tool_nodes: dict[str, ToolNode], + conditional_logic: ConditionalLogic, + ): + """Initialize with required components.""" + self.quick_thinking_llm = quick_thinking_llm + self.deep_thinking_llm = deep_thinking_llm + self.tool_nodes = tool_nodes + self.conditional_logic = conditional_logic + + def setup_graph( + self, selected_analysts=("market", "social", "news", "fundamentals") + ): + """Set up and compile the agent workflow graph. + + Args: + selected_analysts (list): List of analyst types to include. Options are: + - "market": Market analyst + - "social": Social media analyst + - "news": News analyst + - "fundamentals": Fundamentals analyst + """ + plan = build_analyst_execution_plan(selected_analysts) + + analyst_factories = { + "market": lambda: create_market_analyst(self.quick_thinking_llm), + "social": lambda: create_sentiment_analyst(self.quick_thinking_llm), + "news": lambda: create_news_analyst(self.quick_thinking_llm), + "fundamentals": lambda: create_fundamentals_analyst(self.quick_thinking_llm), + } + + # Create researcher and manager nodes + bull_researcher_node = create_bull_researcher(self.quick_thinking_llm) + bear_researcher_node = create_bear_researcher(self.quick_thinking_llm) + research_manager_node = create_research_manager(self.deep_thinking_llm) + trader_node = create_trader(self.quick_thinking_llm) + + # Create risk analysis nodes + aggressive_analyst = create_aggressive_debator(self.quick_thinking_llm) + neutral_analyst = create_neutral_debator(self.quick_thinking_llm) + conservative_analyst = create_conservative_debator(self.quick_thinking_llm) + portfolio_manager_node = create_portfolio_manager(self.deep_thinking_llm) + + # Create workflow + workflow = StateGraph(AgentState) + + # Add analyst nodes to the graph + for spec in plan.specs: + workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) + workflow.add_node(spec.clear_node, create_msg_delete()) + workflow.add_node(spec.tool_node, self.tool_nodes[spec.key]) + + # Add other nodes + workflow.add_node("Bull Researcher", bull_researcher_node) + workflow.add_node("Bear Researcher", bear_researcher_node) + workflow.add_node("Research Manager", research_manager_node) + workflow.add_node("Trader", trader_node) + workflow.add_node("Aggressive Analyst", aggressive_analyst) + workflow.add_node("Neutral Analyst", neutral_analyst) + workflow.add_node("Conservative Analyst", conservative_analyst) + workflow.add_node("Portfolio Manager", portfolio_manager_node) + + # Define edges + # Start with the first analyst + workflow.add_edge(START, plan.specs[0].agent_node) + + # Connect analysts in sequence + for i, spec in enumerate(plan.specs): + current_analyst = spec.agent_node + current_tools = spec.tool_node + current_clear = spec.clear_node + + # Add conditional edges for current analyst + workflow.add_conditional_edges( + current_analyst, + getattr(self.conditional_logic, f"should_continue_{spec.key}"), + [current_tools, current_clear], + ) + workflow.add_edge(current_tools, current_analyst) + + # Connect to next analyst or to Bull Researcher if this is the last analyst + if i < len(plan.specs) - 1: + workflow.add_edge(current_clear, plan.specs[i + 1].agent_node) + else: + workflow.add_edge(current_clear, "Bull Researcher") + + # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). + for debate_node in ("Bull Researcher", "Bear Researcher"): + workflow.add_conditional_edges( + debate_node, + self.conditional_logic.should_continue_debate, + DEBATE_PATH_MAP, + ) + workflow.add_edge("Research Manager", "Trader") + workflow.add_edge("Trader", "Aggressive Analyst") + # All three risk edges share the complete RISK_ANALYSIS_PATH_MAP (#1088). + for risk_node in ("Aggressive Analyst", "Conservative Analyst", "Neutral Analyst"): + workflow.add_conditional_edges( + risk_node, + self.conditional_logic.should_continue_risk_analysis, + RISK_ANALYSIS_PATH_MAP, + ) + + workflow.add_edge("Portfolio Manager", END) + + return workflow diff --git a/tradingagents/graph/signal_processing.py b/tradingagents/graph/signal_processing.py new file mode 100644 index 0000000..90fafd0 --- /dev/null +++ b/tradingagents/graph/signal_processing.py @@ -0,0 +1,31 @@ +"""Extract the 5-tier portfolio rating from the Portfolio Manager's decision. + +The Portfolio Manager produces a typed ``PortfolioDecision`` via structured +output and renders it to markdown that always carries a ``**Rating**: X`` +header (see :func:`tradingagents.agents.schemas.render_pm_decision`). The +deterministic heuristic in :mod:`tradingagents.agents.utils.rating` is more +than sufficient to extract that rating; no extra LLM call is needed. + +This module exists for backwards compatibility with callers that expect a +``SignalProcessor.process_signal(text)`` interface. +""" + +from __future__ import annotations + +from typing import Any + +from tradingagents.agents.utils.rating import parse_rating + + +class SignalProcessor: + """Read the 5-tier rating out of a Portfolio Manager decision.""" + + def __init__(self, quick_thinking_llm: Any = None): + # The LLM argument is accepted for backwards compatibility but no + # longer used: the PM's structured output guarantees the rating is + # parseable from the rendered markdown without a second LLM call. + self.quick_thinking_llm = quick_thinking_llm + + def process_signal(self, full_signal: str) -> str: + """Return one of Buy / Overweight / Hold / Underweight / Sell.""" + return parse_rating(full_signal) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py new file mode 100644 index 0000000..a190217 --- /dev/null +++ b/tradingagents/graph/trading_graph.py @@ -0,0 +1,528 @@ +# TradingAgents/graph/trading_graph.py + +import json +import logging +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +import yfinance as yf +from langgraph.prebuilt import ToolNode + +# Import the abstract tool methods from agent_utils +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + get_balance_sheet, + get_cashflow, + get_fundamentals, + get_global_news, + get_income_statement, + get_indicators, + get_insider_transactions, + get_macro_indicators, + get_news, + get_prediction_markets, + get_stock_data, + get_verified_market_snapshot, + resolve_instrument_identity, +) +from tradingagents.agents.utils.memory import TradingMemoryLog +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.utils import safe_ticker_component +from tradingagents.default_config import DEFAULT_CONFIG +from tradingagents.llm_clients import create_llm_client +from tradingagents.reporting import write_report_tree + +from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id +from .conditional_logic import ConditionalLogic +from .propagation import Propagator +from .reflection import Reflector +from .setup import GraphSetup +from .signal_processing import SignalProcessor + +logger = logging.getLogger(__name__) + + +def _coerce_max_retries(value): + """Validate an ``llm_max_retries`` value to a non-negative int. + + Accepts an int or a numeric string (env vars arrive as strings). Rejects + booleans and negatives loudly so a misconfiguration fails at startup rather + than silently disabling retries. + """ + if isinstance(value, bool): + raise ValueError(f"llm_max_retries must be an integer, not a boolean: {value!r}") + try: + n = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"llm_max_retries must be an integer, got {value!r}") from exc + if n < 0: + raise ValueError(f"llm_max_retries must be >= 0, got {n}") + return n + + +class TradingAgentsGraph: + """Main class that orchestrates the trading agents framework.""" + + def __init__( + self, + selected_analysts=("market", "social", "news", "fundamentals"), + debug=False, + config: dict[str, Any] = None, + callbacks: list | None = None, + ): + """Initialize the trading agents graph and components. + + Args: + selected_analysts: List of analyst types to include + debug: Whether to run in debug mode + config: Configuration dictionary. If None, uses default config + callbacks: Optional list of callback handlers (e.g., for tracking LLM/tool stats) + """ + self.debug = debug + self.config = config or DEFAULT_CONFIG + self.callbacks = callbacks or [] + + # Update the interface's config + set_config(self.config) + + # Create necessary directories + os.makedirs(self.config["data_cache_dir"], exist_ok=True) + os.makedirs(self.config["results_dir"], exist_ok=True) + + # Initialize LLMs with provider-specific thinking configuration + llm_kwargs = self._get_provider_kwargs() + + # Add callbacks to kwargs if provided (passed to LLM constructor) + if self.callbacks: + llm_kwargs["callbacks"] = self.callbacks + + deep_client = create_llm_client( + provider=self.config["llm_provider"], + model=self.config["deep_think_llm"], + base_url=self.config.get("backend_url"), + **llm_kwargs, + ) + quick_client = create_llm_client( + provider=self.config["llm_provider"], + model=self.config["quick_think_llm"], + base_url=self.config.get("backend_url"), + **llm_kwargs, + ) + + self.deep_thinking_llm = deep_client.get_llm() + self.quick_thinking_llm = quick_client.get_llm() + + self.memory_log = TradingMemoryLog(self.config) + + # Create tool nodes + self.tool_nodes = self._create_tool_nodes() + + # Initialize components + self.conditional_logic = ConditionalLogic( + max_debate_rounds=self.config["max_debate_rounds"], + max_risk_discuss_rounds=self.config["max_risk_discuss_rounds"], + ) + self.graph_setup = GraphSetup( + self.quick_thinking_llm, + self.deep_thinking_llm, + self.tool_nodes, + self.conditional_logic, + ) + + self.propagator = Propagator( + max_recur_limit=self.config.get("max_recur_limit", 100), + ) + self.reflector = Reflector(self.quick_thinking_llm) + self.signal_processor = SignalProcessor(self.quick_thinking_llm) + + # State tracking + self.curr_state = None + self.ticker = None + self.log_states_dict = {} # date to full state dict + + # Graph-shape-affecting run choices, kept for the checkpoint signature. + self.selected_analysts = tuple(selected_analysts) + + # Set up the graph: keep the workflow for recompilation with a checkpointer. + self.workflow = self.graph_setup.setup_graph(selected_analysts) + self.graph = self.workflow.compile() + self._checkpointer_ctx = None + + def _get_provider_kwargs(self) -> dict[str, Any]: + """Get provider-specific kwargs for LLM client creation.""" + kwargs = {} + provider = self.config.get("llm_provider", "").lower() + + if provider == "google": + thinking_level = self.config.get("google_thinking_level") + if thinking_level: + kwargs["thinking_level"] = thinking_level + + elif provider == "openai": + reasoning_effort = self.config.get("openai_reasoning_effort") + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort + + elif provider == "anthropic": + effort = self.config.get("anthropic_effort") + if effort: + kwargs["effort"] = effort + + # Sampling temperature is cross-provider: forward it whenever set. + # float() here so a value coming from a TRADINGAGENTS_TEMPERATURE env + # string ("0.2") works the same as a programmatic float. + temperature = self.config.get("temperature") + if temperature is not None and temperature != "": + kwargs["temperature"] = float(temperature) + + # SDK retry budget is cross-provider. Forward it only when explicitly set + # so each provider keeps its own default (usually 2) otherwise (#1091). + max_retries = self.config.get("llm_max_retries") + if max_retries is not None and max_retries != "": + kwargs["max_retries"] = _coerce_max_retries(max_retries) + + return kwargs + + def _create_tool_nodes(self) -> dict[str, ToolNode]: + """Create tool nodes for different data sources using abstract methods.""" + return { + "market": ToolNode( + [ + # Core stock data tools + get_stock_data, + # Technical indicators + get_indicators, + # Deterministic verification snapshot (bound to the analyst + # LLM and required by its prompt; must be executable here or + # the call fails and the model reports it "unavailable"). + get_verified_market_snapshot, + ] + ), + "social": ToolNode( + [ + # News tools for social media analysis + get_news, + ] + ), + "news": ToolNode( + [ + # News and insider information + get_news, + get_global_news, + get_insider_transactions, + get_macro_indicators, + get_prediction_markets, + ] + ), + "fundamentals": ToolNode( + [ + # Fundamental analysis tools + get_fundamentals, + get_balance_sheet, + get_cashflow, + get_income_statement, + ] + ), + } + + def _resolve_benchmark(self, ticker: str) -> str: + """Pick the benchmark ticker for alpha calculation against ``ticker``. + + ``config["benchmark_ticker"]`` overrides everything when set; otherwise + the suffix map matches the ticker's exchange suffix (e.g. ``.T`` for + Tokyo). US-listed tickers without a dotted suffix fall through to the + empty-suffix entry (SPY by default). Unrecognised suffixes (including + US tickers with dots like ``BRK.B``) also fall back to the empty-suffix + entry, which is the right default because the alpha calculation works + in USD. + """ + explicit = self.config.get("benchmark_ticker") + if explicit: + return explicit + benchmark_map = self.config.get("benchmark_map", {}) + ticker_upper = ticker.upper() + for suffix, benchmark in benchmark_map.items(): + if suffix and ticker_upper.endswith(suffix.upper()): + return benchmark + return benchmark_map.get("", "SPY") + + def _fetch_returns( + self, ticker: str, trade_date: str, holding_days: int = 5, + benchmark: str = "SPY", + ) -> tuple[float | None, float | None, int | None]: + """Fetch raw and alpha return for ticker over holding_days from trade_date. + + ``benchmark`` is the index used as the alpha baseline (resolved by the + caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return, + actual_holding_days)`` or ``(None, None, None)`` if price data is + unavailable (too recent, delisted, or network error). + """ + from tradingagents.dataflows.symbol_utils import normalize_symbol + + try: + start = datetime.strptime(trade_date, "%Y-%m-%d") + end = start + timedelta(days=holding_days + 7) # buffer for weekends/holidays + end_str = end.strftime("%Y-%m-%d") + + # Normalize so the realized-return lookup hits the same instrument + # the analysis priced (e.g. XAUUSD -> GC=F) (#984). The benchmark is + # already a canonical Yahoo symbol from ``_resolve_benchmark``. + stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str) + bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str) + + if len(stock) < 2 or len(bench) < 2: + return None, None, None + + actual_days = min(holding_days, len(stock) - 1, len(bench) - 1) + raw = float( + (stock["Close"].iloc[actual_days] - stock["Close"].iloc[0]) + / stock["Close"].iloc[0] + ) + bench_ret = float( + (bench["Close"].iloc[actual_days] - bench["Close"].iloc[0]) + / bench["Close"].iloc[0] + ) + alpha = raw - bench_ret + return raw, alpha, actual_days + except Exception as e: + logger.warning( + "Could not resolve outcome for %s on %s vs %s (will retry next run): %s", + ticker, trade_date, benchmark, e, + ) + return None, None, None + + def _resolve_pending_entries(self, ticker: str) -> None: + """Resolve pending log entries for ticker at the start of a new run. + + Fetches returns for each same-ticker pending entry, generates reflections, + then writes all updates in a single atomic batch write to avoid redundant I/O. + Skips entries whose price data is not yet available (too recent or delisted). + + Trade-off: only same-ticker entries are resolved per run. Entries for + other tickers accumulate until that ticker is run again. + """ + pending = [e for e in self.memory_log.get_pending_entries() if e["ticker"] == ticker] + if not pending: + return + + benchmark = self._resolve_benchmark(ticker) + updates = [] + for entry in pending: + raw, alpha, days = self._fetch_returns( + ticker, entry["date"], benchmark=benchmark, + ) + if raw is None: + continue # price not available yet — try again next run + reflection = self.reflector.reflect_on_final_decision( + final_decision=entry.get("decision", ""), + raw_return=raw, + alpha_return=alpha, + benchmark_name=benchmark, + ) + updates.append({ + "ticker": ticker, + "trade_date": entry["date"], + "raw_return": raw, + "alpha_return": alpha, + "holding_days": days, + "reflection": reflection, + }) + + if updates: + self.memory_log.batch_update_with_outcomes(updates) + + def resolve_instrument_context(self, ticker: str, asset_type: str = "stock") -> str: + """Resolve ticker identity once and return the full instrument context. + + Deterministic yfinance lookup (cached, fail-open) injected into a + context string so every agent anchors to the real company instead of + hallucinating one from the price chart (#814). Both the propagate() + path and the CLI call this so the resolved identity reaches the whole + graph regardless of entry point. + """ + identity = resolve_instrument_identity(ticker) + return build_instrument_context(ticker, asset_type, identity) + + def _run_signature(self, asset_type: str) -> str: + """Graph-shape inputs that must invalidate a checkpoint if changed. + + Keyed into the checkpoint thread ID so a resume under a different analyst + selection, debate/risk depth, or asset mode starts fresh instead of + silently continuing the previous graph (#1089). + """ + return "|".join([ + "analysts=" + ",".join(self.selected_analysts), + f"debate={self.config['max_debate_rounds']}", + f"risk={self.config['max_risk_discuss_rounds']}", + f"asset={asset_type}", + ]) + + def propagate(self, company_name, trade_date, asset_type: str = "stock"): + """Run the trading agents graph for a company on a specific date. + + ``asset_type`` selects between the stock pipeline (default) and the + crypto pipeline (``"crypto"``) shipped in #567 — the CLI auto-detects + from the ticker; programmatic callers pass it explicitly. When + ``checkpoint_enabled`` is set in config, the graph is recompiled with + a per-ticker SqliteSaver so a crashed run can resume from the last + successful node on a subsequent invocation with the same ticker+date. + """ + self.ticker = company_name + + # Resolve any pending memory-log entries for this ticker before the pipeline runs. + self._resolve_pending_entries(company_name) + + # Recompile with a checkpointer if the user opted in. + if self.config.get("checkpoint_enabled"): + self._checkpointer_ctx = get_checkpointer( + self.config["data_cache_dir"], company_name + ) + saver = self._checkpointer_ctx.__enter__() + self.graph = self.workflow.compile(checkpointer=saver) + + step = checkpoint_step( + self.config["data_cache_dir"], company_name, str(trade_date), + self._run_signature(asset_type), + ) + if step is not None: + logger.info( + "Resuming from step %d for %s on %s", step, company_name, trade_date + ) + else: + logger.info("Starting fresh for %s on %s", company_name, trade_date) + + try: + return self._run_graph(company_name, trade_date, asset_type=asset_type) + finally: + if self._checkpointer_ctx is not None: + self._checkpointer_ctx.__exit__(None, None, None) + self._checkpointer_ctx = None + self.graph = self.workflow.compile() + + def save_reports(self, final_state, ticker, save_path=None) -> Path: + """Write the markdown report tree for a completed run, like the CLI does. + + Programmatic callers get the same on-disk reports the CLI produces. Pass + an explicit ``save_path`` or let it default under ``results_dir``. + """ + if save_path is None: + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + save_path = ( + Path(self.config["results_dir"]) + / "reports" + / f"{safe_ticker_component(ticker)}_{stamp}" + ) + return write_report_tree(final_state, ticker, save_path) + + def _run_graph(self, company_name, trade_date, asset_type: str = "stock"): + """Execute the graph and write the resulting state to disk and memory log.""" + # Initialize state — inject memory log context for PM and the + # deterministically resolved instrument identity for all agents. + past_context = self.memory_log.get_past_context(company_name) + instrument_context = self.resolve_instrument_context(company_name, asset_type) + init_agent_state = self.propagator.create_initial_state( + company_name, + trade_date, + asset_type=asset_type, + past_context=past_context, + instrument_context=instrument_context, + ) + args = self.propagator.get_graph_args() + + # Inject thread_id so same ticker+date+graph-shape resumes; a different + # date or graph shape starts fresh (#1089). + if self.config.get("checkpoint_enabled"): + tid = thread_id(company_name, str(trade_date), self._run_signature(asset_type)) + args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = tid + + if self.debug: + trace = [] + last_printed = None + for chunk in self.graph.stream(init_agent_state, **args): + if chunk["messages"]: + msg = chunk["messages"][-1] + # Nodes after the trader don't append to messages, so the + # same trailing message repeats across chunks. Print it only + # when it changes (#1027); the trace/state merge is unchanged. + signature = (type(msg).__name__, getattr(msg, "content", None)) + if signature != last_printed: + msg.pretty_print() + last_printed = signature + trace.append(chunk) + # Streamed chunks are per-node deltas. Merge them so the returned + # state matches what graph.invoke() yields in the non-debug path. + final_state = {} + for chunk in trace: + final_state.update(chunk) + else: + final_state = self.graph.invoke(init_agent_state, **args) + + # Store current state for reflection. + self.curr_state = final_state + + # Log state to disk. + self._log_state(trade_date, final_state) + + # Store decision for deferred reflection on the next same-ticker run. + self.memory_log.store_decision( + ticker=company_name, + trade_date=trade_date, + final_trade_decision=final_state["final_trade_decision"], + ) + + # Clear checkpoint on successful completion to avoid stale state. + if self.config.get("checkpoint_enabled"): + clear_checkpoint( + self.config["data_cache_dir"], company_name, str(trade_date), + self._run_signature(asset_type), + ) + + return final_state, self.process_signal(final_state["final_trade_decision"]) + + def _log_state(self, trade_date, final_state): + """Log the final state to a JSON file.""" + self.log_states_dict[str(trade_date)] = { + "company_of_interest": final_state["company_of_interest"], + "trade_date": final_state["trade_date"], + "market_report": final_state["market_report"], + "sentiment_report": final_state["sentiment_report"], + "news_report": final_state["news_report"], + "fundamentals_report": final_state["fundamentals_report"], + "investment_debate_state": { + "bull_history": final_state["investment_debate_state"]["bull_history"], + "bear_history": final_state["investment_debate_state"]["bear_history"], + "history": final_state["investment_debate_state"]["history"], + "current_response": final_state["investment_debate_state"][ + "current_response" + ], + "judge_decision": final_state["investment_debate_state"][ + "judge_decision" + ], + }, + "trader_investment_decision": final_state["trader_investment_plan"], + "risk_debate_state": { + "aggressive_history": final_state["risk_debate_state"]["aggressive_history"], + "conservative_history": final_state["risk_debate_state"]["conservative_history"], + "neutral_history": final_state["risk_debate_state"]["neutral_history"], + "history": final_state["risk_debate_state"]["history"], + "judge_decision": final_state["risk_debate_state"]["judge_decision"], + }, + "investment_plan": final_state["investment_plan"], + "final_trade_decision": final_state["final_trade_decision"], + } + + # Save to file. Reject ticker values that would escape the + # results directory when joined as a path component. + safe_ticker = safe_ticker_component(self.ticker) + directory = Path(self.config["results_dir"]) / safe_ticker / "TradingAgentsStrategy_logs" + directory.mkdir(parents=True, exist_ok=True) + + log_path = directory / f"full_states_log_{trade_date}.json" + with open(log_path, "w", encoding="utf-8") as f: + json.dump(self.log_states_dict[str(trade_date)], f, indent=4) + + def process_signal(self, full_signal): + """Process a signal to extract the core decision.""" + return self.signal_processor.process_signal(full_signal) diff --git a/tradingagents/llm_clients/__init__.py b/tradingagents/llm_clients/__init__.py new file mode 100644 index 0000000..e528eab --- /dev/null +++ b/tradingagents/llm_clients/__init__.py @@ -0,0 +1,4 @@ +from .base_client import BaseLLMClient +from .factory import create_llm_client + +__all__ = ["BaseLLMClient", "create_llm_client"] diff --git a/tradingagents/llm_clients/anthropic_client.py b/tradingagents/llm_clients/anthropic_client.py new file mode 100644 index 0000000..04afee0 --- /dev/null +++ b/tradingagents/llm_clients/anthropic_client.py @@ -0,0 +1,78 @@ +import re +from typing import Any + +from langchain_anthropic import ChatAnthropic + +from .base_client import BaseLLMClient, normalize_content +from .validators import validate_model + +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "api_key", "max_tokens", "temperature", + "callbacks", "http_client", "http_async_client", "effort", +) + +# Anthropic's extended-thinking ``effort`` parameter is accepted by Opus 4.5+, +# Sonnet 4.6+, and the Claude 5 family (Sonnet 5, Fable 5). Sonnet 4.5 and any +# Haiku version 400 with ``"This model does not support the effort parameter"`` +# (#831). Versions may be dotted (``opus-4-8``) or single-number (``sonnet-5``, +# ``fable-5``); the per-family minimum below is forward-compatible. +_EFFORT_EXACT = { + "claude-mythos-preview", # non-standard preview name; effort-capable + "claude-mythos-5", # Fable 5 twin (Project Glasswing); effort-capable +} +_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?$") +_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)} + + +def _supports_effort(model: str) -> bool: + """Whether Anthropic accepts the ``effort`` parameter for this model.""" + model_lc = model.lower() + if model_lc in _EFFORT_EXACT: + return True + match = _EFFORT_MODEL.match(model_lc) + if not match: + return False + family = match.group(1) + major = int(match.group(2)) + minor = int(match.group(3)) if match.group(3) else 0 + return (major, minor) >= _EFFORT_MIN_VERSION[family] + + +class NormalizedChatAnthropic(ChatAnthropic): + """ChatAnthropic with normalized content output. + + Claude models with extended thinking or tool use return content as a + list of typed blocks. This normalizes to string for consistent + downstream handling. + """ + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + +class AnthropicClient(BaseLLMClient): + """Client for Anthropic Claude models.""" + + def __init__(self, model: str, base_url: str | None = None, **kwargs): + super().__init__(model, base_url, **kwargs) + + def get_llm(self) -> Any: + """Return configured ChatAnthropic instance.""" + self.warn_if_unknown_model() + llm_kwargs = {"model": self.model} + + if self.base_url: + llm_kwargs["base_url"] = self.base_url + + for key in _PASSTHROUGH_KWARGS: + if key not in self.kwargs: + continue + if key == "effort" and not _supports_effort(self.model): + continue + llm_kwargs[key] = self.kwargs[key] + + return NormalizedChatAnthropic(**llm_kwargs) + + def validate_model(self) -> bool: + """Validate model for Anthropic.""" + return validate_model("anthropic", self.model) diff --git a/tradingagents/llm_clients/api_key_env.py b/tradingagents/llm_clients/api_key_env.py new file mode 100644 index 0000000..b97db38 --- /dev/null +++ b/tradingagents/llm_clients/api_key_env.py @@ -0,0 +1,53 @@ +"""Canonical provider -> API-key env-var mapping. + +A single source of truth for which environment variable holds the API +key for each supported LLM provider. Used by the CLI's interactive key +prompt (cli/utils.ensure_api_key) and by anything else that needs to +ask "does this provider require a key, and which env var is it?". + +When adding a new provider, register its env var here so the CLI flow +prompts for it automatically instead of failing on first API call. +""" + +from __future__ import annotations + +PROVIDER_API_KEY_ENV: dict[str, str | None] = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "azure": "AZURE_OPENAI_API_KEY", + # Bedrock authenticates via the AWS credential chain, not a single key env. + "bedrock": None, + "xai": "XAI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + # Dual-region providers each carry their own account; keys are not + # interchangeable between the international and China endpoints. + "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", + # Additional hosted OpenAI-compatible providers (model is user-specified). + # kimi -> Moonshot AI; nvidia -> NVIDIA NIM. + "mistral": "MISTRAL_API_KEY", + "kimi": "MOONSHOT_API_KEY", + "groq": "GROQ_API_KEY", + "nvidia": "NVIDIA_API_KEY", + # Local runtimes do not authenticate. + "ollama": None, + # Generic OpenAI-compatible endpoint: the client reads this when set (keyed + # relays), but it is marked key-optional in the provider registry so the CLI + # never forces a prompt and keyless local servers still work. + "openai_compatible": "OPENAI_COMPATIBLE_API_KEY", +} + + +def get_api_key_env(provider: str) -> str | None: + """Return the env var name for `provider`'s API key, or None if not applicable. + + Unknown providers also return None — callers should treat that as + "no key check possible" rather than as "no key required". + """ + return PROVIDER_API_KEY_ENV.get(provider.lower()) diff --git a/tradingagents/llm_clients/azure_client.py b/tradingagents/llm_clients/azure_client.py new file mode 100644 index 0000000..f6f996e --- /dev/null +++ b/tradingagents/llm_clients/azure_client.py @@ -0,0 +1,51 @@ +import os +from typing import Any + +from langchain_openai import AzureChatOpenAI + +from .base_client import BaseLLMClient, normalize_content + +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "api_key", "reasoning_effort", "temperature", + "callbacks", "http_client", "http_async_client", +) + + +class NormalizedAzureChatOpenAI(AzureChatOpenAI): + """AzureChatOpenAI with normalized content output.""" + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + +class AzureOpenAIClient(BaseLLMClient): + """Client for Azure OpenAI deployments. + + Requires environment variables: + AZURE_OPENAI_API_KEY: API key + AZURE_OPENAI_ENDPOINT: Endpoint URL (e.g. https://.openai.azure.com/) + AZURE_OPENAI_DEPLOYMENT_NAME: Deployment name + OPENAI_API_VERSION: API version (e.g. 2025-03-01-preview) + """ + + def __init__(self, model: str, base_url: str | None = None, **kwargs): + super().__init__(model, base_url, **kwargs) + + def get_llm(self) -> Any: + """Return configured AzureChatOpenAI instance.""" + self.warn_if_unknown_model() + + llm_kwargs = { + "model": self.model, + "azure_deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", self.model), + } + + for key in _PASSTHROUGH_KWARGS: + if key in self.kwargs: + llm_kwargs[key] = self.kwargs[key] + + return NormalizedAzureChatOpenAI(**llm_kwargs) + + def validate_model(self) -> bool: + """Azure accepts any deployed model name.""" + return True diff --git a/tradingagents/llm_clients/base_client.py b/tradingagents/llm_clients/base_client.py new file mode 100644 index 0000000..cb56e25 --- /dev/null +++ b/tradingagents/llm_clients/base_client.py @@ -0,0 +1,62 @@ +import warnings +from abc import ABC, abstractmethod +from typing import Any + + +def normalize_content(response): + """Normalize LLM response content to a plain string. + + Multiple providers (OpenAI Responses API, Google Gemini 3) return content + as a list of typed blocks, e.g. [{'type': 'reasoning', ...}, {'type': 'text', 'text': '...'}]. + Downstream agents expect response.content to be a string. This extracts + and joins the text blocks, discarding reasoning/metadata blocks. + """ + content = response.content + if isinstance(content, list): + texts = [ + item.get("text", "") if isinstance(item, dict) and item.get("type") == "text" + else item if isinstance(item, str) else "" + for item in content + ] + response.content = "\n".join(t for t in texts if t) + return response + + +class BaseLLMClient(ABC): + """Abstract base class for LLM clients.""" + + def __init__(self, model: str, base_url: str | None = None, **kwargs): + self.model = model + self.base_url = base_url + self.kwargs = kwargs + + def get_provider_name(self) -> str: + """Return the provider name used in warning messages.""" + provider = getattr(self, "provider", None) + if provider: + return str(provider) + return self.__class__.__name__.removesuffix("Client").lower() + + def warn_if_unknown_model(self) -> None: + """Warn when the model is outside the known list for the provider.""" + if self.validate_model(): + return + + warnings.warn( + ( + f"Model '{self.model}' is not in the known model list for " + f"provider '{self.get_provider_name()}'. Continuing anyway." + ), + RuntimeWarning, + stacklevel=2, + ) + + @abstractmethod + def get_llm(self) -> Any: + """Return the configured LLM instance.""" + pass + + @abstractmethod + def validate_model(self) -> bool: + """Validate that the model is supported by this client.""" + pass diff --git a/tradingagents/llm_clients/bedrock_client.py b/tradingagents/llm_clients/bedrock_client.py new file mode 100644 index 0000000..406c4ff --- /dev/null +++ b/tradingagents/llm_clients/bedrock_client.py @@ -0,0 +1,76 @@ +import os +from typing import Any + +from .base_client import BaseLLMClient, normalize_content +from .validators import validate_model + +# Bedrock has no global default region; us-west-2 hosts the broadest model set. +_DEFAULT_REGION = "us-west-2" +_BEDROCK_CLASS = None + + +def _bedrock_class(): + """Lazily import langchain-aws (the optional ``[bedrock]`` extra) and return a + ChatBedrockConverse subclass with normalized content output. + + Imported on demand so the optional dependency (and boto3) isn't required by + the rest of the package; cached after the first call. + """ + global _BEDROCK_CLASS + if _BEDROCK_CLASS is not None: + return _BEDROCK_CLASS + + try: + from langchain_aws import ChatBedrockConverse + except ImportError as exc: + raise ImportError( + "AWS Bedrock support requires the optional 'langchain-aws' dependency. " + 'Install it with: pip install "tradingagents[bedrock]"' + ) from exc + + class NormalizedChatBedrockConverse(ChatBedrockConverse): + """ChatBedrockConverse with normalized (string) content output.""" + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + _BEDROCK_CLASS = NormalizedChatBedrockConverse + return _BEDROCK_CLASS + + +class BedrockClient(BaseLLMClient): + """Client for Amazon Bedrock via the Converse API (langchain-aws). + + Authentication is either a Bedrock API key (bearer token) via + ``AWS_BEARER_TOKEN_BEDROCK`` — no AWS access keys required — or the standard + AWS credential chain (env vars, ``~/.aws/credentials``, or an IAM role) with + optional ``AWS_PROFILE``. Set ``AWS_REGION`` / ``AWS_DEFAULT_REGION`` either + way (the token carries no region). The model name is a Bedrock model ID or + cross-region inference profile ID, e.g. ``us.anthropic.claude-opus-4-8-v1:0``. + """ + + def get_llm(self) -> Any: + """Return a configured ChatBedrockConverse instance.""" + self.warn_if_unknown_model() + chat_cls = _bedrock_class() + + region = ( + os.environ.get("AWS_REGION") + or os.environ.get("AWS_DEFAULT_REGION") + or _DEFAULT_REGION + ) + llm_kwargs = {"model": self.model, "region_name": region} + # A Bedrock API key authenticates without AWS access keys. Passing it as + # api_key makes langchain-aws prefer bearer auth, so an ambient + # AWS_PROFILE / SigV4 credentials can't override it (#1103). + bearer_token = os.environ.get("AWS_BEARER_TOKEN_BEDROCK") + if bearer_token: + llm_kwargs["api_key"] = bearer_token + for key in ("temperature", "max_tokens", "max_retries", "callbacks"): + if key in self.kwargs: + llm_kwargs[key] = self.kwargs[key] + return chat_cls(**llm_kwargs) + + def validate_model(self) -> bool: + """Validate model for Bedrock (any model ID accepted).""" + return validate_model("bedrock", self.model) diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py new file mode 100644 index 0000000..d3e9c57 --- /dev/null +++ b/tradingagents/llm_clients/capabilities.py @@ -0,0 +1,126 @@ +"""Declarative per-model capability table for OpenAI-compatible providers. + +This is the single place that knows which model IDs reject which API +parameters or require which structured-output method. The LLM client +subclasses consult ``get_capabilities(model_name)`` instead of hardcoding +model-name ``if`` ladders, so adding a new model (or a new provider quirk) +means editing this table — not the client code. + +Pattern adapted from the per-model ``compat:`` flags DeepSeek themselves +publish in their integration guides (e.g. the Oh My Pi config schema +documents ``supportsToolChoice``, ``requiresReasoningContentForToolCalls`` +as declarative per-model fields). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal + +StructuredMethod = Literal[ + "function_calling", # uses tools; respects supports_tool_choice + "json_mode", # uses response_format={"type":"json_object"} + "json_schema", # uses response_format={"type":"json_schema",...} + "none", # no structured output available; caller falls back to free-text +] + + +@dataclass(frozen=True) +class ModelCapabilities: + """What an OpenAI-compatible model accepts at the API level.""" + + supports_tool_choice: bool + supports_json_mode: bool + supports_json_schema: bool + preferred_structured_method: StructuredMethod + # DeepSeek thinking-mode models 400 if reasoning_content from prior + # assistant turns is not echoed back on the next request. + requires_reasoning_content_roundtrip: bool = False + # MiniMax M2.x reasoning models need ``reasoning_split=True`` so the + # block lands in ``reasoning_details`` instead of polluting + # ``content``. The flag is rejected by non-reasoning MiniMax models + # (Coding Plan, MiniMax-Text-01, etc.), so we only set it where the + # model actually consumes it. (#826) + requires_reasoning_split: bool = False + + +# DeepSeek's thinking models accept the ``tools`` array but reject the +# ``tool_choice`` parameter (official Oh My Pi integration guide and the +# 400 response in issue #678). Their official tool-calling examples +# (api-docs.deepseek.com/guides/tool_calls) pass ``tools=[...]`` without +# ``tool_choice`` — we mirror that pattern by setting supports_tool_choice +# to False and letting the client suppress the kwarg. +_DEEPSEEK_THINKING = ModelCapabilities( + supports_tool_choice=False, + supports_json_mode=True, + supports_json_schema=False, + preferred_structured_method="function_calling", + requires_reasoning_content_roundtrip=True, +) + +_DEEPSEEK_CHAT = ModelCapabilities( + supports_tool_choice=True, + supports_json_mode=True, + supports_json_schema=False, + preferred_structured_method="function_calling", +) + +# MiniMax M2.x reasoning models accept the tools array, but their +# tool_choice parameter is restricted to the enum {"none", "auto"} +# (platform.minimax.io/docs/api-reference/text-post). Langchain's +# function_calling path sends tool_choice as a function-spec dict, which +# MiniMax 400s — same shape as the DeepSeek bug. supports_tool_choice=False +# makes the dispatch in NormalizedChatOpenAI suppress the kwarg; the schema +# still ships as a tool. json_mode response_format is only for +# MiniMax-Text-01, not M2.x. +_MINIMAX_THINKING = ModelCapabilities( + supports_tool_choice=False, + supports_json_mode=False, + supports_json_schema=False, + preferred_structured_method="function_calling", + requires_reasoning_split=True, +) + +_DEFAULT = ModelCapabilities( + supports_tool_choice=True, + supports_json_mode=True, + supports_json_schema=True, + preferred_structured_method="function_calling", +) + + +# Exact-ID matches take precedence over pattern matches. +_BY_ID: dict[str, ModelCapabilities] = { + "deepseek-chat": _DEEPSEEK_CHAT, + "deepseek-reasoner": _DEEPSEEK_THINKING, + "deepseek-v4-flash": _DEEPSEEK_THINKING, + "deepseek-v4-pro": _DEEPSEEK_THINKING, + # MiniMax — full official model lineup per + # platform.minimax.io/docs/api-reference/text-openai-api + "MiniMax-M2.7": _MINIMAX_THINKING, + "MiniMax-M2.7-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.5": _MINIMAX_THINKING, + "MiniMax-M2.5-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.1": _MINIMAX_THINKING, + "MiniMax-M2.1-highspeed": _MINIMAX_THINKING, + "MiniMax-M2": _MINIMAX_THINKING, +} + +# Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` +# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. +_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ + (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), + (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), + (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), +] + + +def get_capabilities(model_name: str) -> ModelCapabilities: + """Resolve capabilities by exact ID, then pattern, then default.""" + if model_name in _BY_ID: + return _BY_ID[model_name] + for pattern, caps in _BY_PATTERN: + if pattern.match(model_name): + return caps + return _DEFAULT diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py new file mode 100644 index 0000000..02c1cf4 --- /dev/null +++ b/tradingagents/llm_clients/factory.py @@ -0,0 +1,54 @@ + +from .base_client import BaseLLMClient + + +def create_llm_client( + provider: str, + model: str, + base_url: str | None = None, + **kwargs, +) -> BaseLLMClient: + """Create an LLM client for the specified provider. + + Provider modules are imported lazily so that simply importing this + factory (e.g. during test collection) does not pull in heavy LLM SDKs + or fail when their API keys are absent. + + Args: + provider: LLM provider name + model: Model name/identifier + base_url: Optional base URL for API endpoint + **kwargs: Additional provider-specific arguments + + Returns: + Configured BaseLLMClient instance + + Raises: + ValueError: If provider is not supported + """ + provider_lower = provider.lower() + + # Native (non-OpenAI) APIs are matched first so their string check doesn't + # import the OpenAI client. Everything else is OpenAI-compatible and routes + # through the provider registry (single source of truth). + if provider_lower == "anthropic": + from .anthropic_client import AnthropicClient + return AnthropicClient(model, base_url, **kwargs) + + if provider_lower == "google": + from .google_client import GoogleClient + return GoogleClient(model, base_url, **kwargs) + + if provider_lower == "azure": + from .azure_client import AzureOpenAIClient + return AzureOpenAIClient(model, base_url, **kwargs) + + if provider_lower == "bedrock": + from .bedrock_client import BedrockClient + return BedrockClient(model, base_url, **kwargs) + + from .openai_client import OpenAIClient, is_openai_compatible + if is_openai_compatible(provider_lower): + return OpenAIClient(model, base_url, provider=provider_lower, **kwargs) + + raise ValueError(f"Unsupported LLM provider: {provider}") diff --git a/tradingagents/llm_clients/google_client.py b/tradingagents/llm_clients/google_client.py new file mode 100644 index 0000000..93bb1d1 --- /dev/null +++ b/tradingagents/llm_clients/google_client.py @@ -0,0 +1,57 @@ +from typing import Any + +from langchain_google_genai import ChatGoogleGenerativeAI + +from .base_client import BaseLLMClient, normalize_content +from .validators import validate_model + + +class NormalizedChatGoogleGenerativeAI(ChatGoogleGenerativeAI): + """ChatGoogleGenerativeAI with normalized content output. + + Gemini 3 models return content as list of typed blocks. + This normalizes to string for consistent downstream handling. + """ + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + +class GoogleClient(BaseLLMClient): + """Client for Google Gemini models.""" + + def __init__(self, model: str, base_url: str | None = None, **kwargs): + super().__init__(model, base_url, **kwargs) + + def get_llm(self) -> Any: + """Return configured ChatGoogleGenerativeAI instance.""" + self.warn_if_unknown_model() + llm_kwargs = {"model": self.model} + + if self.base_url: + llm_kwargs["base_url"] = self.base_url + + for key in ("timeout", "max_retries", "temperature", "callbacks", "http_client", "http_async_client"): + if key in self.kwargs: + llm_kwargs[key] = self.kwargs[key] + + # Unified api_key maps to provider-specific google_api_key + google_api_key = self.kwargs.get("api_key") or self.kwargs.get("google_api_key") + if google_api_key: + llm_kwargs["google_api_key"] = google_api_key + + # Gemini 3.x takes the string ``thinking_level`` (the integer + # ``thinking_budget`` was for the now-retired 2.5 line). Pro accepts + # low/high; Flash also accepts minimal/medium — so map an unsupported + # "minimal" on Pro to the nearest level it does accept. + thinking_level = self.kwargs.get("thinking_level") + if thinking_level: + if "pro" in self.model.lower() and thinking_level == "minimal": + thinking_level = "low" + llm_kwargs["thinking_level"] = thinking_level + + return NormalizedChatGoogleGenerativeAI(**llm_kwargs) + + def validate_model(self) -> bool: + """Validate model for Google.""" + return validate_model("google", self.model) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py new file mode 100644 index 0000000..3dd7171 --- /dev/null +++ b/tradingagents/llm_clients/model_catalog.py @@ -0,0 +1,210 @@ +"""Shared model catalog for CLI selections and validation.""" + +from __future__ import annotations + +ModelOption = tuple[str, str] +ProviderModeOptions = dict[str, dict[str, list[ModelOption]]] + +# Providers that serve many / frequently-changing models: offer only "Custom +# model ID" rather than a list that goes stale. +_CUSTOM_ONLY: dict[str, list[ModelOption]] = { + "quick": [("Custom model ID", "custom")], + "deep": [("Custom model ID", "custom")], +} + + +# Shared model list for GLM via Z.AI (international) and BigModel (China). +# Source: docs.z.ai (GLM Coding Plan supported models + LLM guides). +# All GLM 4.7+ entries support thinking mode via thinking={"type":"enabled"}. +_GLM_MODELS: dict[str, list[ModelOption]] = { + "quick": [ + ("GLM-5-Turbo - Fast, switchable thinking modes", "glm-5-turbo"), + ("GLM-4.7 - Previous-gen flagship", "glm-4.7"), + ("GLM-4.5-Air - Lightweight, cost-efficient", "glm-4.5-air"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("GLM-5.2 - Latest flagship, 1M ctx", "glm-5.2"), + ("GLM-5.1 - 745B, 200K ctx", "glm-5.1"), + ("GLM-5 - Flagship, 204K ctx", "glm-5"), + ("GLM-4.7 - Previous-gen flagship", "glm-4.7"), + ("Custom model ID", "custom"), + ], +} + + +# Shared model list for Qwen's global (dashscope-intl) and CN (dashscope) endpoints. +# Source: modelstudio.console.alibabacloud.com (Featured Models — Flagship + Cost-optimized). +# +# Only versioned IDs are exposed in the dropdown. The version-less aliases +# (qwen-plus, qwen-flash) are documented by Alibaba as auto-upgrading +# pointers ("backbone, latest, and snapshot ... have been upgraded to the +# Qwen3 series"), which means their behavior shifts when Alibaba rotates +# the backing model. Users who want a specific generation pick it +# explicitly; users who really want auto-latest can enter the alias via +# "Custom model ID". +_QWEN_MODELS: dict[str, list[ModelOption]] = { + "quick": [ + ("Qwen 3.7 Plus - Latest, balanced speed/cost", "qwen3.7-plus"), + ("Qwen 3.6 Plus - Previous-gen balanced", "qwen3.6-plus"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("Qwen 3.7 Max - Latest flagship, most intelligent, 1M ctx", "qwen3.7-max"), + ("Qwen 3.6 Max - Previous-gen flagship", "qwen3.6-max"), + ("Qwen 3.7 Plus - Balanced alternative", "qwen3.7-plus"), + ("Custom model ID", "custom"), + ], +} + + +# Shared model list for MiniMax's global and CN endpoints (same IDs). +# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api. +# M3 carries a 1M-token context window; the M2.x line is 204,800 tokens. +_MINIMAX_MODELS: dict[str, list[ModelOption]] = { + "quick": [ + ("MiniMax-M3 - Latest, 1M ctx, native multimodal", "MiniMax-M3"), + ("MiniMax-M2.7-highspeed - Fast M2.7, 204K ctx, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5-highspeed - Previous-gen highspeed, 204K ctx", "MiniMax-M2.5-highspeed"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("MiniMax-M3 - Latest flagship, 1M ctx, multimodal coding/agent", "MiniMax-M3"), + ("MiniMax-M2.7 - Previous flagship, 204K ctx", "MiniMax-M2.7"), + ("MiniMax-M2.7-highspeed - Same quality as M2.7, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5 - Earlier flagship, 204K ctx", "MiniMax-M2.5"), + ("Custom model ID", "custom"), + ], +} + + +MODEL_OPTIONS: ProviderModeOptions = { + "openai": { + "quick": [ + ("GPT-5.4 Mini - Fast, strong coding and tool use", "gpt-5.4-mini"), + ("GPT-5.4 Nano - Cheapest, high-volume tasks", "gpt-5.4-nano"), + ("GPT-5.5 - Latest frontier, 1M context", "gpt-5.5"), + ], + "deep": [ + ("GPT-5.5 - Latest frontier, 1M context", "gpt-5.5"), + ("GPT-5.4 - Previous-gen frontier, 1M context, cost-effective", "gpt-5.4"), + ("GPT-5.2 - Strong reasoning, cost-effective", "gpt-5.2"), + ("GPT-5.5 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.5-pro"), + ], + }, + "anthropic": { + "quick": [ + ("Claude Sonnet 5 - Best speed and intelligence balance", "claude-sonnet-5"), + ("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"), + ], + "deep": [ + ("Claude Fable 5 - Most capable, long-running agents", "claude-fable-5"), + ("Claude Opus 4.8 - Frontier agentic coding and reasoning", "claude-opus-4-8"), + ("Claude Sonnet 5 - Near-frontier intelligence at Sonnet cost", "claude-sonnet-5"), + ("Claude Opus 4.7 - Previous frontier, long-running agents", "claude-opus-4-7"), + ], + }, + "google": { + "quick": [ + ("Gemini 3.5 Flash - Latest, frontier agentic + coding (GA)", "gemini-3.5-flash"), + ("Gemini 3.1 Flash Lite - Most cost-efficient", "gemini-3.1-flash-lite"), + ], + "deep": [ + ("Gemini 3.1 Pro - Reasoning-first, complex workflows (preview)", "gemini-3.1-pro-preview"), + ("Gemini 3.5 Flash - Latest GA, strong agentic + coding", "gemini-3.5-flash"), + ], + }, + "xai": { + "quick": [ + ("Grok 4.3 - Latest flagship, fast with built-in reasoning", "grok-4.3"), + ("Grok 4.20 (Non-Reasoning) - Speed-optimized", "grok-4.20-0309-non-reasoning"), + ("Grok Build 0.1 - Coding-specialized, 256K ctx", "grok-build-0.1"), + ], + "deep": [ + ("Grok 4.3 - Latest flagship, built-in reasoning, 1M ctx", "grok-4.3"), + ("Grok 4.20 (Reasoning) - Previous-gen reasoning", "grok-4.20-0309-reasoning"), + ("Grok 4.20 Multi-Agent - Multi-agent reasoning", "grok-4.20-multi-agent-0309"), + ], + }, + # DeepSeek: the deepseek-chat / deepseek-reasoner aliases are deprecated + # (2026-07-24) and now map to V4 Flash; expose the V4 IDs directly. V4 Flash + # serves both non-thinking and thinking modes (the DeepSeekChatOpenAI client + # handles the reasoning_content round-trip). + "deepseek": { + "quick": [ + ("DeepSeek V4 Flash - Latest fast model, thinking + non-thinking", "deepseek-v4-flash"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("DeepSeek V4 Pro - Latest flagship", "deepseek-v4-pro"), + ("DeepSeek V4 Flash - Fast, supports thinking", "deepseek-v4-flash"), + ("Custom model ID", "custom"), + ], + }, + # Qwen: same model IDs across global (dashscope-intl) and China + # (dashscope) endpoints, so the two provider keys share one model list. + "qwen": _QWEN_MODELS, + "qwen-cn": _QWEN_MODELS, + # GLM: Z.AI (international) and BigModel (China) host the same model + # IDs; the two provider keys share one model list. + "glm": _GLM_MODELS, + "glm-cn": _GLM_MODELS, + # MiniMax: same model IDs across global (.io) and China (.com) regions, + # so the two provider keys share one model list. + "minimax": _MINIMAX_MODELS, + "minimax-cn": _MINIMAX_MODELS, + # OpenRouter: fetched dynamically. Azure: any deployed model name. + # Ollama display labels intentionally omit a "local" marker — the + # endpoint is now configurable via OLLAMA_BASE_URL, so the same labels + # apply whether the user runs ollama-serve on localhost or against a + # remote host. The actual resolved endpoint is surfaced separately by + # cli.utils.confirm_ollama_endpoint() right after provider selection. + # "Custom model ID" lets users pick any model they have pulled via + # `ollama pull` beyond the three suggested defaults. + "ollama": { + "quick": [ + ("Qwen3:latest (8B)", "qwen3:latest"), + ("GPT-OSS:latest (20B)", "gpt-oss:latest"), + ("GLM-4.7-Flash:latest (30B)", "glm-4.7-flash:latest"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("GLM-4.7-Flash:latest (30B)", "glm-4.7-flash:latest"), + ("GPT-OSS:latest (20B)", "gpt-oss:latest"), + ("Qwen3:latest (8B)", "qwen3:latest"), + ("Custom model ID", "custom"), + ], + }, + # Generic OpenAI-compatible endpoint: the model is whatever the user's + # server serves, so only "Custom model ID" is offered. + "openai_compatible": _CUSTOM_ONLY, + # Hosted OpenAI-compatible providers that serve many (and frequently + # changing) models — offer "Custom model ID" rather than a list that goes + # stale. The endpoint + key are wired by the provider; the user picks the + # model their account has access to. + "mistral": _CUSTOM_ONLY, + "kimi": _CUSTOM_ONLY, + "groq": _CUSTOM_ONLY, + "nvidia": _CUSTOM_ONLY, + # Bedrock model IDs / cross-region inference profile IDs are user-specified. + "bedrock": _CUSTOM_ONLY, +} + + +def get_model_options(provider: str, mode: str) -> list[ModelOption]: + """Return shared model options for a provider and selection mode.""" + return MODEL_OPTIONS[provider.lower()][mode] + + +def get_known_models() -> dict[str, list[str]]: + """Build known model names from the shared CLI catalog.""" + return { + provider: sorted( + { + value + for options in mode_options.values() + for _, value in options + } + ) + for provider, mode_options in MODEL_OPTIONS.items() + } diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py new file mode 100644 index 0000000..bf6e554 --- /dev/null +++ b/tradingagents/llm_clients/openai_client.py @@ -0,0 +1,337 @@ +import os +import re +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +from langchain_core.messages import AIMessage +from langchain_openai import ChatOpenAI + +from .api_key_env import get_api_key_env +from .base_client import BaseLLMClient, normalize_content +from .capabilities import get_capabilities +from .validators import validate_model + + +class NormalizedChatOpenAI(ChatOpenAI): + """ChatOpenAI with normalized content output and capability-aware binding. + + The Responses API returns content as a list of typed blocks + (reasoning, text, etc.). ``invoke`` normalizes to string for + consistent downstream handling. + + ``with_structured_output`` consults the per-model capability table + (``capabilities.get_capabilities``) to pick the method and to decide + whether ``tool_choice`` may be sent. Models that reject ``tool_choice`` + (e.g. DeepSeek V4 and reasoner — per their official tool-calling + guide) still bind the schema as a tool, but no ``tool_choice`` + parameter is sent. + + Provider-specific quirks beyond structured-output (e.g. DeepSeek's + reasoning_content roundtrip) live in subclasses so this base class + stays small. + """ + + def invoke(self, input, config=None, **kwargs): + return normalize_content(super().invoke(input, config, **kwargs)) + + def with_structured_output(self, schema, *, method=None, **kwargs): + caps = get_capabilities(self.model_name) + if caps.preferred_structured_method == "none": + raise NotImplementedError( + f"{self.model_name} has no structured-output method available; " + f"agent factories will fall back to free-text generation." + ) + method = method or caps.preferred_structured_method + # When the model rejects tool_choice, suppress langchain's hardcoded + # value. The schema is still bound as a tool — exactly what + # DeepSeek's official tool-calling examples do. + if method == "function_calling" and not caps.supports_tool_choice: + kwargs.setdefault("tool_choice", None) + return super().with_structured_output(schema, method=method, **kwargs) + + +class LocalCompatibleChatOpenAI(NormalizedChatOpenAI): + """OpenAI-compatible client for arbitrary local servers (LM Studio, vLLM, + llama.cpp via the generic ``openai_compatible`` provider). + + Their tool-calling support varies, and many reject the object-form + ``tool_choice`` langchain sends for function-calling structured output. Bind + the schema as a tool but don't force tool_choice, so structured output works + across local servers regardless of the model ID's capabilities (#1057). + """ + + def with_structured_output(self, schema, *, method=None, **kwargs): + resolved = method or get_capabilities(self.model_name).preferred_structured_method + if resolved == "function_calling": + kwargs.setdefault("tool_choice", None) + return super().with_structured_output(schema, method=method, **kwargs) + + +def _input_to_messages(input_: Any) -> list: + """Normalise a langchain LLM input to a list of message objects. + + Accepts a list of messages, a ``ChatPromptValue`` (from a + ChatPromptTemplate), or anything else (treated as no messages). + Used by providers that need to walk the outgoing message history; + in particular DeepSeek thinking-mode propagation must work for + both bare-list invocations and ChatPromptTemplate-driven ones, so + treating only ``list`` here would silently skip half the call sites. + """ + if isinstance(input_, list): + return input_ + if hasattr(input_, "to_messages"): + return input_.to_messages() + return [] + + +class DeepSeekChatOpenAI(NormalizedChatOpenAI): + """DeepSeek-specific overrides on top of the OpenAI-compatible client. + + Thinking-mode round-trip is the only DeepSeek-specific behavior that + stays here. When DeepSeek's thinking models return a response with + ``reasoning_content``, that field must be echoed back as part of the + assistant message on the next turn or the API fails with HTTP 400. + ``_create_chat_result`` captures it on receive and + ``_get_request_payload`` re-attaches it on send. + + Tool-choice handling for V4 and reasoner — those models reject the + ``tool_choice`` parameter — is handled by the capability dispatch in + ``NormalizedChatOpenAI.with_structured_output``, not here. + """ + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + outgoing = payload.get("messages", []) + for message_dict, message in zip(outgoing, _input_to_messages(input_), strict=False): + if not isinstance(message, AIMessage): + continue + reasoning = message.additional_kwargs.get("reasoning_content") + if reasoning is not None: + message_dict["reasoning_content"] = reasoning + return payload + + def _create_chat_result(self, response, generation_info=None): + chat_result = super()._create_chat_result(response, generation_info) + response_dict = ( + response + if isinstance(response, dict) + else response.model_dump( + exclude={"choices": {"__all__": {"message": {"parsed"}}}} + ) + ) + for generation, choice in zip( + chat_result.generations, response_dict.get("choices", []), strict=False + ): + reasoning = choice.get("message", {}).get("reasoning_content") + if reasoning is not None: + generation.message.additional_kwargs["reasoning_content"] = reasoning + return chat_result + + +class MinimaxChatOpenAI(NormalizedChatOpenAI): + """MiniMax-specific overrides on top of the OpenAI-compatible client. + + M2.x reasoning models embed ``...`` blocks directly in + ``message.content`` by default, which would pollute saved reports. + Per platform.minimax.io/docs/api-reference/text-openai-api, + ``reasoning_split=True`` redirects the thinking block into + ``reasoning_details`` so ``content`` stays clean. It is sent via + ``extra_body`` (not a top-level kwarg) because the openai SDK validates + top-level params and rejects unknown ones like reasoning_split (#826). + + The flag is gated by ``ModelCapabilities.requires_reasoning_split`` so + only M2.x reasoning models receive it; non-reasoning MiniMax endpoints + (Coding Plan, MiniMax-Text-01) never see it. + + Tool-choice handling for M2.x — those models accept only the string + enum ``{"none", "auto"}`` and reject langchain's function-spec dict — + is handled by the capability dispatch in + ``NormalizedChatOpenAI.with_structured_output``, not here. + """ + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + if get_capabilities(self.model_name).requires_reasoning_split: + # Pass via extra_body, not as a top-level kwarg: the openai SDK + # (>=1.56) validates top-level params against Completions.create + # and rejects unknown ones like reasoning_split (#826). extra_body + # is forwarded into the request body untouched. + extra_body = payload.setdefault("extra_body", {}) + extra_body.setdefault("reasoning_split", True) + return payload + + +# Kwargs forwarded from user config to ChatOpenAI +_PASSTHROUGH_KWARGS = ( + "timeout", "max_retries", "reasoning_effort", "temperature", + "api_key", "callbacks", "http_client", "http_async_client", +) + +# OpenAI's ``reasoning_effort`` is only accepted by reasoning models — the GPT-5 +# family and the o-series. Non-reasoning models (gpt-4.1, gpt-4o, ...) 400 with +# "Unsupported parameter: 'reasoning.effort' is not supported with this model". +# Drop the kwarg for those rather than crash the run. +_OPENAI_REASONING_MODEL = re.compile(r"^(gpt-5|o[1-9])") + + +def _supports_reasoning_effort(model: str) -> bool: + """Whether the (native OpenAI) model accepts ``reasoning_effort``.""" + return bool(_OPENAI_REASONING_MODEL.match(model.lower().strip())) + + +@dataclass(frozen=True) +class ProviderSpec: + """Declarative config for one OpenAI-compatible provider. + + The OpenAI-compatible family (OpenAI, xAI, DeepSeek, Qwen, GLM, MiniMax, + OpenRouter, Ollama, and any user endpoint) all speak the same Chat + Completions API and differ only by these fields — so one row here replaces + the former per-provider base-URL dict, auth handling, and client-class + branches. Native Anthropic / Google use their own clients (genuinely + different APIs) and are intentionally NOT in this registry. + + The API-key env var stays in ``api_key_env.PROVIDER_API_KEY_ENV`` (the single + source consulted by both this client and the CLI prompt); only behavior that + is provider-specific (base URL, key optionality, wire-format quirks via + ``chat_class``) lives here. + """ + + chat_class: type = NormalizedChatOpenAI # provider quirks live in the subclass + base_url: str | None = None # default endpoint (None -> SDK default) + base_url_env: str | None = None # env var that overrides base_url (e.g. OLLAMA_BASE_URL) + key_optional: bool = False # don't require/prompt; send a placeholder if unset + placeholder_key: str = "EMPTY" # sent when no key is available (keyless local servers) + require_base_url: bool = False # error if no base_url is resolved (generic endpoint) + use_responses_api: bool = False # native OpenAI Responses API + + +# Single source of truth for the OpenAI-compatible provider family. Dual-region +# providers (qwen/glm/minimax) keep separate endpoints because international and +# China accounts cannot share credentials (#758). +OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = { + "openai": ProviderSpec(use_responses_api=True), + "xai": ProviderSpec(base_url="https://api.x.ai/v1"), + "deepseek": ProviderSpec(base_url="https://api.deepseek.com", chat_class=DeepSeekChatOpenAI), + "qwen": ProviderSpec(base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), + "qwen-cn": ProviderSpec(base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"), + "glm": ProviderSpec(base_url="https://api.z.ai/api/paas/v4/"), + "glm-cn": ProviderSpec(base_url="https://open.bigmodel.cn/api/paas/v4/"), + "minimax": ProviderSpec(base_url="https://api.minimax.io/v1", chat_class=MinimaxChatOpenAI), + "minimax-cn": ProviderSpec(base_url="https://api.minimaxi.com/v1", chat_class=MinimaxChatOpenAI), + "openrouter": ProviderSpec(base_url="https://openrouter.ai/api/v1"), + "mistral": ProviderSpec(base_url="https://api.mistral.ai/v1"), + "kimi": ProviderSpec(base_url="https://api.moonshot.ai/v1"), + "groq": ProviderSpec(base_url="https://api.groq.com/openai/v1"), + "nvidia": ProviderSpec(base_url="https://integrate.api.nvidia.com/v1"), + "ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL", + key_optional=True, placeholder_key="ollama"), + # Generic endpoint: user supplies base_url; key optional (keyless local). + "openai_compatible": ProviderSpec( + require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI + ), +} + + +def is_openai_compatible(provider: str) -> bool: + """Whether ``provider`` is served by the OpenAI-compatible registry.""" + return provider.lower() in OPENAI_COMPATIBLE_PROVIDERS + + +def _is_native_openai_base_url(base_url: str | None) -> bool: + """True when ``base_url`` is unset or points at api.openai.com. + + The Responses API (/v1/responses) only exists on native OpenAI. A custom + base_url on the ``openai`` provider (a proxy, gateway, or local server) + speaks only Chat Completions, so the Responses API must stay off there even + though the provider spec enables it (#1024). + """ + if not base_url: + return True + if "://" not in base_url: + base_url = "https://" + base_url + host = urlparse(base_url).hostname or "" + return host == "api.openai.com" or host.endswith(".openai.com") + + +class OpenAIClient(BaseLLMClient): + """Client for OpenAI, Ollama, OpenRouter, and xAI providers. + + For native OpenAI models, uses the Responses API (/v1/responses) which + supports reasoning_effort with function tools across all model families + (GPT-4.1, GPT-5). Third-party compatible providers (xAI, OpenRouter, + Ollama) use standard Chat Completions. + """ + + def __init__( + self, + model: str, + base_url: str | None = None, + provider: str = "openai", + **kwargs, + ): + super().__init__(model, base_url, **kwargs) + self.provider = provider.lower() + + def get_llm(self) -> Any: + """Return a configured ChatOpenAI instance, driven by the provider registry.""" + self.warn_if_unknown_model() + llm_kwargs = {"model": self.model} + spec = OPENAI_COMPATIBLE_PROVIDERS.get(self.provider) + chat_cls = NormalizedChatOpenAI + + if spec is not None: + chat_cls = spec.chat_class + + # base_url precedence: explicit client base_url (carries the config / + # TRADINGAGENTS_LLM_BACKEND_URL value) > provider env override (e.g. + # OLLAMA_BASE_URL) > provider default. None means use the SDK default. + env_base_url = os.environ.get(spec.base_url_env) if spec.base_url_env else None + base_url = self.base_url or env_base_url or spec.base_url + if spec.require_base_url and not base_url: + raise ValueError( + f"Provider '{self.provider}' requires a base_url. Set it via " + "backend_url / TRADINGAGENTS_LLM_BACKEND_URL to your endpoint, " + "e.g. http://localhost:8000/v1 (vLLM) or http://localhost:1234/v1 " + "(LM Studio)." + ) + if base_url: + llm_kwargs["base_url"] = base_url + + # API key: required unless key_optional; keyless local servers get a + # placeholder. The env-var name is the single source in api_key_env. + api_key_env = get_api_key_env(self.provider) + api_key = os.environ.get(api_key_env) if api_key_env else None + if api_key: + llm_kwargs["api_key"] = api_key + elif spec.key_optional: + llm_kwargs["api_key"] = spec.placeholder_key + elif api_key_env: + raise ValueError( + f"API key for provider '{self.provider}' is not set. " + f"Please set the {api_key_env} environment variable " + f"(e.g. add {api_key_env}=your_key to your .env file)." + ) + + # The Responses API only exists on native OpenAI; if the user points + # the openai provider at a custom base_url (proxy/gateway/local), it + # only speaks Chat Completions, so keep Responses off there (#1024). + if spec.use_responses_api and _is_native_openai_base_url(base_url): + llm_kwargs["use_responses_api"] = True + elif self.base_url: + llm_kwargs["base_url"] = self.base_url + + # Forward user-provided kwargs + for key in _PASSTHROUGH_KWARGS: + if key not in self.kwargs: + continue + if key == "reasoning_effort" and not _supports_reasoning_effort(self.model): + continue + llm_kwargs[key] = self.kwargs[key] + + # The subclass (provider quirks) comes from the registry spec. + return chat_cls(**llm_kwargs) + + def validate_model(self) -> bool: + """Validate model for the provider.""" + return validate_model(self.provider, self.model) diff --git a/tradingagents/llm_clients/validators.py b/tradingagents/llm_clients/validators.py new file mode 100644 index 0000000..b03cfb8 --- /dev/null +++ b/tradingagents/llm_clients/validators.py @@ -0,0 +1,33 @@ +"""Model name validators for each provider.""" + +from .model_catalog import get_known_models + +# Providers whose model names are user-defined (local servers, relays, hosted +# OpenAI-compatible endpoints serving many models), so any model string is +# accepted without warning. +_ANY_MODEL_PROVIDERS = ( + "ollama", "openrouter", "openai_compatible", + "mistral", "kimi", "groq", "nvidia", "bedrock", +) + +VALID_MODELS = { + provider: models + for provider, models in get_known_models().items() + if provider not in _ANY_MODEL_PROVIDERS +} + + +def validate_model(provider: str, model: str) -> bool: + """Check if model name is valid for the given provider. + + For ollama, openrouter, and openai_compatible - any model is accepted. + """ + provider_lower = provider.lower() + + if provider_lower in _ANY_MODEL_PROVIDERS: + return True + + if provider_lower not in VALID_MODELS: + return True + + return model in VALID_MODELS[provider_lower] diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py new file mode 100644 index 0000000..f89d6a0 --- /dev/null +++ b/tradingagents/reporting.py @@ -0,0 +1,101 @@ +"""Reusable report-tree writer shared by the CLI and the programmatic API. + +Writes a run's per-section markdown (analysts, research, trading, risk, +portfolio) plus a consolidated ``complete_report.md`` under ``save_path``. The +CLI and ``TradingAgentsGraph.save_reports`` both call this, so a headless / API +run produces the same on-disk report tree a CLI run does. +""" + +from datetime import datetime +from pathlib import Path + + +def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: + """Save a completed run's reports to ``save_path``; return the complete-report path.""" + save_path = Path(save_path) + 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(("Sentiment 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.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"