fix: codex 复审五处(告警死代码/资金流被滤空/只试10台/参数串味/降级丢上限)

复审的价值这轮很明显——其中两条正是上一版修复自己留下的洞。

1. 未来函数告警是死代码
   v0.5.1 给 get_profit_forecast 加了告警,但它的 @tool 只暴露 ticker、
   route_to_vendor 也只传 ticker,curr_date 恒为 None → 判定永远 False,
   告警一次都不会触发。审计了全部 17 个工具,只有这一个漏传。

2. 历史资金流被过滤成一条不剩
   东财接口只给"从今天回溯 N 天"、无 end_date。固定要 20 天再按 curr_date 过滤,
   复盘较早日期时返回的 20 行全在分析日之后,滤完一行不剩——把"数据不对"变成
   "没有数据"。而当时的测试夹具返回了真实请求不会包含的旧数据,把问题盖住了。
   现按间隔放大窗口(上限 500),取不到时明说原因。

3. mootdx 只试 10 台就判"全网不可达"
   v0.5.5 为规避 bestip 全表测速而跳过它,导致 mootdx 自带表其余 28 台永远试不到。
   候选表扩为 38 台(精选在前)逐台真实验证;TCP 预筛改并发(只并行化等 IO,
   不改选取顺序)。实测 73.7s → 27.0s,覆盖面反而从 10 台升到 38 台。

4. 角色模型串味
   role_llms 用别家 provider 时仍复用主 provider 的 llm_kwargs,
   reasoning_effort/thinking_level/effort 是各家私有参数,别家可能直接拒收。
   现按角色 provider 过滤,通用参数保留。

5. 订阅降级后 max_tokens 丢失
   max_tokens 只进 llm_kwargs 没进 fallback_spec,撞额度降级后报告照样被截断。

测试:新增 6 例,322 passed / 13 skipped / 0 failed
This commit is contained in:
Simon Lin
2026-08-09 14:01:55 +12:00
parent 383a38dc31
commit eaf3876b28
8 changed files with 272 additions and 31 deletions
+56
View File
@@ -6,6 +6,62 @@ 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.5.6] — 2026-08-09
Codex **复审**发现的五处,全部实测复现。复审的价值在这一轮体现得很清楚——其中两条
正是上一版修复自己留下的洞。
### 修复:未来函数告警在生产路径上是死代码
v0.5.1 给 `get_profit_forecast` 加了未来函数告警,但它的 LangChain `@tool` 只暴露
`ticker`,`route_to_vendor` 也只传 `ticker` —— `curr_date` 恒为 `None`,判定永远为
False,**告警一次都不会触发**。模型照样把今天的一致预期当成分析日当天的事实。
(审计发现只有这一个工具漏传,其余 16 个都正常。)
现已补上参数与转发,并加测试锁死"凡是数据层按 `curr_date` 做时点处理的工具,
`@tool` 都必须暴露并转发它"。
### 修复:历史资金流被过滤成一条不剩
东财日线资金流接口只提供"从今天回溯 N 个交易日",没有 end_date。v0.5.1 仍固定要
20 天再按 `curr_date` 过滤 —— 复盘一个较早的日期时,返回的 20 行全在分析日之后,
**过滤后一行不剩**。把"数据不对"变成了"没有数据",比不过滤更糟;而当时的测试夹具
恰好返回了一条真实请求不会包含的旧数据,把这个问题盖住了。
现按分析日与今天的间隔放大回溯窗口(上限 500,约两年),并在确实取不到时**明说原因**,
不让正文凭空少一段。
### 修复:mootdx 只试 10 台就判"全网不可达"
v0.5.5 为了规避 bestip 的全表测速而跳过了它,但这样一来 mootdx 自带主机表里的其余
28 台就永远试不到 —— 精选的 10 台恰好都不可用时会被误判成协议被拦,还记 5 分钟负缓存。
- **候选表扩为 38 台**(精选 10 台在前 + mootdx 自带主机表去重),逐台真实取数验证,
覆盖面等同 bestip 而不做它那套要跑几分钟的测速。
- **TCP 预筛改为并发**:多数候选是"连都连不上",串行要等满每个超时。并发只是把等 IO
并行化,不改变选取顺序,精选表依旧优先。
- 实测全表不可用时:**73.7s → 27.0s**(v0.5.5 是 23.7s 但只覆盖 10 台;最初 >170s)。
二次调用命中负缓存 0ms。
### 修复:角色模型串味 —— 主 provider 的专属参数被带给别家
`role_llms` 给某个角色配了别家 provider 时,仍复用主 provider 的 `llm_kwargs`。
`reasoning_effort`(openai)/ `thinking_level`(google)/ `effort`(anthropic)都是
各家私有参数,塞进 qwen / glm / 自建网关的请求体里可能被直接拒收。现按角色 provider
过滤,通用参数(`max_tokens` / `callbacks`)保留。
### 修复:订阅降级后 `max_tokens` 丢失
开着 Claude 订阅覆盖时,`max_tokens` 只进了 `llm_kwargs`,没进 `fallback_spec`。
撞额度降级到付费 provider 后,那边用自己的默认上限——报告照样被截断,而这正是用户
配 `max_tokens` 想避免的事。
### 测试
322 passed / 13 skipped / **0 failed**(新增 6 例)。
---
## [0.5.5] — 2026-08-09
Codex 审计后的五处修复。全部实测复现,无一误报。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents-astock"
version = "0.5.5"
version = "0.5.6"
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
readme = "README.md"
requires-python = ">=3.10"
+62
View File
@@ -150,3 +150,65 @@ def test_profit_forecast_warns_on_historical_date(monkeypatch):
out = a_stock.get_profit_forecast("600519", PAST)
assert "未来函数警告" in out
# ---------------------------------------------------------------------------
# codex 复审补:防护要在**生产调用路径**上真的生效
# ---------------------------------------------------------------------------
def test_profit_forecast_tool_exposes_curr_date():
"""工具不把 curr_date 传下去,数据层的未来函数告警就是死代码。
v0.5.1 给 get_profit_forecast 加了告警,但 @tool 只暴露 ticker,
curr_date 恒为 None → 告警永远不触发,模型照样把今天的一致预期当历史事实。
"""
from tradingagents.agents.utils.agent_utils import get_profit_forecast
assert "curr_date" in get_profit_forecast.args
def test_every_date_aware_tool_forwards_its_date():
"""凡是数据层按 curr_date 做时点处理的工具,@tool 都必须暴露并转发它。"""
import inspect
from tradingagents.agents.utils import signal_data_tools
src = inspect.getsource(signal_data_tools)
for name in ("get_profit_forecast", "get_fund_flow"):
call = f'route_to_vendor("{name}", '
idx = src.find(call)
assert idx != -1, f"找不到 {name} 的路由调用"
line = src[idx:src.find(")", idx)]
assert "curr_date" in line, f"{name} 没有把 curr_date 转发给数据层"
def test_fund_flow_widens_window_for_older_dates(fake_em, monkeypatch):
"""分析日早于最近 20 个交易日时,必须放大回溯窗口。
接口只提供"从今天回溯 lmt 天",仍只要 20 天的话过滤后一行不剩——
把"数据不对"变成"没有数据",比不过滤更糟(codex P2)。
"""
captured = {}
orig = a_stock._em_get
def spy(url, params=None, timeout=10):
if "push2his" in url:
captured["lmt"] = params.get("lmt")
return orig(url, params=params, timeout=timeout)
monkeypatch.setattr(a_stock, "_em_get", spy)
a_stock.get_fund_flow("600519", PAST) # PAST = 90 天前
assert captured["lmt"] > 20, f"回溯窗口没有放大,仍是 {captured.get('lmt')}"
def test_fund_flow_says_when_history_is_unavailable(monkeypatch):
"""过滤后为空要说明原因,不能让正文凭空少一段。"""
def empty_hist(url, params=None, timeout=10):
return FakeResp({"data": {"klines": []}})
monkeypatch.setattr(a_stock, "_em_get", empty_hist)
out = a_stock.get_fund_flow("600519", PAST)
assert "未能取到" in out
+19 -17
View File
@@ -166,6 +166,8 @@ def handshake_fails(monkeypatch):
state = {"factory_calls": [], "bestip_used": False}
monkeypatch.setattr(a_stock, "_TDX_SERVERS", servers)
# 候选表 = 精选表 + mootdx 自带主机表;测试里只保留精选表,断言才可控
monkeypatch.setattr(a_stock, "_candidate_tdx_servers", lambda: list(servers))
monkeypatch.setattr(a_stock, "_mootdx_client", None)
monkeypatch.setattr(a_stock, "_mootdx_unavailable_until", 0.0)
monkeypatch.setattr(a_stock, "_probe_tdx", lambda ip, port, timeout=2.0: True)
@@ -233,25 +235,25 @@ def test_bestip_skipped_when_protocol_is_the_problem(handshake_fails):
assert handshake_fails["bestip_used"] is False
def test_bestip_still_used_when_nothing_is_reachable(monkeypatch):
"""反过来:内置表整体 TCP 都不通(IP 老化)时,bestip 仍要跑——那正是它的用途。"""
import mootdx.quotes
def test_candidate_list_includes_mootdx_own_hosts():
"""候选表必须覆盖 mootdx 自带主机表,而不只是精选的那 10 台。
monkeypatch.setattr(a_stock, "_TDX_SERVERS", [("10.0.0.1", 7709)])
monkeypatch.setattr(a_stock, "_mootdx_client", None)
monkeypatch.setattr(a_stock, "_mootdx_unavailable_until", 0.0)
monkeypatch.setattr(a_stock, "_probe_tdx", lambda ip, port, timeout=2.0: False)
used = {"bestip": False}
只试精选表的话,它们恰好都不可用时会被判成"全网不可达"并记 5 分钟负缓存,
而 mootdx 自带表里可能还有活着的主机(codex 复审指出)。
"""
candidates = a_stock._candidate_tdx_servers()
class FakeQuotes:
@staticmethod
def factory(market="std", server=None, **kwargs):
if kwargs.get("bestip"):
used["bestip"] = True
raise ConnectionResetError("nope")
assert len(candidates) > len(a_stock._TDX_SERVERS)
assert candidates[:len(a_stock._TDX_SERVERS)] == list(a_stock._TDX_SERVERS), (
"实测精选的服务器应排在前面,让常见情况第一台就命中"
)
assert len(candidates) == len(set(candidates)), "候选表不该有重复"
monkeypatch.setattr(mootdx.quotes, "Quotes", FakeQuotes)
with pytest.raises(RuntimeError, match="没有一台"):
def test_bestip_is_never_used(handshake_fails):
"""不再用 bestip:它要把整张表测速一遍(实测几分钟),而候选表已逐台验证过,
覆盖面相当且每台都是"真取到数才算通过"。"""
with pytest.raises(RuntimeError):
a_stock._get_mootdx_client()
assert used["bestip"] is True
assert handshake_fails["bestip_used"] is False
+36
View File
@@ -229,3 +229,39 @@ def test_warns_when_bypassing_subscription(monkeypatch, caplog):
build(cfg, monkeypatch, subscription_on=True)
assert any("bear" in r.getMessage() and "计费" in r.getMessage() for r in caplog.records)
# ---------------------------------------------------------------------------
# codex 复审补的两条
# ---------------------------------------------------------------------------
def test_provider_specific_kwargs_not_leaked_to_other_vendors(monkeypatch):
"""openai 的 reasoning_effort 不能带给 qwen —— 别家可能直接拒收这个参数。"""
from tradingagents.graph import trading_graph as tg
created = []
class FakeClient:
def __init__(self, **kw): pass
def get_llm(self): return FakeLLM("x")
def fake_create(provider, model, base_url=None, **kwargs):
created.append((provider, kwargs))
return FakeClient()
monkeypatch.setattr(tg, "create_llm_client", fake_create)
graph = tg.TradingAgentsGraph.__new__(tg.TradingAgentsGraph)
graph.config = {
"llm_provider": "openai",
"role_llms": {
"bull": {"provider": "qwen", "model": "qwen-plus"},
"bear": {"model": "gpt-5.4-mini"}, # 同一家,应保留
},
}
graph._build_role_llms({"reasoning_effort": "high", "max_tokens": 8000}, False)
by_provider = {p: kw for p, kw in created}
assert "reasoning_effort" not in by_provider["qwen"], "别家 provider 收到了 openai 专属参数"
assert by_provider["qwen"]["max_tokens"] == 8000, "通用参数不该被一起过滤掉"
assert by_provider["openai"]["reasoning_effort"] == "high", "同一家应保留专属参数"
@@ -6,17 +6,24 @@ from tradingagents.dataflows.interface import route_to_vendor
@tool
def get_profit_forecast(
ticker: Annotated[str, "A-stock code (e.g. 688017)"],
curr_date: Annotated[str, "Analysis date in YYYY-MM-DD format"] = "",
) -> 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.
⚠️ curr_date 必须传:一致预期只有"当前"版本,没有历史时点值。数据层靠这个
参数判断是不是在复盘历史,并在正文顶部加未来函数告警。不传的话告警永远不会
触发,模型会把今天的预测当成分析日当天的事实(v0.5.5 修)。
Args:
ticker (str): A-stock code
curr_date (str): Analysis date, used to flag look-ahead when historical
Returns:
str: Consensus forecast report with valuation metrics
"""
return route_to_vendor("get_profit_forecast", ticker)
return route_to_vendor("get_profit_forecast", ticker, curr_date)
@tool
+67 -11
View File
@@ -255,6 +255,43 @@ _mootdx_unavailable_until = 0.0
# 大头是 bestip 全表测速,那个已经单独规避了。
def _candidate_tdx_servers() -> list[tuple[str, int]]:
"""待试的通达信服务器:先用实测精选的 `_TDX_SERVERS`,再补 mootdx 自带的完整主机表。
只试精选的那 10 台是不够的——它们要是恰好都不可用,而 mootdx 自带表里还有活着的
主机,就会被判成"全网不可达"并记 5 分钟负缓存。这里把两张表合起来去重后逐台验证,
覆盖面等同 `bestip`,但不做它那套要跑几分钟的全表测速。
"""
servers = list(_TDX_SERVERS)
seen = set(servers)
try:
from mootdx.consts import HQ_HOSTS
for entry in HQ_HOSTS:
# 形如 ("深圳双线主站1", "110.41.147.114", 7709)
host = (entry[1], entry[2]) if len(entry) >= 3 else None
if host and host not in seen:
seen.add(host)
servers.append(host)
except Exception as e: # mootdx 版本变动导致取不到就只用精选表,不影响主流程
logger.debug("读取 mootdx HQ_HOSTS 失败,仅使用内置精选表:%s", e)
return servers
def _reachable_tdx_servers(servers, timeout: float = 2.0):
"""并发做 TCP 预筛,返回可连的那些(保持原顺序)。
只是把"等超时"这件事并行化,不改变优先级:返回顺序仍是候选表顺序,所以实测
精选的服务器依旧排在前面、依旧第一个被真实验证。
"""
from concurrent.futures import ThreadPoolExecutor
if not servers:
return []
with ThreadPoolExecutor(max_workers=min(16, len(servers))) as pool:
flags = list(pool.map(lambda s: _probe_tdx(s[0], s[1], timeout), servers))
return [srv for srv, ok in zip(servers, flags) if ok]
def _probe_tdx(ip: str, port: int, timeout: float = 2.0) -> bool:
"""TCP 握手探测通达信服务器端口是否开着。
@@ -313,10 +350,13 @@ def _get_mootdx_client():
from mootdx.quotes import Quotes
# TCP 预筛并发跑:38 台里多数是"连都连不上",串行每台要等满超时(实测整轮
# 73.7s,首次调用像卡死)。预筛纯粹是等 IO,并发不改变选取语义——下面仍按
# 原顺序、逐台做真实取数验证,精选表依旧优先。
reachable = _reachable_tdx_servers(_candidate_tdx_servers())
tcp_ok_but_dead = 0
for ip, port in _TDX_SERVERS:
if not _probe_tdx(ip, port):
continue
for ip, port in reachable:
# 「TCP 通但通达信协议不通」有两种表现:factory 建连时握手就被拒,
# 或者建出来了但取不到数。**两种都要算**——只统计后者的话,计数永远是 0
# (实测这批服务器全是在 factory 里抛 ConnectionReset),下面的快速失败
@@ -335,12 +375,11 @@ def _get_mootdx_client():
logger.debug("mootdx %s:%s 建连成功但取不到数,换下一台", ip, port)
# fallback。bestip 会把 mootdx 内置主机表整个测速一遍,实测要几分钟,所以
# **只在内置表整体连不上时才值得跑**——那才是它要解决的问题(IP 老化)。
# 如果 TCP 明明连得上、只是通达信协议被拒,bestip 用的是同一套协议、同一批
# 主机,不可能有别的结果,跑它纯粹是让用户白等几分钟(#90)。
fallbacks = [{}] if tcp_ok_but_dead else [{"bestip": True}, {}]
for kwargs in fallbacks:
# 最后再试一次裸 factory(老用户的 mootdx config 里可能已存了可用 IP)。
# ⚠️ 刻意**不用** `bestip=True`:它会把整张主机表做一遍测速,实测要几分钟。
# 上面 `_candidate_tdx_servers()` 已经把 mootdx 自带的完整主机表逐台验证过了,
# 覆盖面不比 bestip 差,而且每台都是"真取到数才算通过"。
for kwargs in ({},):
try:
candidate = Quotes.factory(market="std", **kwargs)
except Exception as e:
@@ -2019,8 +2058,18 @@ def get_fund_flow(
"https://push2his.eastmoney.com"
"/api/qt/stock/fflow/daykline/get"
)
# 接口返回的是"从今天回溯 lmt 个交易日",没有 end_date 参数。复盘一个
# 较早的日期时,若仍只要 20 天,过滤后会**一行不剩**——把"数据不对"
# 变成"没有数据",比不过滤更糟。按分析日与今天的间隔把窗口放大到能
# 覆盖到那一段(上限 500,够回溯约两年)。
hist_limit = 20
if historical:
gap_days = (datetime.now() - datetime.strptime(
str(curr_date)[:10], "%Y-%m-%d")).days
# 日历日 → 交易日约 ×0.7,再多留 20 天余量
hist_limit = min(500, 20 + int(gap_days * 0.7) + 20)
params_hist = {
"secid": secid, "lmt": 20, "klt": 101,
"secid": secid, "lmt": hist_limit, "klt": 101,
"fields1": "f1,f2,f3,f7",
"fields2": "f51,f52,f53,f54,f55,f56,f57",
}
@@ -2036,7 +2085,14 @@ def get_fund_flow(
k for k in hist_klines if k.split(",")[0][:10] <= cutoff
]
if hist_klines:
if historical and not hist_klines:
# 说清楚是"这个日期取不到",而不是让正文里凭空少一段
lines.append(
f"\n## Historical Daily Fund Flow\n"
f"({str(curr_date)[:10]} 及之前的资金流未能取到:该接口只提供"
f"从今天回溯的窗口,分析日过早时可能已超出可回溯范围。)"
)
elif hist_klines:
lines.append(
f"\n## Historical Daily Fund Flow "
f"(last {len(hist_klines)} trading days"
+23 -1
View File
@@ -54,6 +54,13 @@ from .propagation import Propagator
from .reflection import Reflector
from .signal_processing import SignalProcessor
# 各家 provider 私有的参数:换了 provider 就不能带过去(别家可能直接拒收)。
_PROVIDER_SPECIFIC_KWARGS = frozenset({
"reasoning_effort", # openai
"thinking_level", # google
"effort", # anthropic
})
def _normalize_yfinance_ticker(ticker: str) -> str:
"""Return the Yahoo Finance symbol for a ticker used by the graph.
@@ -221,6 +228,11 @@ class TradingAgentsGraph:
# 带上 callbacks:降级意味着**开始计费**,此时统计/成本回调
# 反而看不到这些调用的话,恰好在花钱的时候统计是瞎的。
**({"callbacks": self.callbacks} if self.callbacks else {}),
# 用户显式配的输出上限也要带过去。否则撞额度降级之后,降级
# provider 用它自己的默认上限,报告照样被截断——而这正是
# 用户配 max_tokens 想避免的事(#91)。
**({"max_tokens": self.config["max_tokens"]}
if self.config.get("max_tokens") else {}),
}
return create_llm_client(
provider="claude_agent_sdk",
@@ -327,13 +339,23 @@ class TradingAgentsGraph:
else:
base_url = None
# 主 provider 的**专属**参数不能带给另一家:openai 的
# `reasoning_effort`、google 的 `thinking_level`、anthropic 的 `effort`
# 都是各家私有的,塞进 qwen / glm / 自建网关的请求体里可能直接被拒。
# 通用参数(max_tokens / callbacks 等)保留。
role_kwargs = (
llm_kwargs if provider.lower() == str(main_provider).lower()
else {k: v for k, v in llm_kwargs.items()
if k not in _PROVIDER_SPECIFIC_KWARGS}
)
key = (provider.lower(), spec["model"], base_url)
if key not in cache:
cache[key] = create_llm_client(
provider=provider,
model=spec["model"],
base_url=base_url,
**llm_kwargs,
**role_kwargs,
).get_llm()
resolved[role] = cache[key]