feat: TradingAgents-Astock v0.2.4 — A股深度特化版
- 适配A股数据源(mootdx+腾讯财经+akshare),全免费无积分墙 - 新增3个A股特化分析师(政策/游资/解禁),共7位AI分析师 - A股交易规则约束(T+1/涨跌停/最小手数/ST标识) - 内置Streamlit Web UI + PDF报告导出 - 支持MiniMax/DeepSeek/智谱/通义/OpenAI/Anthropic等多家LLM - 沪深300基准替代SPY
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
.claude
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
__pycache__
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
results
|
||||
eval_results
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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=
|
||||
ZHIPU_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
@@ -0,0 +1,219 @@
|
||||
# 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/
|
||||
@@ -0,0 +1,266 @@
|
||||
# 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.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
|
||||
@@ -0,0 +1,409 @@
|
||||
# Changes from Upstream TradingAgents
|
||||
|
||||
本文件记录本 Fork 相对于 [TauricResearch/TradingAgents](https://github.com/TauricResearch/TradingAgents) 上游 commit `7e9e7b8`(2026-05-04)的所有改动。
|
||||
|
||||
---
|
||||
|
||||
## Week 1 — 数据层 A 股落地
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `tradingagents/dataflows/a_stock.py` | A 股数据 vendor,~400 行,9 个接口方法 |
|
||||
| `test_astock.py` | E2E 集成测试脚本 |
|
||||
| `.env` | 环境变量模板(已加入 .gitignore) |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/dataflows/interface.py` | 注册 `a_stock` vendor,9 个方法路由到 `a_stock.py` |
|
||||
| `tradingagents/default_config.py` | `data_vendors` 全部默认改为 `"a_stock"`;新增 `output_language` 配置项 |
|
||||
|
||||
### 数据源架构
|
||||
|
||||
| 来源 | 协议 | 数据内容 |
|
||||
|------|------|---------|
|
||||
| mootdx | TCP 7709 | OHLCV K 线、财务快照、F10 文本 |
|
||||
| 腾讯财经 | HTTP (`qt.gtimg.cn`) | PE / PB / 市值 / 换手率 |
|
||||
| akshare | Python 库 | 新闻、财报三表(sina 源)、股票信息 |
|
||||
|
||||
### 踩坑记录
|
||||
|
||||
- mootdx `datetime` 列冲突:需先 `drop` 再 `reset_index`
|
||||
- akshare eastmoney 财报接口崩溃:改用 sina 源
|
||||
- Kimi API 用 `ANTHROPIC_AUTH_TOKEN`(Bearer),非 `ANTHROPIC_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Week 2 — Agent 特化与新角色
|
||||
|
||||
### P0:中文输出切换
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/default_config.py` | 新增 `output_language: "Chinese"` |
|
||||
| `tradingagents/agents/utils/agent_utils.py` | 新增 `get_language_instruction()` 函数 |
|
||||
|
||||
当 `output_language` 为 `"Chinese"` 时,所有用户面向的 Agent 输出中文报告;Bull/Bear 辩论和三方风险辩论保持英文(推理质量 > 输出语言一致性)。
|
||||
|
||||
### P1:4 个原版 Analyst A 股中文化
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/analysts/market_analyst.py` | prompt 加入 A 股技术分析上下文 + 中文指令 |
|
||||
| `tradingagents/agents/analysts/social_media_analyst.py` | prompt 改为 A 股舆情分析 + 中文指令 |
|
||||
| `tradingagents/agents/analysts/news_analyst.py` | prompt 加入 A 股新闻解读 + 中文指令 |
|
||||
| `tradingagents/agents/analysts/fundamentals_analyst.py` | prompt 加入 A 股财报分析 + 中文指令 |
|
||||
|
||||
### P2:3 个 A 股特化角色(新增)
|
||||
|
||||
每个新角色需修改 6 个文件(analyst 本体 + agent_states + __init__ + conditional_logic + trading_graph + setup)。
|
||||
|
||||
| 新文件 | 角色 | 职责 |
|
||||
|--------|------|------|
|
||||
| `tradingagents/agents/analysts/policy_analyst.py` | 政策分析师 | 监管政策、产业政策、窗口指导 |
|
||||
| `tradingagents/agents/analysts/hot_money_tracker.py` | 游资追踪师 | 龙虎榜、大单流向、主力资金动态 |
|
||||
| `tradingagents/agents/analysts/lockup_watcher.py` | 解禁监控师 | 限售股解禁、大股东减持、股权质押 |
|
||||
|
||||
配套修改:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/utils/agent_states.py` | `AgentState` 新增 `policy_report` / `hot_money_report` / `lockup_report` 字段 |
|
||||
| `tradingagents/agents/__init__.py` | 导出 3 个新的 `create_*` 工厂函数 |
|
||||
| `tradingagents/graph/conditional_logic.py` | 新增 3 个 analyst 的 tool call 路由函数 |
|
||||
| `tradingagents/graph/trading_graph.py` | 将 3 个新 analyst 加入默认 analyst 列表 |
|
||||
| `tradingagents/graph/setup.py` | 为 3 个新 analyst 创建节点、工具节点、边和条件路由 |
|
||||
|
||||
### P3:下游 Agent 接入 3 个新报告
|
||||
|
||||
**问题**:原版的 Bull/Bear Researcher 和 3 个 Risk Debater 只读 4 个 report 字段,新增的 policy/hot_money/lockup 不会自动流入辩论。
|
||||
|
||||
**修复**:5 个文件手动添加 `state.get("policy_report", "")` 等读取 + prompt 注入:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/researchers/bull_researcher.py` | 读取 3 个新报告 + 注入 prompt 的 Resources 段 |
|
||||
| `tradingagents/agents/researchers/bear_researcher.py` | 同上 |
|
||||
| `tradingagents/agents/risk_mgmt/aggressive_debator.py` | 读取 3 个新报告 + 注入 prompt |
|
||||
| `tradingagents/agents/risk_mgmt/conservative_debator.py` | 同上 |
|
||||
| `tradingagents/agents/risk_mgmt/neutral_debator.py` | 同上 |
|
||||
|
||||
### P3-C:Portfolio Manager A 股约束
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/managers/portfolio_manager.py` | prompt 新增 A-Stock Trading Constraints 块:T+1 / 涨跌停 / 手数 / 交易时段 / ST / 融资融券 |
|
||||
|
||||
---
|
||||
|
||||
## Week 2.5 — 间隙修补
|
||||
|
||||
| 文件 | 改动 | 分类 |
|
||||
|------|------|------|
|
||||
| `tradingagents/graph/propagation.py` | `create_initial_state()` 新增 3 个缺失字段初始化 | 关键 Bug 修复 |
|
||||
| `tradingagents/agents/trader/trader.py` | 完全重写为 A 股特化 — 读取 3 个新报告 + T+1/涨跌停约束 | 功能增强 |
|
||||
| `tradingagents/graph/reflection.py` | Alpha 基准从 `SPY` 改为 `CSI 300(沪深300)` | A 股适配 |
|
||||
| `tradingagents/agents/managers/research_manager.py` | prompt 加入 A 股分析维度提示 | A 股适配 |
|
||||
|
||||
---
|
||||
|
||||
## 改动文件汇总
|
||||
|
||||
共计 **22 个文件**改动(Week 1 + Week 2 + Week 2.5):
|
||||
|
||||
### 新增(4 个)
|
||||
- `tradingagents/dataflows/a_stock.py`
|
||||
- `tradingagents/agents/analysts/policy_analyst.py`
|
||||
- `tradingagents/agents/analysts/hot_money_tracker.py`
|
||||
- `tradingagents/agents/analysts/lockup_watcher.py`
|
||||
|
||||
### 修改(18 个)
|
||||
- `tradingagents/dataflows/interface.py`
|
||||
- `tradingagents/default_config.py`
|
||||
- `tradingagents/agents/analysts/market_analyst.py`
|
||||
- `tradingagents/agents/analysts/social_media_analyst.py`
|
||||
- `tradingagents/agents/analysts/news_analyst.py`
|
||||
- `tradingagents/agents/analysts/fundamentals_analyst.py`
|
||||
- `tradingagents/agents/utils/agent_states.py`
|
||||
- `tradingagents/agents/__init__.py`
|
||||
- `tradingagents/graph/conditional_logic.py`
|
||||
- `tradingagents/graph/trading_graph.py`
|
||||
- `tradingagents/graph/setup.py`
|
||||
- `tradingagents/graph/propagation.py`
|
||||
- `tradingagents/graph/reflection.py`
|
||||
- `tradingagents/agents/researchers/bull_researcher.py`
|
||||
- `tradingagents/agents/researchers/bear_researcher.py`
|
||||
- `tradingagents/agents/risk_mgmt/aggressive_debator.py`
|
||||
- `tradingagents/agents/risk_mgmt/conservative_debator.py`
|
||||
- `tradingagents/agents/risk_mgmt/neutral_debator.py`
|
||||
- `tradingagents/agents/managers/portfolio_manager.py`
|
||||
- `tradingagents/agents/managers/research_manager.py`
|
||||
- `tradingagents/agents/trader/trader.py`
|
||||
|
||||
### 设计决策
|
||||
|
||||
1. **辩论层保持英文**:Bull/Bear 辩论和三方风险辩论使用英文,因为 LLM 的英文推理能力更强。只有用户面向的报告和最终决策输出中文。
|
||||
|
||||
2. **`state.get()` 模式**:所有新增 report 字段使用 `state.get("field", "")` 而非 `state["field"]`,确保在部分 Analyst 被跳过时不会 KeyError。
|
||||
|
||||
3. **6 文件管线**:新增任何 Analyst 需同时修改 6 个文件(analyst 本体 + agent_states + __init__ + conditional_logic + trading_graph + setup),漏改任何一个都会导致该 Analyst 不被执行或数据不流通。
|
||||
|
||||
4. **数据源选择**:拒绝 Tushare(积分墙限制新用户)、不用 MongoDB/Redis(增加部署复杂度),使用 mootdx + 腾讯财经 + akshare 三个完全免费无门槛的数据源。
|
||||
|
||||
---
|
||||
|
||||
## Week 3.5 — 信号层 + SKILL V2 数据升级(2026-05-11)
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/utils/signal_data_tools.py` | 信号层 5 个 `@tool` 包装(get_profit_forecast / get_hot_stocks / get_northbound_flow / get_concept_blocks / get_fund_flow) |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/dataflows/a_stock.py` | 735→1277 行:增强 `get_fundamentals()` 内联一致预期EPS + forward PE/PEG;新增 5 个方法:`get_profit_forecast()` / `get_hot_stocks()` / `get_northbound_flow()` / `get_concept_blocks()` / `get_fund_flow()`;修复 ResultCode str/int 类型比较 bug |
|
||||
| `tradingagents/dataflows/interface.py` | 导入 5 个新 a_stock 函数;新增 `signal_data` 类别到 `TOOLS_CATEGORIES`;5 个新 entry 到 `VENDOR_METHODS` |
|
||||
| `tradingagents/default_config.py` | `data_vendors` 新增 `signal_data: "a_stock"` |
|
||||
| `tradingagents/agents/utils/agent_utils.py` | re-export 5 个新工具(get_profit_forecast / get_hot_stocks / get_northbound_flow / get_concept_blocks / get_fund_flow) |
|
||||
| `tradingagents/agents/analysts/hot_money_tracker.py` | tools 列表加 `get_hot_stocks` + `get_northbound_flow` + `get_concept_blocks` + `get_fund_flow`;prompt 新增工具说明和分析步骤 4-6 |
|
||||
| `tradingagents/agents/analysts/fundamentals_analyst.py` | tools 列表加 `get_profit_forecast`;prompt 新增估值工具说明 |
|
||||
|
||||
### 新增数据源
|
||||
|
||||
| 来源 | 协议 | 数据内容 |
|
||||
|------|------|---------|
|
||||
| 同花顺热点 | HTTP (`zx.10jqka.com.cn`) | 当日强势股 + 题材归因 reason tags(零鉴权,73ms) |
|
||||
| 同花顺 hsgtApi | HTTP (`data.hexin.cn`) | 北向资金分钟级实时流向 + 历史日级(零鉴权) |
|
||||
| akshare 同花顺 | Python 库 | `stock_profit_forecast_ths` 机构一致预期 EPS |
|
||||
| 百度股市通 PAE | HTTP (`finance.pae.baidu.com`) | 概念板块/行业/地域 + 个股资金流向(零鉴权) |
|
||||
|
||||
---
|
||||
|
||||
## Week 4 — 辩论层 A 股特化(2026-05-12)
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/researchers/bull_researcher.py` | prompt 注入 A-Share Bull Framework:政策顺风、北向确认、游资接力、PE 消化叙事、解禁出清 |
|
||||
| `tradingagents/agents/researchers/bear_researcher.py` | prompt 注入 A-Share Bear Framework:政策反转、解禁压力、游资撤退、T+1 锁仓、估值泡沫、北向撤退 |
|
||||
| `tradingagents/agents/risk_mgmt/aggressive_debator.py` | prompt 注入 A-Share Aggressive Framework:涨停动量、政策底、PE 扩张、散户放大、游资确认 |
|
||||
| `tradingagents/agents/risk_mgmt/conservative_debator.py` | prompt 注入 A-Share Conservative Framework:T+1 不可逃逸、跌停陷阱、解禁悬顶、政策反转、ST/退市 |
|
||||
| `tradingagents/agents/risk_mgmt/neutral_debator.py` | prompt 注入 A-Share Neutral Framework:T+1 双刃剑、政策分级、估值区间、轮动周期、仓位优先 |
|
||||
|
||||
### 设计决策
|
||||
|
||||
5. **辩论层保持英文但注入 A 股框架**:prompt 语言维持英文(推理质量),但论据框架从美股通用替换为 A 股特有机制(T+1/涨跌停/政策市/游资/北向/解禁)。同一机制在三方风控中呈现对立观点。
|
||||
|
||||
---
|
||||
|
||||
## Week 5 — 数据质量门控实测(2026-05-12)
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/dataflows/a_stock.py` | **get_northbound_flow 重写**:删除不可用的 hsgtData 历史 API(返回 2024 年旧数据),改为实时数据 + 本地 CSV 自缓存(`northbound_daily.csv`)+ 趋势对比(today vs N-day avg)。新增 3 个私有函数:`_northbound_cache_path` / `_save_northbound_snapshot` / `_load_northbound_history`。**get_insider_transactions 截断**:F10 股东研究【4.股东变化】只保留最新一期(-70% token,19969→5906 chars) |
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `test_data_quality.py` | 14 接口数据质量回归测试脚本(000858 五粮液) |
|
||||
|
||||
### 根因记录
|
||||
|
||||
eastmoney 全系北向资金接口(含 akshare `stock_hsgt_hist_em`、datacenter API、push2his kamt.kline)自 2024-08-16 后净买额字段全部返回 NaN/None/0。同花顺 `hsgtData` chart 数据也是 2024 年旧缓存。属上游行业性数据断供。解决方案:自建本地缓存。
|
||||
|
||||
### 设计决策
|
||||
|
||||
6. **北向资金历史自缓存**:上游 API 全面断供后,采用"实时快照 + 本地 CSV 累积"模式。缺点是新用户首次无历史,但零外部依赖、数据越跑越丰富,且避免了依赖随时可能变的第三方历史 API。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Week 5.5 — 数据质量门控 + 数据缺口补齐(2026-05-12)
|
||||
|
||||
### P0:数据质量门控架构
|
||||
|
||||
**新增文件**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/quality_gate.py` | 两层数据质量验证节点:Layer 1 硬检查(长度/失败标记/必采清单/表格)→ ABCDF 分级;Layer 2 LLM 复审(4+ 报告硬检查失败时跳过) |
|
||||
|
||||
**修改文件**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/agents/utils/agent_states.py` | `AgentState` 新增 `data_quality_summary` 字段 |
|
||||
| `tradingagents/agents/__init__.py` | 导出 `create_quality_gate` |
|
||||
| `tradingagents/graph/setup.py` | 新增 "Quality Gate" 节点,接线:最后一个 analyst Msg Clear → Quality Gate → Bull Researcher |
|
||||
| 7 个 analyst 文件 | 每个 prompt 末尾新增 📋 必采清单(market 5 项 / social 5 项 / news 5 项 / fundamentals 7 项 / policy 5 项 / hot_money 6 项 / lockup 5 项) |
|
||||
| `tradingagents/agents/researchers/bull_researcher.py` | 读取 `data_quality_summary` + prompt 注入质量警告 |
|
||||
| `tradingagents/agents/researchers/bear_researcher.py` | 同上 |
|
||||
|
||||
### P1:3 个数据缺口补齐
|
||||
|
||||
**新增 a_stock.py 方法**(14→17)
|
||||
|
||||
| 方法 | 数据源 | 内容 |
|
||||
|------|--------|------|
|
||||
| `get_dragon_tiger_board(ticker, trade_date)` | akshare `stock_lhb_detail_em` + `stock_lhb_stock_detail_em` + `stock_lhb_jgmmtj_em` | 龙虎榜上榜记录 + 买卖席位明细 + 机构参与 |
|
||||
| `get_lockup_expiry(ticker, trade_date)` | akshare `stock_restricted_release_queue_em` + `stock_restricted_release_detail_em` | 限售解禁日历(历史 + 未来 90 天) |
|
||||
| `get_industry_comparison(ticker, trade_date)` | akshare `stock_board_industry_summary_ths()` | 全行业横向对比(90 个行业涨跌幅/成交额/净流入排名) |
|
||||
|
||||
**修改文件**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `tradingagents/dataflows/interface.py` | 导入 3 个新方法 + 注册到 `TOOLS_CATEGORIES` 和 `VENDOR_METHODS` |
|
||||
| `tradingagents/agents/utils/signal_data_tools.py` | 新增 3 个 `@tool` 包装 |
|
||||
| `tradingagents/agents/utils/agent_utils.py` | re-export 3 个新工具 |
|
||||
| `tradingagents/agents/analysts/hot_money_tracker.py` | tools 列表加 `get_dragon_tiger_board` + `get_industry_comparison`;prompt 新增工具说明 |
|
||||
| `tradingagents/agents/analysts/lockup_watcher.py` | tools 列表加 `get_lockup_expiry`;prompt 新增工具说明 |
|
||||
| `tradingagents/agents/analysts/fundamentals_analyst.py` | tools 列表加 `get_industry_comparison`;prompt 新增工具说明 |
|
||||
|
||||
### 关键 Bug 修复:ToolNode 工具缺失
|
||||
|
||||
**`tradingagents/graph/trading_graph.py`**:`_create_tool_nodes()` 的 ToolNode 定义只包含基础工具(get_stock_data / get_news 等),缺少信号层工具(get_hot_stocks / get_northbound_flow / get_concept_blocks / get_fund_flow / get_profit_forecast / get_dragon_tiger_board / get_lockup_expiry / get_industry_comparison)。导致 analyst 的 `llm.bind_tools()` 引用的工具在 ToolNode 执行时找不到,任何信号工具调用都会运行时失败。
|
||||
|
||||
修复:为 fundamentals / hot_money / lockup 三个 ToolNode 补全所有信号工具的导入和注册。
|
||||
|
||||
### P2:边界测试结果
|
||||
|
||||
| 股票 | 类型 | OK | WARN | FAIL | 备注 |
|
||||
|------|------|----|----|------|------|
|
||||
| 600696 *ST岩石 | ST(退市中) | 16 | 1 | 0 | WARN: profit_forecast 无分析师覆盖(预期行为) |
|
||||
| 688981 中芯国际 | 科创板 688 | 17 | 0 | 0 | 20% 涨跌停正确识别 |
|
||||
| 002475 立讯精密 | 中小板 002 | 17 | 0 | 0 | 全部数据正常 |
|
||||
|
||||
### 设计决策
|
||||
|
||||
7. **单一质量门控节点**:选择在所有 analyst 完成后设一个质量门(Option B),而非每个 analyst 后各设一个(Option A)。理由:更简单(1 个节点 vs 7 个)、所有报告可一次性评估、质量摘要可被 researcher 整体消费。
|
||||
|
||||
8. **两层验证**:Layer 1 硬检查(代码逻辑)覆盖明确的失败模式(空报告、太短、失败标记、缺项过多);Layer 2 LLM 复审捕获硬检查漏掉的语义问题。当 4+ 报告硬检查失败时跳过 LLM 复审(省 token)。
|
||||
|
||||
---
|
||||
|
||||
## Week 6:MiniMax 集成 + E2E 实战验证(2026-05-12)
|
||||
|
||||
### MiniMax LLM 供应商集成
|
||||
|
||||
**目的:** 接入国内直连、OpenAI 兼容的 MiniMax 作为推荐 LLM 供应商。TradingAgents 每次分析需 30-50 次 API 调用,Claude/ChatGPT 订阅版无法使用。
|
||||
|
||||
**新增/修改文件:**
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `tradingagents/llm_clients/factory.py` | 修改 | `_OPENAI_COMPATIBLE` tuple 增加 `"minimax"` |
|
||||
| `tradingagents/llm_clients/openai_client.py` | 修改 | `_PROVIDER_CONFIG` 增加 MiniMax base_url + env var |
|
||||
| `tradingagents/llm_clients/model_catalog.py` | 修改 | 新增 `"minimax"` 模型目录(M2.7/M2.5 + highspeed) |
|
||||
| `README.md` | 修改 | 重写 LLM 配置章节:7 供应商 + 3 套示例 |
|
||||
| `examples/run_cases.py` | 修改 | 默认改为 MiniMax-M2.7 / M2.7-highspeed |
|
||||
|
||||
**技术要点:** MiniMax 走 `provider="minimax"` 而非 `provider="openai"`,因为 OpenAI 路径启用 `use_responses_api=True`(指向 `/v1/responses`),MiniMax 不实现该端点。
|
||||
|
||||
### E2E 实战结果
|
||||
|
||||
| 股票 | 代码 | 信号 | 耗时 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| 宁德时代 | 300750 | Hold | 977s (16.3 min) | 7 analyst 全通过,解禁评估 10 章完整报告 |
|
||||
| 比亚迪 | 002594 | Hold | 890s (14.8 min) | 识别 4 月销量 -26% + 北向流出 40 亿极端值 |
|
||||
|
||||
全链路:7 Analysts → Quality Gate → Bull/Bear Debate → Trader → Risk Panel → PM ✅
|
||||
|
||||
### 设计决策
|
||||
|
||||
9. **MiniMax 作为推荐供应商**:国内直连零代理、OpenAI 兼容、M2.7 旗舰 + highspeed 双模型配置、价格合理。相比 DeepSeek(高峰限流)、OpenAI(需代理 + 成本高)、Kimi(需额外 backend_url),MiniMax 综合体验最优。
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Week 7:Web UI — Streamlit 可视化界面(2026-05-12)
|
||||
|
||||
**目的:** 提供浏览器操作界面,让不写代码的用户也能用。输入股票代码 → 一键分析 → 实时进度 → 查看报告 → PDF 下载。同时提升 GitHub 仓库的视觉吸引力。
|
||||
|
||||
**新增文件(12 个):**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `web/app.py` | Streamlit 主入口:暗色 CSS 主题、5 态状态机 |
|
||||
| `web/runner.py` | 后台线程运行分析、流式检测 12 阶段完成 |
|
||||
| `web/progress.py` | 线程安全 ProgressTracker,12 阶段定义 |
|
||||
| `web/history.py` | 扫描 `~/.tradingagents/logs/` 历史 JSON |
|
||||
| `web/pdf_export.py` | fpdf2 PDF 报告生成(CJK 字体自检测) |
|
||||
| `web/launch.py` | CLI 启动器(`tradingagents-web` 命令) |
|
||||
| `web/components/sidebar.py` | 侧边栏:Logo + 股票输入 + 历史列表 |
|
||||
| `web/components/progress_panel.py` | 实时进度面板 + 彩色状态徽章 |
|
||||
| `web/components/report_viewer.py` | 信号卡片 + 7 报告 + 辩论 Tabs + PDF 下载 |
|
||||
| `web/__init__.py` | 包标记 |
|
||||
| `web/components/__init__.py` | 包标记 |
|
||||
| `.streamlit/config.toml` | Streamlit 暗色主题配置 |
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `pyproject.toml` | 新增 streamlit/fpdf2/python-dotenv 依赖 + tradingagents-web 脚本 |
|
||||
| `README.md` | 新增 Web UI 章节 + 项目结构更新 |
|
||||
| `assets/web-ui-welcome.png` | 欢迎页截图 |
|
||||
|
||||
### 设计决策
|
||||
|
||||
10. **Streamlit 而非 Gradio/自研前端**:Python 生态内闭环,无需 Node.js/npm;分析任务 15 分钟长跑,Streamlit 的 session_state + rerun 轮询模式天然适配;新手友好 `pip install -e . && tradingagents-web` 即用。
|
||||
|
||||
---
|
||||
|
||||
### 改动文件汇总(累计)
|
||||
|
||||
Week 1-7 共 **47 个文件**受影响(含 22 原有修改 + 22 新增 + 3 配置):
|
||||
|
||||
**tradingagents/ 核心改动(22 原有 + 9 新增):**
|
||||
- `tradingagents/dataflows/a_stock.py` (新增)
|
||||
- `tradingagents/dataflows/interface.py`
|
||||
- `tradingagents/default_config.py`
|
||||
- `tradingagents/agents/quality_gate.py` (新增 — Week 5.5)
|
||||
- `tradingagents/agents/utils/agent_utils.py`
|
||||
- `tradingagents/agents/utils/signal_data_tools.py` (新增)
|
||||
- `tradingagents/agents/utils/agent_states.py`
|
||||
- `tradingagents/agents/__init__.py`
|
||||
- `tradingagents/agents/analysts/market_analyst.py`
|
||||
- `tradingagents/agents/analysts/fundamentals_analyst.py`
|
||||
- `tradingagents/agents/analysts/news_analyst.py`
|
||||
- `tradingagents/agents/analysts/social_media_analyst.py`
|
||||
- `tradingagents/agents/analysts/policy_analyst.py` (新增)
|
||||
- `tradingagents/agents/analysts/hot_money_tracker.py` (新增)
|
||||
- `tradingagents/agents/analysts/lockup_watcher.py` (新增)
|
||||
- `tradingagents/agents/conditional_logic.py`
|
||||
- `tradingagents/graph/trading_graph.py`
|
||||
- `tradingagents/graph/setup.py`
|
||||
- `tradingagents/agents/researchers/bull_researcher.py`
|
||||
- `tradingagents/agents/researchers/bear_researcher.py`
|
||||
- `tradingagents/agents/risk_mgmt/aggressive_debator.py`
|
||||
- `tradingagents/agents/risk_mgmt/conservative_debator.py`
|
||||
- `tradingagents/agents/risk_mgmt/neutral_debator.py`
|
||||
- `tradingagents/agents/managers/portfolio_manager.py`
|
||||
- `tradingagents/agents/managers/research_manager.py`
|
||||
- `tradingagents/agents/trader/trader.py`
|
||||
- `tradingagents/llm_clients/factory.py` (Week 6)
|
||||
- `tradingagents/llm_clients/openai_client.py` (Week 6)
|
||||
- `tradingagents/llm_clients/model_catalog.py` (Week 6)
|
||||
|
||||
**Web UI(12 新增 — Week 7):**
|
||||
- `web/app.py`, `web/runner.py`, `web/progress.py`, `web/history.py`
|
||||
- `web/pdf_export.py`, `web/launch.py`
|
||||
- `web/components/sidebar.py`, `web/components/progress_panel.py`, `web/components/report_viewer.py`
|
||||
- `web/__init__.py`, `web/components/__init__.py`
|
||||
- `.streamlit/config.toml`
|
||||
|
||||
**项目文件:**
|
||||
- `README.md`, `examples/run_cases.py`, `pyproject.toml`, `assets/web-ui-welcome.png`
|
||||
@@ -0,0 +1,549 @@
|
||||
# TradingAgents-Astock 开发者日志
|
||||
|
||||
> 基于 TauricResearch/TradingAgents 的 A 股深度特化 fork
|
||||
>
|
||||
> Fork 点:`7e9e7b8` (feat: DeepSeek V4 thinking-mode round-trip via DeepSeekChatOpenAI subclass)
|
||||
> Fork 时间:2026-05-04
|
||||
> 上游许可:Apache License 2.0
|
||||
> 本项目许可:Apache License 2.0(继承上游,完全开源,不做商业闭源)
|
||||
|
||||
---
|
||||
|
||||
## 项目定位
|
||||
|
||||
**这不是又一个中文翻译版 fork。**
|
||||
|
||||
定位:**A 股市场深度特化的多 Agent 投研框架** — 行业级、轻量、可商用、配套教学。
|
||||
|
||||
目标受众:
|
||||
- 独立投资研究者
|
||||
- 中小型私募/券商研究所
|
||||
- AI Agent 工程从业者
|
||||
- 想学多 Agent 架构的开发者
|
||||
|
||||
差异化锚点(vs 已有方案):
|
||||
|
||||
| 对比对象 | 我们的差异 |
|
||||
|---|---|
|
||||
| **TauricResearch 原版** | 加 A 股全栈数据源 + Agent 中文化 + A 股交易制度适配 + A 股特化新角色 |
|
||||
| **hsliuping/TradingAgents-CN** | 全 Apache 2.0 开源(对方混合商业闭源)、轻量化部署(无需 MongoDB/Redis)、数据源用 mootdx 不封 IP(对方 Tushare 积分墙)、A 股特化深度更深(政策/游资/解禁) |
|
||||
| **微软 Qlib** | LLM 推理范式,补齐 Qlib 的"语义理解"能力,而非替代其因子工程 |
|
||||
|
||||
---
|
||||
|
||||
## 决策与选型记录
|
||||
|
||||
### 决策一:在原版上 fork,不在 CN 版上 fork
|
||||
|
||||
**Why**:CN 版的 `app/` (FastAPI) + `frontend/` (Vue) 是商业闭源,我们要做完全 Apache 2.0 开源,基础必须干净。
|
||||
|
||||
**How to apply**:从 TauricResearch 原版直接 fork,CN 版作为 `tradingagents/` 部分(Apache 2.0)的"借鉴学习对象",而不是直接合并代码。
|
||||
|
||||
### 决策二:数据层用 EP11 的 a-stock-data 组合,不用 Tushare
|
||||
|
||||
**Why**:Tushare 核心接口要 2000 积分,免费数据残废;AKShare 高频调用易封 IP。EP11 已经实测验证 mootdx (TCP 不封 IP) + 腾讯财经 (HTTP 公开 API) + akshare (低频研报/新闻) + iwencai (NL 搜索) 这套组合是免费稳定的最优解。
|
||||
|
||||
**How to apply**:新建 `tradingagents/dataflows/a_stock.py`,把 EP11 的 Skill 包装成 vendor;保留原版 `interface.py` 的 `route_to_vendor()` dispatch 模式不动。(最终选择了与 `y_finance.py` 同级的扁平结构,而非嵌套目录。)
|
||||
|
||||
### 决策三:不引入 MongoDB/Redis
|
||||
|
||||
**Why**:CN 版的 git log 反复出现"MongoDB 迁移脚本""数据库隔离"等 fix,数据库依赖是累赘。我们要的是 `pip install + python main.py` 直接能跑。
|
||||
|
||||
**How to apply**:配置走环境变量 + JSON;缓存走本地 SQLite 或 CSV;部署 Dockerfile 保持单容器。
|
||||
|
||||
### 决策四:A 股特化角色是核心差异化
|
||||
|
||||
**Why**:CN 版只做了基础翻译 + 数据源切换,没有 A 股市场特性的 Agent 角色。A 股是政策市,有龙虎榜、解禁/减持等独特信号,这些值得专门角色。
|
||||
|
||||
**How to apply**:在原版 4 个分析师之外新增三个 A 股特化角色:
|
||||
- 政策分析师 (Policy Analyst)
|
||||
- 游资追踪员 (Hot Money Tracker)
|
||||
- 解禁/减持监控 (Lock-up Watcher)
|
||||
|
||||
### 决策五:T+1 / 涨跌停 / ST / 停牌等交易制度,在风控管理器层落地
|
||||
|
||||
**Why**:这些是 A 股交易的硬约束,Agent 的最终建议必须符合这些规则才有可执行性。CN 版未做这层适配。
|
||||
|
||||
**How to apply**:在 `tradingagents/agents/managers/risk_manager.py` 增加 A 股交易规则约束;Agent 输出建议时必须检验是否落在合法操作空间。
|
||||
|
||||
---
|
||||
|
||||
## 改造路线图(三周)
|
||||
|
||||
### Week 1 — 数据层落地(2026-05-04 ~ 2026-05-04) ✅ 已完成
|
||||
|
||||
**目标:让 TradingAgents 能用 EP11 数据源拉到 A 股真实数据。**
|
||||
|
||||
- [x] 新建 `tradingagents/dataflows/a_stock.py`(~400 行),实现 9 个 vendor 方法:
|
||||
- `get_stock_data` → mootdx OHLCV (TCP 7709,带 CSV 缓存)
|
||||
- `get_indicators` → mootdx OHLCV + stockstats 计算 13 种技术指标
|
||||
- `get_fundamentals` → 腾讯财经 PE/PB/市值 + mootdx finance 季报快照 + akshare 个股基本面
|
||||
- `get_balance_sheet` → akshare sina 资产负债表
|
||||
- `get_cashflow` → akshare sina 现金流量表
|
||||
- `get_income_statement` → akshare sina 利润表
|
||||
- `get_news` → akshare stock_news_em(东财个股新闻)
|
||||
- `get_global_news` → akshare 财联社快讯 + 东财全球资讯
|
||||
- `get_insider_transactions` → mootdx F10 股东研究
|
||||
- [x] 改 `tradingagents/dataflows/interface.py`:注册 `a_stock` 到 `VENDOR_LIST` + 全部 9 个 `VENDOR_METHODS`
|
||||
- [x] 改 `tradingagents/default_config.py`:四个 `data_vendors` 类别默认切到 `a_stock`
|
||||
- [x] ticker 格式处理:内置 `_normalize_ticker()` 支持纯代码/SH前缀/.SH后缀三种写法
|
||||
- [x] 单元测试:688017(绿的谐波)9/9 方法全部通过
|
||||
- [x] **里程碑**:端到端跑通 `python test_astock.py` — 7 个 Agent 完成完整辩论,输出 HOLD 决策
|
||||
|
||||
**实际数据源映射(vs 原版 yfinance):**
|
||||
|
||||
| 方法 | 原版 yfinance | A 股版 a_stock |
|
||||
|---|---|---|
|
||||
| get_stock_data | Yahoo Finance | mootdx TCP K 线 |
|
||||
| get_indicators | stockstats + yfinance | stockstats + mootdx |
|
||||
| get_fundamentals | yf.Ticker.info | 腾讯财经 + mootdx finance + akshare |
|
||||
| get_balance_sheet | yf.balance_sheet | akshare sina 资产负债表 |
|
||||
| get_cashflow | yf.cashflow | akshare sina 现金流量表 |
|
||||
| get_income_statement | yf.income_stmt | akshare sina 利润表 |
|
||||
| get_news | yf.get_news | akshare stock_news_em |
|
||||
| get_global_news | yf.Search | akshare 财联社 + 东财全球 |
|
||||
| get_insider_transactions | yf.insider_transactions | mootdx F10 股东研究 |
|
||||
|
||||
**踩坑记录:**
|
||||
- mootdx `bars()` 返回 DataFrame 的 `datetime` 同时存在于 index 和 column,需先 `drop(columns=["datetime"])` 再 `reset_index()`
|
||||
- akshare `stock_balance_sheet_by_report_em()` 在 v1.18.54 内部 HTML 解析崩溃(东财页面变动),改用 `stock_financial_report_sina()` 绕过
|
||||
- Kimi API 鉴权用 `ANTHROPIC_AUTH_TOKEN` 而非 `ANTHROPIC_API_KEY`(Bearer vs X-Api-Key)
|
||||
|
||||
### Week 2 — Agent 中文化 + A 股特化角色(2026-05-04) ✅ 全部完成
|
||||
|
||||
**目标:让 Agent 真的理解 A 股市场。**
|
||||
|
||||
**P0 — 输出语言切换(1 行改动):**
|
||||
- [x] `default_config.py` 设 `output_language: "Chinese"` — 框架自带 `get_language_instruction()` 钩子,零成本让所有 analyst 输出中文
|
||||
|
||||
**P1 — 四个现有 analyst prompt 中文化 + A 股特化:**
|
||||
- [x] 改写 `market_analyst.py`:全中文 prompt + 涨跌停/T+1/北向资金/换手率/量价关系
|
||||
- [x] 改写 `fundamentals_analyst.py`:全中文 + CAS 准则/A 股 PE 常态(30-50x)/扣非净利润/财报披露节奏 + 修复原版 trailing comma tuple bug
|
||||
- [x] 改写 `news_analyst.py`:全中文 + 政策市框架/消息来源权重/行业轮动/事件驱动
|
||||
- [x] 改写 `social_media_analyst.py`:重定位为「市场情绪分析师」+ 散户情绪/股吧雪球/反向指标/情绪评分体系
|
||||
|
||||
**P2 — 新增 2 个 A 股特化角色(六文件管道联动):**
|
||||
- [x] **新增** `policy_analyst.py`(政策分析师)— 五层政策框架(宏观/监管/产业/地方/国际) + 力度级别评估 + 影响时间窗口
|
||||
- [x] **新增** `hot_money_tracker.py`(游资/龙虎榜追踪)— 量价异动/连板分析/资金博弈格局判断
|
||||
- [x] 管道接入:agent_states + __init__ + conditional_logic + trading_graph + setup 五文件联动
|
||||
- [x] 默认 selected_analysts 从 4 个扩展到 6 个
|
||||
- [x] **里程碑**:688017 E2E 验证通过 — 6 analyst 中文辩论,Kimi 2.6 驱动,SELL(Underweight)
|
||||
|
||||
**P3 — 第 7 角色 + 下游 agent 补报告 + PM A 股约束:**
|
||||
- [x] **新增** `lockup_watcher.py`(解禁/减持监控)— 限售股类型/解禁规模评估/减持新规约束/减持预披露/减持动力评估
|
||||
- [x] 六文件管道联动:agent_states + __init__ + conditional_logic + trading_graph + setup + lockup_watcher 本体
|
||||
- [x] 默认 selected_analysts 从 6 个扩展到 7 个
|
||||
- [x] **下游 agent 补报告字段**(修复原版设计缺陷):bull_researcher + bear_researcher + aggressive/conservative/neutral_debator 全部读取 policy_report、hot_money_report、lockup_report 并注入 prompt
|
||||
- [x] **Portfolio Manager A 股交易约束**:T+1 settlement、涨跌停(主板±10%/科创创业板±20%/ST±5%)、最小交易手数、交易时段、ST/退市风险、融资融券提示
|
||||
- [x] **里程碑**:688017 E2E 验证通过 — 7 analyst 全流程中文辩论,最终 **Underweight**(减持 25-33%)
|
||||
|
||||
**设计决策:辩论层保持英文**
|
||||
- bull/bear researcher + aggressive/conservative/neutral debator 的 prompt 保持英文,不做中文化
|
||||
- 原因:内部推理质量 > 输出语言一致性;只有面向用户的 analyst 报告和 PM 最终决策走中文
|
||||
|
||||
**E2E 对比(688017 绿的谐波):**
|
||||
|
||||
| 对比项 | Week 1(数据层) | Week 2 P2(6 analyst) | Week 2 P3(7 analyst) |
|
||||
|--------|----------------|---------------------|---------------------|
|
||||
| Analyst 数量 | 4(英文) | 6(中文 + 2 A 股特化) | 7(中文 + 3 A 股特化) |
|
||||
| 输出语言 | English | Chinese | Chinese |
|
||||
| 下游报告覆盖 | 4 字段 | 4 字段(gap!) | **7 字段(修复)** |
|
||||
| PM A 股约束 | 无 | 无 | **T+1/涨跌停/ST** |
|
||||
| 最终决策 | HOLD | SELL (Underweight) | **Underweight**(减持 25-33%) |
|
||||
| 报告总行数 | ~600 | ~2000 | ~2100 |
|
||||
|
||||
**改判演进**:
|
||||
- Week 1 → Week 2 P2:游资追踪发现 4月23日天量长阴 + 股东户数激增 81% + 先进制造基金大减持,叠加 250x+ PE 估值风险 → HOLD 改 SELL
|
||||
- Week 2 P2 → P3:解禁监控发现控股股东 5-7 月减持窗口 + 300x PE 历史高位 + 经营现金流恶化 → 保持 Underweight + 给出具体减仓比例(25-33%)和价位(220-225 逢反弹减)
|
||||
|
||||
**踩坑记录:**
|
||||
- 新增 analyst 需要改 6 个文件(analyst 本体 + agent_states + __init__ + conditional_logic + trading_graph + setup),漏一个就 graph 跑不起来
|
||||
- 原版 bull/bear researcher 和 3 个 risk debater 只读 4 个 report 字段,新增的 policy/hot_money/lockup 报告不会自动流入 debate 层 — 这是需要手动修复的"隐性 gap"
|
||||
- fundamentals_analyst 原版有 trailing comma tuple bug:`system_message = ("...",)` 是 tuple 不是 string
|
||||
|
||||
### Week 2.5 — 间隙修补(2026-05-04) ✅ 已完成
|
||||
|
||||
**目标:修复 Week 2 改造后遗留的管道 gap。**
|
||||
|
||||
- [x] `propagation.py`:初始状态缺 3 个新字段 → 新增 `policy_report` / `hot_money_report` / `lockup_report` 初始化
|
||||
- [x] `trader.py`:完全重写为 A 股特化 — 读取 3 个新报告 + T+1/涨跌停/手数/交易时段约束
|
||||
- [x] `reflection.py`:Alpha 基准标签从 `SPY` 改为 `CSI 300(沪深300)`
|
||||
- [x] `research_manager.py`:prompt 加入 A 股分析维度提示
|
||||
- [x] `trading_graph.py`:`_fetch_returns` 基准从 `yf.Ticker("SPY")` 改为 `yf.Ticker("000300.SS")`(沪深300指数)+ 变量名 `spy` → `benchmark`
|
||||
- [x] `tests/test_memory_log.py`:4 个 `_fetch_returns` 测试用例同步更新 mock dispatch key 和变量名
|
||||
|
||||
### Week 3 — 案例库 + 文档 + 发布(2026-05-04)
|
||||
|
||||
**目标:能对外发布的版本。**
|
||||
|
||||
- [x] 写中文 README(`README.md`):架构图、7 角色表、数据源、快速开始、配置说明、差异化对比
|
||||
- [x] 写改造记录(`CHANGES_FROM_UPSTREAM.md`):Week 1/2/2.5 全部改动 + 22 文件清单 + 4 设计决策
|
||||
- [x] 写 Apache 2.0 NOTICE 文件(`NOTICE`):fork 归属声明
|
||||
- [x] 修 `_fetch_returns` SPY → CSI 300 实际取数(trading_graph.py + 测试)— 4/4 测试通过
|
||||
- [x] 创建批量案例运行脚本(`examples/run_cases.py`):10 只跨板块 A 股,输出 md + json
|
||||
- [ ] 跑 5-10 只真实 A 股的完整辩论案例
|
||||
- [ ] 把案例归档到 `examples/cases/`
|
||||
- [ ] 写部署指南(`DEPLOYMENT.md`)(可选)
|
||||
- [ ] **里程碑**:发布到 GitHub,EP16 视频同步上线
|
||||
|
||||
### Week 3.5 — 信号层 + SKILL V2 数据升级(2026-05-11~12) ✅ 已完成
|
||||
|
||||
**目标:补齐 a-stock-data SKILL.md V2 的信号层能力,让 Agent 能感知题材热点和资金流向。**
|
||||
|
||||
**数据层升级(a_stock.py 735→1277 行,9→14 方法):**
|
||||
- [x] 增强 `get_fundamentals()`:内联一致预期EPS(同花顺 `stock_profit_forecast_ths`) + 前向PE + PEG + PE消化时间计算
|
||||
- [x] **新增** `get_profit_forecast()`:独立的机构一致预期端点,返回覆盖机构数、EPS区间、前向PE、PEG、PE消化
|
||||
- [x] **新增** `get_hot_stocks()`:同花顺热点接口 — 当日涨停股 + 题材归因 reason tags(人工运营标签) + 题材词频统计
|
||||
- [x] **新增** `get_northbound_flow()`:同花顺 hsgtApi — 北向资金(沪深股通)实时分钟级流向 + 可选历史日级数据
|
||||
- [x] **新增** `get_concept_blocks()`:百度股市通 PAE — 个股所属概念板块/行业分类(申万)/地域,每个板块含当日涨幅
|
||||
- [x] **新增** `get_fund_flow()`:百度股市通 PAE — 个股主力/散户资金流向(分钟级实时超大单/大单/中单/小单) + 20日历史
|
||||
|
||||
**管道接入(interface.py → tool wrapper → analyst):**
|
||||
- [x] `interface.py`:新增 `signal_data` 类别,注册 5 个新方法到 `VENDOR_METHODS`
|
||||
- [x] `default_config.py`:新增 `signal_data: "a_stock"` 默认配置
|
||||
- [x] **新建** `signal_data_tools.py`:5 个 `@tool` 包装(参照 `news_data_tools.py` 模式)
|
||||
- [x] `agent_utils.py`:re-export 5 个新工具供 analyst 导入
|
||||
- [x] `hot_money_tracker.py`:绑定 `get_hot_stocks` + `get_northbound_flow` + `get_concept_blocks` + `get_fund_flow`,更新 prompt 添加工具说明和分析步骤
|
||||
- [x] `fundamentals_analyst.py`:绑定 `get_profit_forecast`,更新 prompt 添加估值工具说明
|
||||
|
||||
**Bug 修复:**
|
||||
- [x] `get_concept_blocks()` ResultCode 类型不匹配:百度 PAE 返回 `ResultCode` 为字符串 `"0"`,代码用 `!= 0` 比较(int),Python 3 下 `"0" != 0` 始终为 True → 改为统一 `str()` 比较
|
||||
- [x] `get_fund_flow()` 同一模式预防性修复,确保 int/str 两种返回类型都能正确处理
|
||||
|
||||
**数据源映射更新:**
|
||||
|
||||
| 方法 | 数据源 | 用途 |
|
||||
|---|---|---|
|
||||
| get_profit_forecast | akshare → 同花顺 | 机构一致预期EPS + 前向估值 |
|
||||
| get_hot_stocks | 同花顺 zx.10jqka.com.cn | 当日强势股 + 题材归因(零鉴权) |
|
||||
| get_northbound_flow | 同花顺 data.hexin.cn hsgtApi | 北向资金分钟级 + 历史日级(零鉴权) |
|
||||
| get_concept_blocks | 百度股市通 PAE | 概念板块/行业/地域 + 当日涨幅(零鉴权) |
|
||||
| get_fund_flow | 百度股市通 PAE | 个股超大单/大单/中单/小单净流入(零鉴权) |
|
||||
|
||||
**双视角审视结论:**
|
||||
|
||||
投资者视角 — 已修复 P0(信号层接线完成 + 个股资金流向补齐),仍存在 P1 缺口:
|
||||
- 龙虎榜买卖席位数据(hot_money_tracker 的 prompt 提到但 `get_insider_transactions` 只返回 F10 股东研究)
|
||||
- 限售解禁日历(lockup_watcher 只能从新闻间接推断)
|
||||
- 行业横向对比(单票绝对估值 vs 同行相对估值)
|
||||
|
||||
架构师视角 — 已知风险:
|
||||
- mootdx TCP 单例无重连(连接断开后续调用全失败)
|
||||
- 6-file pipeline 维护成本(每加一个 analyst/tool 改 6 个文件)
|
||||
- 错误返回字符串而非异常(LLM 可能把错误当数据分析)
|
||||
- 百度 PAE 接口无版本号/无文档,可能随时变更字段名或返回类型(已踩过 ResultCode str/int 坑)
|
||||
|
||||
### Week 4 — 辩论层 A 股特化(2026-05-12) ✅ 已完成
|
||||
|
||||
**目标:让牛熊辩论和三方风控辩论真正理解 A 股市场结构,而不是套用美股思维。**
|
||||
|
||||
**改造原则:**
|
||||
- 保持英文 prompt(推理质量优先,与 Week 2 辩论层英文决策一致)
|
||||
- 不改 LangGraph 拓扑,只改 prompt 内容
|
||||
- 每个角色注入「A-Share XXX Framework」,用 A 股特有机制作为论据框架
|
||||
|
||||
**5 个文件改造:**
|
||||
|
||||
| 角色 | 注入的 A 股框架 | 核心论据 |
|
||||
|------|----------------|---------|
|
||||
| **Bull Researcher** | A-Share Bull Framework | 政策顺风(专精特新/国家战略)、北向资金确认、游资接力动量、PE消化成长叙事(30x锚点)、解禁出清利好 |
|
||||
| **Bear Researcher** | A-Share Bear Framework | 政策反转风险(窗口指导/行业整顿)、解禁减持压力(预披露窗口)、游资撤退信号(放量滞涨/连板断裂)、估值泡沫(PEG>2)、T+1锁仓陷阱、北向撤退 |
|
||||
| **Aggressive Debater** | A-Share Aggressive Framework | 涨停板动量效应(T+1防恐慌抛售)、政策底(policy put)、PE扩张周期(A股50-100x常态)、散户情绪放大器(80%散户)、游资席位确认 |
|
||||
| **Conservative Debater** | A-Share Conservative Framework | T+1不可逃逸(核心结构性风险)、跌停板流动性陷阱(连续跌停无法出逃)、解禁悬顶、政策市反转、游资快进快出、ST/退市风险(±5%限制+机构强制卖出) |
|
||||
| **Neutral Debater** | A-Share Neutral Framework | T+1双刃剑(锁损 vs 防恐慌)、政策信号分级(国务院>地方>传闻)、北向作辅助信号、估值区间法(非绝对判断)、板块轮动周期定位(2-4周)、仓位管理优先于方向判断 |
|
||||
|
||||
**设计亮点 — 同一机制的三方对立:**
|
||||
|
||||
T+1 制度:
|
||||
- Aggressive:"T+1 防止恐慌抛售,有利多日连板"
|
||||
- Conservative:"T+1 锁死亏损,隔夜风险无法对冲"
|
||||
- Neutral:"关键在仓位控制,让单次隔夜缺口可承受"
|
||||
|
||||
涨跌停制度:
|
||||
- Aggressive:"连续涨停创造爆发性收益"
|
||||
- Conservative:"连续跌停导致灾难性亏损且无法退出"
|
||||
- Neutral:"用限价单和分批建仓控制风险"
|
||||
|
||||
**改造前后对比:**
|
||||
|
||||
| 对比项 | 改造前 | 改造后 |
|
||||
|--------|--------|--------|
|
||||
| 辩论层身份 | 通用"Stock Analyst" | 明确"A-share stock"身份 |
|
||||
| 论据框架 | 美股通用(growth/competitive/macro) | A 股特有(政策市/游资/涨跌停/T+1/北向/解禁) |
|
||||
| 风控维度 | 通用风险偏好差异 | A 股结构性约束(T+1 锁仓、跌停流动性陷阱等) |
|
||||
| 三方对立 | 人为角色差异 | 基于同一 A 股机制的真实多角度辩论 |
|
||||
|
||||
**已完成适配的全部角色(15 个):**
|
||||
|
||||
| 阶段 | 角色 | A 股适配 |
|
||||
|------|------|---------|
|
||||
| Analyst (7) | market / fundamentals / news / social / policy / hot_money / lockup | ✅ 中文 prompt + A 股框架 |
|
||||
| Debate (2) | bull_researcher / bear_researcher | ✅ A-Share Bull/Bear Framework |
|
||||
| Risk (3) | aggressive / conservative / neutral debater | ✅ A-Share Risk Framework |
|
||||
| Manager (2) | research_manager / portfolio_manager | ✅ A-share 标注 + 交易约束 |
|
||||
| Trader (1) | trader | ✅ T+1/涨跌停/手数/时段 |
|
||||
|
||||
**至此全部 15 个 Agent 角色完成 A 股适配。**
|
||||
|
||||
### Week 5 — 数据质量门控实测(2026-05-12) ✅ 已完成
|
||||
|
||||
**目标:用真实 A 股跑全部 14 个数据接口,发现并修复数据源问题。**
|
||||
|
||||
**实测标的:000858 五粮液(白马蓝筹,数据最全 → 如果它都有缺说明是结构性问题)**
|
||||
|
||||
**实测发现与修复:**
|
||||
|
||||
| 接口 | 问题 | 严重性 | 修复 |
|
||||
|------|------|--------|------|
|
||||
| get_northbound_flow | 历史数据来自 `hsgtData` API,返回 2024 年旧缓存;且只输出日期不输出金额 | **P0** | 删掉不可用的 hsgtData,改为实时数据 + 本地 CSV 自缓存历史 + 趋势对比 |
|
||||
| get_insider_transactions | mootdx F10 原文 19969 chars,其中【4.股东变化】占 16121 chars(十大股东历史),浪费 token | **P1** | 截取最新一期,尾部标注 truncated |
|
||||
| get_indicators | 测试脚本参数传错导致假报错(实际功能正常) | 测试 | 修正测试参数 |
|
||||
|
||||
**P0 北向资金历史数据——根因分析:**
|
||||
|
||||
eastmoney 全系北向资金接口(含 akshare `stock_hsgt_hist_em`、datacenter RPT_MUTUAL_DEAL_HISTORY、push2his kamt.kline)自 2024-08-16 后净买额字段全部返回 NaN/None/0。同花顺 `hsgtData` chart 数据也是 2024 年旧缓存。这是上游行业性数据断供,非我方 bug。
|
||||
|
||||
**解决方案:自建历史缓存**
|
||||
- 实时数据仍用同花顺 `dayChart`(实测正常:HGT=-9.28, SGT=-31.10)
|
||||
- 每次调用自动把当天收盘数据写入 `~/.tradingagents/cache/northbound_daily.csv`
|
||||
- `include_history=True` 时从本地缓存读取最近 20 天 + 计算均值 + 今日 vs 均值对比
|
||||
- 优点:零外部依赖,数据自己攒,越跑越丰富
|
||||
- 缺点:新用户首次跑只有当天数据,无历史对比
|
||||
|
||||
**实测结果(修复后):**
|
||||
- 14/14 接口全部 OK
|
||||
- 北向资金:716 chars,实时+历史+趋势(修复前:1120 chars 含错误数据)
|
||||
- 股东研究:5906 chars(修复前:19969 chars,**-70% token**)
|
||||
- 技术指标:1207 chars(修复前:77 chars 错误信息)
|
||||
|
||||
### Week 6 — MiniMax 集成 + E2E 实战验证(2026-05-12) ✅ 已完成
|
||||
|
||||
**目标:接入 MiniMax 作为推荐 LLM 供应商,用真实 A 股跑通完整 Pipeline。**
|
||||
|
||||
**背景决策:为什么需要新增 LLM 供应商**
|
||||
|
||||
TradingAgents 每次分析需 30-50 次 LLM API 调用,必须使用 API Key 模式。Claude/ChatGPT 订阅版无法使用(订阅版走 Web 界面,不暴露 chat completions API)。需要一个国内直连、OpenAI 兼容、成本可控的供应商。
|
||||
|
||||
**供应商选型:**
|
||||
|
||||
| 供应商 | 优势 | 劣势 |
|
||||
|--------|------|------|
|
||||
| **MiniMax(推荐)** | 国内直连、OpenAI 兼容、M2.7 旗舰 + highspeed 双模型、价格合理 | 生态知名度不如 DeepSeek |
|
||||
| DeepSeek | 性价比极高、推理能力强 | 高峰期限流严重 |
|
||||
| 智谱 GLM | 国产老牌、API 稳定 | 模型能力略逊 |
|
||||
| Kimi | Anthropic 兼容 API | 需要额外 backend_url 配置 |
|
||||
| OpenAI | 全球最强模型 | 国内需代理、成本最高 |
|
||||
|
||||
**代码改动(4 文件):**
|
||||
|
||||
| 文件 | 改动 | 说明 |
|
||||
|------|------|------|
|
||||
| `tradingagents/llm_clients/factory.py` | `_OPENAI_COMPATIBLE` 增加 `"minimax"` | 路由 minimax → OpenAIClient |
|
||||
| `tradingagents/llm_clients/openai_client.py` | `_PROVIDER_CONFIG` 增加 `"minimax": ("https://api.minimax.chat/v1", "MINIMAX_API_KEY")` | base_url + API key 环境变量 |
|
||||
| `tradingagents/llm_clients/model_catalog.py` | 新增 `"minimax"` 模型目录(M2.7/M2.7-highspeed/M2.5/M2.5-highspeed) | CLI 选项 + 模型验证 |
|
||||
| `README.md` | 重写「配置 LLM」章节:7 个供应商(.env 配置) + 3 套 config 示例(MiniMax/DeepSeek/Anthropic) | 明确说明"必须用 API Key,不能用订阅版" |
|
||||
|
||||
**关键技术细节:**
|
||||
- MiniMax 走 `provider="minimax"` 而非 `provider="openai"`,因为 OpenAI 原生路径会启用 `use_responses_api=True`(指向 `/v1/responses`),MiniMax 不实现该端点
|
||||
- MiniMax API 的模型列表通过 `GET /v1/models` 获取,当前可用:M2.7 / M2.7-highspeed / M2.5 / M2.5-highspeed / M2.1 / M2.1-highspeed / M2
|
||||
- 选型:deep_think 用 `MiniMax-M2.7`(旗舰),quick_think 用 `MiniMax-M2.7-highspeed`(快速)
|
||||
|
||||
**E2E 实战结果(2 只股票):**
|
||||
|
||||
| 股票 | 代码 | 信号 | 耗时 | AI Message 数 | 数据层错误 |
|
||||
|------|------|------|------|---------------|-----------|
|
||||
| 宁德时代(创业板·动力电池) | 300750 | **Hold** | 977s (16.3 min) | 17 | 1 (akshare 超时) |
|
||||
| 比亚迪(主板·新能源汽车) | 002594 | **Hold** | 890s (14.8 min) | 23 | 2 (akshare 超时) |
|
||||
|
||||
**Pipeline 全链路验证:**
|
||||
|
||||
```
|
||||
7 Analysts → Quality Gate → Bull/Bear Debate → Trader → Risk Panel (3方) → PM
|
||||
✅ ✅ ✅ ✅ ✅ ✅
|
||||
```
|
||||
|
||||
**宁德时代分析亮点:**
|
||||
- 技术分析:MACD/布林带/RSI 全套计算,识别出 436-460 区间震荡
|
||||
- 基本面:Q1 净利润 +48.52%,钠电 60GWh 订单,H 股融资 391 亿港元
|
||||
- 解禁评估:10 章完整报告,未来 90 天零解禁,减持压力评级"无明显压力"
|
||||
- 最终决策:Hold(短期技术面偏弱,北向资金净流出 40.38 亿,等量能恢复再加仓)
|
||||
|
||||
**比亚迪分析亮点:**
|
||||
- 识别出 4 月销量同比下滑 26%,北向资金单日流出 40 亿创极端值
|
||||
- 技术面发现 3 次均线死叉,主力资金近 5 日累计净流出 28 亿
|
||||
- 但高管集体增持 + 社保基金逆势加仓 + 员工持股计划净流入
|
||||
- 最终决策:Hold(基本面短期承压 + 政策面中长期支撑的博弈格局)
|
||||
|
||||
**已知问题(不影响功能):**
|
||||
|
||||
| 问题 | 严重性 | 说明 |
|
||||
|------|--------|------|
|
||||
| Risk Panel 重复输出 | P2 | Hold 信号在 Risk Debate 阶段重复 3-4 次,可能是 debate loop 或 MiniMax stop token 处理 |
|
||||
| `<think>` 标签泄漏 | P2 | MiniMax 的 thinking 内容被保存到最终决策文本,需在输出时过滤 |
|
||||
| akshare 偶发超时 | P3 | 东方财富 `stock_individual_info_em` 偶尔 ProxyError,不影响整体流程 |
|
||||
|
||||
**案例文件归档:**
|
||||
- `examples/cases/300750_宁德时代.md` — 完整分析报告 + Hold 信号
|
||||
- `examples/cases/300750_summary.json` — 结构化摘要
|
||||
- `examples/cases/002594_比亚迪.md` — 完整分析报告 + Hold 信号
|
||||
- `examples/cases/002594_summary.json` — 结构化摘要
|
||||
|
||||
**run_cases.py 配置更新:**
|
||||
- `llm_provider`: `"anthropic"` → `"minimax"`
|
||||
- `deep_think_llm`: `"claude-sonnet-4-6"` → `"MiniMax-M2.7"`
|
||||
- `quick_think_llm`: `"claude-sonnet-4-6"` → `"MiniMax-M2.7-highspeed"`
|
||||
- 移除 `backend_url`(MiniMax 用 `_PROVIDER_CONFIG` 内置 URL)
|
||||
|
||||
---
|
||||
|
||||
## Week 7:Web UI(Streamlit 可视化界面)
|
||||
|
||||
**目标**:让用户不写代码也能用,一键分析 → 实时看进度 → 查看完整报告 → 下载 PDF。
|
||||
|
||||
**时间**:2026-05-12
|
||||
|
||||
### 架构设计
|
||||
|
||||
- **技术栈**:Streamlit(Python 生态内,零前端依赖)
|
||||
- **线程模型**:分析在 daemon 线程跑(15 分钟长任务),主线程通过 `ProgressTracker` + `st.session_state` + 2 秒轮询 `st.rerun()` 实时刷新
|
||||
- **启动方式**:`tradingagents-web` CLI 命令 或 `streamlit run web/app.py`
|
||||
|
||||
### 文件清单(10 Python + 2 配置 = 12 文件)
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `web/app.py` | ~210 | 主入口:Page config、CSS 注入、5 态状态机 |
|
||||
| `web/runner.py` | ~110 | 后台线程:创建 TradingAgentsGraph、stream 状态、检测 12 阶段完成 |
|
||||
| `web/progress.py` | ~95 | ProgressTracker:线程安全、12 阶段定义、stage 状态追踪 |
|
||||
| `web/history.py` | ~55 | 扫描 `~/.tradingagents/logs/` 历史 JSON |
|
||||
| `web/pdf_export.py` | ~170 | fpdf2 PDF 生成:CJK 字体自动检测、封面 + 7 报告 + 辩论 + 风控 |
|
||||
| `web/launch.py` | ~15 | CLI 启动器:`tradingagents-web` 命令入口 |
|
||||
| `web/components/sidebar.py` | ~75 | 侧边栏:Logo + 股票输入 + 日期选择 + 历史列表 |
|
||||
| `web/components/progress_panel.py` | ~105 | 进度面板:彩色状态徽章 + 进度条 + 4 指标 |
|
||||
| `web/components/report_viewer.py` | ~130 | 报告展示:信号卡片 + 7 报告 + 辩论 Tabs + 风控 Tabs + PDF 下载 |
|
||||
| `.streamlit/config.toml` | 6 | Streamlit 暗色主题配置 |
|
||||
| `pyproject.toml` | +3 deps | 新增 streamlit / fpdf2 / python-dotenv |
|
||||
|
||||
### 视觉设计
|
||||
|
||||
- **暗色主题**:`#0a0a0a` 背景 + `#ff5a1f` 橙色主色(匹配视频录制风格)
|
||||
- **Inter 字体**:Google Fonts CDN
|
||||
- **Streamlit 去 Chrome**:隐藏 Deploy 按钮、汉堡菜单、Made with Streamlit 水印
|
||||
- **渐变按钮**:主按钮带 box-shadow 发光效果 + hover 动画
|
||||
- **信号卡片**:Buy 绿色 / Hold 金色 / Sell 红色,大字居中
|
||||
|
||||
### 修复的 Bug
|
||||
|
||||
| Bug | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| Signal 显示 N/A | `extract_signal` 只检查 `final_trade_decision`,该字段含 `<think>` 且无 BUY/SELL/HOLD 关键词 | 改为按优先级检查 `investment_plan` → `trader_investment_decision` → `final_trade_decision`,先 strip `<think>` |
|
||||
| PDF 下载报错 | fpdf2 `output()` 返回 `bytearray`,Streamlit download 需要 `bytes` | 包 `bytes()` 转换 |
|
||||
| 报告缺 trader/investment_plan | 字段名错误:代码写 `trader_investment_plan` 实际是 `trader_investment_decision` + `investment_plan` | 修正字段名 |
|
||||
|
||||
### 依赖变化
|
||||
|
||||
```
|
||||
pyproject.toml:
|
||||
+ "streamlit>=1.45.0",
|
||||
+ "fpdf2>=2.8.0",
|
||||
+ "python-dotenv>=1.1.0",
|
||||
|
||||
[project.scripts]
|
||||
+ tradingagents-web = "web.launch:main"
|
||||
```
|
||||
|
||||
### 合规免责声明(5 处覆盖)
|
||||
|
||||
中国《证券投资咨询管理暂行办法》要求:向公众提供证券投资建议需持牌,与是否收费无关。本项目定位为"开源学习工具"而非"投资咨询服务",但仍在所有用户可见位置添加免责声明:
|
||||
|
||||
| 位置 | 内容 |
|
||||
|------|------|
|
||||
| Web UI 欢迎页底部 | 完整三句免责(学习研究 + 咨询持牌机构 + 不承担损失) |
|
||||
| 侧边栏底部 | 短版提示 |
|
||||
| 报告页信号卡片下方 | "本报告由 AI 自动生成,仅供学习研究,不构成投资建议" |
|
||||
| PDF 报告封面 | 完整免责声明段落 |
|
||||
| README.md | 顶部醒目提示 + 底部独立免责声明章节(含 4 条细则) |
|
||||
|
||||
**涉及文件**:`web/app.py`、`web/components/sidebar.py`、`web/components/report_viewer.py`、`web/pdf_export.py`、`README.md`
|
||||
|
||||
---
|
||||
|
||||
## 视频内容映射(EP12 - EP16)
|
||||
|
||||
| 集数 | 主题 | 对应里程碑 |
|
||||
|---|---|---|
|
||||
| EP12 | 拆解 TradingAgents 架构 + 改造路线图 | 调研阶段(已完成) |
|
||||
| EP13 | 改造数据层 — 9 个 vendor 方法落地 A 股 | Week 1 |
|
||||
| EP14 | Agent 提示词中文化 + 加 A 股特化角色 | Week 2 |
|
||||
| EP15 | 实战 — 多空 Agent 辩论真实 A 股 | Week 3 案例 |
|
||||
| EP16 | 发布开源版本 + 企业版邀约 | Week 3 收尾 |
|
||||
| EP17 (可选) | 对比 hsliuping CN 版,讲清差异 | 收尾后 |
|
||||
|
||||
---
|
||||
|
||||
## 当前进展(2026-05-12)
|
||||
|
||||
### 已完成
|
||||
|
||||
- ✅ 调研原版 TradingAgents 架构(数据层 dispatch、Agent 编排、LangGraph 用法)
|
||||
- ✅ 调研 hsliuping CN 版(扒清 Apache 2.0 部分 vs 商业闭源部分)
|
||||
- ✅ 与 EP11 a-stock-data Skill 的桥接路径已明确
|
||||
- ✅ 三周改造路线图、五大决策、差异化定位全部锁定
|
||||
- ✅ Fork 原版仓库到本目录,记录 fork 点 commit `7e9e7b8`
|
||||
- ✅ 写本 DEV_LOG
|
||||
- ✅ **Week 1 数据层改造完成** — `a_stock.py` 9 个 vendor 方法 + interface 注册 + config 默认切换
|
||||
- ✅ **Week 1 E2E 验证通过** — 688017(绿的谐波)4 analyst 英文辩论,Kimi 2.6 驱动,输出 HOLD 决策
|
||||
- ✅ **Week 2 P0 输出语言** — `output_language: "Chinese"` 一行搞定
|
||||
- ✅ **Week 2 P1 四 analyst 中文化** — market/fundamentals/news/social 全部改为 A 股特化中文 prompt
|
||||
- ✅ **Week 2 P2 两个新角色** — policy_analyst(政策分析师) + hot_money_tracker(游资追踪) + 六文件管道联动
|
||||
- ✅ **Week 2 P3 第七角色 + 下游修复** — lockup_watcher + 5 个下游 agent 补 3 新报告字段 + PM A 股约束
|
||||
- ✅ **Week 2 E2E 最终验证** — 688017 七 analyst 全流程中文辩论,Underweight 决策,报告 ~2100 行
|
||||
- ✅ **Week 2.5 间隙修补** — propagation 初始化 + trader 重写 + reflection/research_manager 适配 + _fetch_returns SPY→CSI300
|
||||
- ✅ **Week 3 文档三件套** — README.md(中文) + CHANGES_FROM_UPSTREAM.md + NOTICE
|
||||
- ✅ **Week 3 基准修复** — `_fetch_returns` SPY→000300.SS 实际取数 + 测试 4/4 通过
|
||||
- ✅ **Week 3 案例脚本** — `examples/run_cases.py` 10 只跨板块 A 股批量运行
|
||||
- ✅ **Week 3.5 信号层 + SKILL V2 升级** — a_stock.py 9→14 方法(含百度PAE概念板块+资金流向) + signal_data_tools 5工具 + analyst 绑定 + ResultCode 类型 bug 修复 + 5/5 端点实测通过
|
||||
- ✅ **Week 4 辩论层 A 股特化** — 5 个辩论角色 prompt 注入 A 股论据框架(详见下方 Week 4 节)
|
||||
- ✅ **Week 5 数据源修复** — 北向资金历史自缓存 + F10 截断(-70% token) + 14/14 接口实测通过
|
||||
- ✅ **Week 5.5 数据质量门控(P0)** — `quality_gate.py` 两层验证(硬检查+LLM复审) + 7 analyst 必采清单 + graph 接线 + researcher 消费质量摘要
|
||||
- ✅ **Week 5.5 数据缺口补齐(P1)** — a_stock.py 14→17 方法:龙虎榜席位(`get_dragon_tiger_board`)、限售解禁日历(`get_lockup_expiry`)、行业横向对比(`get_industry_comparison`) + signal_data_tools 3新工具 + analyst 绑定 + ToolNode 关键 bug 修复
|
||||
- ✅ **Week 5.5 边界测试(P2)** — ST(*ST岩石 600696) 16OK/1WARN/0FAIL + 科创板(中芯国际 688981) 17/0/0 + 中小板(立讯精密 002475) 17/0/0
|
||||
- ✅ **ToolNode 关键 Bug 修复** — `trading_graph.py` 的 `_create_tool_nodes()` 未包含 signal 工具(get_hot_stocks/get_northbound_flow 等),导致 analyst 的 tool call 在 ToolNode 执行时找不到函数。Week 2-4 的所有信号工具调用都受此影响
|
||||
- ✅ **Week 6 MiniMax 集成** — factory.py + openai_client.py + model_catalog.py + README.md 四文件改动,MiniMax 成为推荐供应商
|
||||
- ✅ **Week 6 E2E 实战验证** — 300750 宁德时代(Hold, 16.3min) + 002594 比亚迪(Hold, 14.8min),MiniMax-M2.7 全链路通过,案例归档到 `examples/cases/`
|
||||
- ✅ **Week 7 Web UI** — Streamlit 可视化界面:一键分析 + 12 阶段实时进度 + 7 报告展示 + 多空/风控辩论 Tabs + PDF 导出 + 历史记录(10 文件 + 2 配置)
|
||||
|
||||
### 未开始
|
||||
|
||||
- ⏳ Risk Panel 重复输出问题排查(P2)
|
||||
- ⏳ mootdx TCP 重连逻辑
|
||||
- ⏳ 发布到 GitHub + EP16 视频
|
||||
|
||||
---
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
### 已识别风险
|
||||
|
||||
| 风险 | 应对 |
|
||||
|---|---|
|
||||
| mootdx 必须国内 IP 才稳定 | 视频里说明,海外用户用 akshare fallback |
|
||||
| LLM 调用成本累积 | 默认推荐 MiniMax-M2.7(国内直连,成本远低于 GPT/Claude) |
|
||||
| 与 hsliuping 的 CN 版定位重叠 | 文档/视频里清楚说明差异:License + 部署轻量 + 数据源 + A 股深度 + 配套教学 |
|
||||
| 维护成本(上游频繁更新) | 选择性同步策略,关键改动在 fork 内独立演进 |
|
||||
|
||||
### 开放问题(待决策)
|
||||
|
||||
- [ ] 项目要不要加 Web UI?(原版无,CN 版有但商业闭源)— 倾向**先不加**,保持轻量
|
||||
- [ ] 要不要做 Docker 镜像?— 倾向加,降低部署门槛
|
||||
- [ ] 要不要 GitHub Actions CI?— 倾向先放着,发布时再加
|
||||
- [ ] 中文 README 之外要不要英文 README?— 国际化考虑可以加,优先级低
|
||||
|
||||
---
|
||||
|
||||
## 协作约定
|
||||
|
||||
- 写代码前先读 `CHANGES_FROM_UPSTREAM.md`(待建)了解哪些已改、哪些未动
|
||||
- 改 prompt 必须有真实 A 股案例验证,不能"看起来对就提交"
|
||||
- 提交前用 `pytest tests/` 跑一遍原有测试,确保没有 regression
|
||||
- License 头不要去掉,保留 Apache 2.0 attribution
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
USER appuser
|
||||
WORKDIR /home/appuser/app
|
||||
|
||||
COPY --from=builder --chown=appuser:appuser /build .
|
||||
|
||||
ENTRYPOINT ["tradingagents"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,20 @@
|
||||
TradingAgents-Astock
|
||||
Copyright 2026 Simon (github.com/simonlin1212)
|
||||
|
||||
This product is a fork of TradingAgents, originally developed by
|
||||
TauricResearch (https://github.com/TauricResearch/TradingAgents),
|
||||
licensed under the Apache License, Version 2.0.
|
||||
|
||||
Original work Copyright 2024-2025 TauricResearch
|
||||
Licensed under the Apache License, Version 2.0
|
||||
(https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
Modifications in this fork include:
|
||||
- A-stock (China mainland) data layer using mootdx, Tencent Finance, and akshare
|
||||
- Three new A-stock specific analyst agents (Policy, Hot Money, Lockup)
|
||||
- A-stock trading constraints (T+1, price limits, lot sizes) in Trader and PM
|
||||
- Chinese language output support
|
||||
- CSI 300 benchmark for alpha calculation (replacing SPY)
|
||||
- Various prompt and pipeline adaptations for A-share market specifics
|
||||
|
||||
See CHANGES_FROM_UPSTREAM.md for the full list of modifications.
|
||||
@@ -0,0 +1,364 @@
|
||||
<h1 align="center">TradingAgents-Astock</h1>
|
||||
|
||||
<p align="center">
|
||||
基于 <a href="https://github.com/TauricResearch/TradingAgents">TauricResearch/TradingAgents</a>(65K ⭐)的 A 股深度特化 fork<br>
|
||||
全 Apache 2.0 开源 · pip install 即跑 · 零外部服务依赖
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>⚠️ 免责声明:本项目仅供学习研究与技术演示,不构成任何投资建议。投资决策请咨询持牌专业机构。</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/simonlin1212/tradingagents-astock/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/simonlin1212/tradingagents-astock?style=social"/></a>
|
||||
<a href="https://github.com/simonlin1212/tradingagents-astock/network/members"><img alt="Forks" src="https://img.shields.io/github/forks/simonlin1212/tradingagents-astock?style=social"/></a>
|
||||
<a href="https://arxiv.org/abs/2412.20138"><img alt="论文" src="https://img.shields.io/badge/论文-arXiv_2412.20138-B31B1B?logo=arxiv"/></a>
|
||||
<a href="./LICENSE"><img alt="License" src="https://img.shields.io/badge/License-Apache_2.0-blue"/></a>
|
||||
<a href="./CHANGES_FROM_UPSTREAM.md"><img alt="改动记录" src="https://img.shields.io/badge/改动记录-CHANGES-orange"/></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [为什么做这个 Fork](#为什么做这个-fork)
|
||||
- [与上游 / CN 版对比](#与上游--cn-版对比)
|
||||
- [架构概览](#架构概览)
|
||||
- [7 个 Analyst 角色](#7-个-analyst-角色)
|
||||
- [数据源](#数据源)
|
||||
- [快速开始](#快速开始)
|
||||
- [Web UI](#web-ui)
|
||||
- [配置说明](#配置说明)
|
||||
- [运行案例](#运行案例)
|
||||
- [项目结构](#项目结构)
|
||||
- [致谢](#致谢)
|
||||
- [许可证](#许可证)
|
||||
|
||||
---
|
||||
|
||||
## 为什么做这个 Fork
|
||||
|
||||
原版 TradingAgents 是一个出色的多 Agent 投研框架,但它针对美股设计:数据走 Yahoo Finance / Alpha Vantage,分析师不懂 A 股制度,辩论和决策完全面向美股市场。
|
||||
|
||||
**本 Fork 的目标**:把 TradingAgents 的多 Agent 辩论架构真正落地到 A 股,不是简单翻译,而是从数据层、Agent 角色、交易规则三个维度做深度特化。
|
||||
|
||||
### 核心改造
|
||||
|
||||
| 维度 | 原版 | 本 Fork |
|
||||
|------|------|---------|
|
||||
| **数据源** | Yahoo Finance / Alpha Vantage | mootdx + 腾讯财经 + akshare(全免费) |
|
||||
| **Analyst 角色** | 4 个(市场/情绪/新闻/基本面) | **7 个**(+政策分析师/游资追踪/解禁监控) |
|
||||
| **交易规则** | 美股(T+0、无涨跌停) | A 股(T+1、涨跌停、最小手数、交易时段) |
|
||||
| **输出语言** | 英文 | 中文报告(内部辩论保持英文以保证推理质量) |
|
||||
| **Alpha 基准** | SPY | 沪深 300(CSI 300) |
|
||||
|
||||
---
|
||||
|
||||
## 与上游 / CN 版对比
|
||||
|
||||
| 特性 | 原版 TradingAgents | hsliuping/TradingAgents-CN | **本 Fork** |
|
||||
|------|-------------------|--------------------------|-------------|
|
||||
| 许可证 | Apache 2.0 | app/ + frontend/ 商业闭源 | **全 Apache 2.0** |
|
||||
| 部署依赖 | pip install | MongoDB + Redis 必须 | **pip install 即跑** |
|
||||
| A 股数据 | ❌ | Tushare(积分墙) | **mootdx + 腾讯 + akshare(免费)** |
|
||||
| A 股特化角色 | ❌ | 基础翻译 | **政策/游资/解禁 3 个深度角色** |
|
||||
| A 股交易约束 | ❌ | 部分 | **T+1/涨跌停/手数/ST 全覆盖** |
|
||||
| 配套教程 | 英文文档 | 仅代码 | **EP12-EP16 视频系列** |
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 7 Analyst 研报生成 │
|
||||
│ Market → Social → News → Fundamentals │
|
||||
│ → Policy → Hot Money → Lockup │
|
||||
│ (每个 Analyst 带工具循环) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Bull vs Bear 投研辩论 │
|
||||
│ Bull Researcher ←→ Bear Researcher │
|
||||
│ (最多 N 轮辩论) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Research Manager 综合研判 │
|
||||
│ (深度思考 LLM,输出投资计划) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Trader 交易方案 │
|
||||
│ (A 股约束:T+1/涨跌停/手数) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Aggressive ←→ Conservative ←→ Neutral │
|
||||
│ 三方风险辩论 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Portfolio Manager 最终决策 │
|
||||
│ (深度思考 LLM,输出 Buy/Hold/Sell + 仓位) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**双 LLM 设计**:
|
||||
- `quick_think_llm`:所有 Analyst、Researcher、Trader、Risk Debater
|
||||
- `deep_think_llm`:Research Manager 和 Portfolio Manager(需要综合全局信息做决策)
|
||||
|
||||
---
|
||||
|
||||
## 7 个 Analyst 角色
|
||||
|
||||
### 原版 4 角色(A 股适配)
|
||||
|
||||
| 角色 | 职责 | 数据工具 |
|
||||
|------|------|---------|
|
||||
| 🏪 市场分析师 | K 线形态、技术指标、量价分析 | `get_stock_data`, `get_indicators` |
|
||||
| 💬 舆情分析师 | 社交媒体情绪、散户讨论热度 | `get_news` |
|
||||
| 📰 新闻分析师 | 行业新闻、公告、宏观事件 | `get_news`, `get_global_news`, `get_insider_transactions` |
|
||||
| 📊 基本面分析师 | 财报三表、盈利能力、估值 | `get_fundamentals`, `get_balance_sheet`, `get_cashflow`, `get_income_statement` |
|
||||
|
||||
### A 股特化 3 角色(新增)
|
||||
|
||||
| 角色 | 职责 | 数据工具 | 为什么需要 |
|
||||
|------|------|---------|-----------|
|
||||
| 🏛️ 政策分析师 | 监管政策、产业政策、窗口指导 | `get_news`, `get_global_news` | A 股是政策市,政策变化直接影响板块轮动 |
|
||||
| 🔥 游资追踪师 | 龙虎榜、大单流向、主力资金动态 | `get_stock_data`, `get_news`, `get_insider_transactions` | 游资是 A 股短线定价的核心力量 |
|
||||
| 🔓 解禁监控师 | 限售股解禁、大股东减持、股权质押 | `get_insider_transactions`, `get_news`, `get_fundamentals` | 解禁是 A 股特有的重大供给冲击因素 |
|
||||
|
||||
所有 7 个 Analyst 的报告会流入后续的 Bull/Bear 辩论和三方风险辩论,确保 A 股特色因素贯穿整条决策链。
|
||||
|
||||
---
|
||||
|
||||
## 数据源
|
||||
|
||||
全部免费,无需 API Key,无积分墙:
|
||||
|
||||
| 来源 | 协议 | 提供内容 |
|
||||
|------|------|---------|
|
||||
| **mootdx** | TCP 7709 | OHLCV K 线、财务快照、F10 文本 |
|
||||
| **腾讯财经** | HTTP (`qt.gtimg.cn`) | PE / PB / 市值 / 换手率(实时) |
|
||||
| **akshare** | Python 库 | 新闻、财报三表(sina 源)、股票信息 |
|
||||
|
||||
> 完全不依赖 Tushare(积分墙)、Alpha Vantage(海外 API)、Yahoo Finance(不支持 A 股)。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# Python >= 3.10
|
||||
git clone https://github.com/simonlin1212/tradingagents-astock.git
|
||||
cd tradingagents-astock
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2. 配置 LLM
|
||||
|
||||
> **必须使用 API Key**,不能用 Claude/ChatGPT 订阅版。每次分析需 30-50 次 LLM 调用,只有 API 模式支持。
|
||||
|
||||
在项目根目录创建 `.env` 文件,按你选择的供应商配置:
|
||||
|
||||
```bash
|
||||
# ── 方案 A:MiniMax(推荐,国内直连,性价比高)──────────
|
||||
MINIMAX_API_KEY=sk-xxx
|
||||
# 申请地址:https://platform.minimaxi.com/
|
||||
|
||||
# ── 方案 B:DeepSeek ─────────────────────────────────
|
||||
DEEPSEEK_API_KEY=sk-xxx
|
||||
# 申请地址:https://platform.deepseek.com/
|
||||
|
||||
# ── 方案 C:智谱 GLM ─────────────────────────────────
|
||||
ZHIPU_API_KEY=xxx
|
||||
# 申请地址:https://open.bigmodel.cn/
|
||||
|
||||
# ── 方案 D:通义千问 Qwen ────────────────────────────
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
# 申请地址:https://dashscope.console.aliyun.com/
|
||||
|
||||
# ── 方案 E:OpenAI ───────────────────────────────────
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
|
||||
# ── 方案 F:Anthropic ────────────────────────────────
|
||||
ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
|
||||
# ── 方案 G:Kimi(Anthropic 兼容 API)────────────────
|
||||
ANTHROPIC_AUTH_TOKEN=your-kimi-token
|
||||
```
|
||||
|
||||
### 3. 运行分析
|
||||
|
||||
根据你选择的供应商修改 config:
|
||||
|
||||
```python
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
|
||||
# ── MiniMax 示例(推荐)─────────────────────────────
|
||||
config = {
|
||||
"llm_provider": "minimax",
|
||||
"deep_think_llm": "MiniMax-M2.7",
|
||||
"quick_think_llm": "MiniMax-M2.7-highspeed",
|
||||
"output_language": "Chinese",
|
||||
}
|
||||
|
||||
# ── DeepSeek 示例 ───────────────────────────────────
|
||||
# config = {
|
||||
# "llm_provider": "deepseek",
|
||||
# "deep_think_llm": "deepseek-chat",
|
||||
# "quick_think_llm": "deepseek-chat",
|
||||
# "output_language": "Chinese",
|
||||
# }
|
||||
|
||||
# ── Anthropic + Kimi 示例 ───────────────────────────
|
||||
# config = {
|
||||
# "llm_provider": "anthropic",
|
||||
# "deep_think_llm": "claude-sonnet-4-6",
|
||||
# "quick_think_llm": "claude-sonnet-4-6",
|
||||
# "backend_url": "https://api.kimi.com/coding/",
|
||||
# "output_language": "Chinese",
|
||||
# }
|
||||
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
final_state, decision = ta.propagate("688017", "2026-05-12")
|
||||
print(decision)
|
||||
```
|
||||
|
||||
### 4. CLI 方式
|
||||
|
||||
```bash
|
||||
tradingagents # 交互式 CLI
|
||||
tradingagents --help # 查看所有选项
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web UI
|
||||
|
||||
内置 Streamlit 可视化界面,输入股票代码即可一键分析,适合不写代码的用户。
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
# 方式一:命令行启动(推荐)
|
||||
tradingagents-web
|
||||
|
||||
# 方式二:直接运行
|
||||
streamlit run web/app.py
|
||||
```
|
||||
|
||||
打开浏览器访问 `http://localhost:8501`。
|
||||
|
||||
### 功能
|
||||
|
||||
- **一键分析**:输入 6 位 A 股代码 + 日期,点击「开始分析」
|
||||
- **实时进度**:12 阶段 pipeline 实时显示(7 分析师 → 质量门控 → 辩论 → 风控 → 决策)
|
||||
- **完整报告**:信号卡片(Buy/Hold/Sell)、7 份分析师报告、多空辩论、风控评估
|
||||
- **PDF 导出**:一键下载完整 PDF 分析报告
|
||||
- **历史记录**:自动保存并展示所有历史分析
|
||||
|
||||
### 截图
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/web-ui-welcome.png" width="80%" alt="Web UI 欢迎页"/>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
所有配置通过 `config` 字典传入,完整选项:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `llm_provider` | `"openai"` | LLM 提供商:`openai` / `anthropic` / `google` |
|
||||
| `deep_think_llm` | `"gpt-5.4"` | Research Manager + Portfolio Manager 用的模型 |
|
||||
| `quick_think_llm` | `"gpt-5.4-mini"` | 所有 Analyst / Researcher / Trader 用的模型 |
|
||||
| `backend_url` | `None` | 自定义 API 端点(Kimi、deepseek 等兼容 API) |
|
||||
| `output_language` | `"Chinese"` | 报告输出语言(内部辩论始终英文) |
|
||||
| `max_debate_rounds` | `1` | Bull vs Bear 辩论轮数 |
|
||||
| `max_risk_discuss_rounds` | `1` | 风险三方辩论轮数 |
|
||||
| `data_vendors` | 全部 `"a_stock"` | 数据供应商路由 |
|
||||
| `checkpoint_enabled` | `False` | 启用 SQLite 断点续跑 |
|
||||
| `memory_log_max_entries` | `None` | 交易记忆最大条目数 |
|
||||
|
||||
---
|
||||
|
||||
## 运行案例
|
||||
|
||||
以下是使用 Kimi 2.6 对科创板股票 688017(绝味食品-SH)的分析结果摘要:
|
||||
|
||||
> **最终决策:Underweight(减持)**
|
||||
>
|
||||
> 7 个 Analyst 经过 Bull/Bear 辩论和三方风险评估后,Portfolio Manager 建议减持 25-33% 仓位。
|
||||
> 关键因素:政策面中性偏正、游资净流出明显、近期有解禁压力、基本面现金流恶化。
|
||||
|
||||
更多案例将在 `examples/` 目录持续更新。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
TradingAgents-Astock/
|
||||
├── tradingagents/
|
||||
│ ├── agents/
|
||||
│ │ ├── analysts/ # 7 个分析师
|
||||
│ │ │ ├── market_analyst.py
|
||||
│ │ │ ├── social_media_analyst.py
|
||||
│ │ │ ├── news_analyst.py
|
||||
│ │ │ ├── fundamentals_analyst.py
|
||||
│ │ │ ├── policy_analyst.py # A 股特化
|
||||
│ │ │ ├── hot_money_tracker.py # A 股特化
|
||||
│ │ │ └── lockup_watcher.py # A 股特化
|
||||
│ │ ├── researchers/ # Bull / Bear 研究员
|
||||
│ │ ├── risk_mgmt/ # 激进 / 保守 / 中立 辩手
|
||||
│ │ ├── managers/ # Research Manager + Portfolio Manager
|
||||
│ │ ├── trader/ # Trader(A 股交易约束)
|
||||
│ │ └── utils/ # 状态定义、工具函数
|
||||
│ ├── dataflows/
|
||||
│ │ ├── a_stock.py # A 股数据 vendor(mootdx + 腾讯 + akshare)
|
||||
│ │ ├── interface.py # 数据接口抽象层
|
||||
│ │ └── ...
|
||||
│ └── graph/
|
||||
│ ├── trading_graph.py # 主入口:TradingAgentsGraph
|
||||
│ ├── setup.py # LangGraph 拓扑定义
|
||||
│ ├── propagation.py # 状态初始化与传播
|
||||
│ ├── reflection.py # 交易反思(CSI 300 基准)
|
||||
│ └── conditional_logic.py
|
||||
├── web/
|
||||
│ ├── app.py # Streamlit 主入口
|
||||
│ ├── runner.py # 后台线程运行分析
|
||||
│ ├── progress.py # 线程安全进度追踪
|
||||
│ ├── history.py # 历史记录扫描
|
||||
│ ├── pdf_export.py # PDF 报告生成
|
||||
│ ├── launch.py # CLI 启动器
|
||||
│ └── components/ # UI 组件
|
||||
│ ├── sidebar.py # 侧边栏(输入 + 历史)
|
||||
│ ├── progress_panel.py # 实时进度面板
|
||||
│ └── report_viewer.py # 报告展示
|
||||
├── test_astock.py # E2E 集成测试
|
||||
├── CHANGES_FROM_UPSTREAM.md # 与上游的完整改动记录
|
||||
├── NOTICE # Apache 2.0 归属声明
|
||||
├── LICENSE # Apache 2.0 许可证
|
||||
└── pyproject.toml # 包定义与依赖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目基于 [TauricResearch/TradingAgents](https://github.com/TauricResearch/TradingAgents) 开源项目进行 A 股特化改造。感谢原作者的出色工作和 Apache 2.0 开源精神。
|
||||
|
||||
**原始论文**:[TradingAgents: Multi-Agents LLM Financial Trading Framework](https://arxiv.org/abs/2412.20138)
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache License 2.0](./LICENSE)
|
||||
|
||||
本项目是 TauricResearch/TradingAgents 的 fork,继承 Apache 2.0 许可证。详见 [NOTICE](./NOTICE)。
|
||||
|
||||
## 免责声明
|
||||
|
||||
> **本项目仅供学习研究与技术演示,不构成任何投资建议。**
|
||||
>
|
||||
> - 本系统产出的所有分析报告和交易信号均由 AI 自动生成,可能存在错误或偏差
|
||||
> - 投资决策请咨询持有中国证监会颁发资质的专业机构
|
||||
> - 作者不对使用本工具产生的任何投资损失承担责任
|
||||
> - 股市有风险,投资需谨慎
|
||||
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 791 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,51 @@
|
||||
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()
|
||||
@@ -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]",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AnalystType(str, Enum):
|
||||
MARKET = "market"
|
||||
SOCIAL = "social"
|
||||
NEWS = "news"
|
||||
FUNDAMENTALS = "fundamentals"
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
______ ___ ___ __
|
||||
/_ __/________ _____/ (_)___ ____ _/ | ____ ____ ____ / /______
|
||||
/ / / ___/ __ `/ __ / / __ \/ __ `/ /| |/ __ `/ _ \/ __ \/ __/ ___/
|
||||
/ / / / / /_/ / /_/ / / / / / /_/ / ___ / /_/ / __/ / / / /_(__ )
|
||||
/_/ /_/ \__,_/\__,_/_/_/ /_/\__, /_/ |_\__, /\___/_/ /_/\__/____/
|
||||
/____/ /____/
|
||||
@@ -0,0 +1,76 @@
|
||||
import threading
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.outputs import LLMResult
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import questionary
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from cli.models import AnalystType
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
|
||||
console = Console()
|
||||
|
||||
TICKER_INPUT_EXAMPLES = "Examples: SPY, CNC.TO, 7203.T, 0700.HK"
|
||||
|
||||
ANALYST_ORDER = [
|
||||
("Market Analyst", AnalystType.MARKET),
|
||||
("Social Media Analyst", AnalystType.SOCIAL),
|
||||
("News Analyst", AnalystType.NEWS),
|
||||
("Fundamentals Analyst", AnalystType.FUNDAMENTALS),
|
||||
]
|
||||
|
||||
|
||||
def get_ticker() -> str:
|
||||
"""Prompt the user to enter a ticker symbol."""
|
||||
ticker = questionary.text(
|
||||
f"Enter the exact ticker symbol to analyze ({TICKER_INPUT_EXAMPLES}):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a valid ticker symbol.",
|
||||
style=questionary.Style(
|
||||
[
|
||||
("text", "fg:green"),
|
||||
("highlighted", "noinherit"),
|
||||
]
|
||||
),
|
||||
).ask()
|
||||
|
||||
if not ticker:
|
||||
console.print("\n[red]No ticker symbol provided. Exiting...[/red]")
|
||||
exit(1)
|
||||
|
||||
return normalize_ticker_symbol(ticker)
|
||||
|
||||
|
||||
def normalize_ticker_symbol(ticker: str) -> str:
|
||||
"""Normalize ticker input while preserving exchange suffixes."""
|
||||
return ticker.strip().upper()
|
||||
|
||||
|
||||
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() -> List[AnalystType]:
|
||||
"""Select analysts using an interactive checkbox."""
|
||||
choices = questionary.checkbox(
|
||||
"Select Your [Analysts Team]:",
|
||||
choices=[
|
||||
questionary.Choice(display, value=value) for display, value in ANALYST_ORDER
|
||||
],
|
||||
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
|
||||
|
||||
|
||||
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", [])
|
||||
return [(m.get("name") or m["id"], m["id"]) for m in models]
|
||||
except Exception as e:
|
||||
console.print(f"\n[yellow]Could not fetch OpenRouter models: {e}[/yellow]")
|
||||
return []
|
||||
|
||||
|
||||
def select_openrouter_model() -> str:
|
||||
"""Select an OpenRouter model from the newest available, or enter a custom ID."""
|
||||
models = _fetch_openrouter_models()
|
||||
|
||||
choices = [questionary.Choice(name, value=mid) for name, mid in models[:5]]
|
||||
choices.append(questionary.Choice("Custom model ID", value="custom"))
|
||||
|
||||
choice = questionary.select(
|
||||
"Select 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 or choice == "custom":
|
||||
return questionary.text(
|
||||
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.",
|
||||
).ask().strip()
|
||||
|
||||
return choice
|
||||
|
||||
|
||||
def _prompt_custom_model_id() -> str:
|
||||
"""Prompt user to type a custom model ID."""
|
||||
return questionary.text(
|
||||
"Enter model ID:",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.",
|
||||
).ask().strip()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
if provider.lower() == "azure":
|
||||
return questionary.text(
|
||||
f"Enter Azure deployment name ({mode}-thinking):",
|
||||
validate=lambda x: len(x.strip()) > 0 or "Please enter a deployment name.",
|
||||
).ask().strip()
|
||||
|
||||
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 select_llm_provider() -> tuple[str, str | None]:
|
||||
"""Select the LLM provider and its API endpoint."""
|
||||
# (display_name, provider_key, base_url)
|
||||
PROVIDERS = [
|
||||
("OpenAI", "openai", "https://api.openai.com/v1"),
|
||||
("Google", "google", None),
|
||||
("Anthropic", "anthropic", "https://api.anthropic.com/"),
|
||||
("xAI", "xai", "https://api.x.ai/v1"),
|
||||
("DeepSeek", "deepseek", "https://api.deepseek.com"),
|
||||
("Qwen", "qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
|
||||
("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"),
|
||||
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
|
||||
("Azure OpenAI", "azure", None),
|
||||
("Ollama", "ollama", "http://localhost:11434/v1"),
|
||||
]
|
||||
|
||||
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+ and 4.6 models.
|
||||
"""
|
||||
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_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()
|
||||
|
||||
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().strip()
|
||||
|
||||
return choice
|
||||
@@ -0,0 +1,35 @@
|
||||
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:
|
||||
- LLM_PROVIDER=ollama
|
||||
volumes:
|
||||
- tradingagents_data:/home/appuser/.tradingagents
|
||||
depends_on:
|
||||
- ollama
|
||||
tty: true
|
||||
stdin_open: true
|
||||
profiles:
|
||||
- ollama
|
||||
|
||||
volumes:
|
||||
tradingagents_data:
|
||||
ollama_data:
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ticker": "002594",
|
||||
"label": "比亚迪 (主板·新能源汽车)",
|
||||
"trade_date": "2026-05-12",
|
||||
"run_time": "2026-05-12T10:54:47.430175",
|
||||
"duration_seconds": 890,
|
||||
"signal": "Hold",
|
||||
"decision_preview": "**Rating**: Hold\n\n**Executive Summary**: 比亚迪002594评级为 Hold。长期配置逻辑成立(前向PE 21.3倍、历史低位;账上757亿现金+经营现金流591亿为硬实力防线;FY2026 EPS CAGR 约25%),但4月销量下滑26%、北向外资系统性撤退、T+1制度下止损机制失效率三大短期风险尚未完全释放,当前99元一线并非理想重仓窗口。建议以不超过20%总仓位建立底仓(等待回踩97-98元支撑确认后轻仓),严格止损设于95元下方;加仓条件:①股价放量突破102元站稳 AND ②月度销量环比改善≥10%;目标价105元(第一目标)/117元(第二目标),持有期6-12个月。\n\n**Investment Thesis**: 多空辩论综合裁决:①估值方向有利(PE 21.3倍/PEG 0.86)但FY2026 EPS预测分歧±43%,不可作为单一锚点;②销量下滑26%已非季节性因素,渗透率红利期结束,结构性价格战压力持续,基本面拐点尚需等待月销环比改善信号确认;③内部人增持(社保/高管)和外资撤退形成资金面对峙,但T+1制度使止损机制在A股高波动环境下实效化,倒逼更保守的初始仓位;④技术面RSI 47处于中性偏低区域而非超卖,MACD仍在零轴下方,均线死叉3次,短期趋势反转条件尚未齐备。综合来看,方向正确但时机未成熟,应以底仓控险+等信号加仓的滚动策略应对,避免在信号不完整时重仓承受T+1隔夜敞口。"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# 002594 比亚迪 (主板·新能源汽车)
|
||||
|
||||
- **Trade Date**: 2026-05-12
|
||||
- **Run Time**: 2026-05-12 10:54
|
||||
- **Duration**: 890s (14.8 min)
|
||||
- **LLM**: MiniMax-M2.7 / M2.7-highspeed
|
||||
|
||||
## Signal: Hold
|
||||
|
||||
## Full Analysis
|
||||
|
||||
**Rating**: Hold
|
||||
|
||||
**Executive Summary**: 比亚迪002594评级为 Hold。长期配置逻辑成立(前向PE 21.3倍、历史低位;账上757亿现金+经营现金流591亿为硬实力防线;FY2026 EPS CAGR 约25%),但4月销量下滑26%、北向外资系统性撤退、T+1制度下止损机制失效率三大短期风险尚未完全释放,当前99元一线并非理想重仓窗口。建议以不超过20%总仓位建立底仓(等待回踩97-98元支撑确认后轻仓),严格止损设于95元下方;加仓条件:①股价放量突破102元站稳 AND ②月度销量环比改善≥10%;目标价105元(第一目标)/117元(第二目标),持有期6-12个月。
|
||||
|
||||
**Investment Thesis**: 多空辩论综合裁决:①估值方向有利(PE 21.3倍/PEG 0.86)但FY2026 EPS预测分歧±43%,不可作为单一锚点;②销量下滑26%已非季节性因素,渗透率红利期结束,结构性价格战压力持续,基本面拐点尚需等待月销环比改善信号确认;③内部人增持(社保/高管)和外资撤退形成资金面对峙,但T+1制度使止损机制在A股高波动环境下实效化,倒逼更保守的初始仓位;④技术面RSI 47处于中性偏低区域而非超卖,MACD仍在零轴下方,均线死叉3次,短期趋势反转条件尚未齐备。综合来看,方向正确但时机未成熟,应以底仓控险+等信号加仓的滚动策略应对,避免在信号不完整时重仓承受T+1隔夜敞口。
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ticker": "300750",
|
||||
"label": "宁德时代 (创业板·动力电池)",
|
||||
"trade_date": "2026-05-12",
|
||||
"run_time": "2026-05-12T10:38:34.698774",
|
||||
"duration_seconds": 977,
|
||||
"signal": "Hold",
|
||||
"decision_preview": "<think>\nThe user wants me to engage in a debate format about investing in 300750 (宁德时代/CATL). They've asked for a \"neutral\" position that serves as a middle-ground between the bullish and bearish arguments. Based on the context, I need to present a balanced analysis that acknowledges both sides but advocates for a measured, tactical approach rather than extreme positions.\n\nLet me craft a response that:\n1. Takes a balanced, middle-ground stance\n2. Draws specific evidence from the data provided\n3. Presents a nuanced \"折中方案\" (middle-ground solution)\n4. Covers technical analysis, fundamental analysis, sentiment, policy, and capital flows\n5. Provides specific, actionable guidance\n6. Is written entirely in Chinese\n7. Has a natural, conversational tone without special formatting\n\nI need to be comprehensive and present all three perspectives mentioned (bullish, conservative, and my balanced/neutral stance), but the user specifically asked for my \"Conservative Analyst\" persona to argue for a neutral/balanced position. So my response should be the balanced view.\n\nLet me structure this:\n- Opening: Acknowledge both sides have valid points\n- Technical analysis: Neutral reading\n- Fundamental analysis: Balanced assessment\n- Sentiment/capital flows: Acknowledge risks but not excessive worry\n- Policy: Stay neutral, acknowledge both support and risks\n- Actionable recommendation: Medium position, tactical approach\n- Key metrics to watch\n- Mental framework for this position\n</think>\n\n我觉得两边说的都有道理,不如找个折中的方案。\n\n先承认激进派的判断没错——基本面确实强劲,一季度业绩摆在那里,估值也不算贵,政策红利实实在在。但保守派的担忧也不是杞人忧天,T+1制度是实实在在的约束,外资流出40亿不是小数目,连续缩量说明市场情绪在降温。\n\n我的建议是:与其非此即彼,不如把仓位控制好。\n\n具体来说,现在可以先建个底仓,比如占总仓位10-15%,不用急着重仓。然后设置一个买入条件——等它回落到420到425这个区间再加仓。这个位置是布林带中轨和ATR止损位的交汇处,技术面支撑比较扎实。如果跌不到呢?那就持有现有的底仓,至少不会踏空后面的行情。\n\n止损就设在420块,跌破就出来,不要幻想反弹。这个止损幅度大概是当前价格的4%,考虑到宁德时代的波动性,这个风险敞口是合理的。\n\n涨上去怎么办?如果反弹到460到465的前高附近,可以先减掉一半,把成本降下来。剩下的仓位设个移动止损,比如回撤5%就出。这样就算判断错了,亏不了太多;判断对了,能留住大部分利润。\n\n说实话,我不太认同现在满仓杀进去的做法。不是说这股票不好,而是市场短期情绪不明朗的时候,把仓位控制好,心态会更稳。散户最大的优势就是资金灵活,没"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# 300750 宁德时代 (创业板·动力电池)
|
||||
|
||||
- **Trade Date**: 2026-05-12
|
||||
- **Run Time**: 2026-05-12 10:38
|
||||
- **Duration**: 977s (16.3 min)
|
||||
- **LLM**: MiniMax-M2.7 / M2.7-highspeed
|
||||
|
||||
## Signal: Hold
|
||||
|
||||
## Full Analysis
|
||||
|
||||
<think>
|
||||
The user wants me to engage in a debate format about investing in 300750 (宁德时代/CATL). They've asked for a "neutral" position that serves as a middle-ground between the bullish and bearish arguments. Based on the context, I need to present a balanced analysis that acknowledges both sides but advocates for a measured, tactical approach rather than extreme positions.
|
||||
|
||||
Let me craft a response that:
|
||||
1. Takes a balanced, middle-ground stance
|
||||
2. Draws specific evidence from the data provided
|
||||
3. Presents a nuanced "折中方案" (middle-ground solution)
|
||||
4. Covers technical analysis, fundamental analysis, sentiment, policy, and capital flows
|
||||
5. Provides specific, actionable guidance
|
||||
6. Is written entirely in Chinese
|
||||
7. Has a natural, conversational tone without special formatting
|
||||
|
||||
I need to be comprehensive and present all three perspectives mentioned (bullish, conservative, and my balanced/neutral stance), but the user specifically asked for my "Conservative Analyst" persona to argue for a neutral/balanced position. So my response should be the balanced view.
|
||||
|
||||
Let me structure this:
|
||||
- Opening: Acknowledge both sides have valid points
|
||||
- Technical analysis: Neutral reading
|
||||
- Fundamental analysis: Balanced assessment
|
||||
- Sentiment/capital flows: Acknowledge risks but not excessive worry
|
||||
- Policy: Stay neutral, acknowledge both support and risks
|
||||
- Actionable recommendation: Medium position, tactical approach
|
||||
- Key metrics to watch
|
||||
- Mental framework for this position
|
||||
</think>
|
||||
|
||||
我觉得两边说的都有道理,不如找个折中的方案。
|
||||
|
||||
先承认激进派的判断没错——基本面确实强劲,一季度业绩摆在那里,估值也不算贵,政策红利实实在在。但保守派的担忧也不是杞人忧天,T+1制度是实实在在的约束,外资流出40亿不是小数目,连续缩量说明市场情绪在降温。
|
||||
|
||||
我的建议是:与其非此即彼,不如把仓位控制好。
|
||||
|
||||
具体来说,现在可以先建个底仓,比如占总仓位10-15%,不用急着重仓。然后设置一个买入条件——等它回落到420到425这个区间再加仓。这个位置是布林带中轨和ATR止损位的交汇处,技术面支撑比较扎实。如果跌不到呢?那就持有现有的底仓,至少不会踏空后面的行情。
|
||||
|
||||
止损就设在420块,跌破就出来,不要幻想反弹。这个止损幅度大概是当前价格的4%,考虑到宁德时代的波动性,这个风险敞口是合理的。
|
||||
|
||||
涨上去怎么办?如果反弹到460到465的前高附近,可以先减掉一半,把成本降下来。剩下的仓位设个移动止损,比如回撤5%就出。这样就算判断错了,亏不了太多;判断对了,能留住大部分利润。
|
||||
|
||||
说实话,我不太认同现在满仓杀进去的做法。不是说这股票不好,而是市场短期情绪不明朗的时候,把仓位控制好,心态会更稳。散户最大的优势就是资金灵活,没必要跟机构比谁跑得快。用10%的仓位去试错,用条件单去捕捉更好的买点,这比一把梭哈聪明多了。
|
||||
|
||||
关键是别跟自己较劲。觉得好就轻仓先进,觉得不确定就等一等,投资不需要每天都做决定。耐心本身就是一种优势,特别是在A股这种T+1的市场里。
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ticker": "600519",
|
||||
"label": "贵州茅台 (主板·白酒龙头)",
|
||||
"trade_date": "2026-04-30",
|
||||
"run_time": "2026-05-04T21:15:40.226353",
|
||||
"duration_seconds": 823,
|
||||
"decision_preview": "Hold"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 600519 贵州茅台 (主板·白酒龙头)
|
||||
|
||||
- **Trade Date**: 2026-04-30
|
||||
- **Run Time**: 2026-05-04 21:15
|
||||
- **Duration**: 823s (13.7 min)
|
||||
- **LLM**: Kimi 2.6 (claude-sonnet-4-6)
|
||||
|
||||
## Final Decision
|
||||
|
||||
Hold
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Batch runner: analyse multiple A-stock tickers and save results to examples/cases/.
|
||||
|
||||
Usage:
|
||||
uv run python examples/run_cases.py # run all cases
|
||||
uv run python examples/run_cases.py 688017 # run a single ticker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
CASES_DIR = Path(__file__).parent / "cases"
|
||||
CASES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TRADE_DATE = "2026-05-12"
|
||||
|
||||
# 10 tickers across different sectors
|
||||
# fmt: off
|
||||
TICKERS = {
|
||||
"688017": "绿的谐波 (科创板·谐波减速器)",
|
||||
"300750": "宁德时代 (创业板·动力电池)",
|
||||
"600519": "贵州茅台 (主板·白酒龙头)",
|
||||
"000858": "五粮液 (主板·白酒)",
|
||||
"300059": "东方财富 (创业板·互联网券商)",
|
||||
"601012": "隆基绿能 (主板·光伏)",
|
||||
"300760": "迈瑞医疗 (创业板·医疗器械)",
|
||||
"688981": "中芯国际 (科创板·芯片代工)",
|
||||
"002594": "比亚迪 (主板·新能源汽车)",
|
||||
"300124": "汇川技术 (创业板·工业自动化)",
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
|
||||
def build_config() -> dict:
|
||||
"""Build the TradingAgents config for case runs."""
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["llm_provider"] = "minimax"
|
||||
config["deep_think_llm"] = "MiniMax-M2.7"
|
||||
config["quick_think_llm"] = "MiniMax-M2.7-highspeed"
|
||||
config["data_vendors"] = {
|
||||
"core_stock_apis": "a_stock",
|
||||
"technical_indicators": "a_stock",
|
||||
"fundamental_data": "a_stock",
|
||||
"news_data": "a_stock",
|
||||
"signal_data": "a_stock",
|
||||
}
|
||||
config["max_debate_rounds"] = 1
|
||||
config["max_risk_discuss_rounds"] = 1
|
||||
config["output_language"] = "Chinese"
|
||||
return config
|
||||
|
||||
|
||||
def run_single(ticker: str, label: str, config: dict) -> None:
|
||||
"""Run one ticker and save the decision to a markdown file."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Analysing {ticker} — {label}")
|
||||
print(f"Trade date: {TRADE_DATE}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
start_time = time.time()
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
|
||||
full_decision = ""
|
||||
try:
|
||||
final_state, decision = ta.propagate(ticker, TRADE_DATE)
|
||||
full_decision = final_state.get("final_trade_decision", "")
|
||||
except Exception as e:
|
||||
decision = f"ERROR: {e}"
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Save result — full decision + short signal
|
||||
out_path = CASES_DIR / f"{ticker}_{label.split('(')[0].strip()}.md"
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"# {ticker} {label}\n\n")
|
||||
f.write(f"- **Trade Date**: {TRADE_DATE}\n")
|
||||
f.write(f"- **Run Time**: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n")
|
||||
f.write(f"- **Duration**: {elapsed:.0f}s ({elapsed / 60:.1f} min)\n")
|
||||
f.write(f"- **LLM**: MiniMax-M2.7 / M2.7-highspeed\n\n")
|
||||
f.write(f"## Signal: {decision}\n\n")
|
||||
if full_decision and full_decision != decision:
|
||||
f.write(f"## Full Analysis\n\n{full_decision}\n")
|
||||
|
||||
print(f"\n✅ Saved to {out_path} ({elapsed:.0f}s)")
|
||||
|
||||
# Also save a JSON summary for programmatic use
|
||||
summary_path = CASES_DIR / f"{ticker}_summary.json"
|
||||
with open(summary_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"label": label,
|
||||
"trade_date": TRADE_DATE,
|
||||
"run_time": datetime.now().isoformat(),
|
||||
"duration_seconds": round(elapsed),
|
||||
"signal": decision,
|
||||
"decision_preview": (full_decision or decision)[:2000],
|
||||
},
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = build_config()
|
||||
|
||||
# Allow single ticker override from CLI
|
||||
if len(sys.argv) > 1:
|
||||
ticker = sys.argv[1]
|
||||
label = TICKERS.get(ticker, ticker)
|
||||
run_single(ticker, label, config)
|
||||
return
|
||||
|
||||
# Run all
|
||||
print(f"Running {len(TICKERS)} cases...")
|
||||
for ticker, label in TICKERS.items():
|
||||
run_single(ticker, label, config)
|
||||
print(f"\n{'─' * 40}")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"All {len(TICKERS)} cases complete. Results in {CASES_DIR}/")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Create a custom config
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
config["deep_think_llm"] = "gpt-5.4-mini" # Use a different model
|
||||
config["quick_think_llm"] = "gpt-5.4-mini" # Use a different model
|
||||
config["max_debate_rounds"] = 1 # Increase debate rounds
|
||||
|
||||
# Configure data vendors (default uses yfinance, no extra API keys needed)
|
||||
config["data_vendors"] = {
|
||||
"core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"technical_indicators": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"fundamental_data": "yfinance", # Options: alpha_vantage, yfinance
|
||||
"news_data": "yfinance", # Options: alpha_vantage, yfinance
|
||||
}
|
||||
|
||||
# Initialize with custom config
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,68 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tradingagents-astock"
|
||||
version = "0.2.4"
|
||||
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "Apache-2.0"}
|
||||
authors = [
|
||||
{name = "simonlin1212", email = "simonlin1212@users.noreply.github.com"},
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/simonlin1212/tradingagents-astock"
|
||||
Repository = "https://github.com/simonlin1212/tradingagents-astock"
|
||||
Issues = "https://github.com/simonlin1212/tradingagents-astock/issues"
|
||||
Upstream = "https://github.com/TauricResearch/TradingAgents"
|
||||
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",
|
||||
"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>=0.2.63",
|
||||
"streamlit>=1.45.0",
|
||||
"fpdf2>=2.8.0",
|
||||
"python-dotenv>=1.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
tradingagents = "cli.main:app"
|
||||
tradingagents-web = "web.launch:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["tradingagents*", "cli*", "web*"]
|
||||
|
||||
[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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
.
|
||||
@@ -0,0 +1,176 @@
|
||||
"""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 os
|
||||
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-2.5-flash", None),
|
||||
"anthropic": ("claude-sonnet-4-6", None),
|
||||
"deepseek": ("deepseek-chat", None),
|
||||
"qwen": ("qwen-plus", None),
|
||||
"glm": ("glm-5", None),
|
||||
"xai": ("grok-4", 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())
|
||||
@@ -0,0 +1,11 @@
|
||||
import time
|
||||
from tradingagents.dataflows.y_finance import get_YFin_data_online, get_stock_stats_indicators_window, get_balance_sheet as get_yfinance_balance_sheet, get_cashflow as get_yfinance_cashflow, get_income_statement as get_yfinance_income_statement, get_insider_transactions as get_yfinance_insider_transactions
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""End-to-end test: run TradingAgents pipeline on A-stock 688017 via Kimi 2.6."""
|
||||
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.default_config import DEFAULT_CONFIG
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
# --- LLM: Kimi 2.6 via Anthropic-compatible API ---
|
||||
config["llm_provider"] = "anthropic"
|
||||
config["deep_think_llm"] = "claude-sonnet-4-6" # Kimi maps internally
|
||||
config["quick_think_llm"] = "claude-sonnet-4-6"
|
||||
config["backend_url"] = "https://api.kimi.com/coding/"
|
||||
|
||||
# --- Data: A-stock vendor (mootdx + tencent + akshare) ---
|
||||
config["data_vendors"] = {
|
||||
"core_stock_apis": "a_stock",
|
||||
"technical_indicators": "a_stock",
|
||||
"fundamental_data": "a_stock",
|
||||
"news_data": "a_stock",
|
||||
}
|
||||
|
||||
# --- Debate settings: minimal for first test ---
|
||||
config["max_debate_rounds"] = 1
|
||||
config["max_risk_discuss_rounds"] = 1
|
||||
config["output_language"] = "Chinese"
|
||||
|
||||
print("=" * 60)
|
||||
print("TradingAgents-Astock E2E Test")
|
||||
print("Ticker: 688017")
|
||||
print("Trade date: 2026-04-30")
|
||||
print("LLM: Kimi 2.6 via Anthropic API")
|
||||
print("Data: a_stock (mootdx + tencent + akshare)")
|
||||
print("=" * 60)
|
||||
|
||||
ta = TradingAgentsGraph(debug=True, config=config)
|
||||
|
||||
_, decision = ta.propagate("688017", "2026-04-30")
|
||||
print("\n" + "=" * 60)
|
||||
print("FINAL DECISION:")
|
||||
print("=" * 60)
|
||||
print(decision)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Data quality gate test: run all 14 a_stock.py endpoints on 000858 五粮液.
|
||||
|
||||
Checks: return type, non-empty, data freshness, key fields present.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
TICKER = "300750"
|
||||
TRADE_DATE = "2026-05-12"
|
||||
START_DATE = "2026-04-01"
|
||||
END_DATE = "2026-05-12"
|
||||
|
||||
from tradingagents.dataflows.a_stock import (
|
||||
get_stock_data,
|
||||
get_indicators,
|
||||
get_fundamentals,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_income_statement,
|
||||
get_news,
|
||||
get_global_news,
|
||||
get_insider_transactions,
|
||||
get_profit_forecast,
|
||||
get_hot_stocks,
|
||||
get_northbound_flow,
|
||||
get_concept_blocks,
|
||||
get_fund_flow,
|
||||
get_dragon_tiger_board,
|
||||
get_lockup_expiry,
|
||||
get_industry_comparison,
|
||||
)
|
||||
|
||||
TESTS = [
|
||||
("1. get_stock_data", lambda: get_stock_data(TICKER, START_DATE, END_DATE)),
|
||||
("2. get_indicators", lambda: get_indicators(TICKER, "rsi", END_DATE, 30)),
|
||||
("3. get_fundamentals", lambda: get_fundamentals(TICKER, TRADE_DATE)),
|
||||
("4. get_balance_sheet", lambda: get_balance_sheet(TICKER, "quarterly", TRADE_DATE)),
|
||||
("5. get_cashflow", lambda: get_cashflow(TICKER, "quarterly", TRADE_DATE)),
|
||||
("6. get_income_statement", lambda: get_income_statement(TICKER, "quarterly", TRADE_DATE)),
|
||||
("7. get_news", lambda: get_news(TICKER, START_DATE, END_DATE)),
|
||||
("8. get_global_news", lambda: get_global_news(TRADE_DATE, 7, 10)),
|
||||
("9. get_insider_transactions", lambda: get_insider_transactions(TICKER)),
|
||||
("10. get_profit_forecast", lambda: get_profit_forecast(TICKER, TRADE_DATE)),
|
||||
("11. get_hot_stocks", lambda: get_hot_stocks(TRADE_DATE)),
|
||||
("12. get_northbound_flow", lambda: get_northbound_flow(TRADE_DATE, True)),
|
||||
("13. get_concept_blocks", lambda: get_concept_blocks(TICKER)),
|
||||
("14. get_fund_flow", lambda: get_fund_flow(TICKER, TRADE_DATE, True)),
|
||||
("15. get_dragon_tiger_board", lambda: get_dragon_tiger_board(TICKER, TRADE_DATE)),
|
||||
("16. get_lockup_expiry", lambda: get_lockup_expiry(TICKER, TRADE_DATE)),
|
||||
("17. get_industry_comparison", lambda: get_industry_comparison(TICKER, TRADE_DATE)),
|
||||
]
|
||||
|
||||
print(f"{'='*70}")
|
||||
print(f"数据质量实测 | {TICKER} 五粮液 | {TRADE_DATE}")
|
||||
print(f"{'='*70}\n")
|
||||
|
||||
results = []
|
||||
|
||||
for name, fn in TESTS:
|
||||
print(f"--- {name} ---")
|
||||
t0 = time.time()
|
||||
try:
|
||||
out = fn()
|
||||
elapsed = time.time() - t0
|
||||
length = len(out) if isinstance(out, str) else len(str(out))
|
||||
preview = out[:800] if isinstance(out, str) else str(out)[:800]
|
||||
print(f" OK | {elapsed:.1f}s | {length} chars")
|
||||
print(f" >>> {preview}")
|
||||
if length < 50:
|
||||
results.append((name, "WARN", f"too short ({length} chars)"))
|
||||
else:
|
||||
results.append((name, "OK", f"{length} chars, {elapsed:.1f}s"))
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
print(f" FAIL | {elapsed:.1f}s | {type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
results.append((name, "FAIL", str(e)[:100]))
|
||||
print()
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("SUMMARY")
|
||||
print(f"{'='*70}")
|
||||
ok = sum(1 for _, s, _ in results if s == "OK")
|
||||
warn = sum(1 for _, s, _ in results if s == "WARN")
|
||||
fail = sum(1 for _, s, _ in results if s == "FAIL")
|
||||
print(f"OK: {ok} | WARN: {warn} | FAIL: {fail} / {len(results)} total\n")
|
||||
for name, status, detail in results:
|
||||
marker = "✓" if status == "OK" else ("⚠" if status == "WARN" else "✗")
|
||||
print(f" {marker} {name}: {detail}")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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",
|
||||
"ZHIPU_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:
|
||||
monkeypatch.setenv(env_var, os.environ.get(env_var, "placeholder"))
|
||||
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Test checkpoint resume: crash mid-analysis, re-run resumes from last node."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from tradingagents.graph.checkpointer import (
|
||||
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))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""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`` raises NotImplementedError for
|
||||
``deepseek-reasoner`` so the agent factories' free-text fallback
|
||||
handles the request instead of failing at runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.prompt_values import ChatPromptValue
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deepseek-reasoner: structured output unavailable, falls through to free-text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepSeekReasonerStructuredOutput:
|
||||
def test_with_structured_output_raises_for_reasoner(self):
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-reasoner",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
client.with_structured_output(_Sample)
|
||||
|
||||
def test_with_structured_output_works_for_v4(self):
|
||||
"""V4 models (non-reasoner) accept tool_choice; structured output works."""
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.deepseek.com",
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
# Should return a Runnable, not raise. (The actual API call would
|
||||
# require a real key; we only assert binding succeeds.)
|
||||
wrapped = client.with_structured_output(_Sample)
|
||||
assert wrapped is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
)
|
||||
@@ -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-2.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()
|
||||
@@ -0,0 +1,773 @@
|
||||
"""Tests for TradingMemoryLog — storage, deferred reflection, PM injection, legacy removal."""
|
||||
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
|
||||
from tradingagents.graph.reflection import Reflector
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
from tradingagents.graph.propagation import Propagator
|
||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||
|
||||
_SEP = TradingMemoryLog._SEPARATOR
|
||||
|
||||
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]
|
||||
bench_prices = [4000.0, 4020.0, 4040.0, 4030.0, 4050.0, 4060.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(bench_prices if sym == "000300.SS" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "688017", "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_benchmark_shorter_than_stock(self):
|
||||
"""CSI 300 having fewer rows than the stock must not raise IndexError."""
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
|
||||
bench_prices = [4000.0, 4020.0, 4030.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(bench_prices if sym == "000300.SS" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "688017", "2026-01-05")
|
||||
assert raw is not None and alpha is not None and days is not None
|
||||
assert days == 2
|
||||
|
||||
# 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
|
||||
@@ -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, [])
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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_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()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Tests for structured-output agents (Trader and Research Manager).
|
||||
|
||||
The Portfolio Manager has its own coverage in tests/test_memory_log.py
|
||||
(which exercises the full memory-log → PM injection cycle). This file
|
||||
covers the parallel schemas, render functions, and graceful-fallback
|
||||
behavior we added for the Trader and Research Manager so all three
|
||||
decision-making agents share the same shape.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.managers.research_manager import create_research_manager
|
||||
from tradingagents.agents.schemas import (
|
||||
PortfolioRating,
|
||||
ResearchPlan,
|
||||
TraderAction,
|
||||
TraderProposal,
|
||||
render_research_plan,
|
||||
render_trader_proposal,
|
||||
)
|
||||
from tradingagents.agents.trader.trader import create_trader
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Render functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRenderTraderProposal:
|
||||
def test_minimal_required_fields(self):
|
||||
p = TraderProposal(action=TraderAction.HOLD, reasoning="Balanced setup; no edge.")
|
||||
md = render_trader_proposal(p)
|
||||
assert "**Action**: Hold" in md
|
||||
assert "**Reasoning**: Balanced setup; no edge." in md
|
||||
# The trailing FINAL TRANSACTION PROPOSAL line is preserved for the
|
||||
# analyst stop-signal text and any external code that greps for it.
|
||||
assert "FINAL TRANSACTION PROPOSAL: **HOLD**" in md
|
||||
|
||||
def test_optional_fields_included_when_present(self):
|
||||
p = TraderProposal(
|
||||
action=TraderAction.BUY,
|
||||
reasoning="Strong technicals + fundamentals.",
|
||||
entry_price=189.5,
|
||||
stop_loss=178.0,
|
||||
position_sizing="6% of portfolio",
|
||||
)
|
||||
md = render_trader_proposal(p)
|
||||
assert "**Action**: Buy" in md
|
||||
assert "**Entry Price**: 189.5" in md
|
||||
assert "**Stop Loss**: 178.0" in md
|
||||
assert "**Position Sizing**: 6% of portfolio" in md
|
||||
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in md
|
||||
|
||||
def test_optional_fields_omitted_when_absent(self):
|
||||
p = TraderProposal(action=TraderAction.SELL, reasoning="Guidance cut.")
|
||||
md = render_trader_proposal(p)
|
||||
assert "Entry Price" not in md
|
||||
assert "Stop Loss" not in md
|
||||
assert "Position Sizing" not in md
|
||||
assert "FINAL TRANSACTION PROPOSAL: **SELL**" in md
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRenderResearchPlan:
|
||||
def test_required_fields(self):
|
||||
p = ResearchPlan(
|
||||
recommendation=PortfolioRating.OVERWEIGHT,
|
||||
rationale="Bull case carried; tailwinds intact.",
|
||||
strategic_actions="Build position over two weeks; cap at 5%.",
|
||||
)
|
||||
md = render_research_plan(p)
|
||||
assert "**Recommendation**: Overweight" in md
|
||||
assert "**Rationale**: Bull case carried" in md
|
||||
assert "**Strategic Actions**: Build position" in md
|
||||
|
||||
def test_all_5_tier_ratings_render(self):
|
||||
for rating in PortfolioRating:
|
||||
p = ResearchPlan(
|
||||
recommendation=rating,
|
||||
rationale="r",
|
||||
strategic_actions="s",
|
||||
)
|
||||
md = render_research_plan(p)
|
||||
assert f"**Recommendation**: {rating.value}" in md
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trader agent: structured happy path + fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_trader_state():
|
||||
return {
|
||||
"company_of_interest": "NVDA",
|
||||
"investment_plan": "**Recommendation**: Buy\n**Rationale**: ...\n**Strategic Actions**: ...",
|
||||
}
|
||||
|
||||
|
||||
def _structured_trader_llm(captured: dict, proposal: TraderProposal | None = None):
|
||||
"""Build a MagicMock LLM whose with_structured_output binding captures the
|
||||
prompt and returns a real TraderProposal so render_trader_proposal works.
|
||||
"""
|
||||
if proposal is None:
|
||||
proposal = TraderProposal(
|
||||
action=TraderAction.BUY,
|
||||
reasoning="Strong setup.",
|
||||
)
|
||||
structured = MagicMock()
|
||||
structured.invoke.side_effect = lambda prompt: (
|
||||
captured.__setitem__("prompt", prompt) or proposal
|
||||
)
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.return_value = structured
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTraderAgent:
|
||||
def test_structured_path_produces_rendered_markdown(self):
|
||||
captured = {}
|
||||
proposal = TraderProposal(
|
||||
action=TraderAction.BUY,
|
||||
reasoning="AI capex cycle intact; institutional flows constructive.",
|
||||
entry_price=189.5,
|
||||
stop_loss=178.0,
|
||||
position_sizing="6% of portfolio",
|
||||
)
|
||||
llm = _structured_trader_llm(captured, proposal)
|
||||
trader = create_trader(llm)
|
||||
result = trader(_make_trader_state())
|
||||
plan = result["trader_investment_plan"]
|
||||
assert "**Action**: Buy" in plan
|
||||
assert "**Entry Price**: 189.5" in plan
|
||||
assert "FINAL TRANSACTION PROPOSAL: **BUY**" in plan
|
||||
# The same rendered markdown is also added to messages for downstream agents.
|
||||
assert plan in result["messages"][0].content
|
||||
|
||||
def test_prompt_includes_investment_plan(self):
|
||||
captured = {}
|
||||
llm = _structured_trader_llm(captured)
|
||||
trader = create_trader(llm)
|
||||
trader(_make_trader_state())
|
||||
# The investment plan is in the user message of the captured prompt.
|
||||
prompt = captured["prompt"]
|
||||
assert any("Proposed Investment Plan" in m["content"] for m in prompt)
|
||||
|
||||
def test_falls_back_to_freetext_when_structured_unavailable(self):
|
||||
plain_response = (
|
||||
"**Action**: Sell\n\nGuidance cut hits margins.\n\n"
|
||||
"FINAL TRANSACTION PROPOSAL: **SELL**"
|
||||
)
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
|
||||
llm.invoke.return_value = MagicMock(content=plain_response)
|
||||
trader = create_trader(llm)
|
||||
result = trader(_make_trader_state())
|
||||
assert result["trader_investment_plan"] == plain_response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Research Manager agent: structured happy path + fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_rm_state():
|
||||
return {
|
||||
"company_of_interest": "NVDA",
|
||||
"investment_debate_state": {
|
||||
"history": "Bull and bear arguments here.",
|
||||
"bull_history": "Bull says...",
|
||||
"bear_history": "Bear says...",
|
||||
"current_response": "",
|
||||
"judge_decision": "",
|
||||
"count": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _structured_rm_llm(captured: dict, plan: ResearchPlan | None = None):
|
||||
if plan is None:
|
||||
plan = ResearchPlan(
|
||||
recommendation=PortfolioRating.HOLD,
|
||||
rationale="Balanced view across both sides.",
|
||||
strategic_actions="Hold current position; reassess after earnings.",
|
||||
)
|
||||
structured = MagicMock()
|
||||
structured.invoke.side_effect = lambda prompt: (
|
||||
captured.__setitem__("prompt", prompt) or plan
|
||||
)
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.return_value = structured
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResearchManagerAgent:
|
||||
def test_structured_path_produces_rendered_markdown(self):
|
||||
captured = {}
|
||||
plan = ResearchPlan(
|
||||
recommendation=PortfolioRating.OVERWEIGHT,
|
||||
rationale="Bull case is stronger; AI tailwind intact.",
|
||||
strategic_actions="Build position gradually over two weeks.",
|
||||
)
|
||||
llm = _structured_rm_llm(captured, plan)
|
||||
rm = create_research_manager(llm)
|
||||
result = rm(_make_rm_state())
|
||||
ip = result["investment_plan"]
|
||||
assert "**Recommendation**: Overweight" in ip
|
||||
assert "**Rationale**: Bull case" in ip
|
||||
assert "**Strategic Actions**: Build position" in ip
|
||||
|
||||
def test_prompt_uses_5_tier_rating_scale(self):
|
||||
"""The RM prompt must list all five tiers so the schema enum matches user expectations."""
|
||||
captured = {}
|
||||
llm = _structured_rm_llm(captured)
|
||||
rm = create_research_manager(llm)
|
||||
rm(_make_rm_state())
|
||||
prompt = captured["prompt"]
|
||||
for tier in ("Buy", "Overweight", "Hold", "Underweight", "Sell"):
|
||||
assert f"**{tier}**" in prompt, f"missing {tier} in prompt"
|
||||
|
||||
def test_falls_back_to_freetext_when_structured_unavailable(self):
|
||||
plain_response = "**Recommendation**: Sell\n\n**Rationale**: ...\n\n**Strategic Actions**: ..."
|
||||
llm = MagicMock()
|
||||
llm.with_structured_output.side_effect = NotImplementedError("provider unsupported")
|
||||
llm.invoke.return_value = MagicMock(content=plain_response)
|
||||
rm = create_research_manager(llm)
|
||||
result = rm(_make_rm_state())
|
||||
assert result["investment_plan"] == plain_response
|
||||
@@ -0,0 +1,21 @@
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
from .utils.agent_utils import create_msg_delete
|
||||
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
|
||||
|
||||
from .analysts.fundamentals_analyst import create_fundamentals_analyst
|
||||
from .analysts.hot_money_tracker import create_hot_money_tracker
|
||||
from .analysts.lockup_watcher import create_lockup_watcher
|
||||
from .analysts.market_analyst import create_market_analyst
|
||||
from .analysts.news_analyst import create_news_analyst
|
||||
from .analysts.policy_analyst import create_policy_analyst
|
||||
from .analysts.social_media_analyst import create_social_media_analyst
|
||||
|
||||
from .quality_gate import create_quality_gate
|
||||
|
||||
from .researchers.bear_researcher import create_bear_researcher
|
||||
from .researchers.bull_researcher import create_bull_researcher
|
||||
|
||||
from .risk_mgmt.aggressive_debator import create_aggressive_debator
|
||||
from .risk_mgmt.conservative_debator import create_conservative_debator
|
||||
from .risk_mgmt.neutral_debator import create_neutral_debator
|
||||
|
||||
from .managers.research_manager import create_research_manager
|
||||
from .managers.portfolio_manager import create_portfolio_manager
|
||||
|
||||
from .trader.trader import create_trader
|
||||
|
||||
__all__ = [
|
||||
"AgentState",
|
||||
"create_msg_delete",
|
||||
"InvestDebateState",
|
||||
"RiskDebateState",
|
||||
"create_bear_researcher",
|
||||
"create_bull_researcher",
|
||||
"create_research_manager",
|
||||
"create_fundamentals_analyst",
|
||||
"create_hot_money_tracker",
|
||||
"create_lockup_watcher",
|
||||
"create_market_analyst",
|
||||
"create_neutral_debator",
|
||||
"create_news_analyst",
|
||||
"create_aggressive_debator",
|
||||
"create_policy_analyst",
|
||||
"create_quality_gate",
|
||||
"create_portfolio_manager",
|
||||
"create_conservative_debator",
|
||||
"create_social_media_analyst",
|
||||
"create_trader",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_fundamentals,
|
||||
get_income_statement,
|
||||
get_industry_comparison,
|
||||
get_insider_transactions,
|
||||
get_language_instruction,
|
||||
get_profit_forecast,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_fundamentals_analyst(llm):
|
||||
def fundamentals_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_fundamentals,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_income_statement,
|
||||
get_profit_forecast,
|
||||
get_industry_comparison,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的基本面分析师。你的任务是全面分析目标公司的基本面信息,为投资决策提供扎实的数据支撑。"
|
||||
"\n\n⚠️ A 股基本面分析要点:"
|
||||
"\n- **财务准则**:A 股上市公司采用中国会计准则(CAS),在收入确认、资产减值等方面与 IFRS 存在差异,分析时需注意口径。"
|
||||
"\n- **估值参照系**:A 股整体 PE 中位数偏高(30-50x 为常态),不能照搬美股 15-25x 标准;应对标同行业 A 股公司横向比较。"
|
||||
"\n- **核心指标**:重点关注营收增长率、归母净利润、扣非净利润(剔除非经常性损益)、ROE、毛利率、经营性现金流与净利润的匹配度。"
|
||||
"\n- **财报披露节奏**:一季报(4月底前)、半年报(8月底前)、三季报(10月底前)、年报(次年4月底前)。分析时注意数据的时效性。"
|
||||
"\n- **特殊风险关注**:商誉减值(并购后遗症)、股权质押比例、大股东减持计划、关联交易规模。"
|
||||
"\n\n请使用以下工具获取数据:"
|
||||
"\n- `get_fundamentals`:获取公司综合基本面信息(PE/PB/总市值/季报财务快照/一致预期EPS/前向PE/PEG等)"
|
||||
"\n- `get_profit_forecast`:获取机构一致预期EPS详情(覆盖机构数、EPS区间、前向PE、PEG、PE消化时间)"
|
||||
"\n- `get_balance_sheet`:资产负债表详细数据"
|
||||
"\n- `get_cashflow`:现金流量表详细数据"
|
||||
"\n- `get_income_statement`:利润表详细数据"
|
||||
"\n- `get_industry_comparison(ticker, curr_date)`:获取全行业横向对比(90个行业涨跌幅/成交额/净流入排名,用于估值对标和行业定位)"
|
||||
"\n\n撰写详尽的基本面研究报告,给出具体数据支撑的投资建议。报告末尾附 Markdown 表格汇总关键财务指标和估值水平。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. PE(TTM)、PB、总市值"
|
||||
"\n2. 营收同比增长率"
|
||||
"\n3. 归母净利润及同比增长率"
|
||||
"\n4. ROE"
|
||||
"\n5. 资产负债率"
|
||||
"\n6. 经营性现金流与净利润比值"
|
||||
"\n7. 机构一致预期 EPS(调用 get_profit_forecast 获取)"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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
|
||||
@@ -0,0 +1,108 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_concept_blocks,
|
||||
get_dragon_tiger_board,
|
||||
get_fund_flow,
|
||||
get_hot_stocks,
|
||||
get_industry_comparison,
|
||||
get_insider_transactions,
|
||||
get_language_instruction,
|
||||
get_news,
|
||||
get_northbound_flow,
|
||||
get_stock_data,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_hot_money_tracker(llm):
|
||||
"""A-stock hot money tracker: analyzes capital flow, volume anomalies, and major player movements."""
|
||||
|
||||
def hot_money_tracker_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_stock_data,
|
||||
get_news,
|
||||
get_insider_transactions,
|
||||
get_hot_stocks,
|
||||
get_northbound_flow,
|
||||
get_concept_blocks,
|
||||
get_fund_flow,
|
||||
get_dragon_tiger_board,
|
||||
get_industry_comparison,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的游资与资金流向追踪分析师。你的核心任务是通过分析成交量异动、股东变化和市场新闻,追踪主力资金和游资的动向,判断短期资金博弈格局。"
|
||||
"\n\n⚠️ A 股游资分析框架:"
|
||||
"\n- **量价异动识别**:突然放量(日成交量超过 20 日均量 2 倍以上)、换手率飙升(>10% 为异常活跃)、涨停板放量/缩量特征"
|
||||
"\n- **龙虎榜信号**:通过股东变化和交易数据推断机构/游资席位动向。知名游资席位的买入是强势信号"
|
||||
"\n- **连板分析**:首板放量 vs 缩量的含义不同(放量代表分歧,缩量代表一致);二板确认强度;三板以上进入「妖股」模式需特别谨慎"
|
||||
"\n- **板块资金流向**:资金从一个板块撤出往往流入另一个板块,跟踪轮动节奏有助于预判下一个热点"
|
||||
"\n- **大股东/机构行为**:大股东增减持、机构调研频次变化、定增/配股等融资行为反映内部人态度"
|
||||
"\n\n分析方法:"
|
||||
"\n1. 先调用 get_stock_data 获取近期 K 线和成交量数据,识别量价异动"
|
||||
"\n2. 调用 get_insider_transactions 获取股东/内部人交易记录,判断主力动向"
|
||||
"\n3. 调用 get_news 搜索游资、龙虎榜、主力资金相关新闻"
|
||||
"\n4. 调用 get_hot_stocks 获取当日强势股及题材归因(同花顺编辑部人工标注),识别热点板块轮动"
|
||||
"\n5. 调用 get_northbound_flow 获取北向资金(沪深股通)实时分钟级流向,判断外资态度"
|
||||
"\n6. 综合判断当前资金博弈格局:主力吸筹 / 主力出货 / 游资接力 / 散户主导"
|
||||
"\n\n请使用以下工具:"
|
||||
"\n- `get_stock_data`:获取 K 线和成交量数据"
|
||||
"\n- `get_news(query, start_date, end_date)`:搜索游资/资金流向相关新闻"
|
||||
"\n- `get_insider_transactions`:获取股东和内部人交易数据"
|
||||
"\n- `get_hot_stocks(curr_date)`:获取当日涨停股 + 题材归因 reason tags(同花顺独家)"
|
||||
"\n- `get_northbound_flow(curr_date)`:获取北向资金实时分钟级流向(沪股通+深股通累计净买入)"
|
||||
"\n- `get_concept_blocks(ticker)`:获取个股所属概念板块/行业分类/地域(百度股市通,含当日涨幅)"
|
||||
"\n- `get_fund_flow(ticker, curr_date)`:获取个股主力/散户资金流向(分钟级实时+20日历史,超大单/大单/中单/小单净流入)"
|
||||
"\n- `get_dragon_tiger_board(ticker, curr_date)`:获取龙虎榜上榜记录、买卖席位明细(营业部)、机构参与情况"
|
||||
"\n- `get_industry_comparison(ticker, curr_date)`:获取全行业横向对比(90个行业涨跌幅/成交额/净流入排名,判断板块轮动)"
|
||||
"\n\n撰写详细的资金面分析报告,给出资金面总体判断(主力流入/主力流出/资金博弈/无明显信号)和短期操作建议。报告末尾附 Markdown 表格汇总量价信号、资金动向和结论。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. 近 5 日成交量变化趋势(放量/缩量/平稳)"
|
||||
"\n2. 当日北向资金净流入金额(沪股通 + 深股通)"
|
||||
"\n3. 个股主力资金净流入(超大单 + 大单)"
|
||||
"\n4. 所属概念板块及当日板块涨幅"
|
||||
"\n5. 当日是否上榜热门股及题材归因"
|
||||
"\n6. 资金面总体判断"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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],
|
||||
"hot_money_report": report,
|
||||
}
|
||||
|
||||
return hot_money_tracker_node
|
||||
@@ -0,0 +1,91 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_fundamentals,
|
||||
get_insider_transactions,
|
||||
get_language_instruction,
|
||||
get_lockup_expiry,
|
||||
get_news,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_lockup_watcher(llm):
|
||||
"""A-stock lockup expiry and insider reduction watcher."""
|
||||
|
||||
def lockup_watcher_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_insider_transactions,
|
||||
get_news,
|
||||
get_fundamentals,
|
||||
get_lockup_expiry,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的解禁与减持监控分析师。你的核心任务是追踪目标公司的限售股解禁计划、大股东减持动态和股权结构变化,评估供给端压力对股价的影响。"
|
||||
"\n\n⚠️ A 股解禁/减持分析框架:"
|
||||
"\n- **限售股类型**:首发原股东限售(IPO 后 1-3 年)、定增限售(6-18 个月)、股权激励限售、战略配售限售。不同类型的减持意愿和节奏差异很大。"
|
||||
"\n- **解禁规模评估**:解禁市值占流通市值比例 >20% 为重大解禁压力;<5% 影响有限。结合当前股价和解禁成本(原始获取价)判断减持动力。"
|
||||
"\n- **减持新规约束**:大股东(持股 5%+)每 90 天通过集中竞价减持不超过总股本 1%、大宗交易不超过 2%;董监高每年减持不超过持股 25%。"
|
||||
"\n- **减持预披露**:大股东/董监高减持需提前 15 个交易日披露减持计划(时间窗口、数量、方式)。已披露的减持计划是确定性利空。"
|
||||
"\n- **减持动力评估**:当前股价 vs 解禁成本的溢价倍数越高,减持动力越强。若股价低于解禁成本,减持概率大幅降低。"
|
||||
"\n- **历史减持行为**:大股东过往减持频率和规模反映其套现意愿。频繁���持的大股东在新一轮解禁时减持概率更高。"
|
||||
"\n\n分析方法:"
|
||||
"\n1. 调用 get_insider_transactions 获取股东/内部人交易记录和持股变化"
|
||||
"\n2. 调用 get_fundamentals 获取公司股本结构和大股东持股比例"
|
||||
"\n3. 调用 get_news 搜索解禁、减持计划、股东变动相关公告和新闻"
|
||||
"\n4. 综合评估未来 1-3 个月的减持压力等级"
|
||||
"\n\n请使用以下工具:"
|
||||
"\n- `get_insider_transactions`:获取股东和内部人交易记录"
|
||||
"\n- `get_fundamentals`:获取公司股本结构信息"
|
||||
"\n- `get_news(query, start_date, end_date)`:搜索解禁/减持相关新闻和公告"
|
||||
"\n- `get_lockup_expiry(ticker, curr_date)`:获取限售解禁日历(历史解禁记录+未来90天待解禁计划,含解禁数量/占比/影响评估)"
|
||||
"\n\n撰写详细的解禁/减持风险评估报告,给出减持压力总体评级(重大压力/中等压力/轻微压力/无明显压力),并估算潜在减持规模和时间窗口。报告末尾附 Markdown 表格列出关键解禁/减持事件、规模和影响评估。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. 近 6 个月内部人/大股东交易记录(增持/减持/无变动)"
|
||||
"\n2. 前十大股东持股变化趋势"
|
||||
"\n3. 解禁/减持相关新闻及公告"
|
||||
"\n4. 减持压力评级(重大压力/中等压力/轻微压力/无明显压力)"
|
||||
"\n5. 未来 3 个月潜在减持风险评估"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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],
|
||||
"lockup_report": report,
|
||||
}
|
||||
|
||||
return lockup_watcher_node
|
||||
@@ -0,0 +1,107 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_indicators,
|
||||
get_language_instruction,
|
||||
get_stock_data,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_market_analyst(llm):
|
||||
|
||||
def market_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_stock_data,
|
||||
get_indicators,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"""你是一位专注于 A 股市场的技术分析师。你的任务是从以下技术指标中选择最多 **8 个**最相关的指标,为给定的 A 股标的提供技术面分析。选择时应注重指标间的互补性,避免冗余。
|
||||
|
||||
⚠️ A 股市场特殊规则(分析时必须纳入考量):
|
||||
- **涨跌停制度**:主板 ±10%,科创板/创业板 ±20%,ST 股 ±5%。触及涨跌停后流动性骤降,技术指标可能失真。
|
||||
- **T+1 交易制度**:当日买入次日才能卖出,短线策略的可执行性受限。
|
||||
- **北向资金**:外资通过沪深港通的流入流出是重要的市场风向标,大幅流入/流出常领先于趋势转折。
|
||||
- **换手率**:A 股散户占比高,换手率是判断资金活跃度和筹码松动的关键指标。
|
||||
- **量价关系**:A 股「量在价先」规律显著,放量突破和缩量回调是核心交易信号。
|
||||
|
||||
可选技术指标(调用 get_indicators 时必须使用下列英文标识符作为参数名):
|
||||
|
||||
均线类 (Moving Averages):
|
||||
- close_50_sma:50 日简单均线 - 中期趋势方向判断,动态支撑/阻力位。滞后性较强,需配合短期指标。
|
||||
- close_200_sma:200 日简单均线 - 长期趋势基准,金叉/死叉战略信号。反应缓慢,适合趋势确认。
|
||||
- close_10_ema:10 日指数均线 - 短期动量快速捕捉,适合活跃交易。震荡市噪音多,需配合长均线过滤。
|
||||
|
||||
MACD 类:
|
||||
- macd:MACD 主线 - 趋势动量的核心信号,关注交叉与背离。横盘市需配合其他指标确认。
|
||||
- macds:MACD 信号线 - 与主线交叉触发交易信号。单独使用易产生假信号。
|
||||
- macdh:MACD 柱状图 - 动量强度可视化,提前发现顶/底背离。波动较大,需配合趋势过滤。
|
||||
|
||||
动量类 (Momentum):
|
||||
- rsi:RSI 相对强弱指标 - 超买(>70)/超卖(<30)判断。注意:A 股强势股 RSI 可长期维持在 60-80 区间,不能机械套用阈值。
|
||||
|
||||
波动率类 (Volatility):
|
||||
- boll:布林带中轨 - 20 日均线基准,价格运动的中枢参考。
|
||||
- boll_ub:布林带上轨 - 价格触及时为潜在超买/突破信号。强趋势中价格可能沿上轨运行。
|
||||
- boll_lb:布林带下轨 - 价格触及时为潜在超卖信号。需配合其他指标确认是否真正见底。
|
||||
- atr:ATR 平均真实波幅 - 衡量波动率,用于动态止损和仓位管理。
|
||||
|
||||
成交量类 (Volume):
|
||||
- vwma:成交量加权均线 - 结合量价验证趋势的可靠性。注意异常放量可能扭曲结果。
|
||||
|
||||
操作要求:
|
||||
1. **必须**先调用 get_stock_data 获取 K 线数据
|
||||
2. 再调用 get_indicators 获取选定指标(参数名使用上述英文标识符,否则调用会失败)
|
||||
3. 撰写详细的技术分析报告,包含具体数值和可操作的交易建议
|
||||
4. 报告末尾附 Markdown 表格汇总关键技术信号和结论
|
||||
|
||||
📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:
|
||||
1. 最新收盘价、日期、当日涨跌幅
|
||||
2. 近 30 日累计涨跌幅
|
||||
3. 近 5 日平均成交量 vs 近 20 日平均成交量(判断放量/缩量)
|
||||
4. 至少 3 个技术指标的当前数值和多空信号
|
||||
5. 关键支撑位和阻力位"""
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_global_news,
|
||||
get_language_instruction,
|
||||
get_news,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_news_analyst(llm):
|
||||
def news_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_news,
|
||||
get_global_news,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的新闻与政策分析师。你的任务是分析近期新闻动态,评估其对目标公司和 A 股市场的影响。"
|
||||
"\n\n⚠️ A 股新闻分析框架:"
|
||||
"\n- **政策敏感度**:A 股是典型的「政策市」,国务院/证监会/央行/发改委的政策发布对市场影响巨大。重点关注:货币政策(降准降息)、产业政策(扶持/限制)、监管政策(IPO 节奏、再融资、减持新规)。"
|
||||
"\n- **消息来源权重**:财联社快讯(最快)> 新华财经/证券时报(权威)> 东方财富/同花顺(广泛)。注意区分官方消息与市场传闻。"
|
||||
"\n- **行业轮动**:A 股板块轮动特征明显,一个行业利好政策可能带动整个板块,分析时需关注产业链上下游联动。"
|
||||
"\n- **事件驱动**:关注财报预告/业绩快报、股东大会决议、重大合同公告、机构调研记录等公司层面事件。"
|
||||
"\n\n请使用以下工具:"
|
||||
"\n- `get_news(query, start_date, end_date)`:获取公司相关的个股新闻"
|
||||
"\n- `get_global_news(curr_date, look_back_days, limit)`:获取宏观经济和市场整体新闻"
|
||||
"\n\n撰写全面的新闻分析报告,区分利好/利空/中性消息,评估影响程度和持续时间。报告末尾附 Markdown 表格汇总关键新闻事件及其影响评级。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. 个股新闻条数和时间范围"
|
||||
"\n2. 宏观新闻条数和时间范围"
|
||||
"\n3. 关键事件时间线(至少列出 3 个重要事件及日期)"
|
||||
"\n4. 利好/利空/中性事件分类统计"
|
||||
"\n5. 风险事件清单(如有)"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import (
|
||||
build_instrument_context,
|
||||
get_global_news,
|
||||
get_language_instruction,
|
||||
get_news,
|
||||
)
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_policy_analyst(llm):
|
||||
"""A-stock policy analyst: tracks regulatory and industrial policy signals."""
|
||||
|
||||
def policy_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_news,
|
||||
get_global_news,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的政策分析师。你的核心任务是追踪和解读影响目标公司及所在行业的政策动态,评估政策对股价的潜在影响方向和力度。"
|
||||
"\n\nA 股是全球最典型的「政策市」,政策分析是投资决策中权重最高的因子之一。"
|
||||
"\n\n⚠️ 政策分析框架:"
|
||||
"\n- **宏观政策层**:货币政策(降准/降息/MLF/LPR 调整)、财政政策(专项债/减税)、汇率政策(人民币升贬值对出口/进口行业的影响)"
|
||||
"\n- **监管政策层**:证监会(IPO 节奏/再融资/减持新规/退市制度)、银保监会(信贷政策)、发改委(产业审批)"
|
||||
"\n- **产业政策层**:国务院/部委发布的行业扶持或限制政策(如「新质生产力」、半导体自主可控、新能源补贴、房地产调控、平台经济监管)"
|
||||
"\n- **地方政策层**:地方政府出台的区域性扶持政策(如自贸区、特区优惠、地方产业基金)"
|
||||
"\n- **国际政策层**:中美关系、出口管制、关税变动、国际制裁等对特定行业的传导效应"
|
||||
"\n\n分析方法:"
|
||||
"\n1. 识别近期发布的与目标公司直接或间接相关的政策"
|
||||
"\n2. 评估政策的力度级别:指导意见(弱)< 部委通知(中)< 国务院文件(强)< 法律法规(最强)"
|
||||
"\n3. 判断政策的影响时间窗口:短期脉冲(1-2 周)vs 中期趋势(1-3 月)vs 长期结构性(半年以上)"
|
||||
"\n4. 分析政策的受益/受损逻辑链:政策 → 行业影响 → 公司业务映射 → 财务影响估算"
|
||||
"\n\n请使用以下工具:"
|
||||
"\n- `get_news(query, start_date, end_date)`:搜索与公司/行业相关的政策新闻"
|
||||
"\n- `get_global_news(curr_date, look_back_days, limit)`:获取宏观经济和政策面新闻"
|
||||
"\n\n撰写详细的政策分析报告,明确给出政策面对该公司的总体评级(重大利好/利好/中性/利空/重大利空),并量化影响程度。报告末尾附 Markdown 表格列出关键政策事件、影响方向和持续时间。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. 近期相关政策事件清单(含发布日期和发布机构)"
|
||||
"\n2. 行业政策方向判断(扶持/限制/中性)"
|
||||
"\n3. 政策影响力度评级(强/中/弱)"
|
||||
"\n4. 政策影响时间窗口估算"
|
||||
"\n5. 政策面总体评级"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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],
|
||||
"policy_report": report,
|
||||
}
|
||||
|
||||
return policy_analyst_node
|
||||
@@ -0,0 +1,70 @@
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from tradingagents.agents.utils.agent_utils import build_instrument_context, get_language_instruction, get_news
|
||||
from tradingagents.dataflows.config import get_config
|
||||
|
||||
|
||||
def create_social_media_analyst(llm):
|
||||
def social_media_analyst_node(state):
|
||||
current_date = state["trade_date"]
|
||||
instrument_context = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
tools = [
|
||||
get_news,
|
||||
]
|
||||
|
||||
system_message = (
|
||||
"你是一位专注于 A 股市场的市场情绪分析师。你的任务是通过分析公司相关新闻、市场讨论和公众情绪,判断市场对目标公司的整体态度和情绪走向。"
|
||||
"\n\n⚠️ A 股情绪分析框架:"
|
||||
"\n- **散户情绪权重高**:A 股散户占比超过 60%,市场情绪对股价的短期影响远大于成熟市场。恐慌和贪婪的情绪波动更剧烈。"
|
||||
"\n- **舆论阵地**:东方财富股吧、雪球、同花顺社区是 A 股投资者最活跃的讨论平台。分析新闻时注意推断这些平台可能的情绪反应。"
|
||||
"\n- **情绪指标**:关注以下情绪信号 - 连续涨停后的追涨情绪、业绩暴雷后的恐慌抛售、机构调研后的预期变化、热门概念炒作的跟风程度。"
|
||||
"\n- **反向指标**:当市场情绪一致性过高(极度乐观或极度悲观)时,往往是反转信号。散户一致看多可能是阶段顶部。"
|
||||
"\n- **时间维度**:区分短期情绪波动(1-3 天,由单一事件驱动)和中期情绪趋势(1-4 周,由基本面变化驱动)。"
|
||||
"\n\n请使用 `get_news(query, start_date, end_date)` 工具获取公司相关新闻和市场讨论。从新闻内容中推断市场情绪方向、强度和可能的转折点。"
|
||||
"\n\n撰写详细的市场情绪分析报告,包含情绪评分(极度悲观/悲观/中性/乐观/极度乐观)和趋势判断。报告末尾附 Markdown 表格汇总情绪信号和结论。"
|
||||
"\n\n📋 必采清单 — 以下数据点必须出现在报告中,无法获取时标注 [数据缺失: xxx]:"
|
||||
"\n1. 新闻检索条数和时间范围"
|
||||
"\n2. 正面/负面/中性新闻比例"
|
||||
"\n3. 排名前 3 的舆情主题"
|
||||
"\n4. 情绪评分(极度悲观/悲观/中性/乐观/极度乐观)"
|
||||
"\n5. 情绪趋势变化方向(升温/降温/平稳)"
|
||||
+ 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}.\n{system_message}"
|
||||
"For your reference, the current date is {current_date}. {instrument_context}",
|
||||
),
|
||||
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],
|
||||
"sentiment_report": report,
|
||||
}
|
||||
|
||||
return social_media_analyst_node
|
||||
@@ -0,0 +1,102 @@
|
||||
"""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 (
|
||||
build_instrument_context,
|
||||
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 = build_instrument_context(state["company_of_interest"])
|
||||
|
||||
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}
|
||||
|
||||
---
|
||||
|
||||
**A-Stock Trading Constraints** (must factor into your decision):
|
||||
- T+1 settlement: shares bought today cannot be sold until the next trading day
|
||||
- Daily price limits: main board ±10%, STAR/ChiNext ±20%, ST stocks ±5%
|
||||
- Minimum lot size: 100 shares (1 手) for main board; 200 shares for STAR/ChiNext
|
||||
- Trading hours: 09:30-11:30, 13:00-15:00 (Beijing time)
|
||||
- ST/delisting risk: ST or *ST status signals regulatory warning; factor into position sizing
|
||||
- Margin eligibility: not all A-shares are margin-eligible; assume cash-only unless stated
|
||||
|
||||
---
|
||||
|
||||
**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
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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 build_instrument_context
|
||||
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 = build_instrument_context(state["company_of_interest"])
|
||||
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}
|
||||
|
||||
Note: This is an A-share (China mainland) stock. Factor in regulatory policy impact, hot money / capital flow dynamics, and lockup expiry / insider reduction risks when synthesising the debate.
|
||||
|
||||
---
|
||||
|
||||
**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}"""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,168 @@
|
||||
from typing import Annotated
|
||||
|
||||
REPORT_FIELDS = {
|
||||
"market": "market_report",
|
||||
"social": "sentiment_report",
|
||||
"news": "news_report",
|
||||
"fundamentals": "fundamentals_report",
|
||||
"policy": "policy_report",
|
||||
"hot_money": "hot_money_report",
|
||||
"lockup": "lockup_report",
|
||||
}
|
||||
|
||||
ANALYST_NAMES = {
|
||||
"market": "技术分析师",
|
||||
"social": "情绪分析师",
|
||||
"news": "新闻分析师",
|
||||
"fundamentals": "基本面分析师",
|
||||
"policy": "政策分析师",
|
||||
"hot_money": "游资追踪师",
|
||||
"lockup": "解禁监控师",
|
||||
}
|
||||
|
||||
MIN_REPORT_LENGTH = 200
|
||||
|
||||
FAILURE_MARKERS = [
|
||||
"无法获取",
|
||||
"I cannot retrieve",
|
||||
"I don't have access",
|
||||
"unable to fetch",
|
||||
"工具调用失败",
|
||||
]
|
||||
|
||||
|
||||
def _hard_check_report(analyst_type: str, report: str) -> tuple:
|
||||
"""Run hard checks on a single report. Returns (grade, detail)."""
|
||||
if not report or not report.strip():
|
||||
return ("F", "报告为空")
|
||||
|
||||
length = len(report.strip())
|
||||
if length < MIN_REPORT_LENGTH:
|
||||
return ("D", f"报告过短 ({length} chars < {MIN_REPORT_LENGTH})")
|
||||
|
||||
failure_count = sum(1 for m in FAILURE_MARKERS if m in report)
|
||||
stripped = report
|
||||
for m in FAILURE_MARKERS:
|
||||
stripped = stripped.replace(m, "")
|
||||
if failure_count > 0 and len(stripped.strip()) < MIN_REPORT_LENGTH:
|
||||
return ("D", f"报告主要由失败信息构成 ({failure_count} 处)")
|
||||
|
||||
has_table = "|" in report and "---" in report
|
||||
missing_count = report.count("[数据缺失")
|
||||
|
||||
issues = []
|
||||
if not has_table:
|
||||
issues.append("缺少汇总表格")
|
||||
if missing_count > 0:
|
||||
issues.append(f"{missing_count} 处数据缺失")
|
||||
|
||||
if missing_count >= 3:
|
||||
return ("C", ";".join(issues))
|
||||
if not has_table or missing_count > 0:
|
||||
return ("B", ";".join(issues) if issues else "基本合格")
|
||||
|
||||
return ("A", f"完整 ({length} chars)")
|
||||
|
||||
|
||||
def _build_review_prompt(
|
||||
reports: dict, trade_date: str, ticker: str
|
||||
) -> str:
|
||||
"""Build the LLM review prompt."""
|
||||
report_sections = []
|
||||
for analyst_type, field in REPORT_FIELDS.items():
|
||||
name = ANALYST_NAMES[analyst_type]
|
||||
content = reports.get(field, "(未运行)")
|
||||
if not content:
|
||||
content = "(报告为空)"
|
||||
if len(content) > 3000:
|
||||
content = content[:3000] + "\n... (truncated for review)"
|
||||
report_sections.append(f"### {name} ({analyst_type})\n{content}")
|
||||
|
||||
all_reports = "\n\n".join(report_sections)
|
||||
|
||||
return f"""你是数据质量审核员。以下是 7 位分析师对 {ticker} 在 {trade_date} 的研究报告。请逐一审核。
|
||||
|
||||
{all_reports}
|
||||
|
||||
---
|
||||
|
||||
请按以下格式输出审核结果(不要输出其他内容):
|
||||
|
||||
## 数据质量审核报告
|
||||
|
||||
**标的**: {ticker} | **日期**: {trade_date}
|
||||
|
||||
| 分析师 | 评级 | 数据时效 | 缺失项 | 备注 |
|
||||
|--------|------|----------|--------|------|
|
||||
| 技术分析师 | A/B/C/D/F | 是否匹配交易日 | 列出缺失的必采项 | 简要说明 |
|
||||
| 情绪分析师 | ... | ... | ... | ... |
|
||||
| 新闻分析师 | ... | ... | ... | ... |
|
||||
| 基本面分析师 | ... | ... | ... | ... |
|
||||
| 政策分析师 | ... | ... | ... | ... |
|
||||
| 游资追踪师 | ... | ... | ... | ... |
|
||||
| 解禁监控师 | ... | ... | ... | ... |
|
||||
|
||||
**整体评级**: A/B/C/D/F
|
||||
**数据可信度**: 高/中/低
|
||||
**建议**: (如有数据缺失,提醒辩论阶段谨慎使用该报告)
|
||||
|
||||
评级标准:
|
||||
- A: 必采清单全部覆盖,数据时效匹配,有汇总表格
|
||||
- B: 缺少 1-2 项非关键数据,整体可用
|
||||
- C: 缺少 3+ 项或有数据时效问题,需谨慎使用
|
||||
- D: 大量缺失或主要为失败信息,可信度低
|
||||
- F: 报告为空或完全无效
|
||||
"""
|
||||
|
||||
|
||||
def create_quality_gate(llm):
|
||||
"""Factory for the data quality gate node.
|
||||
|
||||
Sits between the last analyst Msg Clear and Bull Researcher.
|
||||
Layer 1: hard checks (code). Layer 2: LLM review (one call).
|
||||
Writes data_quality_summary to state for downstream consumers.
|
||||
"""
|
||||
|
||||
def quality_gate_node(state) -> dict:
|
||||
trade_date = state["trade_date"]
|
||||
ticker = state["company_of_interest"]
|
||||
|
||||
reports = {}
|
||||
for analyst_type, field in REPORT_FIELDS.items():
|
||||
reports[field] = state.get(field, "")
|
||||
|
||||
hard_results = {}
|
||||
for analyst_type, field in REPORT_FIELDS.items():
|
||||
grade, detail = _hard_check_report(analyst_type, reports[field])
|
||||
hard_results[analyst_type] = (grade, detail)
|
||||
|
||||
hard_summary_lines = []
|
||||
for analyst_type, (grade, detail) in hard_results.items():
|
||||
name = ANALYST_NAMES[analyst_type]
|
||||
hard_summary_lines.append(f"- {name}: [{grade}] {detail}")
|
||||
hard_summary = "\n".join(hard_summary_lines)
|
||||
|
||||
fail_count = sum(
|
||||
1 for _, (g, _) in hard_results.items() if g in ("F", "D")
|
||||
)
|
||||
|
||||
llm_review = ""
|
||||
if fail_count < 4:
|
||||
try:
|
||||
review_prompt = _build_review_prompt(reports, trade_date, ticker)
|
||||
response = llm.invoke(review_prompt)
|
||||
llm_review = response.content
|
||||
except Exception as e:
|
||||
llm_review = f"(LLM 复审失败: {type(e).__name__}: {e})"
|
||||
|
||||
summary = (
|
||||
f"## 数据质量门控结果\n\n"
|
||||
f"**标的**: {ticker} | **交易日**: {trade_date}\n\n"
|
||||
f"### 硬检查结果\n{hard_summary}\n\n"
|
||||
f"### LLM 复审\n"
|
||||
f"{llm_review if llm_review else '(跳过 — 多数报告未通过硬检查)'}\n"
|
||||
)
|
||||
|
||||
return {"data_quality_summary": summary}
|
||||
|
||||
return quality_gate_node
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
|
||||
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"]
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
data_quality_summary = state.get("data_quality_summary", "")
|
||||
|
||||
prompt = f"""You are a Bear Analyst making the case against investing in this A-share (China mainland) stock. Your goal is to present a well-reasoned argument emphasizing risks, challenges, and negative indicators unique to the Chinese market. Leverage the provided research and data to highlight potential downsides and counter bullish arguments effectively.
|
||||
|
||||
A-Share Bear Framework — prioritize these China-specific risk factors:
|
||||
- Policy Headwinds: Sudden regulatory crackdowns (e.g. industry rectification, antitrust), CSRC window guidance (窗口指导), sector-wide trading restrictions, or political risk signals
|
||||
- Lockup & Insider Selling: Upcoming lockup expiry dates with large overhang, controlling shareholders in pre-disclosure reduction windows, equity pledge liquidation risk
|
||||
- Hot Money Withdrawal (游资撤退): Volume divergence after limit-ups (放量滞涨), declining limit-up board count (连板断裂), sector rotation moving away from this theme
|
||||
- Valuation Bubble: PE far above 30x A-stock growth anchor with EPS unable to digest within 3 years, PEG > 2 indicating overpriced growth, retail-driven speculative premium
|
||||
- T+1 Trap: After a sharp rally, buyers today cannot exit until tomorrow — if sentiment reverses overnight or a gap-down opens, losses are locked in
|
||||
- Northbound Retreat: Net outflow from Stock Connect signals foreign institutions reducing exposure
|
||||
|
||||
General bear points:
|
||||
- Risks and Challenges: Market saturation, financial instability, or macroeconomic threats
|
||||
- Competitive Weaknesses: Weaker market positioning, declining innovation, or competitor threats
|
||||
- Negative Indicators: Evidence from financial data, market trends, or adverse news
|
||||
- Bull Counterpoints: Expose over-optimistic assumptions with specific data
|
||||
- Engagement: Present your argument conversationally, directly engaging with the bull analyst's points
|
||||
|
||||
Resources available:
|
||||
Market research report: {market_research_report}
|
||||
Social media sentiment report: {sentiment_report}
|
||||
Latest news report: {news_report}
|
||||
Company fundamentals report: {fundamentals_report}
|
||||
Policy analysis report: {policy_report}
|
||||
Hot money / capital flow report: {hot_money_report}
|
||||
Lockup expiry / insider reduction report: {lockup_report}
|
||||
Data quality assessment: {data_quality_summary}
|
||||
Conversation history of the debate: {history}
|
||||
Last bull argument: {current_response}
|
||||
|
||||
⚠️ If the data quality assessment flags any report as low-confidence (grade C/D/F), reduce your reliance on that report and note the data limitation in your argument.
|
||||
|
||||
Deliver a compelling bear argument grounded in A-share market realities. Refute the bull's claims and demonstrate the risks of investing in this stock within the Chinese regulatory and market structure.
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
|
||||
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"]
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
data_quality_summary = state.get("data_quality_summary", "")
|
||||
|
||||
prompt = f"""You are a Bull Analyst advocating for investing in this A-share (China mainland) stock. Your task is to build a strong, evidence-based case emphasizing growth potential, competitive advantages, and positive market indicators. Leverage the provided research and data to address concerns and counter bearish arguments effectively.
|
||||
|
||||
A-Share Bull Framework — prioritize these China-specific bullish catalysts:
|
||||
- Policy Tailwinds: Government subsidies, industry support policies (e.g. "专精特新", national strategic sectors), favorable regulatory signals from CSRC/State Council
|
||||
- Northbound Capital (北向资金): Sustained net inflow from Hong Kong Stock Connect indicates foreign institutional conviction
|
||||
- Hot Money Momentum (游资接力): Consecutive limit-ups with volume confirmation, strong theme attribution (reason tags), sector rotation just beginning
|
||||
- Valuation Growth Story: Use forward PE, PEG, and PE digestion timeframe (30x anchor for A-stock growth stocks) to argue the current premium is justified by earnings trajectory
|
||||
- Lockup Expiry Cleared: If major lockup periods have passed or insiders are NOT reducing, this removes a key overhang
|
||||
|
||||
General bull points:
|
||||
- Growth Potential: Market opportunities, revenue projections, and scalability
|
||||
- Competitive Advantages: Unique products, dominant market positioning, or moat in the domestic market
|
||||
- Positive Indicators: Financial health, industry trends, and recent positive news
|
||||
- Bear Counterpoints: Critically analyze the bear argument with specific data and sound reasoning
|
||||
- Engagement: Present your argument conversationally, engaging directly with the bear analyst's points
|
||||
|
||||
Resources available:
|
||||
Market research report: {market_research_report}
|
||||
Social media sentiment report: {sentiment_report}
|
||||
Latest news report: {news_report}
|
||||
Company fundamentals report: {fundamentals_report}
|
||||
Policy analysis report: {policy_report}
|
||||
Hot money / capital flow report: {hot_money_report}
|
||||
Lockup expiry / insider reduction report: {lockup_report}
|
||||
Data quality assessment: {data_quality_summary}
|
||||
Conversation history of the debate: {history}
|
||||
Last bear argument: {current_response}
|
||||
|
||||
⚠️ If the data quality assessment flags any report as low-confidence (grade C/D/F), reduce your reliance on that report and note the data limitation in your argument.
|
||||
|
||||
Deliver a compelling bull argument that integrates A-share market dynamics. Refute the bear's concerns and demonstrate why the bull position holds stronger merit in the Chinese market context.
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,69 @@
|
||||
|
||||
|
||||
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"]
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
prompt = f"""As the Aggressive Risk Analyst evaluating an A-share (China mainland) stock, your role is to champion high-reward opportunities and bold strategies. Focus on the potential upside, growth potential, and momentum—even when these come with elevated risk. Counter the conservative and neutral analysts with data-driven rebuttals.
|
||||
|
||||
A-Share Aggressive Framework — leverage these China-specific upside arguments:
|
||||
- Limit-Up Momentum (涨停板效应): In A-shares, consecutive limit-ups create powerful momentum; T+1 actually helps by preventing same-day profit-taking, allowing multi-day runs
|
||||
- Policy-Driven Sectors: When Beijing backs a sector (e.g. AI, chips, new energy), the policy put is real — government support creates a floor that doesn't exist in Western markets
|
||||
- Hot Money Conviction: When top hot money seats (游资席位) pile in with strong reason tags, the short-term upside can be explosive; missing these moves is also a risk
|
||||
- Northbound Validation: If foreign institutions via Stock Connect are net buying alongside domestic momentum, this dual confirmation is a strong signal
|
||||
- PE Expansion Phase: In A-share bull cycles, PEs routinely expand to 50-100x for thematic leaders; applying US-market valuation discipline too early means missing the main move
|
||||
- Retail Sentiment Tailwind: A-shares are 80% retail; when sentiment turns positive, the herd effect amplifies gains far beyond what fundamentals alone would suggest
|
||||
|
||||
Here is the trader's decision:
|
||||
|
||||
{trader_decision}
|
||||
|
||||
Challenge the conservative and neutral stances. Demonstrate why their caution risks missing the opportunity. Use these data sources:
|
||||
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest News Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Policy Analysis Report: {policy_report}
|
||||
Hot Money / Capital Flow Report: {hot_money_report}
|
||||
Lockup Expiry / Insider Reduction Report: {lockup_report}
|
||||
Conversation history: {history} Last conservative argument: {current_conservative_response} Last neutral argument: {current_neutral_response}. If no responses yet, present your own argument.
|
||||
|
||||
Engage actively, debate persuasively, and assert why aggressive positioning is optimal for this A-share opportunity. Output conversationally without special formatting."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
|
||||
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"]
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
prompt = f"""As the Conservative Risk Analyst evaluating an A-share (China mainland) stock, your primary objective is to protect assets, minimize volatility, and ensure steady, reliable growth. Critically examine high-risk elements in the trader's plan, pointing out where it may expose the firm to undue risk.
|
||||
|
||||
A-Share Conservative Framework — emphasize these China-specific downside risks:
|
||||
- T+1 Settlement Lock: Any position taken today CANNOT be exited until tomorrow. If the stock gaps down at open (e.g. after overnight policy news or global sell-off), losses are locked in with no recourse. This is the single most important structural risk in A-shares.
|
||||
- Daily Price Limit Trap (涨跌停板): If a stock hits limit-down (main board -10%, STAR/ChiNext -20%), sell orders cannot execute — you are trapped. Multiple consecutive limit-downs can cause catastrophic losses with no ability to exit.
|
||||
- Lockup Expiry Overhang: Large lockup expiries (限售解禁) create massive potential sell pressure. Even if insiders haven't started selling, the OPTION to sell depresses sentiment and caps upside.
|
||||
- Policy Reversal Risk: A-shares are a policy market (政策市). What the government gives, it can take away overnight — sector support can turn to sector crackdown with a single State Council directive.
|
||||
- Hot Money Exit Risk (游资撤退): Hot money moves fast in both directions. Today's limit-up star is tomorrow's limit-down casualty. Retail investors are the last to know when hot money exits.
|
||||
- Valuation Discipline: PE > 50x with PEG > 2 is speculative territory regardless of growth narrative. The 30x PE digestion framework should be the anchor — if it takes 5+ years to digest, the position is overvalued.
|
||||
- ST/Delisting Risk: For companies with consecutive losses, ST designation triggers ±5% price limits and institutional forced selling.
|
||||
|
||||
Here is the trader's decision:
|
||||
|
||||
{trader_decision}
|
||||
|
||||
Counter the aggressive and neutral analysts. Highlight where their optimism overlooks A-share structural risks. Use these data sources:
|
||||
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest News Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Policy Analysis Report: {policy_report}
|
||||
Hot Money / Capital Flow Report: {hot_money_report}
|
||||
Lockup Expiry / Insider Reduction Report: {lockup_report}
|
||||
Conversation history: {history} Last aggressive argument: {current_aggressive_response} Last neutral argument: {current_neutral_response}. If no responses yet, present your own argument.
|
||||
|
||||
Demonstrate why a conservative stance is the safest path, especially given A-share market structure where downside protection mechanisms (stop-loss, same-day exit) are severely limited. Output conversationally without special formatting."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
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"]
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
|
||||
trader_decision = state["trader_investment_plan"]
|
||||
|
||||
prompt = f"""As the Neutral Risk Analyst evaluating an A-share (China mainland) stock, your role is to provide a balanced perspective, weighing both the potential benefits and risks. Factor in A-share market structure, broader trends, and diversification strategies.
|
||||
|
||||
A-Share Neutral Framework — use these China-specific balancing considerations:
|
||||
- T+1 as Double-Edged Sword: T+1 locks in losses (conservative point) BUT also prevents panic selling and allows multi-day momentum to develop (aggressive point). The neutral view: size positions so that a single overnight gap-down is survivable.
|
||||
- Policy Sensitivity Calibration: Not all policy signals are equal. Distinguish between top-level State Council directives (high conviction) vs local government incentives (lower reliability) vs market rumors (noise). Weight your risk assessment accordingly.
|
||||
- Northbound Flow as Smart Money Gauge: Foreign institutional flow via Stock Connect is more informed than retail flow, but also more fickle — they exit faster than domestic funds. Use it as a confirming signal, not a primary thesis.
|
||||
- Valuation Band Approach: Rather than rigid "PE > 30x is expensive" or "PE doesn't matter in growth", propose a valuation band — what PE range is defensible given the earnings trajectory? Use the PE digestion timeframe as a practical anchor.
|
||||
- Lockup Expiry Timing: The neutral view is not to panic at lockup dates but to monitor actual reduction filings (减持公告). The risk is real but the timing is uncertain — reducing exposure gradually near lockup windows is more sensible than binary all-in/all-out.
|
||||
- Sector Rotation Awareness: A-share themes rotate fast (typically 2-4 weeks). The neutral question is: where are we in the rotation cycle? Early rotation = room to run; late rotation = reduced upside with elevated downside.
|
||||
- Position Sizing over Direction: In a market with ±10-20% daily limits and T+1 settlement, position sizing is more important than directional conviction. A moderate position captures upside while limiting locked-in loss scenarios.
|
||||
|
||||
Here is the trader's decision:
|
||||
|
||||
{trader_decision}
|
||||
|
||||
Challenge both the aggressive and conservative analysts. Point out where each perspective is overly optimistic or overly cautious in the A-share context. Use these data sources:
|
||||
|
||||
Market Research Report: {market_research_report}
|
||||
Social Media Sentiment Report: {sentiment_report}
|
||||
Latest News Report: {news_report}
|
||||
Company Fundamentals Report: {fundamentals_report}
|
||||
Policy Analysis Report: {policy_report}
|
||||
Hot Money / Capital Flow Report: {hot_money_report}
|
||||
Lockup Expiry / Insider Reduction Report: {lockup_report}
|
||||
Conversation history: {history} Last aggressive argument: {current_aggressive_response} Last conservative argument: {current_conservative_response}. If no responses yet, present your own argument.
|
||||
|
||||
Advocate for a balanced, position-sized approach that captures A-share upside while respecting the market's structural constraints. Output conversationally without special formatting."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,228 @@
|
||||
"""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 Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Optional entry price target in the instrument's quote currency.",
|
||||
)
|
||||
stop_loss: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Optional stop-loss price in the instrument's quote currency.",
|
||||
)
|
||||
position_sizing: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional sizing guidance, e.g. '5% of portfolio'.",
|
||||
)
|
||||
|
||||
|
||||
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: Optional[float] = Field(
|
||||
default=None,
|
||||
description="Optional target price in the instrument's quote currency.",
|
||||
)
|
||||
time_horizon: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional recommended holding period, e.g. '3-6 months'.",
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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 build_instrument_context
|
||||
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 = build_instrument_context(company_name)
|
||||
investment_plan = state["investment_plan"]
|
||||
|
||||
# Collect A-stock specific analyst reports
|
||||
policy_report = state.get("policy_report", "")
|
||||
hot_money_report = state.get("hot_money_report", "")
|
||||
lockup_report = state.get("lockup_report", "")
|
||||
|
||||
# Build optional A-stock context block
|
||||
astock_context_parts = []
|
||||
if policy_report:
|
||||
astock_context_parts.append(f"Policy Analysis Report:\n{policy_report}")
|
||||
if hot_money_report:
|
||||
astock_context_parts.append(f"Hot Money / Capital Flow Report:\n{hot_money_report}")
|
||||
if lockup_report:
|
||||
astock_context_parts.append(f"Lockup Expiry / Insider Reduction Report:\n{lockup_report}")
|
||||
astock_context = "\n\n".join(astock_context_parts)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a trading agent specialising in A-share (China mainland) stocks. "
|
||||
"Translate the Research Manager's investment plan into a concrete, executable "
|
||||
"transaction proposal. You must factor in A-stock trading constraints:\n"
|
||||
"- T+1 settlement: shares bought today cannot be sold until the next trading day\n"
|
||||
"- Daily price limits: main board ±10%, STAR/ChiNext ±20%, ST stocks ±5%\n"
|
||||
"- Minimum lot: 100 shares (main board) or 200 shares (STAR/ChiNext)\n"
|
||||
"- Trading hours: 09:30-11:30, 13:00-15:00 Beijing time\n"
|
||||
"Anchor your reasoning in the analysts' reports and the research plan. "
|
||||
"Be specific about entry price, stop loss, and position sizing."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Based on a comprehensive analysis by a team of analysts (including market, "
|
||||
f"sentiment, news, fundamentals, policy, capital flow, and lockup/reduction "
|
||||
f"specialists), here is an investment plan for {company_name}.\n\n"
|
||||
f"{instrument_context}\n\n"
|
||||
f"Proposed Investment Plan:\n{investment_plan}\n\n"
|
||||
+ (f"Additional A-Stock Analyst Context:\n{astock_context}\n\n" if astock_context else "")
|
||||
+ "Leverage these insights to craft a precise transaction proposal."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import Annotated
|
||||
from typing_extensions import TypedDict
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
|
||||
# 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"]
|
||||
trade_date: Annotated[str, "What date we are trading at"]
|
||||
|
||||
sender: Annotated[str, "Agent that sent this message"]
|
||||
|
||||
# research step
|
||||
market_report: Annotated[str, "Report from the Market Analyst"]
|
||||
sentiment_report: Annotated[str, "Report from the Social Media Analyst"]
|
||||
news_report: Annotated[
|
||||
str, "Report from the News Researcher of current world affairs"
|
||||
]
|
||||
fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"]
|
||||
policy_report: Annotated[str, "Report from the Policy Analyst (A-stock specific)"]
|
||||
hot_money_report: Annotated[str, "Report from the Hot Money Tracker (A-stock specific)"]
|
||||
lockup_report: Annotated[str, "Report from the Lockup/Reduction Watcher (A-stock specific)"]
|
||||
|
||||
# data quality gate
|
||||
data_quality_summary: Annotated[str, "Quality gate assessment of all analyst reports (hard checks + LLM review)"]
|
||||
|
||||
# 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)"]
|
||||
@@ -0,0 +1,71 @@
|
||||
from langchain_core.messages import HumanMessage, RemoveMessage
|
||||
|
||||
# Import tools from separate utility files
|
||||
from tradingagents.agents.utils.core_stock_tools import (
|
||||
get_stock_data
|
||||
)
|
||||
from tradingagents.agents.utils.technical_indicators_tools import (
|
||||
get_indicators
|
||||
)
|
||||
from tradingagents.agents.utils.fundamental_data_tools import (
|
||||
get_fundamentals,
|
||||
get_balance_sheet,
|
||||
get_cashflow,
|
||||
get_income_statement
|
||||
)
|
||||
from tradingagents.agents.utils.news_data_tools import (
|
||||
get_news,
|
||||
get_insider_transactions,
|
||||
get_global_news
|
||||
)
|
||||
from tradingagents.agents.utils.signal_data_tools import (
|
||||
get_profit_forecast,
|
||||
get_hot_stocks,
|
||||
get_northbound_flow,
|
||||
get_concept_blocks,
|
||||
get_fund_flow,
|
||||
get_dragon_tiger_board,
|
||||
get_lockup_expiry,
|
||||
get_industry_comparison,
|
||||
)
|
||||
|
||||
|
||||
def get_language_instruction() -> str:
|
||||
"""Return a prompt instruction for the configured output language.
|
||||
|
||||
Returns empty string when English (default), so no extra tokens are used.
|
||||
Only applied to user-facing agents (analysts, portfolio manager).
|
||||
Internal debate agents stay in English for reasoning quality.
|
||||
"""
|
||||
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 build_instrument_context(ticker: str) -> str:
|
||||
"""Describe the exact instrument so agents preserve exchange-qualified tickers."""
|
||||
return (
|
||||
f"The instrument to analyze is `{ticker}`. "
|
||||
"Use this exact ticker in every tool call, report, and recommendation, "
|
||||
"preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`)."
|
||||
)
|
||||
|
||||
def create_msg_delete():
|
||||
def delete_messages(state):
|
||||
"""Clear messages and add placeholder for Anthropic compatibility"""
|
||||
messages = state["messages"]
|
||||
|
||||
# Remove all messages
|
||||
removal_operations = [RemoveMessage(id=m.id) for m in messages]
|
||||
|
||||
# Add a minimal placeholder message
|
||||
placeholder = HumanMessage(content="Continue")
|
||||
|
||||
return {"messages": removal_operations + [placeholder]}
|
||||
|
||||
return delete_messages
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
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)
|
||||
@@ -0,0 +1,77 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
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)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Append-only markdown decision log for TradingAgents."""
|
||||
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
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<!-- ENTRY_END -->\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) -> Optional[dict]:
|
||||
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}"
|
||||
@@ -0,0 +1,53 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
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, "Number of days to look back"] = 7,
|
||||
limit: Annotated[int, "Maximum number of articles to return"] = 5,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve global news data.
|
||||
Uses the configured news_data vendor.
|
||||
Args:
|
||||
curr_date (str): Current date in yyyy-mm-dd format
|
||||
look_back_days (int): Number of days to look back (default 7)
|
||||
limit (int): Maximum number of articles to return (default 5)
|
||||
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)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,155 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
from tradingagents.dataflows.interface import route_to_vendor
|
||||
|
||||
|
||||
@tool
|
||||
def get_profit_forecast(
|
||||
ticker: Annotated[str, "A-stock code (e.g. 688017)"],
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve consensus EPS forecasts with forward valuation metrics.
|
||||
Returns analyst coverage count, EPS range, forward PE, PEG, and PE digestion time.
|
||||
Uses the configured signal_data vendor.
|
||||
Args:
|
||||
ticker (str): A-stock code
|
||||
Returns:
|
||||
str: Consensus forecast report with valuation metrics
|
||||
"""
|
||||
return route_to_vendor("get_profit_forecast", ticker)
|
||||
|
||||
|
||||
@tool
|
||||
def get_hot_stocks(
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format, empty for today"] = "",
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve today's strong stocks with topic attribution reason tags.
|
||||
Shows WHY stocks surged (e.g. '算力租赁+AI政务'), curated by 同花顺 editorial team.
|
||||
Includes theme frequency analysis.
|
||||
Uses the configured signal_data vendor.
|
||||
Args:
|
||||
curr_date (str): Date in YYYY-MM-DD format, empty string for today
|
||||
Returns:
|
||||
str: Hot stocks list with reason tags and theme frequency
|
||||
"""
|
||||
return route_to_vendor("get_hot_stocks", curr_date)
|
||||
|
||||
|
||||
@tool
|
||||
def get_northbound_flow(
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format"],
|
||||
include_history: Annotated[
|
||||
bool, "Include historical daily data (last 20 trading days)"
|
||||
] = False,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve northbound capital flow (沪深股通) data.
|
||||
Realtime: minute-level cumulative net buying for HGT + SGT.
|
||||
History (optional): daily-level data for trend analysis.
|
||||
Uses the configured signal_data vendor.
|
||||
Args:
|
||||
curr_date (str): Date in YYYY-MM-DD format
|
||||
include_history (bool): Whether to include historical daily data
|
||||
Returns:
|
||||
str: Northbound capital flow report with bullish/bearish signal
|
||||
"""
|
||||
return route_to_vendor("get_northbound_flow", curr_date, include_history)
|
||||
|
||||
|
||||
@tool
|
||||
def get_concept_blocks(
|
||||
ticker: Annotated[str, "A-stock code (e.g. 688017)"],
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve concept/sector/region blocks that a stock belongs to.
|
||||
Shows industry (申万), concept themes (e.g. 机器人概念, 减速器), and region.
|
||||
Each block includes current day's change percentage.
|
||||
Uses the configured signal_data vendor.
|
||||
Args:
|
||||
ticker (str): A-stock code
|
||||
Returns:
|
||||
str: Concept and sector block membership with daily changes
|
||||
"""
|
||||
return route_to_vendor("get_concept_blocks", ticker)
|
||||
|
||||
|
||||
@tool
|
||||
def get_fund_flow(
|
||||
ticker: Annotated[str, "A-stock code"],
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format"],
|
||||
include_history: Annotated[
|
||||
bool, "Include historical daily fund flow (last 20 days)"
|
||||
] = True,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve individual stock fund flow (main force vs retail investor).
|
||||
Realtime: minute-level super/large/medium/small order flow.
|
||||
History: daily net inflow by order size for 20 trading days.
|
||||
Uses the configured signal_data vendor.
|
||||
Args:
|
||||
ticker (str): A-stock code
|
||||
curr_date (str): Date in YYYY-MM-DD format
|
||||
include_history (bool): Include 20-day historical daily flow
|
||||
Returns:
|
||||
str: Fund flow report with main force signal
|
||||
"""
|
||||
return route_to_vendor("get_fund_flow", ticker, curr_date, include_history)
|
||||
|
||||
|
||||
@tool
|
||||
def get_dragon_tiger_board(
|
||||
ticker: Annotated[str, "A-stock code (e.g. 000858)"],
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format"],
|
||||
look_back_days: Annotated[int, "Days to look back (default 30)"] = 30,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve dragon-tiger board (龙虎榜) data for a stock.
|
||||
Shows recent LHB appearances, top buyer/seller seats (营业部),
|
||||
and institutional involvement. Key signal for hot money tracking.
|
||||
Args:
|
||||
ticker (str): A-stock code
|
||||
curr_date (str): Date in YYYY-MM-DD format
|
||||
look_back_days (int): How many days back to search
|
||||
Returns:
|
||||
str: LHB appearances with seat details and institutional activity
|
||||
"""
|
||||
return route_to_vendor("get_dragon_tiger_board", ticker, curr_date, look_back_days)
|
||||
|
||||
|
||||
@tool
|
||||
def get_lockup_expiry(
|
||||
ticker: Annotated[str, "A-stock code (e.g. 000858)"],
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format"],
|
||||
forward_days: Annotated[int, "Days forward to check (default 90)"] = 90,
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve lockup expiry (限售解禁) schedule for a stock.
|
||||
Shows historical unlock records and upcoming expiry calendar
|
||||
with impact metrics (unlock quantity, market cap ratio).
|
||||
Args:
|
||||
ticker (str): A-stock code
|
||||
curr_date (str): Date in YYYY-MM-DD format
|
||||
forward_days (int): How many days forward to check
|
||||
Returns:
|
||||
str: Lockup expiry schedule with impact assessment
|
||||
"""
|
||||
return route_to_vendor("get_lockup_expiry", ticker, curr_date, forward_days)
|
||||
|
||||
|
||||
@tool
|
||||
def get_industry_comparison(
|
||||
ticker: Annotated[str, "A-stock code (e.g. 000858)"],
|
||||
curr_date: Annotated[str, "Date in YYYY-MM-DD format"],
|
||||
) -> str:
|
||||
"""
|
||||
Retrieve industry sector performance comparison (行业横向对比).
|
||||
Shows all 90 THS industries ranked by performance with turnover,
|
||||
net capital flow, and leading stocks. Useful for sector rotation analysis.
|
||||
Args:
|
||||
ticker (str): A-stock code (used to identify relevant sector)
|
||||
curr_date (str): Date in YYYY-MM-DD format
|
||||
Returns:
|
||||
str: Industry performance ranking with key metrics
|
||||
"""
|
||||
return route_to_vendor("get_industry_comparison", ticker, curr_date)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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 typing import Any, Callable, Optional, 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) -> Optional[Any]:
|
||||
"""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: Optional[Any],
|
||||
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)
|
||||
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
|
||||
@@ -0,0 +1,32 @@
|
||||
from langchain_core.tools import tool
|
||||
from typing import Annotated
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Import functions from specialized modules
|
||||
from .alpha_vantage_stock import get_stock
|
||||
from .alpha_vantage_indicator import get_indicator
|
||||
from .alpha_vantage_fundamentals import get_fundamentals, get_balance_sheet, get_cashflow, get_income_statement
|
||||
from .alpha_vantage_news import get_news, get_global_news, get_insider_transactions
|
||||
@@ -0,0 +1,122 @@
|
||||
import os
|
||||
import requests
|
||||
import pandas as pd
|
||||
import json
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
|
||||
API_BASE_URL = "https://www.alphavantage.co/query"
|
||||
|
||||
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 ValueError("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}")
|
||||
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(Exception):
|
||||
"""Exception raised when 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)
|
||||
response.raise_for_status()
|
||||
|
||||
response_text = response.text
|
||||
|
||||
# Check if response is JSON (error responses are typically JSON)
|
||||
try:
|
||||
response_json = json.loads(response_text)
|
||||
# Check for rate limit error
|
||||
if "Information" in response_json:
|
||||
info_message = response_json["Information"]
|
||||
if "rate limit" in info_message.lower() or "api key" in info_message.lower():
|
||||
raise AlphaVantageRateLimitError(f"Alpha Vantage rate limit exceeded: {info_message}")
|
||||
except json.JSONDecodeError:
|
||||
# Response is not JSON (likely CSV data), which is normal
|
||||
pass
|
||||
|
||||
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
|
||||
@@ -0,0 +1,55 @@
|
||||
from .alpha_vantage_common import _make_api_request
|
||||
|
||||
|
||||
def _filter_reports_by_date(result, curr_date: str):
|
||||
"""Filter annualReports/quarterlyReports to exclude entries after curr_date.
|
||||
|
||||
Prevents look-ahead bias by removing fiscal periods that end after
|
||||
the simulation's current date.
|
||||
"""
|
||||
if not curr_date or not isinstance(result, dict):
|
||||
return result
|
||||
for key in ("annualReports", "quarterlyReports"):
|
||||
if key in result:
|
||||
result[key] = [
|
||||
r for r in result[key]
|
||||
if r.get("fiscalDateEnding", "") <= curr_date
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
from .alpha_vantage_common import _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":
|
||||
data = _make_api_request("MACD", {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"series_type": series_type,
|
||||
"datatype": "csv"
|
||||
})
|
||||
elif indicator == "macds":
|
||||
data = _make_api_request("MACD", {
|
||||
"symbol": symbol,
|
||||
"interval": interval,
|
||||
"series_type": series_type,
|
||||
"datatype": "csv"
|
||||
})
|
||||
elif 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 Exception as e:
|
||||
print(f"Error getting Alpha Vantage indicator data for {indicator}: {e}")
|
||||
return f"Error retrieving {indicator} data: {str(e)}"
|
||||
@@ -0,0 +1,71 @@
|
||||
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)
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
from .alpha_vantage_common import _make_api_request, _filter_csv_by_date_range
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,31 @@
|
||||
import tradingagents.default_config as default_config
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Use default config but allow it to be overridden
|
||||
_config: Optional[Dict] = None
|
||||
|
||||
|
||||
def initialize_config():
|
||||
"""Initialize the configuration with default values."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = default_config.DEFAULT_CONFIG.copy()
|
||||
|
||||
|
||||
def set_config(config: Dict):
|
||||
"""Update the configuration with custom values."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = default_config.DEFAULT_CONFIG.copy()
|
||||
_config.update(config)
|
||||
|
||||
|
||||
def get_config() -> Dict:
|
||||
"""Get the current configuration."""
|
||||
if _config is None:
|
||||
initialize_config()
|
||||
return _config.copy()
|
||||
|
||||
|
||||
# Initialize with default config
|
||||
initialize_config()
|
||||
@@ -0,0 +1,229 @@
|
||||
from typing import Annotated
|
||||
|
||||
# Import from vendor-specific modules
|
||||
from .y_finance import (
|
||||
get_YFin_data_online,
|
||||
get_stock_stats_indicators_window,
|
||||
get_fundamentals as get_yfinance_fundamentals,
|
||||
get_balance_sheet as get_yfinance_balance_sheet,
|
||||
get_cashflow as get_yfinance_cashflow,
|
||||
get_income_statement as get_yfinance_income_statement,
|
||||
get_insider_transactions as get_yfinance_insider_transactions,
|
||||
)
|
||||
from .yfinance_news import get_news_yfinance, get_global_news_yfinance
|
||||
from .alpha_vantage import (
|
||||
get_stock as get_alpha_vantage_stock,
|
||||
get_indicator as get_alpha_vantage_indicator,
|
||||
get_fundamentals as get_alpha_vantage_fundamentals,
|
||||
get_balance_sheet as get_alpha_vantage_balance_sheet,
|
||||
get_cashflow as get_alpha_vantage_cashflow,
|
||||
get_income_statement as get_alpha_vantage_income_statement,
|
||||
get_insider_transactions as get_alpha_vantage_insider_transactions,
|
||||
get_news as get_alpha_vantage_news,
|
||||
get_global_news as get_alpha_vantage_global_news,
|
||||
)
|
||||
from .alpha_vantage_common import AlphaVantageRateLimitError
|
||||
from .a_stock import (
|
||||
get_stock_data as get_astock_stock_data,
|
||||
get_indicators as get_astock_indicators,
|
||||
get_fundamentals as get_astock_fundamentals,
|
||||
get_balance_sheet as get_astock_balance_sheet,
|
||||
get_cashflow as get_astock_cashflow,
|
||||
get_income_statement as get_astock_income_statement,
|
||||
get_news as get_astock_news,
|
||||
get_global_news as get_astock_global_news,
|
||||
get_insider_transactions as get_astock_insider_transactions,
|
||||
get_profit_forecast as get_astock_profit_forecast,
|
||||
get_hot_stocks as get_astock_hot_stocks,
|
||||
get_northbound_flow as get_astock_northbound_flow,
|
||||
get_concept_blocks as get_astock_concept_blocks,
|
||||
get_fund_flow as get_astock_fund_flow,
|
||||
get_dragon_tiger_board as get_astock_dragon_tiger_board,
|
||||
get_lockup_expiry as get_astock_lockup_expiry,
|
||||
get_industry_comparison as get_astock_industry_comparison,
|
||||
)
|
||||
|
||||
# Configuration and routing logic
|
||||
from .config import get_config
|
||||
|
||||
# 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",
|
||||
]
|
||||
},
|
||||
"signal_data": {
|
||||
"description": "A-stock signal layer (topic attribution, capital flow, consensus forecast)",
|
||||
"tools": [
|
||||
"get_profit_forecast",
|
||||
"get_hot_stocks",
|
||||
"get_northbound_flow",
|
||||
"get_concept_blocks",
|
||||
"get_fund_flow",
|
||||
"get_dragon_tiger_board",
|
||||
"get_lockup_expiry",
|
||||
"get_industry_comparison",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
VENDOR_LIST = [
|
||||
"a_stock",
|
||||
"yfinance",
|
||||
"alpha_vantage",
|
||||
]
|
||||
|
||||
# Mapping of methods to their vendor-specific implementations
|
||||
VENDOR_METHODS = {
|
||||
# core_stock_apis
|
||||
"get_stock_data": {
|
||||
"a_stock": get_astock_stock_data,
|
||||
"alpha_vantage": get_alpha_vantage_stock,
|
||||
"yfinance": get_YFin_data_online,
|
||||
},
|
||||
# technical_indicators
|
||||
"get_indicators": {
|
||||
"a_stock": get_astock_indicators,
|
||||
"alpha_vantage": get_alpha_vantage_indicator,
|
||||
"yfinance": get_stock_stats_indicators_window,
|
||||
},
|
||||
# fundamental_data
|
||||
"get_fundamentals": {
|
||||
"a_stock": get_astock_fundamentals,
|
||||
"alpha_vantage": get_alpha_vantage_fundamentals,
|
||||
"yfinance": get_yfinance_fundamentals,
|
||||
},
|
||||
"get_balance_sheet": {
|
||||
"a_stock": get_astock_balance_sheet,
|
||||
"alpha_vantage": get_alpha_vantage_balance_sheet,
|
||||
"yfinance": get_yfinance_balance_sheet,
|
||||
},
|
||||
"get_cashflow": {
|
||||
"a_stock": get_astock_cashflow,
|
||||
"alpha_vantage": get_alpha_vantage_cashflow,
|
||||
"yfinance": get_yfinance_cashflow,
|
||||
},
|
||||
"get_income_statement": {
|
||||
"a_stock": get_astock_income_statement,
|
||||
"alpha_vantage": get_alpha_vantage_income_statement,
|
||||
"yfinance": get_yfinance_income_statement,
|
||||
},
|
||||
# news_data
|
||||
"get_news": {
|
||||
"a_stock": get_astock_news,
|
||||
"alpha_vantage": get_alpha_vantage_news,
|
||||
"yfinance": get_news_yfinance,
|
||||
},
|
||||
"get_global_news": {
|
||||
"a_stock": get_astock_global_news,
|
||||
"yfinance": get_global_news_yfinance,
|
||||
"alpha_vantage": get_alpha_vantage_global_news,
|
||||
},
|
||||
"get_insider_transactions": {
|
||||
"a_stock": get_astock_insider_transactions,
|
||||
"alpha_vantage": get_alpha_vantage_insider_transactions,
|
||||
"yfinance": get_yfinance_insider_transactions,
|
||||
},
|
||||
# signal_data (A-stock only)
|
||||
"get_profit_forecast": {
|
||||
"a_stock": get_astock_profit_forecast,
|
||||
},
|
||||
"get_hot_stocks": {
|
||||
"a_stock": get_astock_hot_stocks,
|
||||
},
|
||||
"get_northbound_flow": {
|
||||
"a_stock": get_astock_northbound_flow,
|
||||
},
|
||||
"get_concept_blocks": {
|
||||
"a_stock": get_astock_concept_blocks,
|
||||
},
|
||||
"get_fund_flow": {
|
||||
"a_stock": get_astock_fund_flow,
|
||||
},
|
||||
"get_dragon_tiger_board": {
|
||||
"a_stock": get_astock_dragon_tiger_board,
|
||||
},
|
||||
"get_lockup_expiry": {
|
||||
"a_stock": get_astock_lockup_expiry,
|
||||
},
|
||||
"get_industry_comparison": {
|
||||
"a_stock": get_astock_industry_comparison,
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
# Build fallback chain: primary vendors first, then remaining available vendors
|
||||
all_available_vendors = list(VENDOR_METHODS[method].keys())
|
||||
fallback_vendors = primary_vendors.copy()
|
||||
for vendor in all_available_vendors:
|
||||
if vendor not in fallback_vendors:
|
||||
fallback_vendors.append(vendor)
|
||||
|
||||
for vendor in fallback_vendors:
|
||||
if vendor not in VENDOR_METHODS[method]:
|
||||
continue
|
||||
|
||||
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 AlphaVantageRateLimitError:
|
||||
continue # Only rate limits trigger fallback
|
||||
|
||||
raise RuntimeError(f"No available vendor for '{method}'")
|
||||
@@ -0,0 +1,133 @@
|
||||
import time
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
from yfinance.exceptions import YFRateLimitError
|
||||
from stockstats import wrap
|
||||
from typing import Annotated
|
||||
import os
|
||||
from .config import get_config
|
||||
from .utils import safe_ticker_component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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 _clean_dataframe(data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Normalize a stock DataFrame for stockstats: parse dates, drop invalid rows, fill price gaps."""
|
||||
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 load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
||||
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
|
||||
|
||||
Downloads 15 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.
|
||||
"""
|
||||
# Reject ticker values that would escape the cache directory when
|
||||
# interpolated into the cache filename (e.g. ``../../tmp/x``).
|
||||
safe_symbol = safe_ticker_component(symbol)
|
||||
|
||||
config = get_config()
|
||||
curr_date_dt = pd.to_datetime(curr_date)
|
||||
|
||||
# Cache uses a fixed window (15y 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")
|
||||
end_str = today_date.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",
|
||||
)
|
||||
|
||||
if os.path.exists(data_file):
|
||||
data = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
|
||||
else:
|
||||
data = yf_retry(lambda: yf.download(
|
||||
symbol,
|
||||
start=start_str,
|
||||
end=end_str,
|
||||
multi_level_index=False,
|
||||
progress=False,
|
||||
auto_adjust=True,
|
||||
))
|
||||
data = data.reset_index()
|
||||
data.to_csv(data_file, index=False, encoding="utf-8")
|
||||
|
||||
data = _clean_dataframe(data)
|
||||
|
||||
# Filter to curr_date to prevent look-ahead bias in backtesting
|
||||
data = data[data["Date"] <= curr_date_dt]
|
||||
|
||||
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)"
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import pandas as pd
|
||||
from datetime import date, timedelta, datetime
|
||||
from typing import Annotated
|
||||
|
||||
SavePathType = Annotated[str, "File path to save data. If None, data is not saved."]
|
||||
|
||||
# Tickers can contain letters, digits, dot, dash, underscore, and caret
|
||||
# (for index symbols like ^GSPC). Anything else is rejected so the value
|
||||
# never escapes a containing directory when interpolated into a path.
|
||||
_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
|
||||
@@ -0,0 +1,422 @@
|
||||
from typing import Annotated
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
import os
|
||||
from .stockstats_utils import StockstatsUtils, _clean_dataframe, yf_retry, load_ohlcv, filter_financials_by_date
|
||||
|
||||
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")
|
||||
datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
# Create ticker object
|
||||
ticker = yf.Ticker(symbol.upper())
|
||||
|
||||
# Fetch historical data for the specified date range
|
||||
data = yf_retry(lambda: ticker.history(start=start_date, end=end_date))
|
||||
|
||||
# Check if data is empty
|
||||
if data.empty:
|
||||
return (
|
||||
f"No data found for symbol '{symbol}' 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)
|
||||
|
||||
# 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
|
||||
header = f"# Stock data for {symbol.upper()} 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 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 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."""
|
||||
try:
|
||||
ticker_obj = yf.Ticker(ticker.upper())
|
||||
info = yf_retry(lambda: ticker_obj.info)
|
||||
|
||||
if not info:
|
||||
return f"No fundamentals data found for symbol '{ticker}'"
|
||||
|
||||
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}")
|
||||
|
||||
header = f"# Company Fundamentals for {ticker.upper()}\n"
|
||||
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||||
|
||||
return header + "\n".join(lines)
|
||||
|
||||
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."""
|
||||
try:
|
||||
ticker_obj = yf.Ticker(ticker.upper())
|
||||
|
||||
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:
|
||||
return f"No balance sheet data found for symbol '{ticker}'"
|
||||
|
||||
# Convert to CSV string for consistency with other functions
|
||||
csv_string = data.to_csv()
|
||||
|
||||
# Add header information
|
||||
header = f"# Balance Sheet data for {ticker.upper()} ({freq})\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 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."""
|
||||
try:
|
||||
ticker_obj = yf.Ticker(ticker.upper())
|
||||
|
||||
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:
|
||||
return f"No cash flow data found for symbol '{ticker}'"
|
||||
|
||||
# Convert to CSV string for consistency with other functions
|
||||
csv_string = data.to_csv()
|
||||
|
||||
# Add header information
|
||||
header = f"# Cash Flow data for {ticker.upper()} ({freq})\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 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."""
|
||||
try:
|
||||
ticker_obj = yf.Ticker(ticker.upper())
|
||||
|
||||
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:
|
||||
return f"No income statement data found for symbol '{ticker}'"
|
||||
|
||||
# Convert to CSV string for consistency with other functions
|
||||
csv_string = data.to_csv()
|
||||
|
||||
# Add header information
|
||||
header = f"# Income Statement data for {ticker.upper()} ({freq})\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 income statement for {ticker}: {str(e)}"
|
||||
|
||||
|
||||
def get_insider_transactions(
|
||||
ticker: Annotated[str, "ticker symbol of the company"]
|
||||
):
|
||||
"""Get insider transactions data from yfinance."""
|
||||
try:
|
||||
ticker_obj = yf.Ticker(ticker.upper())
|
||||
data = yf_retry(lambda: ticker_obj.insider_transactions)
|
||||
|
||||
if data is None or data.empty:
|
||||
return f"No insider transactions data found for symbol '{ticker}'"
|
||||
|
||||
# Convert to CSV string for consistency with other functions
|
||||
csv_string = data.to_csv()
|
||||
|
||||
# Add header information
|
||||
header = f"# Insider Transactions data for {ticker.upper()}\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)}"
|
||||
@@ -0,0 +1,197 @@
|
||||
"""yfinance-based news data fetching functions."""
|
||||
|
||||
import yfinance as yf
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from .stockstats_utils import yf_retry
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
pub_date = datetime.fromisoformat(pub_date_str.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"publisher": publisher,
|
||||
"link": link,
|
||||
"pub_date": pub_date,
|
||||
}
|
||||
else:
|
||||
# Fallback for flat structure
|
||||
return {
|
||||
"title": article.get("title", "No title"),
|
||||
"summary": article.get("summary", ""),
|
||||
"publisher": article.get("publisher", "Unknown"),
|
||||
"link": article.get("link", ""),
|
||||
"pub_date": None,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
try:
|
||||
stock = yf.Ticker(ticker)
|
||||
news = yf_retry(lambda: stock.get_news(count=20))
|
||||
|
||||
if not news:
|
||||
return f"No news found for {ticker}"
|
||||
|
||||
# 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)
|
||||
|
||||
# Filter by date if publish time is available
|
||||
if data["pub_date"]:
|
||||
pub_date_naive = data["pub_date"].replace(tzinfo=None)
|
||||
if not (start_dt <= pub_date_naive <= end_dt + relativedelta(days=1)):
|
||||
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} between {start_date} and {end_date}"
|
||||
|
||||
return f"## {ticker} 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 = 7,
|
||||
limit: int = 10,
|
||||
) -> 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
|
||||
limit: Maximum number of articles to return
|
||||
|
||||
Returns:
|
||||
Formatted string containing global news articles
|
||||
"""
|
||||
# Search queries for macro/global news
|
||||
search_queries = [
|
||||
"stock market economy",
|
||||
"Federal Reserve interest rates",
|
||||
"inflation economic outlook",
|
||||
"global markets trading",
|
||||
]
|
||||
|
||||
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 = ""
|
||||
for article in all_news[:limit]:
|
||||
# Handle both flat and nested structures
|
||||
if "content" in article:
|
||||
data = _extract_article_data(article)
|
||||
# Skip articles published after curr_date (look-ahead guard)
|
||||
if data.get("pub_date"):
|
||||
pub_naive = data["pub_date"].replace(tzinfo=None) if hasattr(data["pub_date"], "replace") else data["pub_date"]
|
||||
if pub_naive > curr_dt + relativedelta(days=1):
|
||||
continue
|
||||
title = data["title"]
|
||||
publisher = data["publisher"]
|
||||
link = data["link"]
|
||||
summary = data["summary"]
|
||||
else:
|
||||
title = article.get("title", "No title")
|
||||
publisher = article.get("publisher", "Unknown")
|
||||
link = article.get("link", "")
|
||||
summary = ""
|
||||
|
||||
news_str += f"### {title} (source: {publisher})\n"
|
||||
if summary:
|
||||
news_str += f"{summary}\n"
|
||||
if link:
|
||||
news_str += f"Link: {link}\n"
|
||||
news_str += "\n"
|
||||
|
||||
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)}"
|
||||
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
|
||||
_TRADINGAGENTS_HOME = os.path.join(os.path.expanduser("~"), ".tradingagents")
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"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.4",
|
||||
"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"
|
||||
# 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": "Chinese",
|
||||
# Debate and discussion settings
|
||||
"max_debate_rounds": 1,
|
||||
"max_risk_discuss_rounds": 1,
|
||||
"max_recur_limit": 100,
|
||||
# Data vendor configuration
|
||||
# Category-level configuration (default for all tools in category)
|
||||
"data_vendors": {
|
||||
"core_stock_apis": "a_stock", # Options: a_stock, alpha_vantage, yfinance
|
||||
"technical_indicators": "a_stock", # Options: a_stock, alpha_vantage, yfinance
|
||||
"fundamental_data": "a_stock", # Options: a_stock, alpha_vantage, yfinance
|
||||
"news_data": "a_stock", # Options: a_stock, alpha_vantage, yfinance
|
||||
"signal_data": "a_stock", # A-stock only: topic attribution, capital flow, consensus
|
||||
},
|
||||
# Tool-level configuration (takes precedence over category-level)
|
||||
"tool_vendors": {
|
||||
# Example: "get_stock_data": "alpha_vantage", # Override category default
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# TradingAgents/graph/__init__.py
|
||||
|
||||
from .trading_graph import TradingAgentsGraph
|
||||
from .conditional_logic import ConditionalLogic
|
||||
from .setup import GraphSetup
|
||||
from .propagation import Propagator
|
||||
from .reflection import Reflector
|
||||
from .signal_processing import SignalProcessor
|
||||
|
||||
__all__ = [
|
||||
"TradingAgentsGraph",
|
||||
"ConditionalLogic",
|
||||
"GraphSetup",
|
||||
"Propagator",
|
||||
"Reflector",
|
||||
"SignalProcessor",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""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 contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
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) -> str:
|
||||
"""Deterministic thread ID for a ticker+date pair."""
|
||||
return hashlib.sha256(f"{ticker.upper()}:{date}".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) -> bool:
|
||||
"""Check whether a resumable checkpoint exists for ticker+date."""
|
||||
return checkpoint_step(data_dir, ticker, date) is not None
|
||||
|
||||
|
||||
def checkpoint_step(data_dir: str | Path, ticker: str, date: 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)
|
||||
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) -> 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)
|
||||
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()
|
||||