mirror of
https://github.com/simonlin1212/TradingAgents-astock.git
synced 2026-08-31 01:23:38 +00:00
Merge v0.3.1: 静默失败修复 + 社区 PR #83/#84
解冲突说明:pr84 分支基于较旧的 main,据此误判 v0.3.0 漏更版本号与 CHANGELOG。 实际 main 已有正确的 0.3.0 条目与 version=0.3.0,本次合并保留之,仅叠加 0.3.1。
This commit is contained in:
commit
d082b94eec
65
CHANGELOG.md
65
CHANGELOG.md
@ -6,6 +6,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Breaking changes within the 0.x line are called out explicitly.
|
||||
|
||||
## [0.3.1] — 2026-07-31
|
||||
|
||||
修三个静默失败 + 合并两个社区 PR。无破坏性变更。
|
||||
|
||||
### 修复:`uv sync` 因 `[google]` extra 的无解冲突而对所有人失败(#87)
|
||||
|
||||
感谢 [@jakeparkcolde](https://github.com/jakeparkcolde) 的高质量报告与复现步骤。
|
||||
|
||||
`langchain-google-genai>=4.0.0` 要求 `google-genai>=1.53.0`,而该区间内**每一个**
|
||||
google-genai 版本都要求 `httpx>=0.28.1`;`mootdx`(核心 A 股数据源)钉死
|
||||
`httpx>=0.25,<0.26`。**没有任何版本组合能同时满足——冲突是结构性的,不是坏 pin。**
|
||||
|
||||
真正的杀伤力在于:**uv 构建的是覆盖所有 extra 的 universal lock**,所以只要这个
|
||||
extra 存在,`uv sync` 就对**所有人**失败,包括从不用 Gemini 的用户。
|
||||
|
||||
- **移除 `[google]` extra**。留空更糟——`pip install .[google]` 会静默什么都不装,
|
||||
用户以为装好了,直到运行期才炸。
|
||||
- `google_client.py` 导入失败时抛出**带可直接执行安装命令**的 `ImportError`,
|
||||
而不是裸 `ModuleNotFoundError`(沿用 v0.2.17 处理 fpdf 的做法)。
|
||||
- `tests/test_google_api_key.py` 改为缺依赖时 skip——此前它会让 `pytest tests/`
|
||||
**在收集阶段整体中断**,一个测试都跑不了。
|
||||
- `mootdx` 下限 `0.10.0` → `0.11.7`:放宽会让 uv 回溯到要求 `pandas<1.3.5` 的
|
||||
远古版本,报出与真实成因无关的 pandas 冲突,把真正的 httpx 问题盖住。
|
||||
|
||||
实测 `uv lock` 由失败转为成功解析。
|
||||
|
||||
### 修复:A 股历史决策回报永远查不到,记忆闭环从未生效(社区 PR #84)
|
||||
|
||||
感谢 [@wangyuxun6699](https://github.com/wangyuxun6699)。
|
||||
|
||||
`_fetch_returns` 把裸六位码直接传给 yfinance,而同一函数里 benchmark 用的却是
|
||||
`"000300.SS"`(带后缀)。yfinance 对裸码返回空表 → `len(stock) < 2` → 返回 None
|
||||
→ **记忆条目永久 pending**,且被 `except Exception` 吞成 warning。
|
||||
实测:`600519` → 0 行、`600519.SS` → 10 行;`000001` → 0 行、`000001.SZ` → 10 行。
|
||||
等于 agent「从历史决策学习」的能力对 A 股一直是空转。
|
||||
|
||||
新增 `_normalize_yfinance_ticker()`:沪市 → `.SS`、深市 → `.SZ`,
|
||||
`SH600519` / `600519.SH` 等写法一并归一,非 A 股代码原样返回。
|
||||
|
||||
**本版在 PR 基础上补了一条**:Yahoo **完全不覆盖北交所**(实测 `920002` 的裸码 /
|
||||
`.BJ` / `.SS` / `.SZ` 四种写法全部返回空表)。PR 正确地没有硬造后缀,但这样北交所
|
||||
条目会每次运行白发一次网络请求、且永远 pending 不给任何理由。新增
|
||||
`_is_unsupported_by_yfinance()` 直接短路并明确写清原因。
|
||||
|
||||
### 修复:DeepSeek V4 / MiniMax M2.x 结构化输出不稳定(社区 PR #83)
|
||||
|
||||
感谢 [@wangyuxun6699](https://github.com/wangyuxun6699)。
|
||||
|
||||
这些模型支持工具调用,但不接受 LangChain 结构化输出默认发送的 `tool_choice`,
|
||||
于是结构化阶段失败、退回自由文本 —— 多一次模型调用,且 Research Manager /
|
||||
Trader / Portfolio Manager 的输出格式不稳定,中文评级更容易解析失败。
|
||||
|
||||
**这正是 v0.2.19「中文 TRADING SIGNAL 恒为 HOLD」的上游成因**:v0.2.19 修的是症状
|
||||
(让 `parse_rating` 认中文),本版修的是病因(结构化输出为什么会失败)。
|
||||
|
||||
新增模型能力声明表 `llm_clients/capabilities.py`(精确 ID + 前缀匹配,未知模型
|
||||
保持宽松默认),对 DeepSeek V4/reasoner 与 MiniMax M2.x 抑制不兼容的 `tool_choice`、
|
||||
保留 Schema 工具绑定不再直接降级为自由文本,并为 MiniMax 启用 `reasoning_split`
|
||||
防止 `<think>` 内容污染最终报告。带前向兼容测试(`MiniMax-M3` 不继承 M2 行为)。
|
||||
|
||||
### 测试
|
||||
|
||||
`pytest tests/` **169 passed / 1 skipped / 45 subtests**,且现在**开箱即可运行**
|
||||
(此前缺 langchain-google-genai 会导致收集阶段整体中断)。
|
||||
|
||||
## [0.3.0] — 2026-07-24
|
||||
|
||||
明确项目定位为「框架的工程实现与研究复现」,并**移除可执行价位相关能力**。**有破坏性变更**(见下)。
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
- **仓库**: https://github.com/simonlin1212/TradingAgents-astock
|
||||
- **协议**: Apache 2.0
|
||||
- **Python**: >=3.10
|
||||
- **当前版本**: 0.2.21
|
||||
- **当前版本**: 0.3.1
|
||||
|
||||
## 架构
|
||||
|
||||
@ -39,7 +39,9 @@
|
||||
## 已知问题与注意事项
|
||||
|
||||
### 依赖冲突(v0.2.6 已缓解)
|
||||
mootdx 锁死 httpx==0.25.2,与 langchain-google-genai 的 httpx>=0.28.1 冲突。v0.2.6 将 google-genai 移至可选依赖 `[google]`,`pip install -e .` 不再冲突。需要 Google 模型时 `pip install -e ".[google]"`。
|
||||
mootdx 钉死 `httpx>=0.25,<0.26`,与 langchain-google-genai 所需的 `httpx>=0.28.1` **结构性冲突**(该区间内每个 google-genai 版本都要 0.28.1,无解)。
|
||||
|
||||
**v0.3.1 起 `[google]` extra 已移除**(#87):uv 构建覆盖所有 extra 的 universal lock,extra 存在就让**所有人**的 `uv sync` 失败。留空更糟(`pip install .[google]` 静默装空)。需要 Gemini 时显式装 `pip install --no-deps "langchain-google-genai>=4.0.0"` + `pip install "google-genai>=1.53.0" "httpx>=0.28.1"`;`google_client.py` 导入失败会打印这两条命令。⚠️ 新增依赖后务必跑 `uv lock --dry-run` 验证——**pip 能装通不代表 uv 能锁**。
|
||||
|
||||
### akshare 已移除(v0.2.5)
|
||||
v0.2.5 起完全移除 akshare 依赖,所有数据通过直连 HTTP API 获取。
|
||||
|
||||
22
README.md
22
README.md
@ -149,8 +149,9 @@ git clone https://github.com/simonlin1212/tradingagents-astock.git
|
||||
cd tradingagents-astock
|
||||
pip install -e .
|
||||
|
||||
# 如需使用 Google Gemini 模型(可选):
|
||||
pip install -e ".[google]"
|
||||
# 如需使用 Google Gemini 模型(无 [google] extra,需显式装,见下方 FAQ):
|
||||
pip install --no-deps "langchain-google-genai>=4.0.0"
|
||||
pip install "google-genai>=1.53.0" "httpx>=0.28.1"
|
||||
```
|
||||
|
||||
> **装完即可用,无需 Docker。** 安装后直接跑 `streamlit run web/app.py`(Web UI)或 `tradingagents`(CLI)即可,详见下方「Web UI」「CLI 方式」两节。Docker 仅是可选的部署方式,本地开发不需要。
|
||||
@ -316,8 +317,21 @@ v0.2.12 起 Dockerfile 已内置 `fonts-noto-cjk`,重新 `docker build` 即可
|
||||
**Q: 部分分析师报告(情绪/新闻/基本面/政策/游资/解禁)空白不显示?**
|
||||
这些报告由对应 Analyst 调用数据工具后生成,**空报告会被自动跳过不显示**。数据源本身是健康的(腾讯/mootdx/同花顺/东财实测出数);报告为空通常是**所选模型 tool-call 能力弱**(如部分 deepseek/minimax 轻量模型不稳定地调用工具)。建议换用 tool-call 更稳的模型(deepseek-chat / 通义 / GLM-4 / Claude / GPT 等),或重试。
|
||||
|
||||
**Q: 装 `[google]`(Gemini)后 pip 报 httpx 冲突:mootdx 要 `httpx<0.26`、google-genai 要 `httpx>=0.28`?**
|
||||
先澄清:**litellm / mcp 不是本项目的依赖**——报错里若提到它们,是你环境里其它包带来的,与 TradingAgents 无关。本项目核心安装(`pip install -e .`)不依赖 httpx≥0.28,**默认不冲突**;冲突只在装 `[google]` 用 Gemini 时出现(mootdx 与 google-genai 的 httpx 上下限互斥)。解法:① **mootdx 取行情走 TCP 协议、运行时根本不调用 httpx**,可让 httpx 升到满足 google-genai 的版本,pip 那条 `incompatible` 只是警告、不影响 mootdx 运行(实测 mootdx 0.11.7 在 httpx 0.28.1 下取数正常);② 或把跑 Gemini 的环境与 mootdx 数据层分到不同 venv;③ 最省心是用 MiniMax / DeepSeek / 通义等国内直连模型,不装 `[google]` 就没这问题。
|
||||
**Q: 为什么没有 `[google]` extra 了?装 Gemini 报 httpx 冲突怎么办?**
|
||||
**v0.3.1 起移除了 `[google]` extra**([#87](https://github.com/simonlin1212/TradingAgents-astock/issues/87))。原因:`langchain-google-genai>=4.0.0` 要求 `google-genai>=1.53.0`,而该区间内**每一个** google-genai 版本都要求 `httpx>=0.28.1`;mootdx(核心 A 股数据源)钉死 `httpx>=0.25,<0.26`。**没有任何版本组合能同时满足,冲突是结构性的。**
|
||||
|
||||
真正的问题是:**uv 构建的是覆盖所有 extra 的 universal lock**,所以只要这个 extra 存在,`uv sync` 就对**所有人**失败——包括从不用 Gemini 的用户。把 extra 留空更糟(`pip install .[google]` 会静默什么都不装,用户以为装好了)。所以直接移除,并在 `google_client.py` 导入失败时给出可直接执行的安装命令。
|
||||
|
||||
需要 Gemini 时显式安装(**mootdx 取行情走 TCP 协议、运行时根本不 import httpx**,所以抬高 httpx 实测不影响取数):
|
||||
|
||||
```bash
|
||||
pip install --no-deps "langchain-google-genai>=4.0.0"
|
||||
pip install "google-genai>=1.53.0" "httpx>=0.28.1"
|
||||
```
|
||||
|
||||
或把 Gemini 与数据层分到不同 venv。最省心是用 DeepSeek / MiniMax / 通义 / OpenAI 兼容中继等,完全不涉及这个冲突。
|
||||
|
||||
另澄清:**litellm / mcp 不是本项目的依赖**——报错里若提到它们,是你环境里其它包带来的。
|
||||
|
||||
**Q: 不进 CLI 交互,怎么批量跑多只标的、拿到和 CLI 一样的完整报告?**
|
||||
看 `examples/run_cases.py`:它复用 CLI 的 `save_report_to_disk()`,每只标的输出与 CLI 一致的 `complete_report.md`(分析师 / 研究 / 交易 / 风险 / 组合五个分区)+ 一份字段齐全的 `summary.json`。用法:`uv run python examples/run_cases.py`(跑全部)或 `uv run python examples/run_cases.py 688017`(单只);改 `build_config()` 切换 provider/model。
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tradingagents-astock"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@ -36,7 +36,9 @@ dependencies = [
|
||||
"streamlit>=1.45.0",
|
||||
"fpdf2>=2.8.6",
|
||||
"python-dotenv>=1.1.0",
|
||||
"mootdx>=0.10.0",
|
||||
# 0.11.7 是首个兼容 pandas>=2.3 的版本;放宽到 0.10.x 会让 uv 回溯到
|
||||
# 要求 pandas<1.3.5 的远古版本,报出与真实成因无关的 pandas 冲突(#87)。
|
||||
"mootdx>=0.11.7",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@ -45,8 +47,23 @@ Repository = "https://github.com/simonlin1212/tradingagents-astock"
|
||||
Issues = "https://github.com/simonlin1212/tradingagents-astock/issues"
|
||||
Upstream = "https://github.com/TauricResearch/TradingAgents"
|
||||
|
||||
[project.optional-dependencies]
|
||||
google = ["langchain-google-genai>=4.0.0"]
|
||||
# NOTE: there is deliberately no `[google]` extra — see #87.
|
||||
#
|
||||
# `langchain-google-genai>=4.0.0` requires `google-genai>=1.53.0`, and *every*
|
||||
# google-genai release in that range requires `httpx>=0.28.1`, while `mootdx`
|
||||
# (core A-share data source) pins `httpx>=0.25.0,<0.26.0`. No version
|
||||
# combination satisfies both — the conflict is structural, not a bad pin.
|
||||
#
|
||||
# It used to be declared as an optional extra, but uv builds a **universal
|
||||
# lock covering all extras**, so its presence made `uv sync` fail for
|
||||
# everyone, including users who never asked for Gemini. Declaring it empty
|
||||
# would be worse: `pip install .[google]` would silently install nothing.
|
||||
#
|
||||
# Gemini users: install it into a separate environment, or accept the httpx
|
||||
# bump (mootdx talks TDX over TCP and does not import httpx at runtime):
|
||||
# pip install --no-deps "langchain-google-genai>=4.0.0"
|
||||
# pip install "google-genai>=1.53.0" "httpx>=0.28.1"
|
||||
# See README「Gemini / langchain-google-genai」for details.
|
||||
|
||||
[project.scripts]
|
||||
tradingagents = "cli.main:app"
|
||||
|
||||
102
tests/test_capabilities.py
Normal file
102
tests/test_capabilities.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Unit tests for model-specific structured-output dispatch."""
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tradingagents.llm_clients.capabilities import get_capabilities
|
||||
from tradingagents.llm_clients.openai_client import MinimaxChatOpenAI
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_deepseek_v4_and_reasoner_reject_tool_choice():
|
||||
for model in ("deepseek-v4-flash", "deepseek-v4-pro", "deepseek-reasoner"):
|
||||
capabilities = get_capabilities(model)
|
||||
assert capabilities.supports_tool_choice is False
|
||||
assert capabilities.requires_reasoning_content_roundtrip is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimax_m2_variants_support_tool_choice_and_reasoning_split():
|
||||
for model in ("MiniMax-M2", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"):
|
||||
capabilities = get_capabilities(model)
|
||||
assert capabilities.supports_tool_choice is True
|
||||
assert capabilities.supports_json_mode is False
|
||||
assert capabilities.supports_reasoning_split is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_model_uses_permissive_defaults():
|
||||
capabilities = get_capabilities("some-future-model")
|
||||
assert capabilities.supports_tool_choice is True
|
||||
assert capabilities.preferred_structured_method == "function_calling"
|
||||
assert capabilities.supports_reasoning_split is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_future_minimax_family_does_not_inherit_m2_reasoning_split():
|
||||
capabilities = get_capabilities("MiniMax-M3")
|
||||
assert capabilities.supports_tool_choice is True
|
||||
assert capabilities.supports_reasoning_split is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimax_payload_enables_reasoning_split():
|
||||
client = MinimaxChatOpenAI(
|
||||
model="MiniMax-M2.7",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.minimax.chat/v1",
|
||||
)
|
||||
payload = client._get_request_payload([{"role": "user", "content": "hi"}])
|
||||
assert payload.get("reasoning_split") is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimax_payload_does_not_enable_reasoning_split_for_custom_model():
|
||||
client = MinimaxChatOpenAI(
|
||||
model="custom-minimax-model",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.minimax.chat/v1",
|
||||
)
|
||||
payload = client._get_request_payload([{"role": "user", "content": "hi"}])
|
||||
assert "reasoning_split" not in payload
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimax_structured_output_keeps_schema_and_tool_choice():
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
client = MinimaxChatOpenAI(
|
||||
model="MiniMax-M2.7",
|
||||
api_key="placeholder",
|
||||
base_url="https://api.minimax.chat/v1",
|
||||
)
|
||||
wrapped = client.with_structured_output(_Sample)
|
||||
first = wrapped.steps[0] if hasattr(wrapped, "steps") else wrapped
|
||||
kwargs = getattr(first, "kwargs", {})
|
||||
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
assert tool_choice == {
|
||||
"type": "function",
|
||||
"function": {"name": "_Sample"},
|
||||
}
|
||||
assert any(
|
||||
tool.get("function", {}).get("name") == "_Sample"
|
||||
for tool in kwargs.get("tools", [])
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_deepseek_v3_family_keeps_permissive_defaults():
|
||||
"""V3.2 是 catalog 里在售型号,其 tool_choice 行为未实测过,
|
||||
不能被 V4 的结论覆盖(原 `^deepseek-v\\d` 会误伤)。"""
|
||||
for model in ("deepseek-v3", "deepseek-v3.2", "deepseek-chat"):
|
||||
capabilities = get_capabilities(model)
|
||||
assert capabilities.supports_tool_choice is True
|
||||
assert capabilities.preferred_structured_method == "function_calling"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_deepseek_v4_family_still_matched_by_pattern():
|
||||
for model in ("deepseek-v4", "deepseek-v4.1", "deepseek-v4-turbo"):
|
||||
assert get_capabilities(model).supports_tool_choice is False
|
||||
@ -5,9 +5,8 @@ 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.
|
||||
2. ``with_structured_output`` suppresses the incompatible ``tool_choice``
|
||||
parameter for DeepSeek reasoning models while still binding the schema.
|
||||
"""
|
||||
|
||||
import os
|
||||
@ -115,13 +114,13 @@ class TestDeepSeekReasoningContent:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deepseek-reasoner: structured output unavailable, falls through to free-text
|
||||
# DeepSeek reasoning models: schema binding without tool_choice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeepSeekReasonerStructuredOutput:
|
||||
def test_with_structured_output_raises_for_reasoner(self):
|
||||
def test_with_structured_output_suppresses_tool_choice_for_reasoner(self):
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-reasoner",
|
||||
api_key="placeholder",
|
||||
@ -132,11 +131,13 @@ class TestDeepSeekReasonerStructuredOutput:
|
||||
class _Sample(BaseModel):
|
||||
answer: str
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
client.with_structured_output(_Sample)
|
||||
wrapped = client.with_structured_output(_Sample)
|
||||
first = wrapped.steps[0] if hasattr(wrapped, "steps") else wrapped
|
||||
kwargs = getattr(first, "kwargs", {})
|
||||
assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs
|
||||
|
||||
def test_with_structured_output_works_for_v4(self):
|
||||
"""V4 models (non-reasoner) accept tool_choice; structured output works."""
|
||||
"""V4 models bind the schema while suppressing tool_choice."""
|
||||
client = DeepSeekChatOpenAI(
|
||||
model="deepseek-v4-flash",
|
||||
api_key="placeholder",
|
||||
|
||||
@ -3,7 +3,18 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.google_client import GoogleClient
|
||||
# langchain-google-genai is no longer installable alongside mootdx (#87), so it
|
||||
# is not part of the default environment. Skip instead of failing collection —
|
||||
# otherwise `pytest tests/` aborts before running any test at all.
|
||||
# (pytest.importorskip does not catch the actionable ImportError that
|
||||
# google_client raises, so branch on it explicitly.)
|
||||
try:
|
||||
from tradingagents.llm_clients.google_client import GoogleClient
|
||||
except ImportError as exc:
|
||||
pytest.skip(
|
||||
f"langchain-google-genai not installed (see #87): {exc}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@ -7,7 +7,10 @@ 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.trading_graph import (
|
||||
TradingAgentsGraph,
|
||||
_normalize_yfinance_ticker,
|
||||
)
|
||||
from tradingagents.graph.propagation import Propagator
|
||||
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
|
||||
|
||||
@ -385,6 +388,25 @@ class TestTradingMemoryLogCore:
|
||||
|
||||
class TestDeferredReflection:
|
||||
|
||||
# Yahoo Finance ticker normalization
|
||||
|
||||
def test_normalize_yfinance_ticker_adds_mainland_exchange_suffix(self):
|
||||
assert _normalize_yfinance_ticker("600519") == "600519.SS"
|
||||
assert _normalize_yfinance_ticker("000001") == "000001.SZ"
|
||||
assert _normalize_yfinance_ticker("688017") == "688017.SS"
|
||||
|
||||
def test_normalize_yfinance_ticker_preserves_qualified_symbols(self):
|
||||
assert _normalize_yfinance_ticker("600519.SS") == "600519.SS"
|
||||
assert _normalize_yfinance_ticker("600519.SH") == "600519.SS"
|
||||
assert _normalize_yfinance_ticker("SH600519") == "600519.SS"
|
||||
assert _normalize_yfinance_ticker("NVDA") == "NVDA"
|
||||
|
||||
def test_normalize_yfinance_ticker_does_not_invent_beijing_suffix(self):
|
||||
assert _normalize_yfinance_ticker("830799") == "830799"
|
||||
assert _normalize_yfinance_ticker("920002") == "920002"
|
||||
assert _normalize_yfinance_ticker("BJ830799") == "830799"
|
||||
assert _normalize_yfinance_ticker("830799.BJ") == "830799"
|
||||
|
||||
# update_with_outcome
|
||||
|
||||
def test_update_replaces_pending_tag(self, tmp_path):
|
||||
@ -500,6 +522,16 @@ class TestDeferredReflection:
|
||||
assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
|
||||
assert days == 5
|
||||
|
||||
def test_fetch_returns_uses_exchange_qualified_astock_symbol(self):
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
m = MagicMock()
|
||||
m.history.return_value = _price_df([100.0, 101.0, 102.0, 103.0, 104.0, 105.0])
|
||||
mock_ticker_cls.return_value = m
|
||||
TradingAgentsGraph._fetch_returns(mock_graph, "600519", "2026-01-05")
|
||||
|
||||
assert mock_ticker_cls.call_args_list[0].args == ("600519.SS",)
|
||||
|
||||
def test_fetch_returns_too_recent(self):
|
||||
"""Only 1 data point available → returns (None, None, None), no crash."""
|
||||
mock_graph = MagicMock(spec=TradingAgentsGraph)
|
||||
|
||||
@ -55,6 +55,78 @@ from .reflection import Reflector
|
||||
from .signal_processing import SignalProcessor
|
||||
|
||||
|
||||
def _normalize_yfinance_ticker(ticker: str) -> str:
|
||||
"""Return the Yahoo Finance symbol for a ticker used by the graph.
|
||||
|
||||
A-stock decisions are stored with their six-digit code (for example
|
||||
``600519``), while Yahoo Finance requires an exchange suffix for mainland
|
||||
China listings (``600519.SS``). Without the suffix ``Ticker.history``
|
||||
usually returns an empty frame, so deferred memory outcomes remain pending
|
||||
indefinitely. Common A-share prefixes/suffixes are normalized as well;
|
||||
non-A-share symbols remain unchanged so the graph remains usable for other
|
||||
markets too.
|
||||
"""
|
||||
symbol = str(ticker).strip().upper()
|
||||
|
||||
# The A-stock layer accepts SH/SZ/BJ prefixes and uses .SH for Shanghai,
|
||||
# whereas Yahoo uses .SS. Normalize those forms before handling bare
|
||||
# six-digit codes. Yahoo has no Beijing exchange suffix, so keep BJ codes
|
||||
# unqualified instead of inventing a symbol that cannot return data.
|
||||
if (
|
||||
len(symbol) == 9
|
||||
and symbol[:6].isdigit()
|
||||
and symbol[6:] in (".SH", ".SZ", ".BJ")
|
||||
):
|
||||
code, exchange = symbol[:6], symbol[7:]
|
||||
if exchange == "SH":
|
||||
return f"{code}.SS"
|
||||
if exchange == "SZ":
|
||||
return f"{code}.SZ"
|
||||
return code
|
||||
if (
|
||||
len(symbol) == 8
|
||||
and symbol[:2] in ("SH", "SZ", "BJ")
|
||||
and symbol[2:].isdigit()
|
||||
):
|
||||
code, exchange = symbol[2:], symbol[:2]
|
||||
if exchange == "SH":
|
||||
return f"{code}.SS"
|
||||
if exchange == "SZ":
|
||||
return f"{code}.SZ"
|
||||
return code
|
||||
|
||||
if len(symbol) != 6 or not symbol.isdigit():
|
||||
return symbol
|
||||
|
||||
# The 920xxx range is Beijing-listed; Yahoo has no supported suffix for it.
|
||||
if symbol.startswith("92"):
|
||||
return symbol
|
||||
# Shanghai-listed A shares, B shares and ETFs use the .SS suffix on Yahoo.
|
||||
if symbol.startswith(("5", "6", "9")):
|
||||
return f"{symbol}.SS"
|
||||
# Other Beijing-listed six-digit ranges are not covered by Yahoo either.
|
||||
if symbol.startswith(("4", "8")):
|
||||
return symbol
|
||||
# Shenzhen-listed stocks (000/001/002/003/300/301, etc.).
|
||||
return f"{symbol}.SZ"
|
||||
|
||||
|
||||
def _is_unsupported_by_yfinance(symbol: str) -> bool:
|
||||
"""True for codes Yahoo Finance has no coverage for at all.
|
||||
|
||||
Beijing Stock Exchange listings (920xxx current, 43x/83x/87x legacy) are
|
||||
absent from Yahoo under every suffix — verified 2026-07-31: ``920002``,
|
||||
``920002.BJ``, ``920002.SS`` and ``920002.SZ`` all return an empty frame.
|
||||
Retrying them every run only burns a request and leaves the memory entry
|
||||
pending forever with no stated reason, so short-circuit and say why once.
|
||||
"""
|
||||
return (
|
||||
len(symbol) == 6
|
||||
and symbol.isdigit()
|
||||
and (symbol.startswith("92") or symbol[:2] in ("43", "83", "87"))
|
||||
)
|
||||
|
||||
|
||||
class TradingAgentsGraph:
|
||||
"""Main class that orchestrates the trading agents framework."""
|
||||
|
||||
@ -238,7 +310,18 @@ class TradingAgentsGraph:
|
||||
end = start + timedelta(days=holding_days + 7) # buffer for weekends/holidays
|
||||
end_str = end.strftime("%Y-%m-%d")
|
||||
|
||||
stock = yf.Ticker(ticker).history(start=trade_date, end=end_str)
|
||||
yf_symbol = _normalize_yfinance_ticker(ticker)
|
||||
if _is_unsupported_by_yfinance(yf_symbol):
|
||||
# Say why instead of leaving a silent forever-pending entry.
|
||||
logger.warning(
|
||||
"Cannot resolve outcome for %s: Yahoo Finance has no Beijing "
|
||||
"Stock Exchange coverage under any suffix, so this entry stays "
|
||||
"pending. Use a non-BSE ticker if you need memory reflection.",
|
||||
ticker,
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
stock = yf.Ticker(yf_symbol).history(start=trade_date, end=end_str)
|
||||
benchmark = yf.Ticker("000300.SS").history(start=trade_date, end=end_str)
|
||||
|
||||
if len(stock) < 2 or len(benchmark) < 2:
|
||||
|
||||
102
tradingagents/llm_clients/capabilities.py
Normal file
102
tradingagents/llm_clients/capabilities.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""Declarative capabilities for OpenAI-compatible model adapters.
|
||||
|
||||
Different OpenAI-compatible providers do not expose an identical API. In
|
||||
particular, some reasoning models accept a ``tools`` array but reject the
|
||||
``tool_choice`` value emitted by LangChain's structured-output binding. Keep
|
||||
those quirks in a small, immutable table so the client adapter does not grow
|
||||
model-name conditionals every time a provider adds a model variant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
StructuredMethod = Literal[
|
||||
"function_calling",
|
||||
"json_mode",
|
||||
"json_schema",
|
||||
"none",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelCapabilities:
|
||||
"""API features relevant to structured agent output."""
|
||||
|
||||
supports_tool_choice: bool
|
||||
supports_json_mode: bool
|
||||
supports_json_schema: bool
|
||||
preferred_structured_method: StructuredMethod
|
||||
requires_reasoning_content_roundtrip: bool = False
|
||||
supports_reasoning_split: bool = False
|
||||
|
||||
|
||||
_DEEPSEEK_THINKING = ModelCapabilities(
|
||||
supports_tool_choice=False,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
requires_reasoning_content_roundtrip=True,
|
||||
)
|
||||
|
||||
_DEEPSEEK_CHAT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
_MINIMAX_THINKING = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=False,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
supports_reasoning_split=True,
|
||||
)
|
||||
|
||||
_DEFAULT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=True,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
|
||||
_BY_ID: dict[str, ModelCapabilities] = {
|
||||
"deepseek-chat": _DEEPSEEK_CHAT,
|
||||
"deepseek-reasoner": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-flash": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-pro": _DEEPSEEK_THINKING,
|
||||
"MiniMax-M2": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.1": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.1-highspeed": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.5": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.5-highspeed": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.7": _MINIMAX_THINKING,
|
||||
"MiniMax-M2.7-highspeed": _MINIMAX_THINKING,
|
||||
}
|
||||
|
||||
_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [
|
||||
# 只匹配已实测的 V4 家族。`^deepseek-v\d` 会连 deepseek-v3* 和未来所有版本一起
|
||||
# 吃掉,把「不接受 tool_choice」这个**只在 V4/reasoner 上验证过**的结论强加给
|
||||
# 未验证的型号——结构化输出会从强制 schema 工具调用降级为可选调用,反而更容易
|
||||
# 退回自由文本。与下方 MiniMax 同一把尺子:新家族实测过再加。
|
||||
(re.compile(r"^deepseek-v4(?:$|[.-])"), _DEEPSEEK_THINKING),
|
||||
(re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING),
|
||||
# ``reasoning_split`` is an M2.x capability; do not assume it for a
|
||||
# future MiniMax family (for example M3) until that API is verified.
|
||||
(re.compile(r"^MiniMax-M2(?:$|[.-])"), _MINIMAX_THINKING),
|
||||
]
|
||||
|
||||
|
||||
def get_capabilities(model_name: str) -> ModelCapabilities:
|
||||
"""Resolve exact model IDs first, then forward-compatible patterns."""
|
||||
if model_name in _BY_ID:
|
||||
return _BY_ID[model_name]
|
||||
for pattern, capabilities in _BY_PATTERN:
|
||||
if pattern.match(model_name):
|
||||
return capabilities
|
||||
return _DEFAULT
|
||||
@ -1,6 +1,23 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
try:
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
except ImportError as exc: # pragma: no cover - depends on optional install
|
||||
# No `[google]` extra exists any more (#87): langchain-google-genai needs
|
||||
# httpx>=0.28.1 while mootdx pins httpx<0.26, so the two cannot be locked
|
||||
# together. Give the actual install command instead of a bare
|
||||
# ModuleNotFoundError that leaves the user guessing.
|
||||
raise ImportError(
|
||||
"Gemini support requires langchain-google-genai, which conflicts with "
|
||||
"mootdx's httpx pin and therefore is not installed by default (#87).\n"
|
||||
"Install it explicitly (mootdx talks TDX over TCP and does not import "
|
||||
"httpx at runtime, so bumping httpx is safe in practice):\n"
|
||||
' pip install --no-deps "langchain-google-genai>=4.0.0"\n'
|
||||
' pip install "google-genai>=1.53.0" "httpx>=0.28.1"\n'
|
||||
"Or use a separate environment for Gemini. "
|
||||
"Any other provider (OpenAI / DeepSeek / Qwen / GLM / OpenAI-compatible) "
|
||||
"works without this."
|
||||
) from exc
|
||||
|
||||
from .base_client import BaseLLMClient, normalize_content
|
||||
from .validators import validate_model
|
||||
|
||||
@ -5,6 +5,7 @@ from langchain_core.messages import AIMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from .base_client import BaseLLMClient, normalize_content
|
||||
from .capabilities import get_capabilities
|
||||
from .validators import validate_model
|
||||
|
||||
|
||||
@ -27,8 +28,16 @@ class NormalizedChatOpenAI(ChatOpenAI):
|
||||
return normalize_content(super().invoke(input, config, **kwargs))
|
||||
|
||||
def with_structured_output(self, schema, *, method=None, **kwargs):
|
||||
if method is None:
|
||||
method = "function_calling"
|
||||
capabilities = get_capabilities(self.model_name)
|
||||
if capabilities.preferred_structured_method == "none":
|
||||
raise NotImplementedError(
|
||||
f"{self.model_name} has no structured-output method available"
|
||||
)
|
||||
method = method or capabilities.preferred_structured_method
|
||||
# DeepSeek V4/reasoner accept the schema as a tool, but reject
|
||||
# LangChain's function-spec ``tool_choice`` parameter.
|
||||
if method == "function_calling" and not capabilities.supports_tool_choice:
|
||||
kwargs.setdefault("tool_choice", None)
|
||||
return super().with_structured_output(schema, method=method, **kwargs)
|
||||
|
||||
|
||||
@ -60,10 +69,9 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
||||
fails with HTTP 400. ``_create_chat_result`` captures the field on
|
||||
receive and ``_get_request_payload`` re-attaches it on send.
|
||||
|
||||
2. **deepseek-reasoner has no tool_choice.** Structured output via
|
||||
function-calling is unavailable, so we raise NotImplementedError
|
||||
and let the agent factories fall back to free-text generation
|
||||
(see ``tradingagents/agents/utils/structured.py``).
|
||||
2. **DeepSeek reasoning models reject ``tool_choice``.** Their schema is
|
||||
still bound as a tool, while the capability-aware base class suppresses
|
||||
only the incompatible request parameter.
|
||||
"""
|
||||
|
||||
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
@ -94,14 +102,20 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI):
|
||||
generation.message.additional_kwargs["reasoning_content"] = reasoning
|
||||
return chat_result
|
||||
|
||||
def with_structured_output(self, schema, *, method=None, **kwargs):
|
||||
if self.model_name == "deepseek-reasoner":
|
||||
raise NotImplementedError(
|
||||
"deepseek-reasoner does not support tool_choice; structured "
|
||||
"output is unavailable. Agent factories fall back to "
|
||||
"free-text generation automatically."
|
||||
)
|
||||
return super().with_structured_output(schema, method=method, **kwargs)
|
||||
class MinimaxChatOpenAI(NormalizedChatOpenAI):
|
||||
"""MiniMax M2.x adapter.
|
||||
|
||||
M2.x embeds reasoning in ``<think>`` blocks by default. The provider's
|
||||
``reasoning_split`` request flag keeps that internal trace out of the
|
||||
user-facing content that downstream agents store and render.
|
||||
"""
|
||||
|
||||
def _get_request_payload(self, input_, *, stop=None, **kwargs):
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
capabilities = get_capabilities(self.model_name)
|
||||
if capabilities.supports_reasoning_split:
|
||||
payload.setdefault("reasoning_split", True)
|
||||
return payload
|
||||
|
||||
# Kwargs forwarded from user config to ChatOpenAI
|
||||
_PASSTHROUGH_KWARGS = (
|
||||
@ -206,7 +220,12 @@ class OpenAIClient(BaseLLMClient):
|
||||
|
||||
# DeepSeek's thinking-mode quirks live in their own subclass so the
|
||||
# base NormalizedChatOpenAI stays free of provider-specific branches.
|
||||
chat_cls = DeepSeekChatOpenAI if self.provider == "deepseek" else NormalizedChatOpenAI
|
||||
if self.provider == "deepseek":
|
||||
chat_cls = DeepSeekChatOpenAI
|
||||
elif self.provider == "minimax":
|
||||
chat_cls = MinimaxChatOpenAI
|
||||
else:
|
||||
chat_cls = NormalizedChatOpenAI
|
||||
return chat_cls(**llm_kwargs)
|
||||
|
||||
def validate_model(self) -> bool:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user