Files
easy-tdx/src/easy_tdx/ex/client.py
T
Justin Gu 1f040afabf feat(reconnect): 引入服务器健康分引擎 + K线空数据故障转移
彻底解决通达信服务器"跳来跳去"问题:

1. 新增 _health.py 健康分引擎:失败乘性降权(×0.5)、连续失败3次进
   120s 冷却、成功加性恢复(+0.2)。rank_by_health 按 latency/score
   (有效延迟)重排,冷却中的剔除。全健康时恒等映射,对既有测试零影响。

2. get_index_bars/get_security_bars 空数据时自动逐台换台(此前直接
   返回空 DataFrame,是日志"指数K线响应在第1/800条处被截断"后用户拿
   不到数据的根因)。复用泛化后的 _find_host_returning_data。

3. select_best_host_*/find_working_host_* 应用 rank_by_health 重排;
   空数据验证失败/异常时调 record_failure,命中调 record_success。

4. 8 个 _execute(A股/MAC/EX/MAC-EX × sync/async)统一注入健康分记录:
   成功 record_success、连接失败 record_failure。

5. security_bars 截断日志区分"首条即空(服务器无数据)"与"末尾截断"。

测试:26 个新增(15 health + 7 failover + 4 ex-client 健康分追踪),
全量 reconnect/failover/decode 回归通过,ruff/mypy 通过。
2026-07-13 15:59:28 +08:00

576 lines
20 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.
"""扩展行情高层 APIExTdxClient(同步)和 AsyncExTdxClientasyncio)。"""
import asyncio
import logging
import time
from collections import OrderedDict
from collections.abc import Awaitable
from types import TracebackType
from typing import TypeVar
from .._df import _apply_bar_time_align_bars, _category_to_minutes
from .._health import record_failure, record_success
from .._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
select_best_host_async,
select_best_host_sync,
)
from ..commands.base import BaseCommand
from ..config import get_best_ex_host, get_ex_hosts, save_best_ex_host
from ..exceptions import TdxConnectionError
from .commands.get_history_bars_range import GetExHistoryInstrumentBarsRangeCmd
from .commands.get_instrument_bars import GetExInstrumentBarsCmd
from .commands.get_instrument_count import GetExInstrumentCountCmd
from .commands.get_instrument_info import GetExInstrumentInfoCmd
from .commands.get_instrument_quote import GetExInstrumentQuoteCmd
from .commands.get_instrument_quote_list import GetExInstrumentQuoteListCmd
from .commands.get_markets import GetExMarketsCmd
from .commands.get_minute_time import (
GetExHistoryMinuteTimeDataCmd,
GetExMinuteTimeDataCmd,
)
from .commands.get_transaction import (
GetExHistoryTransactionDataCmd,
GetExTransactionDataCmd,
)
from .models import (
ExInstrumentBar,
ExInstrumentInfo,
ExInstrumentQuote,
ExMarketInfo,
ExMinuteBar,
ExTransactionRecord,
)
from .transport.async_ import AsyncExTdxConnection
from .transport.sync import ExTdxConnection, ping_ex_all
logger = logging.getLogger(__name__)
_DEFAULT_EX_PORT = 7727
_T = TypeVar("_T")
# ============================================================
# 同步客户端
# ============================================================
class ExTdxClient:
"""同步扩展行情客户端(期货、港股、外股等,端口 7727)。
使用示例::
with ExTdxClient("61.152.107.141") as c:
markets = c.get_markets()
quote = c.get_instrument_quote(47, "IFL0")
"""
def __init__(
self,
host: str | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 15.0,
auto_reconnect: bool = True,
) -> None:
self._host = host if host is not None else get_best_ex_host()
self._port = port
self._timeout = timeout
self._auto_reconnect = auto_reconnect
self._conn = ExTdxConnection(self._host, port, timeout)
@classmethod
def from_best_host(
cls,
hosts: list[str] | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 15.0,
ping_timeout: float = 5.0,
auto_reconnect: bool = True,
) -> "ExTdxClient":
"""测量所有扩展行情服务器延迟,选最低延迟建立连接。自动保存最佳主机。"""
if hosts is None:
hosts = get_ex_hosts()
ranked = ping_ex_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_ex_host(best)
return cls(best, port, timeout, auto_reconnect)
@staticmethod
def ping_all(
hosts: list[str] | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
return ping_ex_all(hosts, port, timeout)
# ------------------------------------------------------------------ #
# 连接管理
# ------------------------------------------------------------------ #
def connect(self) -> None:
self._conn.connect()
def close(self) -> None:
self._conn.close()
def __enter__(self) -> "ExTdxClient":
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def _reconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
扩展行情无心跳,重建仅 close → new ExTdxConnection → connect。
供 ``_execute`` 同主机重试与跨主机故障转移复用。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
self._conn.close()
self._conn = ExTdxConnection(target, self._port, self._timeout)
self._conn.connect()
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性与 A 股/MAC 统一(审计 #2):先同主机重试 4 次,再跨主机
故障转移(测速切到另一台扩展行情服务器)。
健康分联动与 A 股 client 一致(成功记 success、连接失败记 failure)。
"""
try:
result = self._conn.execute(cmd)
except TdxConnectionError:
if not self._auto_reconnect:
raise
record_failure(self._host)
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
time.sleep(delay)
self._reconnect()
try:
result = self._conn.execute(cmd)
record_success(self._host)
return result
except TdxConnectionError as e:
last_exc = e
record_failure(self._host)
# 第二阶段:跨主机故障转移
new_host = select_best_host_sync(
get_ex_hosts(),
ping_ex_all,
save_best_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._reconnect(new_host)
try:
result = self._conn.execute(cmd)
record_success(self._host)
return result
except TdxConnectionError as e:
last_exc = e
record_failure(self._host)
raise last_exc # type: ignore[misc]
else:
record_success(self._host)
return result
# ------------------------------------------------------------------ #
# 市场信息
# ------------------------------------------------------------------ #
def get_markets(self) -> list[ExMarketInfo]:
"""获取扩展行情支持的市场列表。"""
return self._execute(GetExMarketsCmd())
def get_instrument_count(self) -> int:
"""获取扩展行情商品总数。"""
return self._execute(GetExInstrumentCountCmd())
def get_instrument_info(self, start: int, count: int = 100) -> list[ExInstrumentInfo]:
"""获取商品信息列表(分页)。"""
return self._execute(GetExInstrumentInfoCmd(start, count))
# ------------------------------------------------------------------ #
# 行情
# ------------------------------------------------------------------ #
def get_instrument_quote(self, market: int, code: str) -> ExInstrumentQuote | None:
"""获取单个商品五档实时行情。"""
return self._execute(GetExInstrumentQuoteCmd(market, code))
def get_instrument_quote_list(
self,
market: int,
category: int,
start: int = 0,
count: int = 80,
) -> list[OrderedDict[str, object]]:
"""按类别获取商品行情列表。"""
return self._execute(GetExInstrumentQuoteListCmd(market, category, start, count))
# ------------------------------------------------------------------ #
# K线
# ------------------------------------------------------------------ #
def get_instrument_bars(
self,
category: int,
market: int,
code: str,
start: int = 0,
count: int = 700,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]:
"""获取K线数据。
Args:
bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始);
``"end"`` = bar 右端点(与 Tushare/同花顺对齐)。仅分钟级周期生效。
"""
bars = self._execute(GetExInstrumentBarsCmd(category, market, code, start, count))
delta = _category_to_minutes(category)
return _apply_bar_time_align_bars(
bars, is_intraday=delta is not None, delta_minutes=delta, bar_time=bar_time
)
def get_history_instrument_bars_range(
self,
market: int,
code: str,
start_date: int,
end_date: int,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]:
"""按日期范围获取历史K线。
Note:
``bar_time="end"`` 需要知道每根 bar 的周期时长,但本接口按日期范围查询、
不携带周期信息,无法推断。传入 ``"end"`` 时发出 warning 并原样返回(通达信
原始开始时间)。如需对齐 Tushare,请改用 :meth:`get_instrument_bars`。
"""
bars = self._execute(GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date))
if bar_time == "end":
logger.warning(
"get_history_instrument_bars_range 不支持 bar_time='end'(缺少周期信息),"
"原样返回通达信开始时间。"
)
return bars
# ------------------------------------------------------------------ #
# 分时
# ------------------------------------------------------------------ #
def get_minute_time_data(self, market: int, code: str) -> list[ExMinuteBar]:
"""获取当日分时行情数据。"""
return self._execute(GetExMinuteTimeDataCmd(market, code))
def get_history_minute_time_data(
self,
market: int,
code: str,
date: int,
) -> list[ExMinuteBar]:
"""获取历史某日分时行情数据(date: YYYYMMDD)。"""
return self._execute(GetExHistoryMinuteTimeDataCmd(market, code, date))
# ------------------------------------------------------------------ #
# 成交
# ------------------------------------------------------------------ #
def get_transaction_data(
self,
market: int,
code: str,
start: int = 0,
count: int = 1800,
) -> list[ExTransactionRecord]:
"""获取当日分笔成交数据。"""
return self._execute(GetExTransactionDataCmd(market, code, start, count))
def get_history_transaction_data(
self,
market: int,
code: str,
date: int,
start: int = 0,
count: int = 1800,
) -> list[ExTransactionRecord]:
"""获取历史某日分笔成交数据(date: YYYYMMDD)。"""
return self._execute(GetExHistoryTransactionDataCmd(market, code, date, start, count))
# ============================================================
# 异步客户端
# ============================================================
class AsyncExTdxClient(AsyncHeartbeatMixin):
"""异步扩展行情客户端(asyncio,端口 7727)。
使用示例::
async with AsyncExTdxClient("61.152.107.141") as c:
markets = await c.get_markets()
"""
def __init__(
self,
host: str | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 15.0,
auto_reconnect: bool = True,
heartbeat_interval: float = 60.0,
) -> None:
self._host = host if host is not None else get_best_ex_host()
self._port = port
self._timeout = timeout
self._auto_reconnect = auto_reconnect
self._heartbeat_interval = heartbeat_interval
self._conn = AsyncExTdxConnection(self._host, port, timeout)
self._execute_lock = asyncio.Lock()
self._heartbeat_task: asyncio.Task[None] | None = None
@classmethod
def from_best_host(
cls,
hosts: list[str] | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 15.0,
ping_timeout: float = 5.0,
auto_reconnect: bool = True,
heartbeat_interval: float = 60.0,
) -> "AsyncExTdxClient":
if hosts is None:
hosts = get_ex_hosts()
ranked = ping_ex_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_ex_host(best)
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
@staticmethod
def ping_all(
hosts: list[str] | None = None,
port: int = _DEFAULT_EX_PORT,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
return ping_ex_all(hosts, port, timeout)
# ------------------------------------------------------------------ #
# 连接管理
# ------------------------------------------------------------------ #
async def connect(self) -> None:
await self._conn.connect()
self._start_heartbeat()
async def close(self) -> None:
await self._stop_heartbeat()
await self._conn.close()
async def __aenter__(self) -> "AsyncExTdxClient":
await self.connect()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
await self.close()
def _heartbeat_cmd(self) -> Awaitable[object]:
"""心跳使用的轻量请求(get_instrument_count,复用 _execute 重连)。"""
return self.get_instrument_count()
async def _areconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
供 ``_execute`` 同主机重试与跨主机故障转移复用。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
await self._stop_heartbeat()
await self._conn.close()
self._conn = AsyncExTdxConnection(target, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
健康分联动与 sync 版对称。
"""
async with self._execute_lock:
try:
result = await self._conn.execute(cmd)
except TdxConnectionError:
if not self._auto_reconnect:
raise
record_failure(self._host)
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
await asyncio.sleep(delay)
await self._areconnect()
try:
result = await self._conn.execute(cmd)
record_success(self._host)
return result
except TdxConnectionError as e:
last_exc = e
record_failure(self._host)
# 第二阶段:跨主机故障转移
new_host = await select_best_host_async(
get_ex_hosts(),
ping_ex_all,
save_best_ex_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
await self._areconnect(new_host)
try:
result = await self._conn.execute(cmd)
record_success(self._host)
return result
except TdxConnectionError as e:
last_exc = e
record_failure(self._host)
raise last_exc # type: ignore[misc]
else:
record_success(self._host)
return result
# ------------------------------------------------------------------ #
# 市场信息
# ------------------------------------------------------------------ #
async def get_markets(self) -> list[ExMarketInfo]:
return await self._execute(GetExMarketsCmd())
async def get_instrument_count(self) -> int:
return await self._execute(GetExInstrumentCountCmd())
async def get_instrument_info(
self,
start: int,
count: int = 100,
) -> list[ExInstrumentInfo]:
return await self._execute(GetExInstrumentInfoCmd(start, count))
# ------------------------------------------------------------------ #
# 行情
# ------------------------------------------------------------------ #
async def get_instrument_quote(
self,
market: int,
code: str,
) -> ExInstrumentQuote | None:
return await self._execute(GetExInstrumentQuoteCmd(market, code))
async def get_instrument_quote_list(
self,
market: int,
category: int,
start: int = 0,
count: int = 80,
) -> list[OrderedDict[str, object]]:
return await self._execute(GetExInstrumentQuoteListCmd(market, category, start, count))
# ------------------------------------------------------------------ #
# K线
# ------------------------------------------------------------------ #
async def get_instrument_bars(
self,
category: int,
market: int,
code: str,
start: int = 0,
count: int = 700,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]:
"""获取K线数据。``bar_time`` 见同步版 :meth:`get_instrument_bars`。"""
bars = await self._execute(GetExInstrumentBarsCmd(category, market, code, start, count))
delta = _category_to_minutes(category)
return _apply_bar_time_align_bars(
bars, is_intraday=delta is not None, delta_minutes=delta, bar_time=bar_time
)
async def get_history_instrument_bars_range(
self,
market: int,
code: str,
start_date: int,
end_date: int,
*,
bar_time: str = "start",
) -> list[ExInstrumentBar]:
"""按日期范围获取历史K线。``bar_time`` 见同步版(不支持 ``"end"``)。"""
bars = await self._execute(
GetExHistoryInstrumentBarsRangeCmd(market, code, start_date, end_date)
)
if bar_time == "end":
logger.warning(
"get_history_instrument_bars_range 不支持 bar_time='end'(缺少周期信息),"
"原样返回通达信开始时间。"
)
return bars
# ------------------------------------------------------------------ #
# 分时
# ------------------------------------------------------------------ #
async def get_minute_time_data(self, market: int, code: str) -> list[ExMinuteBar]:
return await self._execute(GetExMinuteTimeDataCmd(market, code))
async def get_history_minute_time_data(
self,
market: int,
code: str,
date: int,
) -> list[ExMinuteBar]:
return await self._execute(GetExHistoryMinuteTimeDataCmd(market, code, date))
# ------------------------------------------------------------------ #
# 成交
# ------------------------------------------------------------------ #
async def get_transaction_data(
self,
market: int,
code: str,
start: int = 0,
count: int = 1800,
) -> list[ExTransactionRecord]:
return await self._execute(GetExTransactionDataCmd(market, code, start, count))
async def get_history_transaction_data(
self,
market: int,
code: str,
date: int,
start: int = 0,
count: int = 1800,
) -> list[ExTransactionRecord]:
return await self._execute(GetExHistoryTransactionDataCmd(market, code, date, start, count))