feat: 离线 fixture 测试、unknown 字段探测脚本、高可用传输层

- tests/fixtures/:录制 12 条命令真实响应 body(hex),配套 json 预期值
- tests/unit/test_commands_offline.py:13 个离线 pytest,无需网络,验证
  各命令解析正确性及所有已知 bug 修复(#1~#5)
- scripts/probe_unknowns.py:对比 unknown_1/2/3/5~8 与均价、涨停价等假设,
  输出相关性分析报告,供后续字段逆向使用
- transport/sync.py:新增 ping_host()、ping_all()(并发测速)、KNOWN_HOSTS
- client.py:TdxClient.from_best_host() 工厂方法(自动优选最低延迟服务器)、
  TdxClient.ping_all() 静态代理、_execute() 断线自动重连(重试一次)
- 修复 GetTransactionDataCmd.parse_response 多余 skip 参数调用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
minionszyw
2026-04-11 20:33:41 +08:00
co-authored by Claude Sonnet 4.6
parent 283682f6b4
commit 0fa685dbdd
31 changed files with 782 additions and 26 deletions
View File
+189
View File
@@ -0,0 +1,189 @@
"""未知字段探测脚本:通过批量拉取多只股票数据,尝试推断各 unknown_N 字段的含义。
用法:
cd /home/m/xmtdx
python3 scripts/probe_unknowns.py
输出:
1. MinuteBar.unknown_1 vs 分钟均价(累计成交额 / 累计成交量)
2. SecurityQuote.unknown_2/3/5/6/7/8 与已知行情指标的相关关系
"""
from __future__ import annotations
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src"))
from xmtdx import TdxClient, Market, KlineCategory
HOST = "180.153.18.170"
# 沪深各取若干活跃股票
SH_CODES = ["600000", "600036", "601318", "600519", "601628"]
SZ_CODES = ["000001", "000002", "000858", "002415", "300750"]
SEP = "-" * 72
# ---------------------------------------------------------------------------
# Part 1: MinuteBar.unknown_1 — 是否为分钟均价?
# ---------------------------------------------------------------------------
def probe_minute_unknown_1(c: TdxClient) -> None:
print(SEP)
print("Part 1: MinuteBar.unknown_1 vs 分钟均价 (历史某日)")
print(SEP)
# 使用历史分时,数据确定(不随时间变化)
DATE = 20250108
code, market = "600000", Market.SH
bars = c.get_history_minute_time_data(market, code, DATE)
print(f" {market.name} {code} 日期={DATE}{len(bars)} 条分时\n")
# 同时拉取当日日线 K 作为参考(含 amount/vol 可算均价)
# 分时数据无直接成交额,需要用 price × vol 近似
# 若 unknown_1 == round(price × 100) 则为原始价格单位均价
print(f" {'分钟':>6} {'price':>8} {'vol':>8} {'unknown_1':>12} {'price*100':>10} {'diff':>8}")
print(f" {'':-<6} {'':-<8} {'':-<8} {'':-<12} {'':-<10} {'':-<8}")
exact_match = 0
close_match = 0
for i, b in enumerate(bars[:30]): # 只打印前30条
price_x100 = round(b.price * 100)
diff = b.unknown_1 - price_x100
exact = b.unknown_1 == price_x100
close = abs(diff) <= 2
if exact:
exact_match += 1
if close:
close_match += 1
flag = " <<< exact" if exact else ("" if close else "")
print(f" {i+1:>6} {b.price:>8.2f} {b.vol:>8} {b.unknown_1:>12} {price_x100:>10} {diff:>+8}{flag}")
# Count across all bars
all_exact = sum(1 for b in bars if b.unknown_1 == round(b.price * 100))
all_close = sum(1 for b in bars if abs(b.unknown_1 - round(b.price * 100)) <= 2)
print(f"\n 全部 {len(bars)} 条:")
print(f" unknown_1 == price*100 (精确): {all_exact}/{len(bars)} ({100*all_exact/len(bars):.1f}%)")
print(f" unknown_1 ≈ price*100 (±2): {all_close}/{len(bars)} ({100*all_close/len(bars):.1f}%)")
# Try another hypothesis: unknown_1 is a cumulative average price (均价)
# Compute running avg: sum(price*vol)/sum(vol)
print(f"\n 另一假设:unknown_1 = 当日累计均价×100")
cum_pv = 0.0
cum_v = 0
correct_avg = 0
for b in bars:
cum_pv += b.price * b.vol
cum_v += b.vol
if cum_v > 0:
avg = cum_pv / cum_v
expected = round(avg * 100)
if abs(b.unknown_1 - expected) <= 2:
correct_avg += 1
print(f" unknown_1 ≈ 累计均价×100 (±2): {correct_avg}/{len(bars)} ({100*correct_avg/len(bars):.1f}%)")
# ---------------------------------------------------------------------------
# Part 2: SecurityQuote.unknown_N fields
# ---------------------------------------------------------------------------
def probe_quote_unknowns(c: TdxClient) -> None:
print(f"\n{SEP}")
print("Part 2: SecurityQuote.unknown_2/3/5/6/7/8 — 与已知字段的关系")
print(SEP)
pairs = [(Market.SH, code) for code in SH_CODES] + [(Market.SZ, code) for code in SZ_CODES]
quotes = c.get_security_quotes(pairs)
print(f" {'market':>6} {'code':>8} {'pre_close':>10} {'price':>8} "
f"{'u2':>6} {'u3':>8} {'u5':>6} {'u6':>6} {'u7':>6} {'u8':>6} {'rise_spd':>10}")
print(f" {'':-<6} {'':-<8} {'':-<10} {'':-<8} "
f"{'':-<6} {'':-<8} {'':-<6} {'':-<6} {'':-<6} {'':-<6} {'':-<10}")
for q in quotes:
pct = (q.price - q.pre_close) / q.pre_close * 100 if q.pre_close else 0
print(
f" {q.market.name:>6} {q.code:>8} {q.pre_close:>10.2f} {q.price:>8.2f} "
f"{q.unknown_2:>6} {q.unknown_3:>8} {q.unknown_5:>6} "
f"{q.unknown_6:>6} {q.unknown_7:>6} {q.unknown_8:>6} {q.rise_speed:>10.4f}"
)
print(f"\n 注:rise_speed = reversed_bytes9/100(已确认 = 涨速)")
# Hypothesis: unknown_3 might relate to 涨停/跌停 price
# 涨停 = pre_close * 1.10 (rounded to 2 decimal)
print(f"\n 假设 unknown_3 = 涨停价×100")
print(f" {'code':>8} {'涨停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}")
for q in quotes:
if q.pre_close > 0:
limit_up = round(q.pre_close * 1.10 * 100)
diff = q.unknown_3 - limit_up
print(f" {q.code:>8} {limit_up:>16} {q.unknown_3:>10} {diff:>+6}")
print(f"\n 假设 unknown_3 = 跌停价×100")
print(f" {'code':>8} {'跌停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}")
for q in quotes:
if q.pre_close > 0:
limit_dn = round(q.pre_close * 0.90 * 100)
diff = q.unknown_3 - limit_dn
print(f" {q.code:>8} {limit_dn:>16} {q.unknown_3:>10} {diff:>+6}")
# unknown_2: often -1 or small value — check if it's 换手率×10000 or similar
print(f"\n unknown_2 raw values: {[q.unknown_2 for q in quotes]}")
print(f" unknown_5 raw values: {[q.unknown_5 for q in quotes]}")
print(f" unknown_6 raw values: {[q.unknown_6 for q in quotes]}")
print(f" unknown_7 raw values: {[q.unknown_7 for q in quotes]}")
print(f" unknown_8 raw values: {[q.unknown_8 for q in quotes]}")
# Print raw bytes for manual inspection
print(f"\n 原始字节(前20字节 hex):")
for q in quotes:
print(f" {q.code}: {q._raw[:20].hex()}")
# ---------------------------------------------------------------------------
# Part 3: TransactionRecord.unknown_last — 是否为秒数?
# ---------------------------------------------------------------------------
def probe_transaction_unknown_last(c: TdxClient) -> None:
print(f"\n{SEP}")
print("Part 3: TransactionRecord.unknown_last — 是否为秒或序号?")
print(SEP)
recs = c.get_history_transaction_data(Market.SH, "600000", 20250108, 0, 30)
print(f" {'序号':>4} {'时间':>6} {'price':>8} {'vol':>6} {'buy':>4} {'unknown_last':>14}")
print(f" {'':-<4} {'':-<6} {'':-<8} {'':-<6} {'':-<4} {'':-<14}")
for i, r in enumerate(recs):
print(f" {i+1:>4} {r.hour:02d}:{r.minute:02d} {r.price:>8.2f} {r.vol:>6} {r.buyorsell:>4} {r.unknown_last:>14}")
unique = len({r.unknown_last for r in recs})
print(f"\n unknown_last 唯一值数量: {unique}/{len(recs)}")
print(f" 值分布: {sorted({r.unknown_last for r in recs})}")
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> None:
print(f"连接 {HOST}:7709 ...")
with TdxClient(HOST) as c:
probe_minute_unknown_1(c)
probe_quote_unknowns(c)
probe_transaction_unknown_last(c)
print(f"\n{SEP}")
print("探测完成。根据以上输出可判断各字段含义,更新 models/ 文档注释。")
if __name__ == "__main__":
main()
+4
View File
@@ -22,6 +22,7 @@ asyncio 版本::
from .client import AsyncTdxClient, TdxClient from .client import AsyncTdxClient, TdxClient
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
from .transport.sync import KNOWN_HOSTS, ping_all
from .models import ( from .models import (
XDXR_CATEGORY_NAMES, XDXR_CATEGORY_NAMES,
CompanyInfoCategory, CompanyInfoCategory,
@@ -58,6 +59,9 @@ __all__ = [
"TdxConnectionError", "TdxConnectionError",
"TdxDecodeError", "TdxDecodeError",
"TdxCommandError", "TdxCommandError",
# 工具
"ping_all",
"KNOWN_HOSTS",
] ]
__version__ = "0.1.0" __version__ = "0.1.0"
+82 -18
View File
@@ -1,7 +1,9 @@
"""高层行情 APITdxClient(同步)和 AsyncTdxClientasyncio)。""" """高层行情 APITdxClient(同步)和 AsyncTdxClientasyncio)。"""
from types import TracebackType from types import TracebackType
from typing import TypeVar
from .commands.base import BaseCommand
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
from .commands.finance_info import GetFinanceInfoCmd from .commands.finance_info import GetFinanceInfoCmd
from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd
@@ -11,6 +13,7 @@ from .commands.security_list import GetSecurityListCmd
from .commands.security_quotes import GetSecurityQuotesCmd from .commands.security_quotes import GetSecurityQuotesCmd
from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd
from .commands.xdxr_info import GetXdxrInfoCmd from .commands.xdxr_info import GetXdxrInfoCmd
from .exceptions import TdxConnectionError
from .models.bar import SecurityBar from .models.bar import SecurityBar
from .models.enums import KlineCategory, Market from .models.enums import KlineCategory, Market
from .models.finance import CompanyInfoCategory, FinanceInfo, XdxrRecord from .models.finance import CompanyInfoCategory, FinanceInfo, XdxrRecord
@@ -18,10 +21,10 @@ from .models.quote import SecurityQuote
from .models.security import SecurityInfo from .models.security import SecurityInfo
from .models.timeseries import MinuteBar, TransactionRecord from .models.timeseries import MinuteBar, TransactionRecord
from .transport.async_ import AsyncTdxConnection from .transport.async_ import AsyncTdxConnection
from .transport.sync import TdxConnection from .transport.sync import KNOWN_HOSTS, TdxConnection, ping_all
_DEFAULT_HOST = "180.153.18.170"
_DEFAULT_PORT = 7709 _DEFAULT_PORT = 7709
_T = TypeVar("_T")
# ============================================================ # ============================================================
@@ -30,22 +33,66 @@ _DEFAULT_PORT = 7709
class TdxClient: class TdxClient:
"""同步通达信行情客户端。 """同步通达信行情客户端,支持 IP 优选与断线自动重连
使用示例:: 使用示例::
# 单台服务器
with TdxClient("180.153.18.170") as c: with TdxClient("180.153.18.170") as c:
bars = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100) bars = c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100)
# 自动从候选列表中选延迟最低的服务器
with TdxClient.from_best_host() as c:
count = c.get_security_count(Market.SH)
""" """
def __init__( def __init__(
self, self,
host: str = _DEFAULT_HOST, host: str = KNOWN_HOSTS[0],
port: int = _DEFAULT_PORT, port: int = _DEFAULT_PORT,
timeout: float = 15.0, timeout: float = 15.0,
auto_reconnect: bool = True,
) -> None: ) -> None:
self._host = host
self._port = port
self._timeout = timeout
self._auto_reconnect = auto_reconnect
self._conn = TdxConnection(host, port, timeout) self._conn = TdxConnection(host, port, timeout)
# ------------------------------------------------------------------ #
# 工厂方法:自动优选最低延迟服务器
# ------------------------------------------------------------------ #
@classmethod
def from_best_host(
cls,
hosts: list[str] = KNOWN_HOSTS,
port: int = _DEFAULT_PORT,
timeout: float = 15.0,
ping_timeout: float = 5.0,
auto_reconnect: bool = True,
) -> "TdxClient":
"""测量 hosts 中所有服务器延迟,选最低延迟的建立连接。
若所有服务器均不可达,回退到 hosts[0]。
"""
ranked = ping_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
return cls(best, port, timeout, auto_reconnect)
@staticmethod
def ping_all(
hosts: list[str] = KNOWN_HOSTS,
port: int = _DEFAULT_PORT,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。"""
return ping_all(hosts, port, timeout)
# ------------------------------------------------------------------ #
# 连接管理
# ------------------------------------------------------------------ #
def connect(self) -> None: def connect(self) -> None:
self._conn.connect() self._conn.connect()
@@ -64,23 +111,40 @@ class TdxClient:
) -> None: ) -> None:
self.close() self.close()
# ------------------------------------------------------------------ #
# 内部执行:含自动重连
# ------------------------------------------------------------------ #
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时尝试重连一次再重试(若 auto_reconnect=True)。"""
try:
return self._conn.execute(cmd)
except TdxConnectionError:
if not self._auto_reconnect:
raise
# 重连后重试一次
self._conn.close()
self._conn = TdxConnection(self._host, self._port, self._timeout)
self._conn.connect()
return self._conn.execute(cmd)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 市场信息 # 市场信息
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def get_security_count(self, market: Market) -> int: def get_security_count(self, market: Market) -> int:
"""获取市场证券总数。""" """获取市场证券总数。"""
return self._conn.execute(GetSecurityCountCmd(market)) return self._execute(GetSecurityCountCmd(market))
def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]: def get_security_list(self, market: Market, start: int) -> list[SecurityInfo]:
"""获取证券列表(每页约1000条,按 start 分页)。""" """获取证券列表(每页约1000条,按 start 分页)。"""
return self._conn.execute(GetSecurityListCmd(market, start)) return self._execute(GetSecurityListCmd(market, start))
def get_security_quotes( def get_security_quotes(
self, stocks: list[tuple[Market, str]] self, stocks: list[tuple[Market, str]]
) -> list[SecurityQuote]: ) -> list[SecurityQuote]:
"""批量获取实时五档行情(最多80只/次)。""" """批量获取实时五档行情(最多80只/次)。"""
return self._conn.execute(GetSecurityQuotesCmd(stocks)) return self._execute(GetSecurityQuotesCmd(stocks))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# K 线 # K 线
@@ -95,7 +159,7 @@ class TdxClient:
count: int = 800, count: int = 800,
) -> list[SecurityBar]: ) -> list[SecurityBar]:
"""获取 K 线数据(最多800条/次,按 start 分页)。""" """获取 K 线数据(最多800条/次,按 start 分页)。"""
return self._conn.execute(GetSecurityBarsCmd(market, code, category, start, count)) return self._execute(GetSecurityBarsCmd(market, code, category, start, count))
def get_index_bars( def get_index_bars(
self, self,
@@ -106,7 +170,7 @@ class TdxClient:
count: int = 800, count: int = 800,
) -> list[SecurityBar]: ) -> list[SecurityBar]:
"""获取指数 K 线数据。""" """获取指数 K 线数据。"""
return self._conn.execute(GetIndexBarsCmd(market, code, category, start, count)) return self._execute(GetIndexBarsCmd(market, code, category, start, count))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 分时 # 分时
@@ -114,13 +178,13 @@ class TdxClient:
def get_minute_time_data(self, market: Market, code: str) -> list[MinuteBar]: def get_minute_time_data(self, market: Market, code: str) -> list[MinuteBar]:
"""获取今日分时数据(240条)。""" """获取今日分时数据(240条)。"""
return self._conn.execute(GetMinuteTimeDataCmd(market, code)) return self._execute(GetMinuteTimeDataCmd(market, code))
def get_history_minute_time_data( def get_history_minute_time_data(
self, market: Market, code: str, date: int self, market: Market, code: str, date: int
) -> list[MinuteBar]: ) -> list[MinuteBar]:
"""获取历史某日分时数据(date: YYYYMMDD)。""" """获取历史某日分时数据(date: YYYYMMDD)。"""
return self._conn.execute(GetHistoryMinuteTimeDataCmd(market, code, date)) return self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 逐笔成交 # 逐笔成交
@@ -130,13 +194,13 @@ class TdxClient:
self, market: Market, code: str, start: int, count: int = 800 self, market: Market, code: str, start: int, count: int = 800
) -> list[TransactionRecord]: ) -> list[TransactionRecord]:
"""获取当日逐笔成交(分页)。""" """获取当日逐笔成交(分页)。"""
return self._conn.execute(GetTransactionDataCmd(market, code, start, count)) return self._execute(GetTransactionDataCmd(market, code, start, count))
def get_history_transaction_data( def get_history_transaction_data(
self, market: Market, code: str, date: int, start: int, count: int = 800 self, market: Market, code: str, date: int, start: int, count: int = 800
) -> list[TransactionRecord]: ) -> list[TransactionRecord]:
"""获取历史逐笔成交(date: YYYYMMDD,分页)。""" """获取历史逐笔成交(date: YYYYMMDD,分页)。"""
return self._conn.execute( return self._execute(
GetHistoryTransactionDataCmd(market, code, date, start, count) GetHistoryTransactionDataCmd(market, code, date, start, count)
) )
@@ -146,23 +210,23 @@ class TdxClient:
def get_xdxr_info(self, market: Market, code: str) -> list[XdxrRecord]: def get_xdxr_info(self, market: Market, code: str) -> list[XdxrRecord]:
"""获取除权除息历史记录。""" """获取除权除息历史记录。"""
return self._conn.execute(GetXdxrInfoCmd(market, code)) return self._execute(GetXdxrInfoCmd(market, code))
def get_finance_info(self, market: Market, code: str) -> FinanceInfo: def get_finance_info(self, market: Market, code: str) -> FinanceInfo:
"""获取最新财务数据。""" """获取最新财务数据。"""
return self._conn.execute(GetFinanceInfoCmd(market, code)) return self._execute(GetFinanceInfoCmd(market, code))
def get_company_info_category( def get_company_info_category(
self, market: Market, code: str self, market: Market, code: str
) -> list[CompanyInfoCategory]: ) -> list[CompanyInfoCategory]:
"""获取公司信息文件目录。""" """获取公司信息文件目录。"""
return self._conn.execute(GetCompanyInfoCategoryCmd(market, code)) return self._execute(GetCompanyInfoCategoryCmd(market, code))
def get_company_info_content( def get_company_info_content(
self, market: Market, code: str, filename: str, offset: int, length: int self, market: Market, code: str, filename: str, offset: int, length: int
) -> str: ) -> str:
"""读取公司信息文本。""" """读取公司信息文本。"""
return self._conn.execute( return self._execute(
GetCompanyInfoContentCmd(market, code, filename, offset, length) GetCompanyInfoContentCmd(market, code, filename, offset, length)
) )
@@ -183,7 +247,7 @@ class AsyncTdxClient:
def __init__( def __init__(
self, self,
host: str = _DEFAULT_HOST, host: str = KNOWN_HOSTS[0],
port: int = _DEFAULT_PORT, port: int = _DEFAULT_PORT,
timeout: float = 15.0, timeout: float = 15.0,
) -> None: ) -> None:
+1 -1
View File
@@ -28,7 +28,7 @@ class GetTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
def parse_response(self, body: bytes) -> list[TransactionRecord]: def parse_response(self, body: bytes) -> list[TransactionRecord]:
return _parse_transaction_body(body, skip=2) return _parse_transaction_body(body)
class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]): class GetHistoryTransactionDataCmd(BaseCommand[list[TransactionRecord]]):
+77 -7
View File
@@ -1,6 +1,7 @@
"""同步 TCP 连接(基于 socket)。""" """同步 TCP 连接(基于 socket)。"""
import socket import socket
import time
from types import TracebackType from types import TracebackType
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
@@ -17,6 +18,81 @@ _DEFAULT_HOST = "180.153.18.170"
_DEFAULT_PORT = 7709 _DEFAULT_PORT = 7709
_DEFAULT_TIMEOUT = 15.0 _DEFAULT_TIMEOUT = 15.0
# 已知可用的通达信行情服务器(按优先级排序)
KNOWN_HOSTS: list[str] = [
"180.153.18.170",
"180.153.18.171",
"180.153.18.172",
"115.238.56.198",
"115.238.90.165",
"218.75.126.9",
"47.107.75.159",
"59.175.238.38",
]
def ping_host(
host: str,
port: int = _DEFAULT_PORT,
timeout: float = 5.0,
) -> float | None:
"""测量连接到指定服务器并完成握手所需的时间(秒)。
返回延迟(秒),连接失败时返回 None。
"""
t0 = time.monotonic()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
# 发送第一条握手命令并等待响应作为可用性验证
sock.sendall(SETUP_COMMANDS[0])
hdr_buf = _recv_exact_sock(sock, HEADER_SIZE)
hdr = parse_header(hdr_buf)
if hdr.zipsize > 0:
_recv_exact_sock(sock, hdr.zipsize)
return time.monotonic() - t0
except OSError:
return None
finally:
try:
sock.close()
except OSError:
pass
def ping_all(
hosts: list[str] = KNOWN_HOSTS,
port: int = _DEFAULT_PORT,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""并发测量多台服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。
不可达的服务器不包含在结果中。
"""
import concurrent.futures
results: list[tuple[str, float]] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=len(hosts)) as pool:
futures = {pool.submit(ping_host, h, port, timeout): h for h in hosts}
for fut in concurrent.futures.as_completed(futures):
host = futures[fut]
latency = fut.result()
if latency is not None:
results.append((host, latency))
results.sort(key=lambda t: t[1])
return results
def _recv_exact_sock(sock: socket.socket, n: int) -> bytes:
buf = bytearray()
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise TdxConnectionError("连接被服务器关闭")
buf.extend(chunk)
return bytes(buf)
class TdxConnection: class TdxConnection:
"""同步通达信 TCP 连接。 """同步通达信 TCP 连接。
@@ -112,10 +188,4 @@ class TdxConnection:
def _recv_exact(self, n: int) -> bytes: def _recv_exact(self, n: int) -> bytes:
"""循环 recv 直到读满 n 字节。""" """循环 recv 直到读满 n 字节。"""
assert self._sock is not None assert self._sock is not None
buf = bytearray() return _recv_exact_sock(self._sock, n)
while len(buf) < n:
chunk = self._sock.recv(n - len(buf))
if not chunk:
raise TdxConnectionError("连接被服务器关闭")
buf.extend(chunk)
return bytes(buf)
+1
View File
@@ -0,0 +1 @@
1000d7eed0c2cce1cabe00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a22c0000b9abcbbeb8c5bff600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a22c0000ec510000b2c6cef1b7d6cef600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008e7e00003fd70000b9c9b6abd1d0bebf00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd55010061580000b9c9b1bebde1b9b900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002eae01002f270000d7cab1bed4cbd7f700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005dd501005d360000d2b5c4dab5e3c6c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004cba0b020037210100d0d0d2b5b7d6cef6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b3630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a3f12c0300c1240000b9abcbbeb4f3cac200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a13630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000edb251030065430600d1d0bebfb1a8b8e600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e43630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ce179509009e1a0100beadd3aab7d6cef600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bb3630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b5af0a0029790000d6f7c1a6d7b7d7d9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006d3630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007ede280b0031680000b7d6baecc0a9b9c900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000273630303030302e7478740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0f910b00edea0400b8dfb2e3d6cec0ed00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081fc7b1000b16d0000c1fabba2b0f1b5a5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e3630303030302e74787400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016c41d1100b51e0000b9d8c1aab8f6b9c900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000473630303030302e747874000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cfade9100017340000
+8
View File
@@ -0,0 +1,8 @@
{
"num_records": 16,
"first": {
"filename": "600000.txt",
"start": 0,
"length": 11426
}
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
{
"length": 8070,
"prefix": "☆最新提示☆ ◇600000 浦发银行 更新日期:2026-04-10◇ 港澳资讯 灵通V9.0\r\n"
}
+1
View File
@@ -0,0 +1 @@
0100013630303030305f484b4a10000100eb253501460a31015f484b4a00c0f5444aa42c4c86d6224d00000000000000005c8fc23fcf3a1650000000009138814ce8a30c4b400b1d4800000000c05ee54a643b004d79c4424eaee7254d948fe54c00000000907f4c4cf4f4934b6b36b34d48e26e4c000000000c9b4b4ca247404cbacc3e4c44566f4d3d0ab14100004041
+5
View File
@@ -0,0 +1,5 @@
{
"liutong_guben": 33305837500.0,
"zong_guben": 33305837500.0,
"meigujing_zichan": 22.1299991607666
}
+1
View File
@@ -0,0 +1 @@
f000ec5124418510ee03bc9c014123a9344101805000008744010d8032000bae3802189b2c42039a21010fad3a038101bc68012f844f4117ad3c000d9826011a8f3f010fa8204106a312000a9d1f022ab5580012b4294404ac4e42489f490041b90e0043b11c004286180201ac120001b013424384240141b3370101a80f414096110202a4144101a60d0204ab164103901d01028b0d000b90440002b10b03108d3400059c114209a62f4204a53b0001a50c0001be1100019a0f0104b9210105891f0008a632000386124103981f0005ba3700029d1b0001b9080206a120410381150004941d4202923500019e254241a71a00418f0f4143b81c0301ad1f4242a92001408d124141841b0140a5140040982b4141ba0b0041851b4141ba0e0042b21d0042bb134143841e0042af130042950f0041850b004194090141b108414281104149a13d0043a5170043b0140048b5400041b50701419206414185070141bc090041ab074453b357014485170141ac094244ad13414e933d414c832e00478b1b0156b36901438a10424ab32d0247ad2d41499b3241419c060142b20a414587170143961341468a1b024f93694148a03241479b24034296120242962000419c150043a13941409e054243bf184148933700438b120241be0c4244a91d0042831100419c0a41449b190444bc4242449c210042b8150341a7244243b2214243961b0142ab160042b5150141b3070041bb100141b2100040bd040042b51e4142ab16024186114141910b0041b30e414194090041930c0041ac080141b9164142971a0042b61b0240b80c00409d0e0040ba0a004198270140b00400408f104141be15014099090101866341409a104143b4810141429f3201408c104141a7140140be110041951601409a0c4241be1501409d1401408a170040a9064140800e0041b5200404b4544200a41801018f3000019d110000a40a0000a10e0001b710410082170101b32d0000b2094240811b01018d400000b7190100a110010283214100b50e0100a20a00019a0f4100840c0202a61c000287204200a50f01019c130202bd1a0104902e41049a310002a01f4101bd170202871c41049f340002af200002ae1f00019e0e0001b8144101b81401018e104101bf130001ab130001a50a0001b1150003b633000191150001b1194101be274101971f4240a41f004098260040a61641419d2441459d7b014181140140aa130100971200009b0c0000ac160203866c4100a0110101ab1a41019a244100a51c0200810e4100b20e00008c0e4100ac1b4140be1b010094230000980e0000851d0000ac1f000084164140961e0000170000000202a07b
+7
View File
@@ -0,0 +1,7 @@
{
"num_records": 240,
"first": {
"price": 10.29,
"vol": 10044
}
}
+1
View File
@@ -0,0 +1 @@
0a00ec512441800386103200008003411301008003009d0401008003003001008003009d02010080034183020100800301b50202008003411e0100810300170100840302a07b0200
+9
View File
@@ -0,0 +1,9 @@
{
"num_records": 10,
"first": {
"hour": 14,
"minute": 56,
"price": 10.3,
"vol": 50
}
}
+1
View File
@@ -0,0 +1 @@
f0000000013630303030303e109d0f04040643acfaa60edd0f9889349483013a6ec94da7831cb2851841a6e2020001bd0788124102a8cd0187314203906aab11560fa00f9f01ae920144d203a9a30201f8019bf6010201be2d0009a346410cbd7e020ba33a4104bf37000593210204b51e000f8d400005aa150008b02d4107843a4101821101049a240005ba32410186150103bb1e0101b50b420598580001833e0203b53e0003881a0001af060002ac0e000c8c5700059c3d4101b20c0101b10a41029c280102a31f0002861c00008d054102962000018215010187190001b0260001af11000193164101a7264100a5200000b9320040a12c4140a60c0040bf0801409014424a95e0010141bd1b0042ba330141b41c4140ae030141a7150040a10e41428d320041b71a4141ae0b0141aa09004198140043bc434141bc1b0141841900418c0e0041ba164141a9130141bd160041ad1c00438c360041a40c414189124147a37d0142a01d00419a1c014094070040a8044140a70a00408c0700409f060140ab0841408d0b0040b4120140850b0040ac130040a7050040a60b4140950a0140990441408b0a0140b3094140800d01409a064140bc090141be430040b1110041be1f00408c07004081160040810d01008d05414082200042b13200408e0500409f080040960b4140bf070042843b00409c040140ba090040bd050040af110040ae0400408907004098050040b80d0041bb1e414088050140930a0040a0064142802e0040910b014182364144b36b0041a30c4141870c0140a9080040860b41418e160140bd0a0040a40d4142803201408c0a41408d0b0140880600418a1f0140960d0040ac0801409b184140b812004099090040b204004087084140a7060140b8020040b4064141a62d0140b3024140970301408c044140aa0f0040af0a0240a8370000af1b4100bd080100a1024100a5040040a22e0040810441408d0a00408e0a0140ad0d004094120040ae06414094100140890541409c0a0140bb0b4140a0060040bd0b0040aa140140b3100100a32101008f2542008b170000aa06010099030100b911000094100000900600018d114101be180101a20f0100940a0000be050000a5040001ac164101a01400028b32000086070000ae030000b31b0000940641009a1a01009b080000890a0000b10a0000b6094100a00700009f214140ae040000b90b0040ac4a41418618024082184140962201009b044140920c01009a0d4140a6250040ae100100af1000008d050000890500009f0e0040b81400008a090000a1150000b40a0000bb080000a5060001983800409a0e0000ad0f4141bd5e0040801c0040961600409b120040b025004092130040bc150040851e414092160040ad1e01408f15004090254140941d0141802d41458fde0101001d0000004143948301
+8
View File
@@ -0,0 +1,8 @@
{
"num_records": 240,
"first": {
"price": 0.01,
"vol": 48,
"unknown_1": 54
}
}
+1
View File
@@ -0,0 +1 @@
0500332635018aa001c20200ea0253fb1c4c32f1c64d3726350100cc0232ea02bc82104c573fb54d38263501149a01ae01721025384c2b39e74d3926350154cc0232cc02d2d52f4c4a73db4d3a263501006814c601a7ba224c3a6ec94d
+10
View File
@@ -0,0 +1,10 @@
{
"num_records": 5,
"first": {
"open": 10.25,
"high": 10.25,
"low": 10.08,
"close": 10.12,
"vol": 41151820.0
}
}
+1
View File
@@ -0,0 +1 @@
0569
+3
View File
@@ -0,0 +1,3 @@
{
"count": 26885
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
{
"num_records": 1000,
"first": {
"code": "999999",
"name": "上证指数",
"pre_close": 11654847.33
}
}
+1
View File
@@ -0,0 +1 @@
01050100013630303030303e109d0f04040643acfaa60edd0f9889349483013a6ec94da7831cb2851841a6e2020001bd0788124102a8cd0187314203906aab114304a7b4018f1f4405ab96019812560f0000000000003e10
+9
View File
@@ -0,0 +1,9 @@
{
"num_records": 1,
"first": {
"code": "600000",
"pre_close": 9.93,
"unknown_2": -1,
"unknown_3": 22694
}
}
+1
View File
@@ -0,0 +1 @@
0a0083039e0f0000080083030000000800830300000008008303000000080083030000000800830300000008008303410000080083030000000800830300000008008403009483019f040200
+9
View File
@@ -0,0 +1,9 @@
{
"num_records": 10,
"first": {
"hour": 14,
"minute": 59,
"price": 9.9,
"vol": 0
}
}
+1
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
{
"num_records": 87,
"first": {
"year": 1999,
"month": 11,
"day": 10,
"category": 5
}
}
+328
View File
@@ -0,0 +1,328 @@
"""离线 fixture 测试:将录制的原始 body 字节喂给各命令 parse_response,验证解析结果。
fixtures/ 目录下每个 .hex 文件是一次真实服务器响应的 body(已解压),
对应的 .json 文件记录关键预期值,供手工核对。
此测试文件直接断言解析结果,无需网络连接。
"""
from __future__ import annotations
import pathlib
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
def load_hex(name: str) -> bytes:
return bytes.fromhex((FIXTURES / f"{name}.hex").read_text().strip())
# ---------------------------------------------------------------------------
# security_count
# ---------------------------------------------------------------------------
def test_security_count_parse():
from xmtdx.commands.security_count import GetSecurityCountCmd
from xmtdx.models.enums import Market
body = load_hex("security_count")
cmd = GetSecurityCountCmd(Market.SH)
count = cmd.parse_response(body)
assert isinstance(count, int)
assert count > 0
# 体积固定为 2 字节,结果与录制时完全一致
assert count == 26885
# ---------------------------------------------------------------------------
# security_list
# ---------------------------------------------------------------------------
def test_security_list_parse():
from xmtdx.commands.security_list import GetSecurityListCmd
from xmtdx.models.enums import Market
body = load_hex("security_list")
cmd = GetSecurityListCmd(Market.SH, 0)
records = cmd.parse_response(body)
assert len(records) == 1000
r0 = records[0]
assert r0.code == "999999"
assert r0.name == "上证指数"
# pre_close is a float; the index level is stored ×100 and has many decimal places
assert abs(r0.pre_close - 11654847.33) < 1.0
# _raw present and non-empty for every record
assert all(len(r._raw) > 0 for r in records)
def test_security_list_gbk_no_crash():
"""Bug #2 修复验证:GBK 解码不崩溃,所有记录均有 code。"""
from xmtdx.commands.security_list import GetSecurityListCmd
from xmtdx.models.enums import Market
body = load_hex("security_list")
cmd = GetSecurityListCmd(Market.SH, 0)
records = cmd.parse_response(body)
assert all(r.code for r in records)
# ---------------------------------------------------------------------------
# security_bars
# ---------------------------------------------------------------------------
def test_security_bars_parse():
from xmtdx.commands.security_bars import GetSecurityBarsCmd
from xmtdx.models.enums import Market, KlineCategory
body = load_hex("security_bars")
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
bars = cmd.parse_response(body)
assert len(bars) == 5
b0 = bars[0]
assert abs(b0.open - 10.25) < 0.01
assert abs(b0.high - 10.25) < 0.01
assert abs(b0.low - 10.08) < 0.01
assert abs(b0.close - 10.12) < 0.01
assert b0.vol > 0
# OHLC sanity: high ≥ open,close,low; low ≤ open,close
for bar in bars:
assert bar.high >= bar.open - 0.001
assert bar.high >= bar.close - 0.001
assert bar.low <= bar.open + 0.001
assert bar.low <= bar.close + 0.001
assert bar.vol > 0
assert len(bar._raw) > 0
# ---------------------------------------------------------------------------
# security_quotes
# ---------------------------------------------------------------------------
def test_security_quotes_parse():
from xmtdx.commands.security_quotes import GetSecurityQuotesCmd
from xmtdx.models.enums import Market
body = load_hex("security_quotes")
cmd = GetSecurityQuotesCmd([(Market.SH, "600000")])
quotes = cmd.parse_response(body)
assert len(quotes) == 1
q = quotes[0]
assert q.code == "600000"
assert abs(q.pre_close - 9.93) < 0.01
# unknown fields are captured (not discarded)
assert hasattr(q, "unknown_2")
assert hasattr(q, "unknown_3")
assert hasattr(q, "unknown_5")
assert hasattr(q, "unknown_6")
assert hasattr(q, "unknown_7")
assert hasattr(q, "unknown_8")
assert hasattr(q, "rise_speed")
assert len(q._raw) > 0
# fixed values from frozen fixture
assert q.unknown_2 == -1
assert q.unknown_3 == 22694
# ---------------------------------------------------------------------------
# minute_time
# ---------------------------------------------------------------------------
def test_minute_time_parse():
from xmtdx.commands.minute_time import GetMinuteTimeDataCmd
from xmtdx.models.enums import Market
body = load_hex("minute_time")
cmd = GetMinuteTimeDataCmd(Market.SH, "600000")
bars = cmd.parse_response(body)
assert len(bars) == 240
b0 = bars[0]
assert isinstance(b0.price, float)
assert isinstance(b0.vol, int)
# Bug #5 fix: unknown_1 is preserved, not discarded
assert hasattr(b0, "unknown_1")
assert isinstance(b0.unknown_1, int)
assert len(b0._raw) > 0
# fixed values
assert abs(b0.price - 0.01) < 0.001
assert b0.vol == 48
assert b0.unknown_1 == 54
# ---------------------------------------------------------------------------
# history_minute_time
# ---------------------------------------------------------------------------
def test_history_minute_time_parse():
from xmtdx.commands.minute_time import GetHistoryMinuteTimeDataCmd
from xmtdx.models.enums import Market
body = load_hex("history_minute_time")
cmd = GetHistoryMinuteTimeDataCmd(Market.SH, "600000", 20250108)
bars = cmd.parse_response(body)
assert len(bars) == 240
b0 = bars[0]
assert abs(b0.price - 10.29) < 0.01
assert b0.vol == 10044
assert hasattr(b0, "unknown_1")
assert len(b0._raw) > 0
# ---------------------------------------------------------------------------
# transaction (current day)
# ---------------------------------------------------------------------------
def test_transaction_parse():
from xmtdx.commands.transaction import GetTransactionDataCmd
from xmtdx.models.enums import Market
body = load_hex("transaction")
cmd = GetTransactionDataCmd(Market.SH, "600000", 0, 10)
recs = cmd.parse_response(body)
assert len(recs) == 10
r0 = recs[0]
assert r0.hour == 14
assert r0.minute == 59
assert abs(r0.price - 9.9) < 0.01
assert r0.vol == 0
# Bug #4 fix: unknown_last captured
assert hasattr(r0, "unknown_last")
assert len(r0._raw) > 0
# buyorsell: 0=buy, 1=sell, 2=neutral, 8=auction — field is an int
for r in recs:
assert isinstance(r.buyorsell, int)
# ---------------------------------------------------------------------------
# history_transaction
# ---------------------------------------------------------------------------
def test_history_transaction_parse():
from xmtdx.commands.transaction import GetHistoryTransactionDataCmd
from xmtdx.models.enums import Market
body = load_hex("history_transaction")
cmd = GetHistoryTransactionDataCmd(Market.SH, "600000", 20250108, 0, 10)
recs = cmd.parse_response(body)
assert len(recs) == 10
r0 = recs[0]
assert r0.hour == 14
assert r0.minute == 56
assert abs(r0.price - 10.3) < 0.01
assert r0.vol == 50
assert hasattr(r0, "unknown_last")
assert len(r0._raw) > 0
for r in recs:
assert isinstance(r.buyorsell, int)
# ---------------------------------------------------------------------------
# xdxr_info
# ---------------------------------------------------------------------------
def test_xdxr_info_parse():
from xmtdx.commands.xdxr_info import GetXdxrInfoCmd
from xmtdx.models.enums import Market
body = load_hex("xdxr_info")
cmd = GetXdxrInfoCmd(Market.SH, "600000")
recs = cmd.parse_response(body)
assert len(recs) == 87
r0 = recs[0]
assert r0.year == 1999
assert r0.month == 11
assert r0.day == 10
assert r0.category == 5
# Bug #1 fix: each record has a unique date (not all reading from body[:7])
dates = {(r.year, r.month, r.day) for r in recs}
assert len(dates) > 1, "All records have the same date — Bug #1 not fixed!"
assert all(len(r._raw) > 0 for r in recs)
# ---------------------------------------------------------------------------
# finance_info
# ---------------------------------------------------------------------------
def test_finance_info_parse():
from xmtdx.commands.finance_info import GetFinanceInfoCmd
from xmtdx.models.enums import Market
body = load_hex("finance_info")
cmd = GetFinanceInfoCmd(Market.SH, "600000")
info = cmd.parse_response(body)
# Check key fields are present and reasonable
assert info.liutong_guben > 0
assert info.zong_guben > 0
assert info.meigujing_zichan > 0
# Fixed values from frozen fixture
assert abs(info.liutong_guben - 33305837500.0) < 1e6
assert abs(info.zong_guben - 33305837500.0) < 1e6
assert abs(info.meigujing_zichan - 22.13) < 0.1
assert len(info._raw) > 0
# ---------------------------------------------------------------------------
# company_info_category
# ---------------------------------------------------------------------------
def test_company_info_category_parse():
from xmtdx.commands.company_info import GetCompanyInfoCategoryCmd
from xmtdx.models.enums import Market
body = load_hex("company_info_category")
cmd = GetCompanyInfoCategoryCmd(Market.SH, "600000")
cats = cmd.parse_response(body)
assert len(cats) == 16
c0 = cats[0]
assert c0.filename == "600000.txt"
assert c0.start == 0
assert c0.length == 11426
# ---------------------------------------------------------------------------
# company_info_content
# ---------------------------------------------------------------------------
def test_company_info_content_parse():
from xmtdx.commands.company_info import GetCompanyInfoContentCmd
from xmtdx.models.enums import Market
body = load_hex("company_info_content")
cmd = GetCompanyInfoContentCmd(Market.SH, "600000", "600000.txt", 0, 11426)
text = cmd.parse_response(body)
assert isinstance(text, str)
assert len(text) == 8070
assert "600000" in text
assert "浦发银行" in text