From 94f631a06b778483b397333b6dfce2a29244f3e0 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sat, 4 Jul 2026 02:03:47 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.17.6=20=E2=80=94=20=E6=B8=AF?= =?UTF-8?q?=E8=82=A1=E9=80=90=E7=AC=94=E6=88=90=E4=BA=A4=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E5=8F=96=E6=95=B0=20+=20start=20=E5=80=92=E5=BA=8F=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 issue #14 后用户反馈:默认 count=2000 取回的成交时间全集中在尾盘。 根因是通达信逐笔协议(A 股 0x122F 与港股 ex 0x23FC/0x2406 一致)的 start 为 倒序语义——start=0 指向最新一笔(收盘方向),并非 bug。02715 全天成交 13327 笔,count=2000 只取最近 2000 笔故集中在尾盘。 新增: - goods_transaction_all(MacExClient 同步+异步)—— 港股股票类市场自动按 1800/页 翻页取全天全部逐笔成交,安全上限 50 页(90000 条)。返回协议原生倒序,需正序 由调用方 df.iloc[::-1]。market 非港股时抛 ValueError。 - _fetch_all_hk_transactions_sync/async(_hk_transaction.py)底层实现。 变更: - goods_transaction docstring 补 start 倒序语义说明,引导需全天数据用 goods_transaction_all。 修复: - 1.17.5 的 test_hk_transaction.py 未过 CI ruff format --check(注释对齐、 MacTransaction 单行化、文末空行)。 867 单测全绿(+5 全量取数测试),ruff format/check / mypy strict 通过。 --- CHANGELOG.md | 16 +++++ pyproject.toml | 2 +- src/easy_tdx/ex/_hk_transaction.py | 57 +++++++++++++++++ src/easy_tdx/ex/mac_client.py | 65 +++++++++++++++++++- tests/unit/test_hk_transaction.py | 98 ++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a83886..7372433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.17.6] — 2026-07-04 + +**港股逐笔成交:补充 start 倒序语义文档 + 新增 goods_transaction_all 全量取数** —— 回应 issue #14 后用户反馈:默认 `count=2000` 取回的成交记录时间全集中在尾盘(如 02715 全天成交 13327 笔,count=2000 只取到最近 2000 笔)。根因是通达信逐笔协议(A 股 0x122F 与港股 ex 0x23FC/0x2406 一致)的 `start` 为**倒序**语义——start=0 指向最新一笔(收盘方向),并非 bug。本次:补 docstring 说明 start 语义;新增 `goods_transaction_all` 自动翻页取全天全部成交。**867 单测全绿**(+5),ruff format/check / mypy strict 通过。 + +### 新增 + +- **`goods_transaction_all`(全量取数)**(`src/easy_tdx/ex/mac_client.py` 同步 + 异步、`src/easy_tdx/ex/_hk_transaction.py` 新增 `_fetch_all_hk_transactions_sync/async`)—— 港股股票类市场专用,自动按 1800/页翻页直至末页(不足一页或空即停),返回当日全部逐笔成交(港股单日常 1~5 万笔)。安全上限 50 页(90000 条)防止异常数据导致无限翻页。返回顺序为协议原生倒序(最新在前);需正序展示由调用方自行 `df.iloc[::-1]`。market 非港股股票类时抛 `ValueError`。 + +### 变更 + +- **`goods_transaction` docstring 补 start 倒序语义**(`src/easy_tdx/ex/mac_client.py`)—— 明确说明 `start=0` 指向最新一笔(收盘方向),与 A 股 0x122F 语义一致;提示 `count=2000` 默认只取最近 2000 笔会集中在尾盘,需全天数据请用 `goods_transaction_all`。 + +### 修复 + +- **CI ruff format 失败**(`tests/unit/test_hk_transaction.py`)—— 1.17.5 引入的测试文件未过 `ruff format --check`(参数化注释前双空格、MacTransaction 单行化、文末空行)。本次顺手修复。 + ## [1.17.5] — 2026-07-04 **港股逐笔成交协议路由修复** —— 修复 issue #14:`MacExClient.goods_transaction` 对港股市场(HK 主板 / 创业板 / 指数 / 基金 / 港股通 / 暗盘)返回空。根因是对所有扩展市场统一复用了 A 股 MAC 协议的 `SymbolTransactionCmd`(0x122F),而 0x122F 的数据源未接入港股,服务器对港股 market 一律返回 39 字节空响应(count=0)。改为对港股股票类市场路由到 ex 扩展行情协议(当日 0x23FC / 历史 0x2406),并把整数价格换算为港元浮点。**860 单测全绿**(+22),ruff / mypy strict 通过。 diff --git a/pyproject.toml b/pyproject.toml index 591276f..5ecff2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.17.5" +version = "1.17.6" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/ex/_hk_transaction.py b/src/easy_tdx/ex/_hk_transaction.py index e002c32..7d0595f 100644 --- a/src/easy_tdx/ex/_hk_transaction.py +++ b/src/easy_tdx/ex/_hk_transaction.py @@ -156,3 +156,60 @@ async def _fetch_hk_transactions_async( if len(batch) < page_size: break return results + + +# 全量翻页的安全上限:50 页 × 1800 = 90000 条,覆盖港股单日成交峰值绰绰有余。 +# 超过即停,防止异常数据(如服务器循环返回)导致无限翻页。 +_HK_TRANSACTION_MAX_PAGES = 50 + + +def _fetch_all_hk_transactions_sync( + execute_fn: SyncExecute[list[ExTransactionRecord]], + market: int, + code: str, + query_date: date | None, + start: int = 0, +) -> list[MacTransaction]: + """同步获取港股某日**全部**逐笔成交(自动翻页直至末页)。 + + 0x23FC/0x2406 响应不含 total 字段,只能按页翻到不足一页或空为止。 + 返回顺序与协议一致(倒序:start=0 为最新/收盘方向)。 + """ + ymd = _to_ymd(query_date) if query_date is not None else None + + results: list[MacTransaction] = [] + offset = start + for _ in range(_HK_TRANSACTION_MAX_PAGES): + cmd = _build_cmd(market, code, ymd, offset, _HK_TRANSACTION_PAGE_SIZE) + batch = execute_fn(cmd) + if not batch: + break + results.extend(_map_record(r) for r in batch) + offset += len(batch) + if len(batch) < _HK_TRANSACTION_PAGE_SIZE: + break # 末页 + return results + + +async def _fetch_all_hk_transactions_async( + execute_fn: AsyncExecute[list[ExTransactionRecord]], + market: int, + code: str, + query_date: date | None, + start: int = 0, +) -> list[MacTransaction]: + """异步获取港股某日**全部**逐笔成交。语义同同步版 :func:`_fetch_all_hk_transactions_sync`。""" + ymd = _to_ymd(query_date) if query_date is not None else None + + results: list[MacTransaction] = [] + offset = start + for _ in range(_HK_TRANSACTION_MAX_PAGES): + cmd = _build_cmd(market, code, ymd, offset, _HK_TRANSACTION_PAGE_SIZE) + batch = await execute_fn(cmd) + if not batch: + break + results.extend(_map_record(r) for r in batch) + offset += len(batch) + if len(batch) < _HK_TRANSACTION_PAGE_SIZE: + break # 末页 + return results diff --git a/src/easy_tdx/ex/mac_client.py b/src/easy_tdx/ex/mac_client.py index fe66c95..520dd69 100644 --- a/src/easy_tdx/ex/mac_client.py +++ b/src/easy_tdx/ex/mac_client.py @@ -27,6 +27,8 @@ from ..mac.commands.symbol_transaction import SymbolTransactionCmd from ..mac.enums import Adjust, Period, SortOrder, SortType from ..mac.models import MacQuoteField from ._hk_transaction import ( + _fetch_all_hk_transactions_async, + _fetch_all_hk_transactions_sync, _fetch_hk_transactions_async, _fetch_hk_transactions_sync, is_hk_stock_market, @@ -426,9 +428,12 @@ class MacExClient: query_date : date | None 查询日期,None 表示今天。 start : int - 起始偏移。 + 起始偏移。**注意:通达信逐笔协议为倒序**——``start=0`` 指向最新一笔 + (收盘方向),``start`` 越大越早。A 股 0x122F 与港股 ex 协议语义一致。 count : int - 返回条数。 + 返回条数。港股单日成交常达数万笔(如 02715 约 1.3 万笔/日),默认 + ``count=2000`` 只取最近 2000 笔,会集中在尾盘时段。若需全天全部成交, + 请改用 :meth:`goods_transaction_all`。 Note ---- @@ -454,6 +459,42 @@ class MacExClient: result = self._execute(cmd) return _to_df(result) + def goods_transaction_all( + self, + market: int, + code: str, + query_date: date | None = None, + ) -> pd.DataFrame: + """获取港股某日**全部**逐笔成交(仅港股股票类市场,自动翻页取全天)。 + + 与 :meth:`goods_transaction` 的区别:不受 ``count`` 上限约束,自动翻页直至 + 末页,返回当日所有逐笔成交(港股单日常 1~5 万笔)。返回顺序仍为协议原生 + 倒序(最新在前);如需正序展示,调用方自行 ``df.iloc[::-1]`` 反转。 + + Parameters + ---------- + market : int + ExMarket 枚举值(须为港股股票类市场,见 + :data:`easy_tdx.ex._hk_transaction.HK_STOCK_MARKETS`)。 + code : str + 证券代码。 + query_date : date | None + 查询日期,None 表示今天。 + + Raises + ------ + ValueError + ``market`` 不属于港股股票类市场时抛出(本方法专为港股设计;其他扩展 + 市场请用 :meth:`goods_transaction`)。 + """ + if not is_hk_stock_market(market): + raise ValueError( + f"goods_transaction_all 仅支持港股股票类市场(HK_STOCK_MARKETS)," + f"收到 market={market};其他市场请用 goods_transaction。" + ) + result = _fetch_all_hk_transactions_sync(self._execute, market, code, query_date) + return _to_df(result) + # ============================================================ # 异步客户端 @@ -739,7 +780,10 @@ class AsyncMacExClient(AsyncHeartbeatMixin): start: int = 0, count: int = 2000, ) -> pd.DataFrame: - """获取逐笔成交数据(异步)。路由说明见同步版 :meth:`goods_transaction`。""" + """获取逐笔成交数据(异步)。 + + 路由与 ``start`` 倒序语义见同步版 :meth:`goods_transaction`。 + """ if is_hk_stock_market(market): result = await _fetch_hk_transactions_async( self._execute, market, code, query_date, start, count @@ -754,3 +798,18 @@ class AsyncMacExClient(AsyncHeartbeatMixin): ) result = await self._execute(cmd) return _to_df(result) + + async def goods_transaction_all( + self, + market: int, + code: str, + query_date: date | None = None, + ) -> pd.DataFrame: + """获取港股某日全部逐笔成交(异步)。语义见同步版 :meth:`goods_transaction_all`。""" + if not is_hk_stock_market(market): + raise ValueError( + f"goods_transaction_all 仅支持港股股票类市场(HK_STOCK_MARKETS)," + f"收到 market={market};其他市场请用 goods_transaction。" + ) + result = await _fetch_all_hk_transactions_async(self._execute, market, code, query_date) + return _to_df(result) diff --git a/tests/unit/test_hk_transaction.py b/tests/unit/test_hk_transaction.py index d8e32b2..4448547 100644 --- a/tests/unit/test_hk_transaction.py +++ b/tests/unit/test_hk_transaction.py @@ -333,3 +333,101 @@ async def test_async_goods_transaction_non_hk_keeps_0x122f(): assert isinstance(captured[0], SymbolTransactionCmd) assert len(df) == 1 assert df["price"].iloc[0] == pytest.approx(3850.0) + + +# --------------------------------------------------------------------------- +# 6. goods_transaction_all 全量取数(mock _execute,离线) +# --------------------------------------------------------------------------- + + +def test_goods_transaction_all_paginates_until_short_page(): + """全量取数:翻页直到某页返回不足 page_size(末页)即停。""" + from easy_tdx.ex.mac_client import MacExClient + + page_calls: list[int] = [] # 记录每页的 start + + def fake_execute(cmd): + page_calls.append(cmd.start) + # 前 3 页满页(1800),第 4 页返回 500(末页) + if cmd.start < 1800 * 3: + return _build_fake_records(cmd.count) + return _build_fake_records(500) + + client = object.__new__(MacExClient) + client._execute = fake_execute # type: ignore[method-assign] + + df = client.goods_transaction_all(31, "00700", date(2026, 7, 3)) + + assert len(page_calls) == 4 # 3 满页 + 1 末页 + assert page_calls == [0, 1800, 3600, 5400] + assert len(df) == 1800 * 3 + 500 + + +def test_goods_transaction_all_stops_on_empty(): + """全量取数:第一页空(休市日/无数据)应立即返回空。""" + from easy_tdx.ex.mac_client import MacExClient + + call_count = 0 + + def fake_execute(cmd): + nonlocal call_count + call_count += 1 + return [] + + client = object.__new__(MacExClient) + client._execute = fake_execute # type: ignore[method-assign] + + df = client.goods_transaction_all(31, "00700", date(2026, 7, 1)) + + assert call_count == 1 + assert len(df) == 0 + + +def test_goods_transaction_all_rejects_non_hk_market(): + """全量取数仅限港股股票类市场;其他市场应报 ValueError。""" + from easy_tdx.ex.mac_client import MacExClient + + client = object.__new__(MacExClient) + client._execute = lambda cmd: [] # type: ignore[method-assign] + + with pytest.raises(ValueError, match="港股股票类市场"): + client.goods_transaction_all(47, "IFL0") # CFFEX 期货 + + +@pytest.mark.asyncio +async def test_async_goods_transaction_all_paginates(): + """异步全量取数也按页翻到末页停止。""" + from easy_tdx.ex.mac_client import AsyncMacExClient + + page_calls: list[int] = [] + + async def fake_execute(cmd): + page_calls.append(cmd.start) + # 前 1 页满页,第 2 页返回 100(末页) + if cmd.start == 0: + return _build_fake_records(cmd.count) + return _build_fake_records(100) + + client = object.__new__(AsyncMacExClient) + client._execute = fake_execute # type: ignore[method-assign] + + df = await client.goods_transaction_all(31, "00700", date(2026, 7, 3)) + + assert page_calls == [0, 1800] + assert len(df) == 1800 + 100 + + +@pytest.mark.asyncio +async def test_async_goods_transaction_all_rejects_non_hk(): + """异步全量取数:非港股市场报 ValueError。""" + from easy_tdx.ex.mac_client import AsyncMacExClient + + client = object.__new__(AsyncMacExClient) + + async def fake_execute(cmd): + return [] + + client._execute = fake_execute # type: ignore[method-assign] + + with pytest.raises(ValueError, match="港股股票类市场"): + await client.goods_transaction_all(74, "AAPL") # 美股