Files
easy_tdx_max/src/easy_tdx/transport/sync.py
T
awayingsandClaude Code f31790e6d9 fix: 适配 2026-09 主站新式握手,修复 K线/市场统计返回空数据
2026-09 起行情主站拒绝旧版 pytdx 三条固定 msg_id 握手建立的连接:
握手有响应,但该连接上 K 线一律返回 2 字节空包(0x0320,声称 800 条)、
880xxx 统计指数快照返回空——服务器不报错只是不给数据,导致 web
/market/stat 500、/bars/index 空列表。

修复:改为新式单条握手(0x000d + payload 0x01 + 随机 msg_id,每连接
新生成),sync/async transport 连接与 sync 心跳统一走
easy_tdx.commands.setup.build_handshake_command()。业务请求格式与固定
msg_id 无需改动(对照实验:新式握手连接上固定 msg_id 请求全部正常;
旧三条命令即使 msg_id 随机化仍被拒)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:25:28 +08:00

305 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""同步 TCP 连接(基于 socket)。"""
import socket
import threading
import time
from types import TracebackType
from typing import TYPE_CHECKING, TypeVar
from ..codec.frame import HEADER_SIZE, decompress_body, parse_header
from ..commands.setup import build_handshake_command
from ..config import (
get_best_host,
get_calc_hosts,
get_known_hosts,
get_mac_hosts,
get_port,
get_timeout,
)
from ..exceptions import TdxConnectionError
if TYPE_CHECKING:
from ..commands.base import BaseCommand
T = TypeVar("T")
_DEFAULT_HEARTBEAT_INTERVAL = 15.0
_MAX_CONSECUTIVE_HEARTBEATS = 20
# 模块级别名,供外部 `from easy_tdx.transport.sync import KNOWN_HOSTS` 使用。
# 在 import 时从配置读取一次;用户修改 config.json 后需重启生效。
KNOWN_HOSTS = get_known_hosts()
CALC_HOSTS = get_calc_hosts()
MAC_HOSTS = get_mac_hosts()
def ping_host(
host: str,
port: int | None = None,
timeout: float = 5.0,
) -> float | None:
"""测量连接到指定服务器并完成握手所需的时间(秒)。
返回延迟(秒),连接失败时返回 None。
"""
if port is None:
port = get_port()
t0 = time.monotonic()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
# 发送握手命令并等待响应作为可用性验证(新式单条握手,随机 msg_id)
sock.sendall(build_handshake_command())
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, TdxConnectionError):
# OSError: 连接/超时层失败;TdxConnectionError: 握手期服务器关闭连接
# _recv_exact_sock 抛出,继承自 TdxError 而非 OSError)。
# 两者均属"服务器不可用",按 docstring 返回 None,不拖垮整个 ping_all。
return None
finally:
try:
sock.close()
except OSError:
pass
def ping_all(
hosts: list[str] | None = None,
port: int | None = None,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""并发测量多台服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。
不可达的服务器不包含在结果中。
"""
if hosts is None:
hosts = get_known_hosts()
if port is None:
port = get_port()
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]
try:
latency = fut.result()
except Exception:
# 防御层:即使 ping_host 因意外原因抛异常,也只跳过该 host,
# 不让单个服务器拖垮整个 ping_all / `easy-tdx ping` 命令。
continue
if latency is not None:
results.append((host, latency))
results.sort(key=lambda t: t[1])
return results
def ping_mac_all(
hosts: list[str] | None = None,
port: int | None = None,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""并发测量多台 MAC 服务器延迟,返回按延迟排序的 (host, latency_seconds) 列表。"""
if hosts is None:
hosts = get_mac_hosts()
return ping_all(hosts=hosts, port=port, timeout=timeout)
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:
"""同步通达信 TCP 连接。
使用示例::
with TdxConnection("180.153.18.170") as conn:
result = conn.execute(SomeCommand(...))
"""
def __init__(
self,
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
) -> None:
self.host = host if host is not None else get_best_host()
self.port = port if port is not None else get_port()
self.timeout = timeout if timeout is not None else get_timeout()
self._sock: socket.socket | None = None
self._handshake_cmd: bytes = b"" # connect 时生成,心跳复用
self._lock = threading.Lock()
self._heartbeat_interval: float = 0 # 0 = disabled
self._stop_event: threading.Event | None = None
self._heartbeat_thread: threading.Thread | None = None
self._last_active: float = 0.0
self._consecutive_heartbeats: int = 0
def connect(self) -> None:
"""建立 TCP 连接并完成握手(发送3条 setup 命令)。"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
try:
sock.connect((self.host, self.port))
except OSError as e:
sock.close()
raise TdxConnectionError(f"无法连接 {self.host}:{self.port}: {e}") from e
self._sock = sock
try:
self._send_setup()
except Exception:
try:
sock.close()
except OSError:
pass
self._sock = None
raise
def close(self) -> None:
"""关闭连接。"""
self.stop_heartbeat()
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def execute(self, cmd: "BaseCommand[T]") -> T:
"""执行一条命令:发送请求,接收并解压响应,返回解析结果。"""
with self._lock:
self._last_active = time.monotonic()
self._consecutive_heartbeats = 0
if self._sock is None:
raise TdxConnectionError("未连接,请先调用 connect()")
request = cmd.build_request()
try:
self._sock.sendall(request)
header_buf = self._recv_exact(HEADER_SIZE)
header = parse_header(header_buf)
raw_body = self._recv_exact(header.zipsize)
except OSError as e:
raise TdxConnectionError(f"通信错误: {e}") from e
body = decompress_body(header, raw_body)
return cmd.parse_response(body)
# ------------------------------------------------------------------ #
# context manager
# ------------------------------------------------------------------ #
def __enter__(self) -> "TdxConnection":
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
# ------------------------------------------------------------------ #
# heartbeat
# ------------------------------------------------------------------ #
def start_heartbeat(self, interval: float = _DEFAULT_HEARTBEAT_INTERVAL) -> None:
"""启动心跳守护线程,定期发送 setup 包保活。"""
self._heartbeat_interval = interval
self._last_active = time.monotonic()
self._stop_event = threading.Event()
self._heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
daemon=True,
name="tdx-heartbeat",
)
self._heartbeat_thread.start()
def stop_heartbeat(self) -> None:
"""停止心跳线程。"""
stop_event = self._stop_event
thread = self._heartbeat_thread
if stop_event is not None:
stop_event.set()
if thread is not None:
thread.join(timeout=2.0)
self._stop_event = None
self._heartbeat_thread = None
self._heartbeat_interval = 0
def _heartbeat_loop(self) -> None:
"""心跳循环:在后台线程中运行。"""
assert self._stop_event is not None
interval = self._heartbeat_interval
while not self._stop_event.wait(timeout=interval):
if time.monotonic() - self._last_active <= interval:
continue
with self._lock:
if self._sock is None:
return
self._consecutive_heartbeats += 1
if self._consecutive_heartbeats >= _MAX_CONSECUTIVE_HEARTBEATS:
try:
self._sock.close()
except OSError:
pass
self._sock = None
return
try:
self._sock.sendall(self._handshake_cmd)
hdr_buf = _recv_exact_sock(self._sock, HEADER_SIZE)
hdr = parse_header(hdr_buf)
if hdr.zipsize > 0:
_recv_exact_sock(self._sock, hdr.zipsize)
except OSError:
try:
self._sock.close()
except OSError:
pass
self._sock = None
return
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
def _send_setup(self) -> None:
"""发送一条新式握手命令并丢弃响应。
2026-09 起主站拒绝旧三条握手(pytdx 固定 msg_id)建立的连接:握手
有响应但后续 K 线/快照一律返回空包。新式握手 = 单条 0x000d 命令、
payload 0x01、随机 msg_id(见 commands/setup.py 模块注释)。
"""
assert self._sock is not None
# 每连接生成一次握手字节:随机 msg_id 是本连接"新式客户端"的标记,
# 心跳复用它(与旧行为对称——旧代码心跳重发同一条固定 setup 命令)。
self._handshake_cmd = build_handshake_command()
self._sock.sendall(self._handshake_cmd)
# 读取并丢弃握手响应
try:
hdr_buf = self._recv_exact(HEADER_SIZE)
hdr = parse_header(hdr_buf)
if hdr.zipsize > 0:
self._recv_exact(hdr.zipsize)
except OSError:
# 部分服务器的握手无响应,忽略错误
pass
def _recv_exact(self, n: int) -> bytes:
"""循环 recv 直到读满 n 字节。"""
assert self._sock is not None
return _recv_exact_sock(self._sock, n)