From eaf3876b28c8dc0ea1bb3781906efce5e24011ae Mon Sep 17 00:00:00 2001 From: Simon Lin Date: Sun, 9 Aug 2026 14:01:55 +1200 Subject: [PATCH] =?UTF-8?q?fix:=20codex=20=E5=A4=8D=E5=AE=A1=E4=BA=94?= =?UTF-8?q?=E5=A4=84=EF=BC=88=E5=91=8A=E8=AD=A6=E6=AD=BB=E4=BB=A3=E7=A0=81?= =?UTF-8?q?/=E8=B5=84=E9=87=91=E6=B5=81=E8=A2=AB=E6=BB=A4=E7=A9=BA/?= =?UTF-8?q?=E5=8F=AA=E8=AF=9510=E5=8F=B0/=E5=8F=82=E6=95=B0=E4=B8=B2?= =?UTF-8?q?=E5=91=B3/=E9=99=8D=E7=BA=A7=E4=B8=A2=E4=B8=8A=E9=99=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审的价值这轮很明显——其中两条正是上一版修复自己留下的洞。 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 --- CHANGELOG.md | 56 +++++++++++++ pyproject.toml | 2 +- tests/test_lookahead_guard.py | 62 +++++++++++++++ tests/test_mootdx_server_selection.py | 36 +++++---- tests/test_role_llms.py | 36 +++++++++ .../agents/utils/signal_data_tools.py | 9 ++- tradingagents/dataflows/a_stock.py | 78 ++++++++++++++++--- tradingagents/graph/trading_graph.py | 24 +++++- 8 files changed, 272 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e912a..ddef1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 审计后的五处修复。全部实测复现,无一误报。 diff --git a/pyproject.toml b/pyproject.toml index 80b6127..78f3dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_lookahead_guard.py b/tests/test_lookahead_guard.py index 2c70122..6232a80 100644 --- a/tests/test_lookahead_guard.py +++ b/tests/test_lookahead_guard.py @@ -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 diff --git a/tests/test_mootdx_server_selection.py b/tests/test_mootdx_server_selection.py index 891b6c5..61ade77 100644 --- a/tests/test_mootdx_server_selection.py +++ b/tests/test_mootdx_server_selection.py @@ -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 diff --git a/tests/test_role_llms.py b/tests/test_role_llms.py index 48c91a0..003c657 100644 --- a/tests/test_role_llms.py +++ b/tests/test_role_llms.py @@ -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", "同一家应保留专属参数" diff --git a/tradingagents/agents/utils/signal_data_tools.py b/tradingagents/agents/utils/signal_data_tools.py index ec4842a..0b4b2a6 100644 --- a/tradingagents/agents/utils/signal_data_tools.py +++ b/tradingagents/agents/utils/signal_data_tools.py @@ -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 diff --git a/tradingagents/dataflows/a_stock.py b/tradingagents/dataflows/a_stock.py index 4b66155..938cdf3 100644 --- a/tradingagents/dataflows/a_stock.py +++ b/tradingagents/dataflows/a_stock.py @@ -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" diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 79db4b7..60b80a7 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -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]