release: v1.16.3 — 修复 market-stat 全市场涨跌统计家数偏小10倍

通达信统计指数 880005/880006 的计数类字段返回真实家数的 1/10,
get_market_stat 未做缩放还原,导致 up/down/total/limit_up 等系统性偏小 10 倍。
对 6 个计数字段统一 round(field * 10),成交额/量/市值不动。
重写 test_get_market_stat_mapping 覆盖还原逻辑。761 单测全绿。
This commit is contained in:
Justin Gu
2026-07-02 12:46:27 +08:00
parent 2067b80958
commit 133c208510
4 changed files with 77 additions and 58 deletions
+9
View File
@@ -2,6 +2,15 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.16.3] — 2026-07-02
### 修复
- **`market-stat` 全市场涨跌统计家数系统性偏小 10 倍**(`client.py`,同步 + 异步 `get_market_stat()`)— 实测 `easy-tdx market-stat` 返回 `up_count=322 / down_count=214 / total_count=553 / limit_up_count=13 / limit_down_count=0`,量级明显不符全 A 股(5000+ 只)。根因:通达信"统计指数"`880005`(涨跌统计)/ `880006`(涨跌停统计)的计数类字段返回的是**真实家数的 1/10**,旧实现直接 `int(q.price)` 当家数用,未做缩放还原。
- 修复:对 6 个计数字段(涨 / 跌 / 平 / 总数 / 涨停 / 跌停)统一 `round(field * 10)` 还原;`total_amount` / `total_volume` / `total_market_cap` 不受此协议缩放影响,保持原样透传。
- 验证:实抓 `up=3225 / down=2148 / neutral=144 / total=5530``3225+2148+144+13(suspended)=5530` 计数守恒;`limit_up=131 / limit_down=6` 量级回归正常。同步 + 异步路径一致修复。
- 重写 `test_get_market_stat_mapping`:用真实协议值(还原前家数 / 10)构造 mock,断言 ×10 还原后的真实家数,并补齐此前未覆盖的 `limit_up_count` / `limit_down_count` / `suspended_count` / `total_amount` / `total_volume` / `total_market_cap` 断言。
## [1.16.2] — 2026-07-02
**质量加固版本** —— 经三轮代码审计(B 6.9 → A 7.6 → A 7.9)后的综合修复,覆盖协议核心层、数据正确性、错误处理、测试真实度与可维护性。**761 单测全绿**(+58),`ruff check` / `ruff format --check` / `mypy strict` 全部通过,CI 加 Windows 矩阵 + trusted publishing + 签名,达到稳定 PyPI 库发布质量。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.16.2"
version = "1.16.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+22 -20
View File
@@ -676,11 +676,11 @@ class TdxClient:
return _to_df(records)
def get_market_stat(self) -> pd.DataFrame:
"""获取 A 股全市场涨跌统计概况(基于 880005 行情统计)。
"""获取 A 股全市场涨跌统计概况(基于 880005/880001/880006 统计指数)。
注意:
`suspended_count` 是 `total - up - down - neutral` 的残差估算值,
用于保证计数守恒,不应视为协议已明确验证的停牌字段
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
quotes = self._execute(
@@ -691,13 +691,14 @@ class TdxClient:
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
up = int(q.price)
down = int(q.open)
neutral = int(q.low)
total = int(q.high)
# 计数字段协议返回值为真实家数 / 10,这里 ×10 还原(见 docstring
up = round(q.price * 10)
down = round(q.open * 10)
neutral = round(q.low * 10)
total = round(q.high * 10)
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
limit_down = int(quotes[2].open) if len(quotes) > 2 else 0
limit_up = int(quotes[2].price) if len(quotes) > 2 else 0
limit_down = round(quotes[2].open * 10) if len(quotes) > 2 else 0
limit_up = round(quotes[2].price * 10) if len(quotes) > 2 else 0
return _to_df(
MarketStat(
up_count=up,
@@ -1192,11 +1193,11 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
return _to_df(records)
async def get_market_stat(self) -> pd.DataFrame:
"""获取 A 股全市场涨跌统计概况(基于 880005 行情统计)。
"""获取 A 股全市场涨跌统计概况(基于 880005/880001/880006 统计指数)。
注意:
`suspended_count` 是 `total - up - down - neutral` 的残差估算值,
用于保证计数守恒,不应视为协议已明确验证的停牌字段
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
quotes = await self._execute(
@@ -1207,13 +1208,14 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
up = int(q.price)
down = int(q.open)
neutral = int(q.low)
total = int(q.high)
# 计数字段协议返回值为真实家数 / 10,这里 ×10 还原(见 docstring
up = round(q.price * 10)
down = round(q.open * 10)
neutral = round(q.low * 10)
total = round(q.high * 10)
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
limit_down = int(quotes[2].open) if len(quotes) > 2 else 0
limit_up = int(quotes[2].price) if len(quotes) > 2 else 0
limit_down = round(quotes[2].open * 10) if len(quotes) > 2 else 0
limit_up = round(quotes[2].price * 10) if len(quotes) > 2 else 0
return _to_df(
MarketStat(
up_count=up,
+45 -37
View File
@@ -102,61 +102,69 @@ def test_get_security_list_all_filtering(_mock_conn_cls):
@patch("easy_tdx.client.TdxConnection")
def test_get_market_stat_mapping(_mock_conn_cls):
"""测试市场统计字段映射。"""
"""测试市场统计字段映射。
通达信统计指数的计数字段返回真实家数的 1/10,get_market_stat 内部需 ×10 还原。
这里构造的原始协议值是还原后家数的 1/10,断言还原后等于真实家数。
"""
client = TdxClient("127.0.0.1")
mock_quote = SecurityQuote(
Market.SH,
def _zero_quote(code, **kw):
"""构造一只仅关键字段非零的 SecurityQuote,其余五档/活跃度字段取默认 0。"""
base = dict(
price=0, pre_close=0, open=0, high=0, low=0,
vol=0, cur_vol=0, amount=0, s_vol=0, b_vol=0,
active1=0, active2=0,
bid1=0, bid_vol1=0, bid2=0, bid_vol2=0, bid3=0, bid_vol3=0,
bid4=0, bid_vol4=0, bid5=0, bid_vol5=0,
ask1=0, ask_vol1=0, ask2=0, ask_vol2=0, ask3=0, ask_vol3=0,
ask4=0, ask_vol4=0, ask5=0, ask_vol5=0,
rise_speed=0, limit_up=0, limit_down=0,
)
base.update(kw)
return SecurityQuote(Market.SH, code, **base)
# 880005: 计数字段=真实家数/10amount/vol 不缩放,原样透传
q_stat = _zero_quote(
"880005",
price=3000.0, # up = int(price)
pre_close=0,
open=2000.0, # down = int(open)
high=5500.0, # total = int(high)
low=500.0, # neutral = int(low)
price=300.0, # up = 300 * 10 = 3000
open=200.0, # down = 200 * 10 = 2000
high=550.0, # total= 550 * 10 = 5500
low=50.0, # neutral = 50 * 10 = 500
vol=1000000.0,
cur_vol=0,
amount=50000000.0,
s_vol=0,
b_vol=0,
active1=0,
active2=0,
bid1=0,
bid_vol1=0,
bid2=0,
bid_vol2=0,
bid3=0,
bid_vol3=0,
bid4=0,
bid_vol4=0,
bid5=0,
bid_vol5=0,
ask1=0,
ask_vol1=0,
ask2=0,
ask_vol2=0,
ask3=0,
ask_vol3=0,
ask4=0,
ask_vol4=0,
ask5=0,
ask_vol5=0,
rise_speed=0,
limit_up=0,
limit_down=0,
)
# 880001: 总市值指数点位(不缩放)
q_cap = _zero_quote("880001", price=1186.579)
# 880006: 涨跌停家数=真实/10
q_limit = _zero_quote(
"880006",
price=13.1, # limit_up = 131
open=0.6, # limit_down = 6
)
def mock_execute(cmd):
if isinstance(cmd, GetSecurityQuotesCmd):
return [mock_quote]
return [q_stat, q_cap, q_limit]
return []
with patch.object(TdxClient, "_execute", side_effect=mock_execute):
stat = client.get_market_stat()
assert isinstance(stat, pd.DataFrame)
# 计数字段 ×10 还原
assert stat["up_count"].iloc[0] == 3000
assert stat["down_count"].iloc[0] == 2000
assert stat["neutral_count"].iloc[0] == 500
assert stat["total_count"].iloc[0] == 5500
assert stat["limit_up_count"].iloc[0] == 131
assert stat["limit_down_count"].iloc[0] == 6
# suspended = total - up - down - neutral = 5500 - 5500 = 0
assert stat["suspended_count"].iloc[0] == 0
# 成交额/量不缩放,原样透传
assert stat["total_amount"].iloc[0] == 50000000.0
assert stat["total_volume"].iloc[0] == 1000000.0
# 总市值 = 1186.579 * 1e10
assert stat["total_market_cap"].iloc[0] == 1186.579 * 1e10
def test_get_history_fund_flow_parsing():