diff --git a/CHANGELOG.md b/CHANGELOG.md index b519a97..70e7bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 库发布质量。 diff --git a/pyproject.toml b/pyproject.toml index 47ade58..b3fe8f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index e5e3dc0..df0da1b 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -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, diff --git a/tests/unit/test_a_share_extensions.py b/tests/unit/test_a_share_extensions.py index b42ae03..1ce8d20 100644 --- a/tests/unit/test_a_share_extensions.py +++ b/tests/unit/test_a_share_extensions.py @@ -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: 计数字段=真实家数/10;amount/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():