fix: 加粗+连字符仍被判成评级(正则回溯,codex 第八轮)

v0.5.12 把 markdown 收尾星号写成 \*{0,2}(?!词字符)——先消耗再判断会回溯:
`建议:**Sell**-off risk remains elevated` 里 \*{0,2} 先吃掉 **,被 - 判否后退一步
只吃一个 *,剩下那个 * 恰好满足边界,于是又判成 Sell。正好把这条规则本来要挡的
连字符散文放了回来。

改成把星号放进前瞻内部((?!\*{0,2}词字符))——"后面不能是(0~2 个星号 + 词字符)"
没有可回溯的余地。`最终评级:**Sell**` 不受影响。

⚠️ 刻意不用占有量词 (?>...) / *+:那是 Python 3.11+ 特性,而本项目声明
requires-python = ">=3.10",3.10 用户会在导入时就 re.error——比逻辑 bug 更硬的破坏。
新增守卫用例直接检查编译后的 pattern。

覆盖矩阵扩到 25 例。测试:新增 4 例,365 passed / 13 skipped / 0 failed。→ v0.5.13
This commit is contained in:
Simon Lin
2026-08-09 15:23:53 +12:00
parent be38f469d8
commit 1edf4be27b
5 changed files with 140 additions and 4 deletions
+24
View File
@@ -6,6 +6,30 @@ 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.13] — 2026-08-09
### 修复:加粗 + 连字符仍会被判成评级(正则回溯)
v0.5.12 把 markdown 收尾星号写成 `\*{0,2}(?!词字符)` —— **先消耗再判断会回溯**:
`建议:**Sell**-off risk remains elevated` 里 `\*{0,2}` 先吃掉 `**`,被 `-` 判否后
退一步只吃一个 `*`,剩下那个 `*` 恰好满足边界,于是又判成 Sell。正好把这条规则本来
要挡的连字符散文放了回来。
改成把星号**放进前瞻内部**(`(?!\*{0,2}词字符)`)——"后面不能是(0~2 个星号 + 词
字符)"没有可回溯的余地。`最终评级:**Sell**` 这类正常写法不受影响。
⚠️ **刻意不用占有量词 `(?>...)` / `*+`**:那是 Python 3.11+ 才有的,而本项目声明
`requires-python = ">=3.10"`,3.10 用户会在**导入时**就 `re.error`——比逻辑 bug
更硬的破坏。新增一条守卫用例,直接检查编译后的 pattern 里没有这些写法。
覆盖矩阵扩到 25 例。
### 测试
365 passed / 13 skipped / **0 failed**(新增 4 例)。
---
## [0.5.12] — 2026-08-09
Codex 第七轮 + **两处重构**。这两块前后各被修了三轮"修了又漏",问题不在某个字符,
+78 -1
View File
@@ -6,7 +6,7 @@
- **仓库**: https://github.com/simonlin1212/TradingAgents-astock
- **协议**: Apache 2.0
- **Python**: >=3.10
- **当前版本**: 0.4.0
- **当前版本**: 0.5.12(2026-08-09 发布,经 codex 八轮审计)
## 架构
@@ -52,9 +52,86 @@ v0.2.5 起完全移除 akshare 依赖,所有数据通过直连 HTTP API 获取
### 东财接口防封限流(v0.2.11 新增,移植自 a-stock-data v3.2)
`a_stock.py` 里所有指向 `eastmoney.com` 的请求(push2 / push2his / datacenter-web / search-api / np-weblist 共 7 个调用点)统一走节流入口 `_em_get()`:模块级时间戳串行限流(默认间隔 `EM_MIN_INTERVAL=1.0s`,可用同名环境变量覆盖)+ 0.1~0.5s 随机抖动 + 复用 `requests.Session`(Keep-Alive)+ 默认 UA。多 Agent 跑批量分析不再触发东财临时封 IP。**仅东财限流**——mootdx(TCP) / 腾讯 / 新浪 / 同花顺 / 财联社 / 百度 等非东财源不受影响。批量场景可设 `EM_MIN_INTERVAL=1.5~2` 进一步降速。新增东财端点时务必走 `_em_get` 而非裸 `requests.get`。
### 未来函数防护(v0.5.1 新增,改数据层必读)
历史日期上跑分析时,数据层**不得**把"今天"的数据当成分析日当天的事实——报告里完全
看不出来,属于静默失败。`a_stock.py` 提供 `_is_historical(curr_date)` 与
`_snapshot_notice()` 两个共用工具:能做时点截断的就截断(如 `get_fund_flow` 按
curr_date 过滤历史行、复盘时整段不取实时分钟数据);数据源根本没有历史时点值的
(腾讯实时估值、同花顺当前一致预期)就在正文顶部**明确告警**并指示模型不得当作当天
事实。**新增任何收 `curr_date` 的接口,必须处理这两种情况之一,不能收了不用。**
### 非 A 股代码防护(v0.5.3 新增,v0.5.5 补全)
`_normalize_ticker()` 会拒绝港股(4~5 位数字 / `.HK`)与美股代码。**新增任何 vendor
方法都必须走它,不要直接调 `safe_ticker_component`**——后者只做路径安全校验,不认市场。
mootdx/腾讯/东财对不存在的代码常返回空值或僵尸报价而非报错,绕过就等于让模型拿别的
市场的数据写报告。
⚠️ v0.5.3 曾声称"一个卡点覆盖 15 个接口",**实际漏了 3 个**(龙虎榜/解禁/行业对比直接
调了 `safe_ticker_component`),拿 `00700` 调用会返回"近30日未上龙虎榜"这种看起来完全
正常的报告。v0.5.5 补全。**改完这类防护要枚举所有 vendor 逐个实测,别从设计推断覆盖面。**
### 截断告警要覆盖每种 provider 形状(v0.5.5)
`warn_if_truncated` 必须认全四种:Anthropic `stop_reason=max_tokens`、OpenAI 兼容
`finish_reason=length`、Gemini `finish_reason=MAX_TOKENS`(大写)、**OpenAI Responses
API `status=incomplete` + `incomplete_details.reason=max_output_tokens`**。最后一种最
要紧——`openai` 是默认 provider 且走 Responses API,v0.5.1 漏掉它导致这个告警在默认
配置下一次都不会触发。新增 provider 时必须补对应形状 + 一个会触发的样本测试。
### 分角色模型 role_llms(v0.5.0 新增)
可选,默认空表=完全维持 quick/deep 两档原行为。合法角色名在 `graph/setup.py` 的
`ROLE_KEYS`;`GraphSetup.llm_for(role)` 是取模型的唯一入口。**新增 agent 节点时用
`self.llm_for("<角色名>")`,别直接引用 `self.quick_thinking_llm`**,否则该角色无法被
单独配置。
### 决策绩效统计(v0.5.2 新增)
`tradingagents performance` 读记忆日志里已结算的决策算指标,零 LLM 调用。
**改 `TradingMemoryLog` 的标签格式会直接打断它**(依赖 `[日期 | 代码 | 评级 | raw |
alpha | holding]` 结构,且 holding 带 `d` 后缀)。
⚠️ **指标命名即口径**:`direction_accuracy`(方向正确率)是唯一衡量判断准不准的——
看多要跑赢、看空要跑输才算对,Hold 不计入。`up_rate` / `outperform_rate` 只描述标的
怎么走,**与判断对错无关**。v0.5.2 曾把前者叫"胜率",导致给 Sell 之后股价下跌(判断
正确)被记成失败,v0.5.5 改正。
### 模型兼容性
deepseek-v4-flash 等模型在 tool call 时可能返回中文股票名而非 6 位代码。`safe_ticker_component` 已加兜底自动转码,但不同模型表现仍有差异。
### 测试
**干净 clone(`pip install -e .` 不带 `[agentsdk]`)跑 `pytest tests/` 应当是
361 passed / 13 skipped / **0 failed**。出现 failed 就是真回归。**
需要可选依赖的用例用 `requires_sdk` 标记跳过——⚠️ **占位类型绝不要用 `Exception`
基类**:`ClaudeSDKError` 曾被占位成 `Exception`,进 `_FALLBACK_ERRORS` 后让"订阅凭据
失效不得降级到计费 provider"这条护栏彻底失效(v0.5.4 修)。
### CLI 必须保住裸跑(v0.5.9 血的教训)
`cli/main.py` 里的 `@app.callback(invoke_without_command=True)` **不能删**。Typer 只
注册一个命令时是"单命令模式",裸跑 `tradingagents` 等于跑那个命令;一旦注册第二个
子命令就切换成"命令组模式",裸跑直接报 `Missing command` 退出——而 README 和所有文档
写的都是裸跑,等于**每个现有用户升级后第一条命令就失败**。加子命令时务必先跑
`tests/test_cli_default_command.py`。
### 评级边界规则:改之前先跑整张矩阵(v0.5.12)
`rating.py` 里「中文标签 + 英文评级词」的边界判据是
**「后面不能延续成更长的词」**(`(?![A-Za-z0-9_-])`),不是"枚举允许的标点"。
这条规则前后被修了三轮才收敛——每轮都是往白名单加字符、每轮都漏:
无边界→`Buyer interest`判 Buy;`(?![A-Za-z])`→`Sell-off`判 Sell;枚举收尾标点
→`Buy(基于风险收益比)`**反被判成 Hold**。
⚠️ **动这条规则必须整张跑 `test_rating_value_boundary_matrix`(22 例)**,
只补自己想到的一两个用例正是前三轮反复的成因。误判会静默改写决策评级,
一路污染记忆日志与绩效统计。
### 探测 mootdx 不能覆写用户配置(v0.5.10,v0.5.12 重构)
`StdQuotes.__init__` 里有 `config.set('BESTIP', {'HQ': self.server})`——**每建一次带
`server` 的 client 都会持久化写进 mootdx 配置文件**。逐台探测会把用户原本配好的服务器
一路覆写,最后留下一台死的,还会连累同机其它用 mootdx 的程序。
用 `with _preserve_mootdx_bestip() as keep:` 包住探测:选出可用服务器时调 `keep()`
表示"这次覆写是想要的",其余每条退出路径(**含异常**)自动还原。
⚠️ **别改回手动调还原函数**——那样再加一条提前返回就会漏掉一处,而漏掉的后果是
静默给用户留下一台死服务器。另外快照**必须在 `config.setup()` 之后**取,否则拿到的是
模块默认空值,"还原"反而把用户真实配置抹成空。
### 待处理 PR
- PR #18(hejingchi):start_date 功能 + 主题切换 + Windows 字体。不建议直接 merge(与 v0.2.6 冲突),start_date 功能值得后续自行实现。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents-astock"
version = "0.5.12"
version = "0.5.13"
description = "A股多Agent投研框架 — 基于 TradingAgents 深度特化"
readme = "README.md"
requires-python = ">=3.10"
+31
View File
@@ -224,6 +224,11 @@ class TestParseRatingWordBoundary:
("最终评级:Buy-side interest is weak", "Hold"),
("最终评级:Buy2024", "Hold"), # 数字后缀
("最终评级:Buy_target", "Hold"), # 下划线
# 加粗 + 连字符:`\*{0,2}` 先消耗再判断会回溯——吃掉 `**` 被 `-` 判否后
# 退一步只吃一个 `*`,剩下的 `*` 恰好满足边界,于是又判成 Sell(第八轮)
("建议:**Sell**-off risk remains elevated", "Hold"),
("最终评级:**Buy**-side interest is weak", "Hold"),
("最终评级:**Buy**2024", "Hold"),
# —— 中文评级词不受影响 ——
("最终评级:买入", "Buy"),
("最终评级:卖出", "Sell"),
@@ -232,3 +237,29 @@ class TestParseRatingWordBoundary:
)
def test_rating_value_boundary_matrix(self, text, expected):
assert parse_rating(text) == expected
@pytest.mark.unit
def test_rating_regex_stays_python310_compatible():
"""评级正则不能用 Python 3.11+ 才有的原子组 / 占有量词。
pyproject 声明 `requires-python = ">=3.10"`,用 `(?>...)` 或 `*+` 会让 3.10
用户在**导入时**就 re.error——比逻辑 bug 更硬的破坏。
"""
import re
from tradingagents.agents.utils import rating
# 直接检查真正编译出来的 pattern,而不是源码文本——注释里提到这些写法是正常的
patterns = [
v.pattern for v in vars(rating).values() if isinstance(v, re.Pattern)
] + [
v for k, v in vars(rating).items()
if isinstance(v, str) and k.isupper() and k.endswith(("_RE", "_END", "_PREFIX", "_CONTINUATION"))
]
assert patterns, "没取到任何正则,用例失去意义"
for pat in patterns:
assert "(?>" not in pat, f"原子组是 Python 3.11+ 特性:{pat}"
for possessive in ("*+", "++", "?+", "}+"):
assert possessive not in pat, f"占有量词 {possessive} 是 Python 3.11+ 特性:{pat}"
+6 -2
View File
@@ -69,8 +69,12 @@ _CN_LABEL_RE = re.compile(_CN_LABEL_PREFIX + r"(" + _CN_ALT + r")")
# 现在只问一件事:紧跟其后的字符会不会让它变成另一个词?会就不算评级。
# 中文、各种括号、标点、空白、行尾一律放行。
_WORD_CONTINUATION = r"[A-Za-z0-9_\-]"
# 先吃掉 markdown 的收尾星号(`最终评级:**Sell**`)
_RATING_VALUE_END = r"\*{0,2}(?!" + _WORD_CONTINUATION + r")"
# ⚠️ markdown 的收尾星号必须**放进前瞻内部**,不能写成 `\*{0,2}(?!...)` 先消耗再判断:
# 那样正则会回溯——`建议:**Sell**-off risk` 里 `\*{0,2}` 先吃掉 `**` 被 `-` 判否,
# 退一步只吃一个 `*`,剩下的 `*` 恰好满足边界,于是又判成 Sell。
# 写成"后面不能是(0~2 个星号 + 词字符)"就没有可回溯的余地。
# (不用占有量词 `(?>...)`:那是 Python 3.11+ 才有的,本项目声明支持 3.10。)
_RATING_VALUE_END = r"(?!\*{0,2}" + _WORD_CONTINUATION + r")"
_CN_LABEL_EN_RE = re.compile(
_CN_LABEL_PREFIX + r"(" + "|".join(RATINGS_5_TIER) + r")" + _RATING_VALUE_END,