From fcd14c1d490d7eb268fc4a5a18e7c76918032046 Mon Sep 17 00:00:00 2001 From: M Date: Tue, 14 Apr 2026 17:23:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=9D=BF=E5=9D=97?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=EF=BC=88block=5Finfo=EF=BC=89=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E4=B8=8E=E8=A7=A3=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 核心模型:增加 TdxBlock dataclass。 2. 协议命令:实现 GetBlockInfoMetaCmd 与 GetBlockInfoCmd。 3. 编解码器:增加 codec/block.py,支持 .dat 板块文件二进制解析。 4. 客户端 API:TdxClient 和 AsyncTdxClient 增加 get_block_info(),支持分片拉取。 5. 测试与验证:增加单元测试 tests/unit/test_block_info.py 及实测脚本。 6. 文档更新:README.md 同步 API 及安装说明。 --- README.md | 10 +++ scripts/verify_block_info.py | 36 +++++++++++ src/xmtdx/client.py | 38 ++++++++++- src/xmtdx/codec/block.py | 69 ++++++++++++++++++++ src/xmtdx/commands/block_info.py | 62 ++++++++++++++++++ src/xmtdx/models/finance.py | 10 +++ tests/unit/test_block_info.py | 106 +++++++++++++++++++++++++++++++ 7 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 scripts/verify_block_info.py create mode 100644 src/xmtdx/codec/block.py create mode 100644 src/xmtdx/commands/block_info.py create mode 100644 tests/unit/test_block_info.py diff --git a/README.md b/README.md index 419832f..6af8b29 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ pytdx 年久失修:多处已知解析 bug、Python 2 包袱、无类型注解 ## 安装 ```bash +python3 -m venv .venv +source .venv/bin/activate pip install -e . # 开发模式 pip install -e ".[dev]" # 含测试/类型检查工具 pip install -e ".[pandas]" # 含 pandas(可选) @@ -102,6 +104,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0) | `get_finance_info(market, code)` | 最新财务数据 | | `get_company_info_category(market, code)` | 公司信息文件目录 | | `get_company_info_content(market, code, filename, offset, length)` | 公司信息文本 | +| `get_block_info(filename)` | 板块信息(行业、概念、风格等) | `AsyncTdxClient` 提供与同步版对应的查询方法与高可用入口,均为 `async def`。 单个 `AsyncTdxClient` 仅维护一条 TCP 连接;并发调用会在连接内串行执行。 @@ -181,8 +184,15 @@ _raw name filename start length ``` +### TdxBlock(板块信息) + +``` +name category count codes +``` + ## 修复的 pytdx Bug + | # | 位置 | 问题 | 修复 | |---|------|------|------| | 1 | `xdxr_info` | 循环内始终读 `body[:7]`,所有记录字段相同 | 改为从当前 `pos` 读取,pos 正确推进 | diff --git a/scripts/verify_block_info.py b/scripts/verify_block_info.py new file mode 100644 index 0000000..b74d15f --- /dev/null +++ b/scripts/verify_block_info.py @@ -0,0 +1,36 @@ +"""实测板块信息获取。""" +import sys +import pathlib + +# 添加 src 到 path +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) + +from xmtdx import TdxClient + +def main(): + print("正在寻找最优服务器...") + try: + with TdxClient.from_best_host() as c: + print(f"已连接到: {c._host}") + + # 尝试获取概念板块 (block_gn.dat) + filename = "block_gn.dat" + print(f"正在获取 {filename} ...") + blocks = c.get_block_info(filename) + + print(f"成功获取 {len(blocks)} 个板块。") + + # 打印前 5 个板块及其前 3 个股票 + for b in blocks[:5]: + print(f"板块名称: {b.name:<12} 股票数: {b.count:<5} 样例: {b.codes[:3]}") + + if not blocks: + print("警告:未获取到任何板块数据。") + + except Exception as e: + print(f"实测失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/src/xmtdx/client.py b/src/xmtdx/client.py index 6e996c7..474f709 100644 --- a/src/xmtdx/client.py +++ b/src/xmtdx/client.py @@ -5,6 +5,7 @@ from types import TracebackType from typing import TypeVar from .commands.base import BaseCommand +from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd from .commands.finance_info import GetFinanceInfoCmd from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd @@ -14,10 +15,11 @@ from .commands.security_list import GetSecurityListCmd from .commands.security_quotes import GetSecurityQuotesCmd from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd from .commands.xdxr_info import GetXdxrInfoCmd +from .codec.block import parse_block_dat from .exceptions import TdxConnectionError from .models.bar import SecurityBar from .models.enums import KlineCategory, Market -from .models.finance import CompanyInfoCategory, FinanceInfo, XdxrRecord +from .models.finance import CompanyInfoCategory, FinanceInfo, TdxBlock, XdxrRecord from .models.quote import SecurityQuote from .models.security import SecurityInfo from .models.timeseries import MinuteBar, TransactionRecord @@ -231,6 +233,26 @@ class TdxClient: GetCompanyInfoContentCmd(market, code, filename, offset, length) ) + def get_block_info(self, filename: str) -> list[TdxBlock]: + """获取并解析板块文件(行业、概念、风格等)。 + + 常用文件名: + 'block_zs.dat' - 行业/指数板块 + 'block_gn.dat' - 概念板块 + 'block_fg.dat' - 风格板块 + """ + size, _hash = self._execute(GetBlockInfoMetaCmd(filename)) + full_data = bytearray() + pos = 0 + chunk_size = 30000 + while pos < size: + chunk = self._execute(GetBlockInfoCmd(filename, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + return parse_block_dat(bytes(full_data), filename) + # ============================================================ # 异步客户端 @@ -387,3 +409,17 @@ class AsyncTdxClient: return await self._execute( GetCompanyInfoContentCmd(market, code, filename, offset, length) ) + + async def get_block_info(self, filename: str) -> list[TdxBlock]: + """获取并解析板块文件(行业、概念、风格等)。""" + size, _hash = await self._execute(GetBlockInfoMetaCmd(filename)) + full_data = bytearray() + pos = 0 + chunk_size = 30000 + while pos < size: + chunk = await self._execute(GetBlockInfoCmd(filename, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + return parse_block_dat(bytes(full_data), filename) diff --git a/src/xmtdx/codec/block.py b/src/xmtdx/codec/block.py new file mode 100644 index 0000000..d29a1f0 --- /dev/null +++ b/src/xmtdx/codec/block.py @@ -0,0 +1,69 @@ +"""板块文件 (.dat) 解析逻辑。""" + +import struct +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..models.finance import TdxBlock + + +def parse_block_dat(data: bytes, filename: str = "") -> list["TdxBlock"]: + """解析通达信 .dat 板块文件内容。 + + 格式: + Header: 384 字节(跳过) + Count: 2 字节 (uint16 LE) + Body: 每条记录 2813 字节 (9s + H + H + 2800s) + """ + from ..models.finance import TdxBlock + + if len(data) < 386: + return [] + + pos = 384 + (count,) = struct.unpack(" None: + self.filename = filename.encode("ascii") + + def build_request(self) -> bytes: + # 固定头 12 字节 + header = bytes.fromhex("0c39186900012a002a00c502") + # Payload 为文件名 + payload = (self.filename + b"\x00" * 40)[:40] + return header + payload + + def parse_response(self, body: bytes) -> tuple[int, str]: + if len(body) < 38: + raise TdxDecodeError(f"GetBlockInfoMeta 响应过短: {len(body)}") + + size, _, hash_b, _ = struct.unpack(" None: + self.filename = filename.encode("ascii") + self.start = start + self.length = length + + def build_request(self) -> bytes: + # 固定头 12 字节 + header = bytes.fromhex("0c37186a00016e006e00b906") + payload = struct.pack(" bytes: + if len(body) < 4: + return b"" + return body[4:] diff --git a/src/xmtdx/models/finance.py b/src/xmtdx/models/finance.py index ce4e3fa..c5a03a4 100644 --- a/src/xmtdx/models/finance.py +++ b/src/xmtdx/models/finance.py @@ -126,3 +126,13 @@ class CompanyInfoCategory: filename: str = "" # 文件名(如 '600000.txt') start: int = 0 # 内容起始偏移 length: int = 0 # 内容长度(字节) + + +@dataclass +class TdxBlock: + """通达信板块信息(行业、概念、风格等)""" + + name: str # 板块名称(如“房地产”) + category: int # 板块分类(0=行业, 1=地域, 2=概念, 3=风格, 等) + count: int # 板块包含股票数量 + codes: list[str] # 股票代码列表(6位数字代码) diff --git a/tests/unit/test_block_info.py b/tests/unit/test_block_info.py new file mode 100644 index 0000000..8e89a6c --- /dev/null +++ b/tests/unit/test_block_info.py @@ -0,0 +1,106 @@ +"""板块信息单元测试。""" + +import pytest +import struct +from unittest.mock import MagicMock, patch + +from xmtdx.client import AsyncTdxClient, TdxClient + + +@patch("xmtdx.client.AsyncTdxConnection") +def test_async_get_block_info_logic(mock_conn_cls): + """测试 AsyncTdxClient.get_block_info 的异步拉取逻辑。""" + mock_conn = mock_conn_cls.return_value + + # 模拟异步 execute + async def mock_execute(cmd): + from xmtdx.commands.block_info import GetBlockInfoMetaCmd, GetBlockInfoCmd + if isinstance(cmd, GetBlockInfoMetaCmd): + return 100, "hash" + if isinstance(cmd, GetBlockInfoCmd): + return b"B" * min(cmd.length, 100 - cmd.start) + return None + + mock_conn.execute.side_effect = mock_execute + mock_conn.connect.return_value = None + mock_conn.close.return_value = None + + async def main(): + client = AsyncTdxClient("127.0.0.1") + with patch("xmtdx.client.parse_block_dat") as mock_parse: + mock_parse.return_value = [] + res = await client.get_block_info("test.dat") + + assert isinstance(res, list) + assert mock_conn.execute.call_count == 2 # 1 meta + 1 data + + import asyncio + asyncio.run(main()) +from xmtdx.codec.block import parse_block_dat +from xmtdx.models.finance import TdxBlock + + +def test_parse_block_dat_empty(): + assert parse_block_dat(b"") == [] + assert parse_block_dat(b"A" * 385) == [] + + +def test_parse_block_dat_basic(): + # 构造一个极小的合法 .dat 文件 + # Header 384 + Count 2 + Record 2813 + data = bytearray(384) + data.extend(struct.pack(" 2 + assert b.codes == ["600000", "000001"] + + +@patch("xmtdx.client.TdxConnection") +def test_get_block_info_logic(mock_conn_cls): + """测试 TdxClient.get_block_info 的分片拉取逻辑。""" + mock_conn = mock_conn_cls.return_value + + client = TdxClient("127.0.0.1") + + # 模拟 GetBlockInfoMeta 响应:size=35000 (需要2次拉取) + def mock_execute(cmd): + from xmtdx.commands.block_info import GetBlockInfoMetaCmd, GetBlockInfoCmd + if isinstance(cmd, GetBlockInfoMetaCmd): + return 35000, "dummy_hash" + if isinstance(cmd, GetBlockInfoCmd): + # 返回对应长度的填充数据 + return b"A" * min(cmd.length, 35000 - cmd.start) + return None + + mock_conn.execute.side_effect = mock_execute + + # 我们主要测试循环是否正确 + with patch("xmtdx.client.parse_block_dat") as mock_parse: + mock_parse.return_value = [TdxBlock("Test", 1, 0, [])] + res = client.get_block_info("test.dat") + + assert len(res) == 1 + # 应该调用了 1 (meta) + 2 (data: 30000 + 5000) = 3 次 execute + assert mock_conn.execute.call_count == 3 + + # 验证最后一次拉取的参数 + last_call_args = mock_conn.execute.call_args_list[-1][0][0] + assert last_call_args.start == 30000 + assert last_call_args.length == 30000