mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
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:
@@ -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 正确推进 |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:]
|
||||
@@ -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位数字代码)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user