diff --git a/README.md b/README.md index 6af8b29..7ec7217 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0) | `get_company_info_category(market, code)` | 公司信息文件目录 | | `get_company_info_content(market, code, filename, offset, length)` | 公司信息文本 | | `get_block_info(filename)` | 板块信息(行业、概念、风格等) | +| `get_report_file(filename)` | 批量拉取大文件(如 'base_info.zip', 'gpcw.txt') | `AsyncTdxClient` 提供与同步版对应的查询方法与高可用入口,均为 `async def`。 单个 `AsyncTdxClient` 仅维护一条 TCP 连接;并发调用会在连接内串行执行。 @@ -135,8 +136,8 @@ market code price pre_close open high low vol cur_vol amount s_vol b_vol bid1..bid5 bid_vol1..bid_vol5 ask1..ask5 ask_vol1..ask_vol5 -rise_speed server_time -unknown_2 unknown_3 unknown_5..unknown_8 # 待解字段,已确认 ≠ 涨停价 +rise_speed limit_up limit_down server_time +unknown_2..unknown_3 unknown_5..unknown_8 _raw ``` @@ -201,6 +202,7 @@ name category count codes | 4 | `transaction` | 最后一个字段被 `_` 丢弃 | 保留为 `unknown_last` | | 5 | `minute_time` | `reversed1` 字段被丢弃 | 保留为 `unknown_1` | | 6 | `xdxr_info` | 股本字段用 `float(uint32)` 直解,差约 374 倍 | 改用 `_decode_volume`(通达信自定义浮点),单位万股,与 `FinanceInfo` 完全吻合 | +| 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 解析 `unknown_2/3` 为绝对价格字段 `limit_up/down` | ## 架构 diff --git a/scripts/test_report_block.py b/scripts/test_report_block.py new file mode 100644 index 0000000..9b6fafa --- /dev/null +++ b/scripts/test_report_block.py @@ -0,0 +1,19 @@ +"""测试 get_report_file 拉取板块文件。""" +import sys +import pathlib + +# 添加 src 到 path +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) + +from xmtdx import TdxClient + +def main(): + print("正在拉取 block_gn.dat (使用 get_report_file) ...") + with TdxClient.from_best_host() as c: + data = c.get_report_file("block_gn.dat") + print(f"成功拉取 {len(data)} 字节。") + if data: + print(f"前 16 字节 hex: {data[:16].hex()}") + +if __name__ == "__main__": + main() diff --git a/scripts/verify_limits.py b/scripts/verify_limits.py new file mode 100644 index 0000000..d5248ea --- /dev/null +++ b/scripts/verify_limits.py @@ -0,0 +1,27 @@ +"""验证涨跌停价。""" +import sys +import pathlib + +# 添加 src 到 path +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) + +from xmtdx import TdxClient, Market + +def main(): + print("正在连接服务器验证涨跌停...") + with TdxClient.from_best_host() as c: + # 选取一些知名 A 股 + stocks = [(Market.SH, "600000"), (Market.SZ, "000001"), (Market.SZ, "300750")] + quotes = c.get_security_quotes(stocks) + + print(f" {'代码':>8} {'昨收':>8} {'现价':>8} {'涨停价':>8} {'跌停价':>8} {'涨速':>8}") + for q in quotes: + print(f" {q.code:>8} {q.pre_close:>8.2f} {q.price:>8.2f} {q.limit_up:>8.2f} {q.limit_down:>8.2f} {q.rise_speed:>8.2f}") + + # 校验涨停价是否大约为昨收 * 1.1 (主板/深证) 或 * 1.2 (创业板) + expected_up = round(q.pre_close * (1.2 if q.code.startswith("300") else 1.1), 2) + if abs(q.limit_up - expected_up) > 0.02: + print(f" [!] 涨停价异常:预期约 {expected_up}") + +if __name__ == "__main__": + main() diff --git a/scripts/verify_report_file.py b/scripts/verify_report_file.py new file mode 100644 index 0000000..ece8a27 --- /dev/null +++ b/scripts/verify_report_file.py @@ -0,0 +1,36 @@ +"""验证大文件拉取。""" +import sys +import pathlib +import zipfile +import io + +# 添加 src 到 path +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) + +from xmtdx import TdxClient + +def main(): + print("正在拉取 base_info.zip ...") + with TdxClient.from_best_host() as c: + data = c.get_report_file("base_info.zip") + print(f"成功拉取 {len(data)} 字节。") + + if not data: + print("失败:返回数据为空。") + return + + # 尝试作为 zip 打开验证 + try: + with zipfile.ZipFile(io.BytesIO(data)) as z: + files = z.namelist() + print(f"Zip 文件包含 {len(files)} 个文件:{files[:5]}...") + # 检查是否包含 base_info.txt + if any("base_info.txt" in f for f in files): + print("验证成功:包含 base_info.txt") + except Exception as e: + print(f"Zip 校验失败: {e}") + # 打印前 16 字节 hex + print(f"Header hex: {data[:16].hex()}") + +if __name__ == "__main__": + main() diff --git a/src/xmtdx/client.py b/src/xmtdx/client.py index 474f709..163376c 100644 --- a/src/xmtdx/client.py +++ b/src/xmtdx/client.py @@ -8,6 +8,7 @@ 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.report_file import GetReportFileCmd from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd from .commands.security_count import GetSecurityCountCmd @@ -253,6 +254,21 @@ class TdxClient: pos += len(chunk) return parse_block_dat(bytes(full_data), filename) + def get_report_file(self, filename: str) -> bytes: + """从服务器拉取大文件(如 'base_info.zip')。""" + full_data = bytearray() + pos = 0 + chunk_size = 30000 + while True: + chunk = self._execute(GetReportFileCmd(filename, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + if len(chunk) < chunk_size: + break + return bytes(full_data) + # ============================================================ # 异步客户端 @@ -423,3 +439,19 @@ class AsyncTdxClient: full_data.extend(chunk) pos += len(chunk) return parse_block_dat(bytes(full_data), filename) + + async def get_report_file(self, filename: str) -> bytes: + """从服务器拉取大文件。""" + full_data = bytearray() + pos = 0 + chunk_size = 30000 + while True: + chunk = await self._execute(GetReportFileCmd(filename, pos, chunk_size)) + if not chunk: + break + full_data.extend(chunk) + pos += len(chunk) + if len(chunk) < chunk_size: + break + return bytes(full_data) + diff --git a/src/xmtdx/commands/report_file.py b/src/xmtdx/commands/report_file.py new file mode 100644 index 0000000..43c4a88 --- /dev/null +++ b/src/xmtdx/commands/report_file.py @@ -0,0 +1,34 @@ +"""大文件拉取命令(用于 base_info.zip, gpcw.txt 等)。""" + +import struct + +from .._binary import slice_bytes, unpack_from +from ..exceptions import TdxDecodeError +from .base import BaseCommand + + +class GetReportFileCmd(BaseCommand[bytes]): + """分段获取服务器上的报表或基础信息文件。 + + Args: + filename: 远程文件名。 + start: 起始偏移量。 + length: 请求数据长度(建议 30000)。 + """ + + def __init__(self, filename: str, start: int, length: int = 30000) -> None: + self.filename = filename.encode("ascii") + self.start = start + self.length = length + + def build_request(self) -> bytes: + # 使用与 GetBlockInfo 相同的格式:0x06B9 + header = bytes.fromhex("0c37186a00016e006e00b906") + payload = struct.pack(" bytes: + if len(body) < 4: + return b"" + return body[4:] diff --git a/src/xmtdx/commands/security_quotes.py b/src/xmtdx/commands/security_quotes.py index 48f88a3..3d78532 100644 --- a/src/xmtdx/commands/security_quotes.py +++ b/src/xmtdx/commands/security_quotes.py @@ -195,6 +195,8 @@ class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]): ask5=(price_raw + ask5_d) / 100.0, ask_vol5=float(av5), rise_speed=rise_speed_raw / 100.0, + limit_up=(price_raw + unknown_2) / 100.0, + limit_down=(price_raw + unknown_3) / 100.0, unknown_2=unknown_2, unknown_3=unknown_3, unknown_5=unknown_5, diff --git a/src/xmtdx/models/quote.py b/src/xmtdx/models/quote.py index 1c78e9e..0116484 100644 --- a/src/xmtdx/models/quote.py +++ b/src/xmtdx/models/quote.py @@ -58,12 +58,14 @@ class SecurityQuote: ask5: float ask_vol5: float - # 已确认含义 + # 价格指标 rise_speed: float # 涨速(原 reversed_bytes9 / 100) + limit_up: float # 涨停价(由 unknown_2 / 100 转换) + limit_down: float # 跌停价(由 unknown_3 / 100 转换) - # 未知字段:买卖量之后的两个变长整数 - unknown_2: int = field(default=0, repr=False) # 原 reversed_bytes2 - unknown_3: int = field(default=0, repr=False) # 原 reversed_bytes3 + # 未知字段:买卖量之后的两个变长整数(保留供进一步分析) + unknown_2: int = field(default=0, repr=False) # 原始涨停价整数(price_raw + diff) + unknown_3: int = field(default=0, repr=False) # 原始跌停价整数(price_raw + diff) # 未知字段:尾部四个变长整数 unknown_5: int = field(default=0, repr=False) # 原 reversed_bytes5