Files
tick-stock-panel/backend/app/plugins/tdxquant/client.py
T
shy3130 ecb49cbaca release: v0.3.6 — 核心+扩展架构完善版
合并 0.3.6 分支全部内容 (36 提交, 详细历史见 0.3.6 分支):

■ 数据与插件
- TdxQuant 数据源: 通达信量化接入 (实时/五档/日K/复权/分钟), tdxaidata 收进默认依赖
- 扩展表两级拉取配方: 概念/行业出厂即带完整可拉配置, 可作自定义接入参考示例
- 能力门禁日志带生效源; 标的池解析只走 instruments 维表

■ 页面扩展化
- 11 个官方页面全量迁移为单文件扩展 (custom/*/extension.tsx), 删目录即卸载
- 运行时页面包系统: 打包产物上传即挂载, 免重建宿主
- 新装用户扩展页默认隐藏, 老用户升级菜单无感

■ AI
- Responses 协议接入; 会话历史与长任务跨设备同步 (服务端注册表)
- 助手头部快捷入口 + 悬浮球拖拽 zoom 坐标系修复 + 系统设置开关

■ 界面
- 设置「关于」子菜单: 版权/更新/打赏 + 开源许可弹窗与合规声明双保险
- 侧栏两态收起、数据/AI 助手快捷行 (同步呼吸光晕)、核心/扩展 ext 标记
- 界面缩放默认 90%; 菜单设置徽标 Swiss 化; 策略池「叠加」分组
- 情绪评分口径可配置; 财务画像 5 张子表手动拉取

■ 文档与其他
- README 重构「核心+扩展」主线 + 架构图重画; 开放端点数核实为 65
- 版本号 bump 0.3.6; tdx 物化库测试夹具补 macOS dylib
2026-10-13 13:40:17 +08:00

261 lines
11 KiB
Python

"""TdxQuant — tdxaidata (通达信量化数据) 原生库封装。
tdxaidata 是 ctypes 包装的私有 TCP 协议客户端(aihs/aids.tdx.com.cn),
DLL 与运行时 ini 随 wheel 分发。两个实测约束决定本模块的形状:
- 原生层每进程加载一次、token 写在 DLL 旁的 ini 里 → Key 物化到私有
lib 目录 + TDX_AI_DATA_LIB 指向, 不污染 site-packages、换 Key 只重写 ini;
- 空闲后首次调用会 "Invalid server response" → 统一重试包装。
"""
from __future__ import annotations
import hashlib
import logging
import os
import shutil
import sys
import time
from datetime import date
from pathlib import Path
logger = logging.getLogger(__name__)
# 全 A 股票池: 五个市场名实测(2026-10)合计约 5579 只; ETF/指数不在枚举内
_MARKETS = ("上证主板", "科创板", "深证主板", "创业板", "北交所")
# 快照批量字段(实测有效名; 注意昨收叫 LastClose、高低叫 Max/Min, 快照无 Name)
SNAPSHOT_FIELDS = (
"Now", "Open", "Max", "Min", "LastClose", "Volume", "Amount", "RefreshTime",
"Buyp", "Buyv", "Sellp", "Sellv",
)
_RETRIES = 3
_NONE_RETRIES = 2 # 空返回(可能是永久性坏代码)只补试一次
_RETRY_SLEEP_S = 0.6
_UNIVERSE_TTL_S = 12 * 3600.0
_DLL_BY_PLATFORM = {"win32": "TdxAiData.dll", "darwin": "libTdxAiData.dylib"}
LIB_DLL_NAME = _DLL_BY_PLATFORM.get(sys.platform, "libTdxAiData.so")
LIB_ENV = "TDX_AI_DATA_LIB" # tdxaidata 读取: 指向 DLL 文件路径
class TdxQuantError(RuntimeError):
"""tdxaidata 调用失败(网络/参数/协议)。"""
def package_lib_dir() -> Path:
"""tdxaidata 包自带的 lib 目录(平台 DLL + 运行时 ini)。"""
import tdxaidata
return Path(tdxaidata.__file__).resolve().parent / "lib"
def write_token_ini(ini: Path, api_key: str) -> None:
"""把 [Token] token=<key> 写入 ini, 保留其余配置行。"""
lines = (
ini.read_text(encoding="utf-8", errors="replace").splitlines()
if ini.exists()
else ["[Token]"]
)
out: list[str] = []
in_token = False
replaced = False
for line in lines:
stripped = line.strip()
if stripped.lower() == "[token]":
in_token = True
out.append(line)
continue
if stripped.startswith("[") and stripped.endswith("]"):
in_token = False
if in_token and "=" in line and stripped.lower().split("=", 1)[0].strip() == "token":
if not replaced:
out.append(f"token={api_key}")
replaced = True
continue
out.append(line)
if "[Token]" not in {line.strip() for line in out}:
out.extend(["[Token]", f"token={api_key}"])
elif not replaced:
idx = next(i for i, line in enumerate(out) if line.strip().lower() == "[token]")
out.insert(idx + 1, f"token={api_key}")
ini.write_text("\n".join(out) + "\n", encoding="utf-8")
def materialize_lib(api_key: str, lib_root: Path, source: Path | None = None) -> Path:
"""复制包内 lib 到私有目录并写入 token, 返回 DLL 路径。
marker 记 token 指纹 + DLL mtime/size, 未变化时直接复用(每轮 realtime
轮询都会走到这里, 不能反复复制几十 MB 的 DLL)。
"""
src = source or package_lib_dir()
dll = src / LIB_DLL_NAME
if not dll.exists():
raise TdxQuantError(f"tdxaidata lib 缺少 {LIB_DLL_NAME}: {src}")
lib_root.mkdir(parents=True, exist_ok=True)
marker = lib_root / ".tdxquant_lib_marker"
stat = dll.stat()
want = f"{hashlib.sha256(api_key.encode()).hexdigest()}:{stat.st_mtime_ns}:{stat.st_size}"
copied = lib_root / LIB_DLL_NAME
if marker.exists() and marker.read_text(encoding="utf-8") == want and copied.exists():
return copied
for item in src.iterdir():
if item.is_file():
shutil.copy2(item, lib_root / item.name)
for ini_name in ("TdxAiData.ini", "user.ini"):
write_token_ini(lib_root / ini_name, api_key)
marker.write_text(want, encoding="utf-8")
return copied
class TdxQuantClient:
"""原生 tqs 的薄封装: 重试、股票池枚举、快照/日K/除权三个取数面。"""
def __init__(self, api_key: str, lib_root: Path | None = None) -> None:
from app.config import settings
self.api_key = api_key
self._lib_root = lib_root or (settings.data_dir / "user_data" / "tdxquant_lib")
dll = materialize_lib(api_key, self._lib_root)
os.environ[LIB_ENV] = str(dll)
from tdxaidata import tqs
from tdxaidata.tdxaidata import _NativeTdxAiData
self._tqs = tqs
self._native_cls = _NativeTdxAiData
self._universe: list[str] = []
self._universe_at = 0.0
self._warmup()
def _warmup(self, attempts: int = 5) -> None:
"""DLL 冷启动后有一段"未就绪"窗口, 业务调用持续失败(实测首批整批丢弃)。
用最小快照轮询到就绪; 一直失败不抛 — 后续调用可能自愈, 只留告警。
"""
for _ in range(attempts):
try:
rows = self._tqs.get_market_snapshot_batch(
stock_list=["600519.SH"], field_list=["Now"]
)
if isinstance(rows, dict) and rows:
return
except Exception: # noqa: BLE001 — 就绪探测, 任何异常都重试
pass
time.sleep(1.2)
logger.warning("TdxQuant 原生层预热未成功(%d 次), 首批业务调用可能失败重试", attempts)
def close(self) -> None: # 原生 DLL 常驻进程, 无可回收句柄; 保留接口对齐 loader
self._universe.clear()
# ---- 原生调用(重试包装) ----
def _raw(self, method: str, *args):
"""直接调原生层并 _clean 解包; 异常重试 3 次、空返回补试 1 次。
空闲后首连 "Invalid server response" 实测重试即恢复; 坏代码等
永久错误重试也只会快失败(返回 None), 不至于长阻塞。
"""
last: Exception = TdxQuantError(f"{method} 未知失败")
for attempt in range(_RETRIES):
try:
out = self._tqs._clean(getattr(self._native_cls.get_instance(), method)(*args))
if out is not None:
return out
last = TdxQuantError(f"{method} 返回空数据(第 {attempt + 1} 次)")
if attempt + 1 >= _NONE_RETRIES:
break
except Exception as exc: # noqa: BLE001 — 原生 ctypes 层异常类型不可枚举
last = TdxQuantError(f"{method}: {exc}")
time.sleep(_RETRY_SLEEP_S)
raise last
# ---- 股票池 ----
def stock_universe(self) -> list[str]:
"""五市场全 A 代码(带交易所后缀)。12h 缓存, 避免每轮快照重复枚举。"""
now = time.time()
if self._universe and now - self._universe_at < _UNIVERSE_TTL_S:
return list(self._universe)
last: TdxQuantError = TdxQuantError("股票列表为空(五个市场均无返回)")
for _attempt in range(_RETRIES):
out: list[str] = []
try:
for market in _MARKETS:
lst = self._tqs.get_stock_list(market=market, list_type=0)
out.extend(code for code in (lst or []) if isinstance(code, str) and code)
except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举
last = TdxQuantError(f"get_stock_list: {exc}")
out = []
if out:
self._universe, self._universe_at = out, now
return list(out)
time.sleep(_RETRY_SLEEP_S)
raise last
# ---- 快照 ----
def snapshot_batch(self, codes: list[str]) -> dict[str, dict]:
"""批量快照 → {code: row(字段见 SNAPSHOT_FIELDS)}。SDK 内部已做批处理。
空闲后首连易 "Invalid server response"(实测重试即恢复), 批级失败必须
重试 — provider 只做批间隔离, 这里不重试会整批丢标的。
"""
if not codes:
return {}
last: TdxQuantError = TdxQuantError(f"snapshot_batch({len(codes)} 只): 未知失败")
for _attempt in range(_RETRIES):
try:
rows = self._tqs.get_market_snapshot_batch(
stock_list=list(codes), field_list=list(SNAPSHOT_FIELDS)
)
except Exception as exc: # noqa: BLE001 — SDK 内部异常类型不可枚举
last = TdxQuantError(f"snapshot_batch({len(codes)} 只): {exc}")
time.sleep(_RETRY_SLEEP_S)
continue
if isinstance(rows, dict) and rows:
return rows
last = TdxQuantError(f"snapshot_batch({len(codes)} 只): 空返回")
time.sleep(_RETRY_SLEEP_S)
raise last
# ---- K 线(count 模式, 原始列) ----
def his_dats(self, code: str, end_d: date, count: int, period: str = "1d") -> dict:
"""单标的 K 线原生 dict: Date 'YYYYMMDD' / Open / High / Low / Close /
Volume(股) / Amount(万元) 等。count 模式下 start 恒空(服务端只用 end)。
"""
if count <= 0:
return {}
end = f"{end_d:%Y-%m-%d} 00:00:00"
out = self._raw("get_his_dats", code, "", end, period, "none", int(count))
return out if isinstance(out, dict) else {}
# ---- 除权事件 ----
def divid_factors(
self, code: str, start_d: date | None = None, end_d: date | None = None
) -> list[dict]:
"""单标的除权事件, 每行 {Date 'YYYYMMDD', Type, Bonus, ShareBonus,
AllotPrice, Allotment} — 金额/比例均为**每 10 股**口径(Bonus=10派X元)。
"""
start = f"{start_d:%Y-%m-%d}" if start_d else ""
end = f"{end_d:%Y-%m-%d}" if end_d else ""
try:
out = self._tqs.get_divid_factors(code, start, end)
except Exception as exc: # SDK 内部异常类型不可枚举
raise TdxQuantError(f"get_divid_factors({code}): {exc}") from exc
if isinstance(out, list):
return out
# 环境装有 pandas 时 SDK 返回 DataFrame, 归一为 list[dict]。
# 注意 Date 会成为 DataFrame 索引(同 get_market_data 的日期索引惯例),
# reset_index 后若列名漂移, 把首列恢复为 'Date'。
to_dict = getattr(out, "to_dict", None)
reset_index = getattr(out, "reset_index", None)
if callable(to_dict) and callable(reset_index):
try:
records = reset_index().to_dict("records")
if isinstance(records, list) and records:
if "Date" not in records[0]:
first_key = next(iter(records[0]))
if first_key not in ("Type", "Bonus", "AllotPrice", "ShareBonus", "Allotment"):
for row in records:
row["Date"] = row.pop(first_key)
return records
except Exception: # noqa: BLE001 — 转换失败按结构异常处理
pass
raise TdxQuantError(f"get_divid_factors({code}) 返回异常结构: {type(out)!r}")