mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
fix(mac): 板块列表涨速恒 0——值槽实为排序键列值,暴露 sort_column(issue #53)
抓包+对值锚定确认: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 示例更新
This commit is contained in:
@@ -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 里根本没有主力净额列。
|
||||
|
||||
@@ -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, ...)` | 板块涨跌幅排行榜(行业/概念排行) |
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# <HHBBHH8x: page_size, board_type, sort_col(0), sort_order(0), start, flag(1)
|
||||
# <HHBBHH8x: page_size, board_type, sort_col, sort_order(0=降序), start, flag(1)
|
||||
body = struct.pack(
|
||||
"<HHBBHH8x",
|
||||
self._page_size,
|
||||
int(self._board_type),
|
||||
0, # sort_column: 0 = rise_speed
|
||||
int(self._sort_column),
|
||||
0, # sort_order
|
||||
self._start,
|
||||
1, # flag
|
||||
@@ -65,14 +71,14 @@ class BoardListCmd(BaseCommand[list[BoardInfo]]):
|
||||
_pad1,
|
||||
name_raw,
|
||||
price,
|
||||
rise_speed,
|
||||
sort_value,
|
||||
pre_close,
|
||||
symbol_market,
|
||||
symbol_code_raw,
|
||||
_pad2,
|
||||
symbol_name_raw,
|
||||
symbol_price,
|
||||
symbol_rise_speed,
|
||||
symbol_sort_value,
|
||||
symbol_pre_close,
|
||||
) = unpack_from(_RECORD_FMT, body, offset, f"board_list record[{i}]")
|
||||
|
||||
@@ -82,13 +88,13 @@ class BoardListCmd(BaseCommand[list[BoardInfo]]):
|
||||
code=code_raw.decode("gbk", errors="replace").rstrip("\x00"),
|
||||
name=name_raw.decode("gbk", errors="replace").rstrip("\x00"),
|
||||
price=price,
|
||||
rise_speed=rise_speed,
|
||||
sort_value=sort_value,
|
||||
pre_close=pre_close,
|
||||
symbol_market=symbol_market,
|
||||
symbol_code=symbol_code_raw.decode("gbk", errors="replace").rstrip("\x00"),
|
||||
symbol_name=symbol_name_raw.decode("gbk", errors="replace").rstrip("\x00"),
|
||||
symbol_price=symbol_price,
|
||||
symbol_rise_speed=symbol_rise_speed,
|
||||
symbol_sort_value=symbol_sort_value,
|
||||
symbol_pre_close=symbol_pre_close,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -46,6 +46,24 @@ class BoardType(IntEnum):
|
||||
ALL = 255 # 全部
|
||||
|
||||
|
||||
class BoardSortColumn(IntEnum):
|
||||
"""板块列表(0x1231)排序键;响应中 price 与 pre_close 之间的值槽
|
||||
返回的就是当前排序列的值(Issue #53:原实现误标为固定"涨速"且
|
||||
硬编码 sort_column=0,该值恒为 0)。
|
||||
|
||||
实测锚定(2026-08-26,与 SymbolQuotesCmd 字段逐一对值):
|
||||
"""
|
||||
|
||||
CHANGE_PCT = 0 # 涨跌幅%(仅排序键,值槽恒 0,可由 price/pre_close 计算)
|
||||
SPEED = 1 # 涨速%
|
||||
CHANGE_3D = 2 # 3日涨幅%
|
||||
CHANGE_20D = 3 # 20日涨幅%
|
||||
CHANGE_60D = 4 # 60日涨幅%
|
||||
YTD = 5 # 年初至今%
|
||||
CHANGE_5D = 6 # 5日涨幅%
|
||||
CHANGE_10D = 7 # 10日涨幅%
|
||||
|
||||
|
||||
class ExBoardType(IntEnum):
|
||||
"""扩展市场板块类型。"""
|
||||
|
||||
|
||||
@@ -125,19 +125,25 @@ class MacTransaction:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoardInfo:
|
||||
"""板块信息。"""
|
||||
"""板块信息。
|
||||
|
||||
``sort_value`` / ``symbol_sort_value`` 是"当前排序列的值"(板块与其领涨股
|
||||
各一份),语义由请求的 ``BoardSortColumn`` 决定——如按涨速排序则为涨速%,
|
||||
按 3 日涨幅排序则为 3 日涨幅%。默认按涨跌幅排序时值槽恒 0(涨跌幅可由
|
||||
price/pre_close 计算;Issue #53:此前误标为固定"涨速"且恒为 0)。
|
||||
"""
|
||||
|
||||
market: int
|
||||
code: str
|
||||
name: str
|
||||
price: float
|
||||
rise_speed: float
|
||||
sort_value: float
|
||||
pre_close: float
|
||||
symbol_market: int
|
||||
symbol_code: str
|
||||
symbol_name: str
|
||||
symbol_price: float
|
||||
symbol_rise_speed: float
|
||||
symbol_sort_value: float
|
||||
symbol_pre_close: float
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,23 @@ def board_type_from_str(s: str) -> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# <HHBBHH8x> 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(
|
||||
"<H6s16s44sfff",
|
||||
1,
|
||||
b"881247",
|
||||
b"",
|
||||
"医疗服务".encode("gbk"),
|
||||
645.06, # price
|
||||
board_mid, # sort_value(涨速等,语义随排序列)
|
||||
653.76, # pre_close
|
||||
)
|
||||
symbol_half = struct.pack(
|
||||
"<H6s16s44sfff",
|
||||
0,
|
||||
b"301235",
|
||||
b"",
|
||||
"领涨股".encode("gbk"),
|
||||
12.34,
|
||||
symbol_mid,
|
||||
11.11,
|
||||
)
|
||||
return struct.pack("<HH", 2, 128) + board_half + symbol_half
|
||||
|
||||
|
||||
class TestBoardListParse:
|
||||
def test_parse_populates_sort_value(self):
|
||||
cmd = BoardListCmd(BoardType.ALL, 0, 1, BoardSortColumn.SPEED)
|
||||
rows = cmd.parse_response(_build_body(0.0791, 0.1587))
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert isinstance(r, BoardInfo)
|
||||
assert r.code == "881247"
|
||||
assert r.name == "医疗服务"
|
||||
assert r.price == pytest.approx(645.06)
|
||||
assert r.pre_close == pytest.approx(653.76)
|
||||
assert r.sort_value == pytest.approx(0.0791)
|
||||
assert r.symbol_sort_value == pytest.approx(0.1587)
|
||||
assert r.symbol_code == "301235"
|
||||
|
||||
def test_model_has_no_legacy_rise_speed_field(self):
|
||||
"""旧字段名已移除(语义错误:值槽并非恒为涨速)。"""
|
||||
assert not hasattr(
|
||||
BoardInfo(
|
||||
market=1,
|
||||
code="1",
|
||||
name="n",
|
||||
price=0.0,
|
||||
sort_value=0.0,
|
||||
pre_close=0.0,
|
||||
symbol_market=1,
|
||||
symbol_code="2",
|
||||
symbol_name="s",
|
||||
symbol_price=0.0,
|
||||
symbol_sort_value=0.0,
|
||||
symbol_pre_close=0.0,
|
||||
),
|
||||
"rise_speed",
|
||||
)
|
||||
|
||||
|
||||
class TestGetBoardListPassThrough:
|
||||
def test_client_forwards_sort_column(self):
|
||||
"""get_board_list 应把 sort_column 透传给 BoardListCmd。"""
|
||||
client = MacClient.__new__(MacClient)
|
||||
seen: list[BoardListCmd] = []
|
||||
|
||||
def fake_execute(cmd):
|
||||
seen.append(cmd)
|
||||
return []
|
||||
|
||||
with patch.object(client, "_execute", side_effect=fake_execute):
|
||||
df = client.get_board_list(BoardType.ALL, 10, BoardSortColumn.SPEED)
|
||||
|
||||
assert df.empty
|
||||
assert seen and seen[0]._sort_column == BoardSortColumn.SPEED
|
||||
|
||||
def test_web_converter(self):
|
||||
from easy_tdx.web.convert import board_sort_from_str
|
||||
|
||||
assert board_sort_from_str("speed") == BoardSortColumn.SPEED
|
||||
assert board_sort_from_str("CHANGE_3D") == BoardSortColumn.CHANGE_3D
|
||||
with pytest.raises(ValueError, match="无效板块排序键"):
|
||||
board_sort_from_str("NOPE")
|
||||
|
||||
def test_record_size_unchanged(self):
|
||||
"""响应记录仍为 160 字节(板块 + 领涨股各 80)。"""
|
||||
assert _RECORD_SIZE == 160
|
||||
|
||||
|
||||
class TestAsyncPassThrough:
|
||||
def test_async_client_forwards_sort_column(self):
|
||||
from easy_tdx.mac.client import AsyncMacClient
|
||||
|
||||
client = AsyncMacClient.__new__(AsyncMacClient)
|
||||
seen: list[BoardListCmd] = []
|
||||
|
||||
async def fake_execute(cmd):
|
||||
seen.append(cmd)
|
||||
return []
|
||||
|
||||
with patch.object(client, "_execute", side_effect=fake_execute):
|
||||
import asyncio
|
||||
|
||||
df = asyncio.run(client.get_board_list(BoardType.ALL, 10, BoardSortColumn.SPEED))
|
||||
|
||||
assert df.empty
|
||||
assert seen and seen[0]._sort_column == BoardSortColumn.SPEED
|
||||
|
||||
|
||||
class TestWebEndpoint:
|
||||
def test_board_list_endpoint_accepts_sort_column(self):
|
||||
"""Web /board-mac/list 端点应接受 sort_column 参数并透传。"""
|
||||
pytest.importorskip("fastapi")
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from easy_tdx.web.routers import board_mac
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(board_mac.router, prefix="/api/v1")
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _FakeMacClient:
|
||||
async def get_board_list(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return pd.DataFrame()
|
||||
|
||||
app.state.mac_client = _FakeMacClient()
|
||||
app.state.tdx_client = object()
|
||||
|
||||
with TestClient(app) as tc:
|
||||
resp = tc.get("/api/v1/board-mac/list", params={"sort_column": "SPEED", "count": 5})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert captured["sort_column"] == BoardSortColumn.SPEED
|
||||
@@ -33,6 +33,7 @@ _EXPECTED_KIND: dict[str, str] = {
|
||||
"Market": "class",
|
||||
"KlineCategory": "class",
|
||||
"Adjust": "class",
|
||||
"BoardSortColumn": "class",
|
||||
"BoardType": "class",
|
||||
"Category": "class",
|
||||
"ExMarket": "class",
|
||||
|
||||
Reference in New Issue
Block a user