Files
easy-tdx/src/easy_tdx/client.py
T
GitHub d0d65d64b8 feat: v1.20.0 服务器失败自动 ping 切换(无需手动 easy-tdx ping)
服务器连不上或返回空数据时,自动测速切到延迟最低的可用服务器再试,
Python API / CLI / Web API 三入口全部自动生效。

核心改动:
- _reconnect.py 新增 select_best_host_sync/async(连接失败 failover)
  和 find_working_host_sync/async(空数据逐台实测)
- 8 个 client 的 _execute 注入跨主机故障转移(复用 auto_reconnect 开关)
- get_market_stat 空数据时按延迟顺序逐台实测找返回数据的服务器
- 新增 _reconnect/_areconnect helper 收敛重建连接副本
- MacClient failover 用 save_best_mac_host(延续 v1.19.4 不污染 best_host)
- 顺手修复 test_commands_offline 未使用 import(main CI failure 根因)

测试:925 passed(新增 18 个 failover 测试),ruff/mypy 零新增错误。
2026-07-08 18:07:37 +08:00

1463 lines
55 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.
"""高层行情 APITdxClient(同步)和 AsyncTdxClientasyncio)。"""
import asyncio
import json
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from types import TracebackType
from typing import Any, TypeVar
from zoneinfo import ZoneInfo
import pandas as pd
from ._df import (
_add_minute_datetime,
_apply_bar_time_align_df,
_category_to_minutes,
_merge_bar_datetime,
_merge_txn_datetime,
_to_df,
)
from ._reconnect import (
_RETRY_DELAYS,
AsyncHeartbeatMixin,
find_working_host_async,
find_working_host_sync,
select_best_host_async,
select_best_host_sync,
)
from .codec.block import parse_block_dat
from .codec.financial import parse_financial_dat, parse_financial_file_list
from .codec.industry import parse_tdxhy_cfg
from .codec.price_rules import compute_price_limits, get_no_limit_window_days
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.fund_flow import GetHistoryFundFlowCmd
from .commands.minute_time import GetHistoryMinuteTimeDataCmd
from .commands.report_file import GetReportFileCmd
from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd
from .commands.security_count import GetSecurityCountCmd
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 .config import (
get_best_host,
get_calc_hosts,
get_known_hosts,
get_port,
get_timeout,
save_best_host,
)
from .exceptions import TdxConnectionError
from .models.bar import SecurityBar
from .models.enums import KlineCategory, Market
from .models.finance import (
FinancialFileInfo,
FinancialRecord,
)
from .models.quote import SecurityQuote
from .models.security import SecurityInfo
from .models.stats import FundFlow, HistoricalFundFlow, MarketStat
from .models.timeseries import TransactionRecord
from .transport.async_ import AsyncTdxConnection
from .transport.sync import TdxConnection, ping_all
_T = TypeVar("_T")
_SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
_DAILY_PLUS = frozenset(
{
KlineCategory.DAY,
KlineCategory.WEEK,
KlineCategory.MONTH,
KlineCategory.YEAR,
KlineCategory.YEAR_ALT,
}
)
def _today_in_shanghai() -> int:
return int(datetime.now(_SHANGHAI_TZ).strftime("%Y%m%d"))
def _record_signature(
record: TransactionRecord,
) -> tuple[int, int, float, int, int, int]:
return (
record.hour,
record.minute,
record.price,
record.vol,
record.buyorsell,
record.unknown_last,
)
def _page_signature(
records: list[TransactionRecord],
) -> tuple[tuple[int, int, float, int, int, int], tuple[int, int, float, int, int, int]]:
return (_record_signature(records[0]), _record_signature(records[-1]))
def _classify_fund_flow(records: list[TransactionRecord]) -> FundFlow:
stats = {
"super_in": 0.0,
"large_in": 0.0,
"medium_in": 0.0,
"small_in": 0.0,
"super_out": 0.0,
"large_out": 0.0,
"medium_out": 0.0,
"small_out": 0.0,
}
for record in records:
amount = record.price * record.vol * 100.0
direction = "in" if record.buyorsell == 0 else "out" if record.buyorsell == 1 else None
if not direction:
continue
if amount > 1_000_000:
stats[f"super_{direction}"] += amount
elif amount > 200_000:
stats[f"large_{direction}"] += amount
elif amount > 40_000:
stats[f"medium_{direction}"] += amount
else:
stats[f"small_{direction}"] += amount
return FundFlow(**stats)
def _date_from_bar(bar: SecurityBar) -> int:
return bar.year * 10000 + bar.month * 100 + bar.day
def _historical_fund_flow_from_records(
date: int, records: list[TransactionRecord]
) -> HistoricalFundFlow:
flow = _classify_fund_flow(records)
year = date // 10000
month = (date // 100) % 100
day = date % 100
return HistoricalFundFlow(
year=year,
month=month,
day=day,
super_in=flow.super_in,
super_out=flow.super_out,
large_in=flow.large_in,
large_out=flow.large_out,
medium_in=flow.medium_in,
medium_out=flow.medium_out,
small_in=flow.small_in,
small_out=flow.small_out,
)
# ============================================================
# 同步客户端
# ============================================================
_CACHE_DIR = Path.home() / ".easy_tdx" / "cache"
_CACHE_MAX_AGE = 86400 # 1 天
def _serialize_stocks(stocks: list[SecurityInfo]) -> list[dict[str, Any]]:
return [{k: v for k, v in asdict(s).items() if k != "_raw"} for s in stocks]
def _deserialize_stocks(data: list[dict[str, Any]]) -> list[SecurityInfo]:
return [SecurityInfo(**{**d, "market": Market(d["market"])}) for d in data]
def _load_cache() -> list[SecurityInfo] | None:
path = _CACHE_DIR / "security_list_all.json"
if not path.exists():
return None
try:
raw = json.loads(path.read_text("utf-8"))
updated = datetime.fromisoformat(raw["updated"])
# 统一用 aware datetime 比较(审计 #18):旧缓存可能写的是 naive datetime
# 此处 localize 到上海时区,避免跨时区机器(如 CI 的 UTC 与本地 +8)误判过期。
if updated.tzinfo is None:
updated = updated.replace(tzinfo=_SHANGHAI_TZ)
if (datetime.now(_SHANGHAI_TZ) - updated).total_seconds() > _CACHE_MAX_AGE:
return None
return _deserialize_stocks(raw["data"])
except Exception:
return None
def _save_cache(stocks: list[SecurityInfo]) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
data = {
"updated": datetime.now(_SHANGHAI_TZ).isoformat(),
"count": len(stocks),
"data": _serialize_stocks(stocks),
}
(_CACHE_DIR / "security_list_all.json").write_text(
json.dumps(data, ensure_ascii=False), "utf-8"
)
class TdxClient:
"""同步通达信行情客户端,支持 IP 优选与断线自动重连。
使用示例::
# 单台服务器
with TdxClient("180.153.18.170") as c:
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__(
self,
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
auto_reconnect: bool = True,
heartbeat_interval: float = 15.0,
) -> 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._auto_reconnect = auto_reconnect
self._heartbeat_interval = heartbeat_interval
self._conn = TdxConnection(host, port, timeout)
# ------------------------------------------------------------------ #
# 工厂方法:自动优选最低延迟服务器
# ------------------------------------------------------------------ #
@classmethod
def from_best_host(
cls,
hosts: list[str] | None = None,
port: int | None = None,
timeout: float | None = None,
ping_timeout: float = 5.0,
auto_reconnect: bool = True,
heartbeat_interval: float = 15.0,
) -> "TdxClient":
"""测量 hosts 中所有服务器延迟,选最低延迟的建立连接。
自动将最佳主机保存到 config.json,后续连接默认使用该主机。
若所有服务器均不可达,回退到 hosts[0]。
"""
if hosts is None:
hosts = get_known_hosts()
if port is None:
port = get_port()
if timeout is None:
timeout = get_timeout()
ranked = ping_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_host(best)
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
@staticmethod
def ping_all(
hosts: list[str] | None = None,
port: int | None = None,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。"""
if hosts is None:
hosts = get_known_hosts()
if port is None:
port = get_port()
return ping_all(hosts, port, timeout)
# ------------------------------------------------------------------ #
# 连接管理
# ------------------------------------------------------------------ #
def connect(self) -> None:
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
def close(self) -> None:
self._conn.stop_heartbeat()
self._conn.close()
def disconnect(self) -> None:
"""Alias for close()."""
self.close()
def ensure_connected(self) -> None:
"""验证连接存活,断线则自动重建。"""
try:
self._execute(GetSecurityCountCmd(Market.SH))
except TdxConnectionError:
self._reconnect()
def _reconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
统一收敛所有"重建 TdxConnection + 起心跳"的副本:``_execute`` 同主机
重试、``_execute`` 跨主机故障转移、``ensure_connected``、
``get_market_stat`` 空数据重试都走这里,保证 4 处重建逻辑一致。
"""
target = host if host is not None else self._host
if host is not None:
self._host = host
self._conn.stop_heartbeat()
self._conn.close()
self._conn = TdxConnection(target, self._port, self._timeout)
self._conn.connect()
if self._heartbeat_interval > 0:
self._conn.start_heartbeat(self._heartbeat_interval)
def __enter__(self) -> "TdxClient":
self.connect()
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
# ------------------------------------------------------------------ #
# 内部执行:含自动重连 + 跨主机故障转移
# ------------------------------------------------------------------ #
def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性:
1. 同主机重试(``_RETRY_DELAYS``,4 次指数退避)——应对瞬时抖动。
2. 跨主机故障转移——同主机重试仍失败时,重新测速选延迟最低的另
一台服务器再试一轮。服务器连不上时用户无需手动 ``ping``。
``auto_reconnect=False`` 时两阶段都不触发,直接抛出原异常。
"""
try:
return self._conn.execute(cmd)
except TdxConnectionError:
if not self._auto_reconnect:
raise
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
time.sleep(delay)
self._reconnect()
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移——重新测速切到另一台服务器再试一次
new_host = select_best_host_sync(
get_known_hosts(),
ping_all,
save_best_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
self._reconnect(new_host)
try:
return self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
raise last_exc # type: ignore[misc]
# ------------------------------------------------------------------ #
# 市场信息
# ------------------------------------------------------------------ #
def get_security_count(self, market: Market) -> int:
"""获取市场证券总数。"""
return self._execute(GetSecurityCountCmd(market))
def get_security_list(self, market: Market, start: int) -> pd.DataFrame:
"""获取证券列表(每页约1000条,按 start 分页)。"""
return _to_df(self._execute(GetSecurityListCmd(market, start)))
def get_security_list_all(self, pages: int | str = "all") -> pd.DataFrame:
"""获取沪深 A 股完整证券列表,并自动挂载行业信息。
Args:
pages: 拉取页数。每个市场每页 1000 条。
"all" 拉取全部(默认,结果会缓存到本地文件)。
整数 N 表示每个市场只拉前 N 页,不缓存。
注意:
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
"""
log = logging.getLogger(__name__)
if pages == "all":
cached = _load_cache()
if cached is not None:
log.info("从缓存加载沪深 A 股列表,共 %d 只", len(cached))
return _to_df(cached)
# 计算每个市场的最大起始偏移
def _max_start(count: int) -> int:
if pages == "all":
return count
return min(count, int(pages) * 1000)
# 尝试获取行业配置
industry_map: dict[str, tuple[str, str]] = {}
try:
cfg_data = self.get_report_file("tdxhy.cfg")
if cfg_data:
industry_map = parse_tdxhy_cfg(cfg_data)
log.info("行业配置已加载,共 %d 条映射", len(industry_map))
except Exception:
log.warning("无法获取 tdxhy.cfg,行业字段将为空")
all_stocks: list[SecurityInfo] = []
for market in [Market.SH, Market.SZ]:
count = self.get_security_count(market)
limit = _max_start(count)
total_pages = (limit + 999) // 1000
for page_idx, start in enumerate(range(0, limit, 1000)):
try:
stocks = self._execute(GetSecurityListCmd(market, start))
except Exception:
log.warning(
"%s%d/%d 页获取失败,跳过", market.name, page_idx + 1, total_pages
)
continue
log.info(
"%s%d/%d 页: %d 条", market.name, page_idx + 1, total_pages, len(stocks)
)
for s in stocks:
is_a_share = (market == Market.SH and s.code.startswith(("60", "68"))) or (
market == Market.SZ and s.code.startswith(("00", "30"))
)
if is_a_share:
if s.code in industry_map:
s.industry_tdx, s.industry_sw = industry_map[s.code]
all_stocks.append(s)
log.info("沪深 A 股总数: %d", len(all_stocks))
if pages == "all":
_save_cache(all_stocks)
return _to_df(all_stocks)
def get_security_quotes(self, stocks: list[tuple[Market, str]]) -> pd.DataFrame:
"""批量获取实时五档行情(最多80只/次)。"""
return _to_df(self._execute(GetSecurityQuotesCmd(stocks)))
def get_price_limits(
self, market: Market, code: str, name: str, pre_close: float
) -> tuple[float | None, float | None]:
"""按当前交易状态计算涨跌停价。
对上市初期不设涨跌幅限制的标的,会先用日 K 线条数估算已上市交易天数。
"""
listed_days: int | None = None
no_limit_window_days = get_no_limit_window_days(market, code, name)
if no_limit_window_days > 0:
try:
bars = self._execute(
GetSecurityBarsCmd(market, code, KlineCategory.DAY, 0, no_limit_window_days + 1)
)
listed_days = len(bars)
except Exception:
listed_days = None
return compute_price_limits(
market,
code,
name,
pre_close,
listed_days=listed_days,
)
# ------------------------------------------------------------------ #
# K 线
# ------------------------------------------------------------------ #
def get_security_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""获取 K 线数据(最多800条/次,按 start 分页)。
Args:
bar_time: 时间戳语义。 ``"start"``(默认)= bar 开始时间(通达信原始,
上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点
(= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。
仅对分钟级周期生效;日线及以上不受影响。
"""
df = _to_df(self._execute(GetSecurityBarsCmd(market, code, category, start, count)))
delta = _category_to_minutes(int(category))
is_intraday = delta is not None
df = _apply_bar_time_align_df(
df,
is_intraday=is_intraday,
delta_minutes=delta,
bar_time=bar_time,
has_time_columns=True,
)
return _merge_bar_datetime(df, not is_intraday)
def get_index_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""获取指数 K 线数据。
Args:
bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。
"""
df = _to_df(self._execute(GetIndexBarsCmd(market, code, category, start, count)))
delta = _category_to_minutes(int(category))
is_intraday = delta is not None
df = _apply_bar_time_align_df(
df,
is_intraday=is_intraday,
delta_minutes=delta,
bar_time=bar_time,
has_time_columns=True,
)
return _merge_bar_datetime(df, not is_intraday)
# ------------------------------------------------------------------ #
# 分时
# ------------------------------------------------------------------ #
def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame:
"""获取今日分时数据(240条,走历史分时接口)。"""
today = _today_in_shanghai()
bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, today))
return _add_minute_datetime(_to_df(bars), today)
def get_history_minute_time_data(self, market: Market, code: str, date: int) -> pd.DataFrame:
"""获取历史某日分时数据(date: YYYYMMDD)。"""
bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
return _add_minute_datetime(_to_df(bars), date)
# ------------------------------------------------------------------ #
# 逐笔成交
# ------------------------------------------------------------------ #
def get_transaction_data(
self, market: Market, code: str, start: int, count: int = 800
) -> pd.DataFrame:
"""获取当日逐笔成交(分页)。"""
df = _to_df(self._execute(GetTransactionDataCmd(market, code, start, count)))
return _merge_txn_datetime(df, _today_in_shanghai())
def get_history_transaction_data(
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> pd.DataFrame:
"""获取历史逐笔成交(date: YYYYMMDD,分页)。"""
df = _to_df(self._execute(GetHistoryTransactionDataCmd(market, code, date, start, count)))
return _merge_txn_datetime(df, date)
# ------------------------------------------------------------------ #
# 财务 / 公司
# ------------------------------------------------------------------ #
def get_xdxr_info(self, market: Market, code: str) -> pd.DataFrame:
"""获取除权除息历史记录。"""
return _to_df(self._execute(GetXdxrInfoCmd(market, code)))
def get_finance_info(self, market: Market, code: str) -> pd.DataFrame:
"""获取最新财务数据。"""
return _to_df(self._execute(GetFinanceInfoCmd(market, code)))
def get_company_info_category(self, market: Market, code: str) -> pd.DataFrame:
"""获取公司信息文件目录。"""
return _to_df(self._execute(GetCompanyInfoCategoryCmd(market, code)))
def get_company_info_content(
self, market: Market, code: str, filename: str, offset: int, length: int
) -> str:
"""读取公司信息文本。"""
return self._execute(GetCompanyInfoContentCmd(market, code, filename, offset, length))
def get_block_info(self, filename: str) -> pd.DataFrame:
"""获取并解析板块文件(行业、概念、风格等)。
常用文件名:
'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 _to_df(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)
@staticmethod
def _download_from_host(
host: str, filename: str, port: int = 7709, timeout: float = 15.0
) -> bytes:
"""从指定服务器创建临时连接并下载文件。"""
conn = TdxConnection(host, port, timeout)
try:
conn.connect()
full_data = bytearray()
pos = 0
chunk_size = 30000
while True:
chunk = conn.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)
finally:
conn.close()
def get_financial_file_list(self, host: str | None = None) -> pd.DataFrame:
"""获取可用的历史专业财报文件列表。
连接到计算服务器,下载 tdxfin/gpcw.txt 并解析。
"""
if host is None:
host = get_calc_hosts()[0]
data = self._download_from_host(host, "tdxfin/gpcw.txt")
raw_list = parse_financial_file_list(data)
return _to_df([FinancialFileInfo(filename=f, hash=h, filesize=s) for f, h, s in raw_list])
def get_financial_file(self, filename: str, host: str | None = None) -> bytes:
"""从计算服务器下载财报 zip 文件。
Args:
filename: 如 'tdxfin/gpcw20260331.zip'
"""
if host is None:
host = get_calc_hosts()[0]
return self._download_from_host(host, filename)
def get_financial_records(self, filename: str, host: str | None = None) -> pd.DataFrame:
"""下载财报 zip 并解析为每只股票的记录列表。
Args:
filename: 如 'tdxfin/gpcw20260331.zip'
"""
if host is None:
host = get_calc_hosts()[0]
import io
import re
import zipfile
zip_data = self.get_financial_file(filename, host)
if not zip_data:
return pd.DataFrame()
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
dat_names = [n for n in zf.namelist() if n.endswith(".dat")]
if not dat_names:
return pd.DataFrame()
dat_data = zf.read(dat_names[0])
m = re.search(r"(\d{8})", filename)
report_date = int(m.group(1)) if m else 0
raw_records = parse_financial_dat(dat_data, report_date)
records: list[FinancialRecord] = []
for code, market_byte, rdate, fields in raw_records:
market = Market.SH if market_byte == 1 else Market.SZ
records.append(
FinancialRecord(code=code, market=market, report_date=rdate, fields=fields)
)
return _to_df(records)
def get_market_stat(self) -> pd.DataFrame:
"""获取 A 股全市场涨跌统计概况(基于 880005/880001/880006 统计指数)。
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒。
空数据容错:880005/880001/880006 并非所有服务器都提供,会返回空 quotes。
此时不只切换到延迟最低的一台(它可能也不提供),而是按延迟顺序逐台实测,
找到第一台返回有效数据的服务器,避免用户手动 ``easy-tdx ping``。
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
_cmd = GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
quotes = self._execute(_cmd)
if not quotes and self._auto_reconnect:
quotes = self._find_host_returning_quotes(_cmd)
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
# 计数字段协议返回值为真实家数 / 10,这里 ×10 还原(见 docstring
up = round(q.price * 10)
down = round(q.open * 10)
neutral = round(q.low * 10)
total = round(q.high * 10)
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
limit_down = round(quotes[2].open * 10) if len(quotes) > 2 else 0
limit_up = round(quotes[2].price * 10) if len(quotes) > 2 else 0
return _to_df(
MarketStat(
up_count=up,
down_count=down,
neutral_count=neutral,
suspended_count=max(0, total - up - down - neutral),
total_count=total,
total_amount=q.amount,
total_volume=q.vol,
total_market_cap=market_cap,
limit_up_count=limit_up,
limit_down_count=limit_down,
)
)
def _find_host_returning_quotes(
self, cmd: "BaseCommand[list[SecurityQuote]]"
) -> list[SecurityQuote]:
"""空数据故障转移:测速后按延迟顺序逐台实测,返回首台有效数据的 quotes。
专供 ``get_market_stat`` 使用——统计指数并非所有服务器都提供,延迟最低
的不一定返回数据,故需逐台实际查询。最多尝试 ``_WORKING_HOST_MAX_ATTEMPTS``
台(见 ``_reconnect``)。找到后 client 停在该 host;全失败返回空。
"""
bad_host = self._host
ranked = ping_all(get_known_hosts(), self._port, 5.0)
def _try(host: str) -> bool:
# 切换到候选 host 并实测;非空即视为该 host 可用
self._reconnect(host)
return bool(self._execute(cmd))
new_host = find_working_host_sync(ranked, _try, save_best_host, bad_host)
if new_host is None:
# 全部候选都不可用,回退到原 host(保持状态可预测)
if self._host != bad_host:
self._reconnect(bad_host)
return []
# _try 已把 client 切到 new_host 并执行过 cmd,重新取一次拿结果
return self._execute(cmd)
def _collect_transaction_records(
self,
fetch_page: Callable[[int, int], list[TransactionRecord]],
page_size: int,
max_start: int = 10000,
) -> list[TransactionRecord]:
all_recs: list[TransactionRecord] = []
seen_sig: set[tuple[int, int, float, int, int, int]] = set()
seen_page_sigs: set[
tuple[
tuple[int, int, float, int, int, int],
tuple[int, int, float, int, int, int],
]
] = set()
start = 0
while start < max_start:
recs = fetch_page(start, page_size)
if not recs:
break
page_sig = _page_signature(recs)
if page_sig in seen_page_sigs:
break
seen_page_sigs.add(page_sig)
new_count = 0
for record in recs:
sig = _record_signature(record)
if sig not in seen_sig:
seen_sig.add(sig)
all_recs.append(record)
new_count += 1
if new_count == 0:
break
start += len(recs)
if len(recs) < 100:
break
return all_recs
def get_fund_flow(self, market: Market, code: str) -> pd.DataFrame:
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
records = self._collect_transaction_records(
lambda start, page_size: self._execute(
GetTransactionDataCmd(market, code, start, page_size)
),
2000,
)
return _to_df(_classify_fund_flow(records))
def get_history_fund_flow(
self, market: Market, code: str, start: int, count: int
) -> pd.DataFrame:
"""获取个股历史日线资金流向序列。
优先走 Category 22 直连接口;若服务器返回空列表,则自动回退为
"日 K 线取日期 + 历史逐笔成交重算资金流"的兼容实现。
"""
try:
direct = self._execute(GetHistoryFundFlowCmd(market, code, start, count))
except Exception:
direct = []
if direct:
return _to_df(direct)
bars = self._execute(GetSecurityBarsCmd(market, code, KlineCategory.DAY, start, count))
results: list[HistoricalFundFlow] = []
for bar in bars:
date = _date_from_bar(bar)
# 用闭包工厂立即绑定 date(审计 #10),避免 lambda 延迟绑定循环变量。
def _fetch_page(
page_start: int, page_size: int, _d: int = date
) -> list[TransactionRecord]:
return self._execute(
GetHistoryTransactionDataCmd(market, code, _d, page_start, page_size)
)
records = self._collect_transaction_records(_fetch_page, 800)
results.append(_historical_fund_flow_from_records(date, records))
return _to_df(results)
# ============================================================
# 异步客户端
# ============================================================
class AsyncTdxClient(AsyncHeartbeatMixin):
"""异步通达信行情客户端(asyncio)。
使用示例::
async with AsyncTdxClient("180.153.18.170") as c:
bars = await c.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 100)
注意:
单个 AsyncTdxClient 仅维护一条 TCP 连接;并发调用会在连接内串行执行。
"""
def __init__(
self,
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
auto_reconnect: bool = True,
heartbeat_interval: float = 60.0,
) -> 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._auto_reconnect = auto_reconnect
self._heartbeat_interval = heartbeat_interval
self._conn = AsyncTdxConnection(self._host, self._port, self._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 | None = None,
timeout: float | None = None,
ping_timeout: float = 5.0,
auto_reconnect: bool = True,
heartbeat_interval: float = 60.0,
) -> "AsyncTdxClient":
"""测量 hosts 中所有服务器延迟,选最低延迟的建立连接。
自动将最佳主机保存到 config.json。
"""
if hosts is None:
hosts = get_known_hosts()
if port is None:
port = get_port()
if timeout is None:
timeout = get_timeout()
ranked = ping_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_host(best)
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
@staticmethod
def ping_all(
hosts: list[str] | None = None,
port: int | None = None,
timeout: float = 5.0,
) -> list[tuple[str, float]]:
"""测量多台服务器延迟,返回按延迟排序的 (host, seconds) 列表。"""
if hosts is None:
hosts = get_known_hosts()
if port is None:
port = get_port()
return ping_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 reconnect_to(self, host: str) -> None:
"""热切换到新 host:关旧连接 → 换 host → 建新连接。
用于 web UI 的"服务器设置"页面——用户点选一个 host 后,无需重启
服务即可切换。复用 ``_execute_lock`` 保证切换期间没有并发行情请求
撞到半开的连接。切换失败抛异常(旧连接已 close,client 处于断开
状态,调用方应捕获并提示用户选别的 host)。
"""
async with self._execute_lock:
await self._stop_heartbeat()
await self._conn.close()
self._host = host
self._conn = AsyncTdxConnection(host, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def __aenter__(self) -> "AsyncTdxClient":
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_security_count,复用 _execute 重连)。"""
return self.get_security_count(Market.SH)
async def _areconnect(self, host: str | None = None) -> None:
"""关闭当前连接并重建(默认连 self._host;传入 host 则切换主机)。
async 版的统一重建入口,与 sync ``_reconnect`` 对称,供 ``_execute``
同主机重试、跨主机故障转移、``get_market_stat`` 空数据重试复用。
"""
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 = AsyncTdxConnection(target, self._port, self._timeout)
await self._conn.connect()
self._start_heartbeat()
async def _execute(self, cmd: "BaseCommand[_T]") -> _T:
"""执行命令;断线时指数退避重试,同主机耗尽则跨主机故障转移。
两阶段韧性与 sync 版对称:先同主机重试(``_RETRY_DELAYS``),再跨主机
故障转移(重新测速切到另一台服务器)。整个流程在 ``_execute_lock`` 内
串行,避免并发请求触发多次故障转移抖动。``auto_reconnect=False`` 时
两阶段都不触发。
"""
async with self._execute_lock:
try:
return await self._conn.execute(cmd)
except TdxConnectionError:
if not self._auto_reconnect:
raise
last_exc: TdxConnectionError | None = None
for delay in _RETRY_DELAYS:
await asyncio.sleep(delay)
await self._areconnect()
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
# 第二阶段:跨主机故障转移
new_host = await select_best_host_async(
get_known_hosts(),
ping_all,
save_best_host,
self._port,
5.0,
self._host,
)
if new_host is not None:
await self._areconnect(new_host)
try:
return await self._conn.execute(cmd)
except TdxConnectionError as e:
last_exc = e
raise last_exc # type: ignore[misc]
async def get_security_count(self, market: Market) -> int:
return await self._execute(GetSecurityCountCmd(market))
async def get_security_list(self, market: Market, start: int) -> pd.DataFrame:
return _to_df(await self._execute(GetSecurityListCmd(market, start)))
async def get_security_list_all(self, pages: int | str = "all") -> pd.DataFrame:
"""获取沪深 A 股完整证券列表,并自动挂载行业信息。
Args:
pages: 拉取页数。每个市场每页 1000 条。
"all" 拉取全部(默认,结果会缓存到本地文件)。
整数 N 表示每个市场只拉前 N 页,不缓存。
注意:
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
"""
log = logging.getLogger(__name__)
if pages == "all":
cached = _load_cache()
if cached is not None:
log.info("从缓存加载沪深 A 股列表,共 %d 只", len(cached))
return _to_df(cached)
def _max_start(count: int) -> int:
if pages == "all":
return count
return min(count, int(pages) * 1000)
industry_map: dict[str, tuple[str, str]] = {}
try:
cfg_data = await self.get_report_file("tdxhy.cfg")
if cfg_data:
industry_map = parse_tdxhy_cfg(cfg_data)
log.info("行业配置已加载,共 %d 条映射", len(industry_map))
except Exception:
log.warning("无法获取 tdxhy.cfg,行业字段将为空")
all_stocks: list[SecurityInfo] = []
for market in [Market.SH, Market.SZ]:
count = await self.get_security_count(market)
limit = _max_start(count)
total_pages = (limit + 999) // 1000
for page_idx, start in enumerate(range(0, limit, 1000)):
try:
stocks = await self._execute(GetSecurityListCmd(market, start))
except Exception:
log.warning(
"%s%d/%d 页获取失败,跳过", market.name, page_idx + 1, total_pages
)
continue
log.info(
"%s%d/%d 页: %d 条", market.name, page_idx + 1, total_pages, len(stocks)
)
for s in stocks:
is_a_share = (market == Market.SH and s.code.startswith(("60", "68"))) or (
market == Market.SZ and s.code.startswith(("00", "30"))
)
if is_a_share:
if s.code in industry_map:
s.industry_tdx, s.industry_sw = industry_map[s.code]
all_stocks.append(s)
log.info("沪深 A 股总数: %d", len(all_stocks))
if pages == "all":
_save_cache(all_stocks)
return _to_df(all_stocks)
async def get_security_quotes(self, stocks: list[tuple[Market, str]]) -> pd.DataFrame:
return _to_df(await self._execute(GetSecurityQuotesCmd(stocks)))
async def get_price_limits(
self, market: Market, code: str, name: str, pre_close: float
) -> tuple[float | None, float | None]:
"""按当前交易状态计算涨跌停价。"""
listed_days: int | None = None
no_limit_window_days = get_no_limit_window_days(market, code, name)
if no_limit_window_days > 0:
try:
bars = await self._execute(
GetSecurityBarsCmd(market, code, KlineCategory.DAY, 0, no_limit_window_days + 1)
)
listed_days = len(bars)
except Exception:
listed_days = None
return compute_price_limits(
market,
code,
name,
pre_close,
listed_days=listed_days,
)
async def get_security_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""获取 K 线数据。``bar_time`` 见同步版 :meth:`get_security_bars`。"""
df = _to_df(await self._execute(GetSecurityBarsCmd(market, code, category, start, count)))
delta = _category_to_minutes(int(category))
is_intraday = delta is not None
df = _apply_bar_time_align_df(
df,
is_intraday=is_intraday,
delta_minutes=delta,
bar_time=bar_time,
has_time_columns=True,
)
return _merge_bar_datetime(df, not is_intraday)
async def get_index_bars(
self,
market: Market,
code: str,
category: KlineCategory,
start: int,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""获取指数 K 线数据。``bar_time`` 见同步版 :meth:`get_index_bars`。"""
df = _to_df(await self._execute(GetIndexBarsCmd(market, code, category, start, count)))
delta = _category_to_minutes(int(category))
is_intraday = delta is not None
df = _apply_bar_time_align_df(
df,
is_intraday=is_intraday,
delta_minutes=delta,
bar_time=bar_time,
has_time_columns=True,
)
return _merge_bar_datetime(df, not is_intraday)
async def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame:
today = _today_in_shanghai()
bars = await self._execute(GetHistoryMinuteTimeDataCmd(market, code, today))
return _add_minute_datetime(_to_df(bars), today)
async def get_history_minute_time_data(
self, market: Market, code: str, date: int
) -> pd.DataFrame:
bars = await self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
return _add_minute_datetime(_to_df(bars), date)
async def get_transaction_data(
self, market: Market, code: str, start: int, count: int = 800
) -> pd.DataFrame:
df = _to_df(await self._execute(GetTransactionDataCmd(market, code, start, count)))
return _merge_txn_datetime(df, _today_in_shanghai())
async def get_history_transaction_data(
self, market: Market, code: str, date: int, start: int, count: int = 800
) -> pd.DataFrame:
df = _to_df(
await self._execute(GetHistoryTransactionDataCmd(market, code, date, start, count))
)
return _merge_txn_datetime(df, date)
async def get_xdxr_info(self, market: Market, code: str) -> pd.DataFrame:
return _to_df(await self._execute(GetXdxrInfoCmd(market, code)))
async def get_finance_info(self, market: Market, code: str) -> pd.DataFrame:
return _to_df(await self._execute(GetFinanceInfoCmd(market, code)))
async def get_company_info_category(self, market: Market, code: str) -> pd.DataFrame:
return _to_df(await self._execute(GetCompanyInfoCategoryCmd(market, code)))
async def get_company_info_content(
self, market: Market, code: str, filename: str, offset: int, length: int
) -> str:
return await self._execute(GetCompanyInfoContentCmd(market, code, filename, offset, length))
async def get_block_info(self, filename: str) -> pd.DataFrame:
"""获取并解析板块文件(行业、概念、风格等)。"""
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 _to_df(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)
@staticmethod
async def _async_download_from_host(
host: str, filename: str, port: int = 7709, timeout: float = 15.0
) -> bytes:
"""从指定服务器创建临时异步连接并下载文件。"""
conn = AsyncTdxConnection(host, port, timeout)
try:
await conn.connect()
full_data = bytearray()
pos = 0
chunk_size = 30000
while True:
chunk = await conn.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)
finally:
await conn.close()
async def get_financial_file_list(self, host: str | None = None) -> pd.DataFrame:
"""获取可用的历史专业财报文件列表(异步)。"""
if host is None:
host = get_calc_hosts()[0]
data = await self._async_download_from_host(host, "tdxfin/gpcw.txt")
raw_list = parse_financial_file_list(data)
return _to_df([FinancialFileInfo(filename=f, hash=h, filesize=s) for f, h, s in raw_list])
async def get_financial_file(self, filename: str, host: str | None = None) -> bytes:
"""从计算服务器下载财报 zip 文件(异步)。"""
if host is None:
host = get_calc_hosts()[0]
return await self._async_download_from_host(host, filename)
async def get_financial_records(self, filename: str, host: str | None = None) -> pd.DataFrame:
"""下载财报 zip 并解析为记录列表(异步)。"""
if host is None:
host = get_calc_hosts()[0]
import io
import re
import zipfile
zip_data = await self.get_financial_file(filename, host)
if not zip_data:
return pd.DataFrame()
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
dat_names = [n for n in zf.namelist() if n.endswith(".dat")]
if not dat_names:
return pd.DataFrame()
dat_data = zf.read(dat_names[0])
m = re.search(r"(\d{8})", filename)
report_date = int(m.group(1)) if m else 0
raw_records = parse_financial_dat(dat_data, report_date)
records: list[FinancialRecord] = []
for code, market_byte, rdate, fields in raw_records:
market = Market.SH if market_byte == 1 else Market.SZ
records.append(
FinancialRecord(code=code, market=market, report_date=rdate, fields=fields)
)
return _to_df(records)
async def get_market_stat(self) -> pd.DataFrame:
"""获取 A 股全市场涨跌统计概况(基于 880005/880001/880006 统计指数)。
通达信这三个"统计指数"的计数类字段(涨/跌/平/总数/涨停/跌停家数)
返回的是真实家数的 1/10,需统一 ×10 还原。成交额/量/市值字段不受影响。
`suspended_count` 由 `total - up - down - neutral` 推得,用于保证计数守恒。
空数据容错:与 sync 版对称——空 quotes 时按延迟顺序逐台实测,找到首台
返回有效数据的服务器。
"""
# 通达信中 880005 是全市场行情统计,880001 是总市值指数,880006 是涨跌停统计
_cmd = GetSecurityQuotesCmd(
[(Market.SH, "880005"), (Market.SH, "880001"), (Market.SH, "880006")]
)
quotes = await self._execute(_cmd)
if not quotes and self._auto_reconnect:
quotes = await self._find_host_returning_quotes(_cmd)
if not quotes:
raise RuntimeError("无法获取市场统计数据")
q = quotes[0]
# 计数字段协议返回值为真实家数 / 10,这里 ×10 还原(见 docstring
up = round(q.price * 10)
down = round(q.open * 10)
neutral = round(q.low * 10)
total = round(q.high * 10)
market_cap = quotes[1].price * 1e10 if len(quotes) > 1 else 0.0
limit_down = round(quotes[2].open * 10) if len(quotes) > 2 else 0
limit_up = round(quotes[2].price * 10) if len(quotes) > 2 else 0
return _to_df(
MarketStat(
up_count=up,
down_count=down,
neutral_count=neutral,
suspended_count=max(0, total - up - down - neutral),
total_count=total,
total_amount=q.amount,
total_volume=q.vol,
total_market_cap=market_cap,
limit_up_count=limit_up,
limit_down_count=limit_down,
)
)
async def _find_host_returning_quotes(
self, cmd: "BaseCommand[list[SecurityQuote]]"
) -> list[SecurityQuote]:
"""空数据故障转移(async):与 sync ``_find_host_returning_quotes`` 对称。"""
bad_host = self._host
ranked = await asyncio.to_thread(ping_all, get_known_hosts(), self._port, 5.0)
async def _try(host: str) -> bool:
await self._areconnect(host)
# mypy 对 async 闭包内泛型参数的推断会宽化为 BaseCommand[object]
# (sync 同模式可正确推断),此处为已知 mypy 限制,非真实类型错误。
return bool(await self._execute(cmd)) # type: ignore[arg-type]
new_host = await find_working_host_async(ranked, _try, save_best_host, bad_host)
if new_host is None:
if self._host != bad_host:
await self._areconnect(bad_host)
return []
return await self._execute(cmd)
async def _collect_transaction_records(
self,
fetch_page: Callable[[int, int], Awaitable[list[TransactionRecord]]],
page_size: int,
max_start: int = 10000,
) -> list[TransactionRecord]:
all_recs: list[TransactionRecord] = []
seen_sig: set[tuple[int, int, float, int, int, int]] = set()
seen_page_sigs: set[
tuple[
tuple[int, int, float, int, int, int],
tuple[int, int, float, int, int, int],
]
] = set()
start = 0
while start < max_start:
recs = await fetch_page(start, page_size)
if not recs:
break
page_sig = _page_signature(recs)
if page_sig in seen_page_sigs:
break
seen_page_sigs.add(page_sig)
new_count = 0
for record in recs:
sig = _record_signature(record)
if sig not in seen_sig:
seen_sig.add(sig)
all_recs.append(record)
new_count += 1
if new_count == 0:
break
start += len(recs)
if len(recs) < 100:
break
return all_recs
async def get_fund_flow(self, market: Market, code: str) -> pd.DataFrame:
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
records = await self._collect_transaction_records(
lambda start, page_size: self._execute(
GetTransactionDataCmd(market, code, start, page_size)
),
2000,
)
return _to_df(_classify_fund_flow(records))
async def get_history_fund_flow(
self, market: Market, code: str, start: int, count: int
) -> pd.DataFrame:
"""获取个股历史日线资金流向序列。
优先走 Category 22 直连接口;若服务器返回空列表,则自动回退为
"日 K 线取日期 + 历史逐笔成交重算资金流"的兼容实现。
"""
try:
direct = await self._execute(GetHistoryFundFlowCmd(market, code, start, count))
except Exception:
direct = []
if direct:
return _to_df(direct)
bars = await self._execute(
GetSecurityBarsCmd(market, code, KlineCategory.DAY, start, count)
)
results: list[HistoricalFundFlow] = []
for bar in bars:
date = _date_from_bar(bar)
# 用闭包工厂立即绑定 date(审计 #10),避免 lambda 延迟绑定循环变量。
async def _fetch_page(
page_start: int, page_size: int, _d: int = date
) -> list[TransactionRecord]:
return await self._execute(
GetHistoryTransactionDataCmd(market, code, _d, page_start, page_size)
)
records = await self._collect_transaction_records(_fetch_page, 800)
results.append(_historical_fund_flow_from_records(date, records))
return _to_df(results)