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:
Simon Lin 2026-07-31 11:09:42 +12:00
commit d082b94eec
12 changed files with 502 additions and 37 deletions

View File

@ -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
明确项目定位为「框架的工程实现与研究复现」,并**移除可执行价位相关能力**。**有破坏性变更**(见下)。

View File

@ -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 已移除**#87uv 构建覆盖所有 extra 的 universal lockextra 存在就让**所有人**的 `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 获取。

View File

@ -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。

View File

@ -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
View 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

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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:

View 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

View File

@ -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

View File

@ -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: