feat: 增加板块信息(block_info)获取与解析功能

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 及安装说明。
This commit is contained in:
M
2026-04-14 17:23:54 +08:00
parent 74ae6b6975
commit fcd14c1d49
7 changed files with 330 additions and 1 deletions
+10
View File
@@ -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 正确推进 |
+36
View File
@@ -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()
+37 -1
View File
@@ -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)
+69
View File
@@ -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("<H", data[pos:pos+2])
pos += 2
results: list[TdxBlock] = []
# 推断板块分类 (0=行业, 1=地域, 2=概念, 3=风格)
category = 0
if "zs" in filename:
category = 0
elif "gn" in filename:
category = 2
elif "fg" in filename:
category = 3
for _ in range(count):
if len(data) < pos + 2813:
break
# 板块元数据 (9 字节名称 + 2 字节股票数 + 2 字节类型)
name_b = data[pos:pos+9]
stock_count, _type = struct.unpack("<HH", data[pos+9:pos+13])
name = name_b.decode("gbk", errors="replace").strip("\x00")
# 股票代码区 (2800 字节,每只股票 7 字节)
codes: list[str] = []
codes_start = pos + 13
# 安全检查:stock_count 不应超过 400 (2800 / 7)
actual_count = min(stock_count, 400)
for i in range(actual_count):
c_start = codes_start + i * 7
c_raw = data[c_start:c_start+7]
code = c_raw.decode("ascii", errors="replace").strip("\x00")
if code:
codes.append(code)
results.append(TdxBlock(
name=name,
category=category,
count=stock_count,
codes=codes,
))
# 跳过整个 2813 字节的记录块
pos += 2813
return results
+62
View File
@@ -0,0 +1,62 @@
"""板块信息获取命令(元数据获取与分片下载)。
板块文件(如 block_zs.dat)包含行业、概念、风格等 A 股分类信息。
"""
import struct
from .._binary import slice_bytes, unpack_from
from ..exceptions import TdxDecodeError
from .base import BaseCommand
class GetBlockInfoMetaCmd(BaseCommand[tuple[int, str]]):
"""获取板块文件的元数据(大小与 MD5 哈希)。
Args:
filename: 板块文件名,如 'block_zs.dat', 'block_gn.dat' 等。
"""
def __init__(self, filename: str) -> 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("<I1s32s1s", body[:38])
return size, hash_b.decode("ascii").strip("\x00")
class GetBlockInfoCmd(BaseCommand[bytes]):
"""分段获取板块文件二进制内容。
Args:
filename: 板块文件名。
start: 起始偏移量(字节)。
length: 请求数据长度。
"""
def __init__(self, filename: str, start: int, length: int) -> 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("<II", self.start, self.length)
payload += (self.filename + b"\x00" * 100)[:100]
return header + payload
def parse_response(self, body: bytes) -> bytes:
if len(body) < 4:
return b""
return body[4:]
+10
View File
@@ -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位数字代码)
+106
View File
@@ -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("<H", 1)) # 1 block
# Block Record: 9s (name) + H (count) + H (type) + 2800s (codes)
name = "测试板块".encode("gbk")
record = bytearray((name + b"\x00" * 9)[:9])
record.extend(struct.pack("<HH", 2, 1)) # 2 stocks, type 1
# 2 stocks: 600000, 000001
codes = "600000\x00000001\x00".encode("ascii")
record.extend((codes + b"\x00" * 2800)[:2800])
data.extend(record)
blocks = parse_block_dat(bytes(data), "block_gn.dat")
assert len(blocks) == 1
b = blocks[0]
assert b.name == "测试板块"
assert b.count == 2
assert b.category == 2 # 'gn' in filename -> 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