From fd53ec4c908d5d0f74ba7491e3f608ead99fda98 Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 26 Aug 2026 15:21:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(mac):=20=E6=9D=BF=E5=9D=97=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=B6=A8=E9=80=9F=E6=81=92=200=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=80=BC=E6=A7=BD=E5=AE=9E=E4=B8=BA=E6=8E=92=E5=BA=8F=E9=94=AE?= =?UTF-8?q?=E5=88=97=E5=80=BC=EF=BC=8C=E6=9A=B4=E9=9C=B2=20sort=5Fcolumn?= =?UTF-8?q?=EF=BC=88issue=20#53=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抓包+对值锚定确认:0x1231 响应中 price 与 pre_close 之间的 float 是 "当前排序列的值"(板块与领涨股各一份),并非固定涨速;此前硬编码 sort_column=0(涨跌幅,仅排序键、值槽恒 0),故该列永远全 0。 排序列映射(实测):0=涨跌幅(值槽恒0) 1=涨速 2=3日 3=20日 4=60日 5=年初至今 6=5日 7=10日。 - 新增 BoardSortColumn 枚举并公开导出;get_board_list(sync/async) 新增 sort_column 参数,取涨速传 SPEED,默认涨跌幅排序行为不变 - BoardInfo 字段更名 rise_speed→sort_value、symbol_rise_speed→ symbol_sort_value(旧名语义错误且恒 0,属破坏性更名) - Web /board-mac/list 新增 sort_column 参数;CLI board-list 新增 --sort - 新增 9 个测试;README 示例更新 --- CHANGELOG.md | 15 ++ README.md | 5 +- pyproject.toml | 2 +- src/easy_tdx/__init__.py | 2 + src/easy_tdx/cli/cmd_board.py | 29 +++- src/easy_tdx/mac/client.py | 35 ++++- src/easy_tdx/mac/commands/board_list.py | 20 ++- src/easy_tdx/mac/enums.py | 18 +++ src/easy_tdx/mac/models.py | 12 +- src/easy_tdx/web/convert.py | 17 +++ src/easy_tdx/web/routers/board_mac.py | 17 ++- tests/unit/test_board_list.py | 180 ++++++++++++++++++++++++ tests/unit/test_public_api.py | 1 + 13 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_board_list.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 18760eb..672f214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.20.10] — 2026-08-26 + +**`get_board_list` 板块涨速列恒为 0**(Issue #53)——用户反馈板块列表的涨速列存在但全是 0。逆向核实(0x1231 抓包 + 与 `SymbolQuotesCmd` 字段逐一对值锚定)发现根因:响应中 price 与 pre_close 之间的那个 float **不是固定的"涨速",而是"当前排序列的值"**(板块与领涨股各一份)——请求里的 sort_column 此前硬编码为 0(涨跌幅),而涨跌幅列仅作排序键、值槽恒 0(客户端可由 price/pre_close 计算),所以永远拿到 0。实测锚定排序列映射:**0=涨跌幅(值槽恒 0)、1=涨速%、2=3日涨幅、3=20日涨幅、4=60日涨幅、5=年初至今、6=5日涨幅、7=10日涨幅**。 + +### 修复 + +- **`get_board_list` 暴露 `sort_column` 参数**(`MacClient` / `AsyncMacClient`)—— 新增 `BoardSortColumn` 枚举(公开导出),取涨速传 `BoardSortColumn.SPEED`,此时按涨速降序返回、`sort_value` 列即涨速%;默认仍按涨跌幅降序(行为不变)。分页请求全程透传同一排序键。 +- **字段更名(破坏性)**:`BoardInfo.rise_speed → sort_value`、`symbol_rise_speed → symbol_sort_value`(`src/easy_tdx/mac/models.py`、`commands/board_list.py`)—— 旧名在语义上是错的(该值槽只有按涨速排序时才是涨速),且从未返回过正确数据(恒 0),更名比留着一个撒谎的列名更安全。 +- **Web 端点 `/board-mac/list` 新增 `sort_column` 查询参数**(`web/convert.py` 新增 `board_sort_from_str`)—— 如 `?sort_column=SPEED`;CLI `easy-tdx board-list` 新增 `--sort` 选项(`CHANGE_PCT/SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/CHANGE_20D/CHANGE_60D/YTD`)。 +- README 板块示例补 `sort_column=BoardSortColumn.SPEED` 用法。 + +### 测试 + +- 新增 `tests/unit/test_board_list.py`(9 例):sort_column 请求字节打包位置断言(帧偏移 16);排序列枚举值锚定;合成 160 字节记录解析(sort_value/symbol_sort_value);sync/async 客户端透传;`board_sort_from_str` 转换器;Web 端点 `?sort_column=SPEED` 端到端透传;记录长度 160 字节不变式;`_EXPECTED_KIND` 公共 API 契约补 `BoardSortColumn`。实测:涨速降序 top10(近期复牌 0.234%、教育培训 0.138%…)、3日/60日/年初至今等排序键数值与 `SymbolQuotesCmd` 同名字段逐一相等。全套 1035 个单测通过(`test_web_api.py` 2 个失败为基线已存在的环境问题)。 + ## [1.20.9] — 2026-08-26 **`get_history_fund_flow` 取不到历史主力净额**(Issue #52)——用户反馈拿不到历史主力净额数据。排查发现三层根因(全部经 52 台已知服务器实测核实):其一,文档声称的"Category 22 直连资金流接口"是**虚构协议**——46 台可达服务器对该请求全部仅回 2 字节空包(0 条或 ret_count 撒谎),从未成功返回过数据,所谓"9 字节头 + 36 字节/条"响应格式系臆造(单测里的格式是 mock);其二,实际数据一直来自"日 K 线取日期 + 历史逐笔成交重算",但历史逐笔接口**当日数据要收盘清算后才有**,而日 K 盘中已包含当日 bar,导致 `start=0` 的最新一行(今天)恒为全 0;其三,`main_net_inflow`(主力净额)此前仅为 dataclass property,`_to_df` 的 `asdict()` 静默丢弃,返回 DataFrame 里根本没有主力净额列。 diff --git a/README.md b/README.md index 6937a57..76419fb 100644 --- a/README.md +++ b/README.md @@ -1348,10 +1348,11 @@ with MacClient.from_best_host() as c: #### 板块 ```python -from easy_tdx import BoardType +from easy_tdx import BoardSortColumn, BoardType with MacClient.from_best_host() as c: df = c.get_board_list(BoardType.GN) # 概念板块 + df = c.get_board_list(sort_column=BoardSortColumn.SPEED) # 按涨速排序取涨速% df = c.get_board_members("881001", sort_type=SortType.CHANGE_PCT) df = c.get_belong_board(Market.SZ, "000001") # 个股所属板块 @@ -1773,7 +1774,7 @@ with MacClient.from_best_host() as client: | `get_chart_sampling(market, code)` | 分时缩略采样 | | `get_transactions(market, code, ...)` | 逐笔成交 | | `get_symbol_info(market, code)` | 个股特征快照 | -| `get_board_list(board_type, ...)` | 板块列表 | +| `get_board_list(board_type, ..., sort_column)` | 板块列表(sort_value 列=排序键指标值) | | `get_board_members(board_symbol, ...)` | 板块成分股报价 | | `get_board_summary(board_symbol, ...)` | 板块汇总(成交额、主力净流入、涨跌家数) | | `get_board_ranking(board_type, top_n, sort_by, ...)` | 板块涨跌幅排行榜(行业/概念排行) | diff --git a/pyproject.toml b/pyproject.toml index 71d4fbd..a8c316d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.20.9" +version = "1.20.10" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/__init__.py b/src/easy_tdx/__init__.py index 73c4196..ed10cef 100644 --- a/src/easy_tdx/__init__.py +++ b/src/easy_tdx/__init__.py @@ -29,6 +29,7 @@ from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, Tdx from .mac.client import AsyncMacClient, MacClient from .mac.enums import ( Adjust, + BoardSortColumn, BoardType, Category, ExMarket, @@ -73,6 +74,7 @@ __all__ = [ "Market", "KlineCategory", "Adjust", + "BoardSortColumn", "BoardType", "Category", "ExMarket", diff --git a/src/easy_tdx/cli/cmd_board.py b/src/easy_tdx/cli/cmd_board.py index d7ac24d..7a31c37 100644 --- a/src/easy_tdx/cli/cmd_board.py +++ b/src/easy_tdx/cli/cmd_board.py @@ -4,15 +4,37 @@ from __future__ import annotations import click +_SORT_CHOICES = [ + "CHANGE_PCT", + "SPEED", + "CHANGE_3D", + "CHANGE_5D", + "CHANGE_10D", + "CHANGE_20D", + "CHANGE_60D", + "YTD", +] + @click.command("board-list") @click.option("--type", "board_type", default="ALL", help="板块类型: ALL/HY/GN/FG/DQ/OTHER") @click.option("--count", default=10000, type=int, help="请求数量") +@click.option( + "--sort", + "sort_column", + default="CHANGE_PCT", + type=click.Choice(_SORT_CHOICES), + help=( + "排序键,sort_value 列即该指标值;要取涨速传 SPEED" + "(CHANGE_PCT 时该列恒 0,涨跌幅=price/pre_close-1)" + ), +) @click.option("--table", "use_table", is_flag=True, help="表格输出") @click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json") def board_list( board_type: str, count: int, + sort_column: str, use_table: bool, output_fmt: str, ) -> None: @@ -25,15 +47,20 @@ def board_list( easy-tdx board-list --type GN --count 200 easy-tdx board-list --type HY + + easy-tdx board-list --sort SPEED --table # 按涨速排序,sort_value 即涨速% """ + from easy_tdx.mac.enums import BoardSortColumn + from .conn import get_mac_client from .output import print_output from .parsers import parse_board_type fmt = "table" if use_table else output_fmt bt = parse_board_type(board_type) + sc = BoardSortColumn[sort_column] with get_mac_client() as client: - df = client.get_board_list(board_type=bt, count=count) + df = client.get_board_list(board_type=bt, count=count, sort_column=sc) print_output(df, fmt) diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py index a99701d..a9065c7 100644 --- a/src/easy_tdx/mac/client.py +++ b/src/easy_tdx/mac/client.py @@ -51,7 +51,16 @@ from .commands import ( from .commands.chart_sampling import ChartSamplingCmd from .commands.file_query import FileDownloadCmd, FileListCmd from .commands.goods_list import GoodsListCmd -from .enums import Adjust, BoardType, Category, FilterType, Period, SortOrder, SortType +from .enums import ( + Adjust, + BoardSortColumn, + BoardType, + Category, + FilterType, + Period, + SortOrder, + SortType, +) from .models import ( MacBar, MacMultiTickChart, @@ -704,20 +713,29 @@ class MacClient: self, board_type: BoardType = BoardType.ALL, count: int = 10000, + sort_column: BoardSortColumn = BoardSortColumn.CHANGE_PCT, ) -> pd.DataFrame: - """获取板块列表(自动分页)。 + """获取板块列表(自动分页,默认按涨跌幅降序)。 Args: board_type: 板块类型。 count: 请求总数。 + sort_column: 排序键(决定返回顺序与 ``sort_value`` 列的语义)。 + 可选:涨跌幅(默认)/涨速/3日/5日/10日/20日/60日/年初至今涨幅。 + 注意 ``sort_value`` 是"当前排序列的值"——要取涨速需传 + ``BoardSortColumn.SPEED``,此时按涨速降序返回;默认按涨跌幅 + 排序时该列恒 0(涨跌幅可由 price/pre_close 计算)。 + + Issue #53:此前该列被误标为"涨速"且恒为 0,根因即 sort_column + 语义未实现。 """ - all_items = self._execute(BoardListCmd(board_type, 0, min(count, 150))) + all_items = self._execute(BoardListCmd(board_type, 0, min(count, 150), sort_column)) fetched = len(all_items) offset = fetched while fetched < count: page_size = min(count - fetched, 150) - batch = self._execute(BoardListCmd(board_type, offset, page_size)) + batch = self._execute(BoardListCmd(board_type, offset, page_size, sort_column)) if not batch: break all_items.extend(batch) @@ -1690,14 +1708,19 @@ class AsyncMacClient(AsyncHeartbeatMixin): self, board_type: BoardType = BoardType.ALL, count: int = 10000, + sort_column: BoardSortColumn = BoardSortColumn.CHANGE_PCT, ) -> pd.DataFrame: - all_items = await self._execute(BoardListCmd(board_type, 0, min(count, 150))) + """获取板块列表(async,自动分页,默认按涨跌幅降序)。 + + ``sort_column`` 语义与同步版一致(Issue #53)。 + """ + all_items = await self._execute(BoardListCmd(board_type, 0, min(count, 150), sort_column)) fetched = len(all_items) offset = fetched while fetched < count: page_size = min(count - fetched, 150) - batch = await self._execute(BoardListCmd(board_type, offset, page_size)) + batch = await self._execute(BoardListCmd(board_type, offset, page_size, sort_column)) if not batch: break all_items.extend(batch) diff --git a/src/easy_tdx/mac/commands/board_list.py b/src/easy_tdx/mac/commands/board_list.py index f73afac..7b33045 100644 --- a/src/easy_tdx/mac/commands/board_list.py +++ b/src/easy_tdx/mac/commands/board_list.py @@ -5,7 +5,7 @@ import struct from ..._binary import unpack_from from ...codec.mac_frame import build_mac_request from ...commands.base import BaseCommand -from ..enums import BoardType +from ..enums import BoardSortColumn, BoardType from ..models import BoardInfo # 板板信息 + 领涨股信息,每组 160 字节 @@ -26,6 +26,10 @@ class BoardListCmd(BaseCommand[list[BoardInfo]]): 起始偏移量。 page_size : int 每页数量。 + sort_column : BoardSortColumn + 排序键。响应中 price 与 pre_close 之间的值槽返回的就是该列的值 + (板块与领涨股各一份);CHANGE_PCT(0) 仅作排序键,值槽恒 0 + (Issue #53:此前硬编码 0 且把值槽误标为"涨速",导致永远全 0)。 """ def __init__( @@ -33,18 +37,20 @@ class BoardListCmd(BaseCommand[list[BoardInfo]]): board_type: BoardType = BoardType.ALL, start: int = 0, page_size: int = 150, + sort_column: BoardSortColumn = BoardSortColumn.CHANGE_PCT, ) -> None: self._board_type = board_type self._start = start self._page_size = page_size + self._sort_column = sort_column def build_request(self) -> bytes: - # Any: raise ValueError(f"无效板块类型 '{s}',可选值: {valid}") from None +def board_sort_from_str(s: str) -> Any: + """将字符串转为 BoardSortColumn 枚举(CHANGE_PCT/SPEED/CHANGE_3D/...)。""" + from easy_tdx.mac.enums import BoardSortColumn + + key = s.upper() + try: + return BoardSortColumn[key] + except KeyError: + pass + try: + return BoardSortColumn(int(key)) + except (ValueError, TypeError): + pass + valid = ", ".join(m.name for m in BoardSortColumn) + raise ValueError(f"无效板块排序键 '{s}',可选值: {valid}") from None + + def sort_type_from_str(s: str) -> Any: """将字符串转为 SortType 枚举(CHANGE_PCT/VOLUME/... 或 hex 数字)。""" from easy_tdx.mac.enums import SortType diff --git a/src/easy_tdx/web/routers/board_mac.py b/src/easy_tdx/web/routers/board_mac.py index 0af822d..9ff2c29 100644 --- a/src/easy_tdx/web/routers/board_mac.py +++ b/src/easy_tdx/web/routers/board_mac.py @@ -7,6 +7,7 @@ from typing import Any from fastapi import APIRouter, Depends, Query from easy_tdx.web.convert import ( + board_sort_from_str, board_type_from_str, market_value_from_str, sort_order_from_str, @@ -26,10 +27,22 @@ def _df_resp(df: Any) -> DataFrameResponse: async def board_list( board_type: str = Query("ALL", description="板块类型: ALL/HY/HY2/GN/FG/DQ"), count: int = Query(500, ge=1, le=50000), + sort_column: str = Query( + "CHANGE_PCT", + description=( + "排序键: CHANGE_PCT/SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/" + "CHANGE_20D/CHANGE_60D/YTD;sort_value 列即该指标值" + "(CHANGE_PCT 时恒 0,涨跌幅=price/pre_close-1)" + ), + ), client: Any = Depends(get_mac_client), ) -> DataFrameResponse: - """获取板块列表。""" - df = await client.get_board_list(board_type=board_type_from_str(board_type), count=count) + """获取板块列表(默认按涨跌幅降序;要取涨速传 sort_column=SPEED)。""" + df = await client.get_board_list( + board_type=board_type_from_str(board_type), + count=count, + sort_column=board_sort_from_str(sort_column), + ) return _df_resp(df) diff --git a/tests/unit/test_board_list.py b/tests/unit/test_board_list.py new file mode 100644 index 0000000..85513dd --- /dev/null +++ b/tests/unit/test_board_list.py @@ -0,0 +1,180 @@ +"""板块列表(0x1231)排序键语义测试(Issue #53)。 + +背景实测(2026-08-26):响应中 price 与 pre_close 之间的值槽是 +"当前排序列的值",板块与领涨股各一份;sort_column=0(涨跌幅)时 +值槽恒 0,此前被误标为固定"涨速"且硬编码 0,导致整列恒 0。 +""" + +import struct +from unittest.mock import patch + +import pytest + +from easy_tdx.mac.client import MacClient +from easy_tdx.mac.commands.board_list import _RECORD_SIZE, BoardListCmd +from easy_tdx.mac.enums import BoardSortColumn, BoardType +from easy_tdx.mac.models import BoardInfo + +# body: page_size(H), board_type(H), sort_col(B), order(B), start(H), flag(H) +# 帧 = 10 字节头 + 2 字节 msg_id + body → sort_col 位于 body[4] = 帧偏移 16。 +_SORT_COL_POS = 10 + 2 + 4 + + +class TestBoardListRequest: + def test_sort_column_packed_into_request(self): + """sort_column 应写入请求第 3 个 body 字节(帧内偏移 15)。""" + req_default = BoardListCmd(BoardType.ALL, 0, 10).build_request() + assert req_default[_SORT_COL_POS] == int(BoardSortColumn.CHANGE_PCT) + + req_speed = BoardListCmd(BoardType.ALL, 0, 10, BoardSortColumn.SPEED).build_request() + assert req_speed[_SORT_COL_POS] == int(BoardSortColumn.SPEED) == 1 + + req_ytd = BoardListCmd(BoardType.ALL, 0, 10, BoardSortColumn.YTD).build_request() + assert req_ytd[_SORT_COL_POS] == int(BoardSortColumn.YTD) == 5 + + # 只差 sort_col 一个字节,其余请求布局不变 + assert len(req_default) == len(req_speed) == len(req_ytd) + + def test_sort_column_enum_values(self): + """实测锚定的排序列映射。""" + assert [c.value for c in BoardSortColumn] == [0, 1, 2, 3, 4, 5, 6, 7] + + +def _build_body(board_mid: float, symbol_mid: float) -> bytes: + """构造一条"板块 + 领涨股"记录的响应 body。""" + board_half = struct.pack( + "