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
This commit is contained in:
simon
2026-05-13 08:23:58 +08:00
commit c567f8aee9
127 changed files with 14668 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.venv
.env
.claude
.idea
.vscode
.DS_Store
__pycache__
*.egg-info
build
dist
results
eval_results
Dockerfile
docker-compose.yml
+5
View File
@@ -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
+9
View File
@@ -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=
+219
View File
@@ -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/
+266
View File
@@ -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
+409
View File
@@ -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`
+549
View File
@@ -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
+27
View File
@@ -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"]
+201
View File
@@ -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.
+20
View File
@@ -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.
+364
View File
@@ -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 自动生成,可能存在错误或偏差
> - 投资决策请咨询持有中国证监会颁发资质的专业机构
> - 作者不对使用本工具产生的任何投资损失承担责任
> - 股市有风险,投资需谨慎
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File
+51
View File
@@ -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()
+6
View File
@@ -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]",
}
+1221
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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"
+7
View File
@@ -0,0 +1,7 @@
______ ___ ___ __
/_ __/________ _____/ (_)___ ____ _/ | ____ ____ ____ / /______
/ / / ___/ __ `/ __ / / __ \/ __ `/ /| |/ __ `/ _ \/ __ \/ __/ ___/
/ / / / / /_/ / /_/ / / / / / /_/ / ___ / /_/ / __/ / / / /_(__ )
/_/ /_/ \__,_/\__,_/_/_/ /_/\__, /_/ |_\__, /\___/_/ /_/\__/____/
/____/ /____/
+76
View File
@@ -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,
}
+360
View File
@@ -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
+35
View File
@@ -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:
+9
View File
@@ -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隔夜敞口。"
}
+16
View File
@@ -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隔夜敞口。
+9
View File
@@ -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说实话,我不太认同现在满仓杀进去的做法。不是说这股票不好,而是市场短期情绪不明朗的时候,把仓位控制好,心态会更稳。散户最大的优势就是资金灵活,没"
}
+51
View File
@@ -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的市场里。
+8
View File
@@ -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"
}
+10
View File
@@ -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
+141
View File
@@ -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()
+31
View File
@@ -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
+68
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
.
+176
View File
@@ -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())
+11
View File
@@ -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)
+45
View File
@@ -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)
+93
View File
@@ -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}")
+42
View File
@@ -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
+147
View File
@@ -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()
+169
View File
@@ -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
)
+31
View File
@@ -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()
+773
View File
@@ -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
+55
View File
@@ -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, [])
+52
View File
@@ -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()
+90
View File
@@ -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"
+232
View File
@@ -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
+21
View File
@@ -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()
View File
+47
View File
@@ -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
+168
View File
@@ -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
+228
View File
@@ -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)
+83
View File
@@ -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)"]
+71
View File
@@ -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)
+300
View File
@@ -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)
+50
View File
@@ -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)
+73
View File
@@ -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)
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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)
+31
View File
@@ -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()
+229
View File
@@ -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}'")
+133
View File
@@ -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)"
+74
View File
@@ -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
+422
View File
@@ -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)}"
+197
View File
@@ -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)}"
+51
View File
@@ -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
},
}
+17
View File
@@ -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",
]
+90
View File
@@ -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()

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