fix(config): v1.19.4 MAC客户端不再污染标准best_host(取不到数据根因)

根因:mac/client.py 的 MacClient.from_best_host 和 AsyncMacClient.from_best_host
调用 save_best_host(best),把选中的 MAC 协议服务器(如 121.36.248.138)
写进了全局 best_host 字段——但这个字段是标准 TDX 协议用的。之后标准
AsyncTdxClient 用 get_best_host 读到这个 MAC host,用标准协议请求 MAC
服务器,返回空 body(偏移 2 剩余 0)。web app lifespan 启动 MAC 客户端
时触发污染。

修复:
- config.py 新增独立的 best_mac_host 字段 + get_best_mac_host / save_best_mac_host
- mac/client.py 4 处改用新字段(不再碰 save_best_host / get_best_host)
- get_best_host 加交叉污染校验:缓存 host 不在标准列表里自动重置
  (自动修复已被污染的 config.json,用户无需手动操作)

测试:新增 5 个回归测试(污染检测 + MAC host 隔离),907 全过。
This commit is contained in:
Justin Gu
2026-07-07 20:36:44 +08:00
parent 032f5a40c0
commit 3bc15e620c
5 changed files with 144 additions and 9 deletions
+10
View File
@@ -2,6 +2,16 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.19.4] — 2026-07-07
**修复取不到行情数据的根因:MAC 客户端污染标准协议的 best_host** —— v1.19.3 在实际机器上"所有股票都取不到数据"(K 线响应偏移 2 剩余 0)。根因是 `mac/client.py``MacClient.from_best_host()``AsyncMacClient.from_best_host()` 调用了 `save_best_host(best)`——把选中的 **MAC 协议服务器**(如 `121.36.248.138`)写进了全局 `best_host` 字段,但这个字段是**标准 TDX 协议**用的。之后标准 `AsyncTdxClient``get_best_host()` 读到这个 MAC host,用标准协议请求 MAC 服务器,返回空 body。web app 启动时 lifespan 会启动 MAC 客户端,这就是污染时机。
### 修复
- **MAC host 不再污染标准 best_host**`src/easy_tdx/config.py` + `src/easy_tdx/mac/client.py`)—— 新增独立的 `best_mac_host` 字段 + `get_best_mac_host()` / `save_best_mac_host()``MacClient` / `AsyncMacClient``from_best_host()``__init__` 改用新字段(4 处),不再调 `save_best_host` / `get_best_host`
- **best_host 交叉污染校验**`src/easy_tdx/config.py:get_best_host`)—— 读取时检测缓存 host 是否在标准 host 候选列表(known_hosts + 源码默认)里,不在则自动重置为默认首个并持久化。**这会自动修复已被污染的 config.json**,用户无需手动删配置。
- **回归测试**`tests/unit/test_config.py`)—— 新增 `TestBestHostPollutionGuard`(3 个测试:MAC host 被重置 / 合法 host 不重置 / 重置持久化)+ `TestMacHostSeparation`2 个测试:save_best_mac_host 不碰 best_host / get_best_mac_host 返回独立字段)。更新既有 `test_config_json_host`host 需在 known_hosts 里才通过校验)。
## [1.19.3] — 2026-07-07
**修复 EXE 运行时两个问题:K 线空 body 仍 500 + 前端路由刷新 404** —— v1.19.2 在实际机器上运行日志暴露两个问题:(1) SH600519 等正常股票偶发请求 K 线时,通达信服务器返回 `ret_count>0` 但 body 完全为空(pos=2 剩余 0 字节),v1.18.3 的容错有 `if bars:` 条件——bars 为空时走 `raise` → 500,老人看到"取行情失败"。(2) 用户在 `/optimize``/portfolio` 等前端路由页面刷新时,后端 StaticFiles 找不到文件返回 404SPA fallback 缺失)。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.19.3"
version = "1.19.4"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+48 -2
View File
@@ -160,12 +160,29 @@ def _save(data: dict[str, Any]) -> None:
def get_best_host() -> str:
"""返回当前最佳主机地址。优先级:环境变量 > config.json > 默认列表首个。"""
"""返回当前最佳主机地址。优先级:环境变量 > config.json > 默认列表首个。
含交叉污染校验:历史上 ``MacClient.from_best_host`` 曾误调 ``save_best_host``
把 MAC 服务器写入 ``best_host``v1.19.3 "取不到行情" bug)。这里检测到
缓存的 host 不在标准 host 候选列表里时,自动重置为默认首个,避免用错协议。
"""
env = os.environ.get("EASY_TDX_HOST")
if env:
return env
cfg = _load()
return cast(str, cfg.get("best_host", _FALLBACK_HOSTS[0]))
cached = cast(str, cfg.get("best_host", _FALLBACK_HOSTS[0]))
# 交叉污染校验:cached 必须在标准 host 候选列表(known_hosts + 源码默认)里。
# 如果是 MAC host121.36.x.x 等),不在标准列表里 → 重置。
all_std_hosts = set(cfg.get("known_hosts", [])) | set(_FALLBACK_HOSTS)
if cached not in all_std_hosts:
# 被污染了(MAC/ex host 混入),重置为默认首个并持久化
cfg["best_host"] = _FALLBACK_HOSTS[0]
cfg["best_host_updated_at"] = datetime.now(_SHANGHAI_TZ).isoformat()
_save(cfg)
return _FALLBACK_HOSTS[0]
return cached
def get_known_hosts() -> list[str]:
@@ -189,6 +206,20 @@ def get_mac_hosts() -> list[str]:
return cast(list[str], cfg.get("mac_hosts", list(_FALLBACK_MAC_HOSTS)))
def get_best_mac_host() -> str:
"""返回当前最佳 MAC 协议主机。
与 ``get_best_host``(标准 TDX 协议)分开存:MAC 和标准协议用不同的
服务器列表和协议格式,共用同一个字段会导致 MAC 客户端选出的 host
被标准客户端误用,用标准协议请求 MAC 服务器返回空 body。
"""
env = os.environ.get("EASY_TDX_MAC_HOST")
if env:
return env
cfg = _load()
return cast(str, cfg.get("best_mac_host", _FALLBACK_MAC_HOSTS[0]))
def get_ex_hosts() -> list[str]:
"""返回扩展行情服务器列表。"""
cfg = _load()
@@ -282,3 +313,18 @@ def save_best_mac_ex_host(host: str) -> None:
if "mac_ex_hosts" not in cfg:
cfg["mac_ex_hosts"] = list(_FALLBACK_MAC_EX_HOSTS)
_save(cfg)
def save_best_mac_host(host: str) -> None:
"""保存最佳 MAC 协议主机到配置文件(独立于标准 TDX 的 best_host)。
MAC 客户端必须用这个,不能复用 ``save_best_host``——否则选出的 MAC
服务器会污染标准 TDX 协议的 ``best_host``,导致标准客户端用错协议请求
MAC 服务器,返回空 bodyv1.19.3 "取不到行情" bug 的根因)。
"""
cfg = _load()
cfg["best_mac_host"] = host
cfg["best_mac_host_updated_at"] = datetime.now(_SHANGHAI_TZ).isoformat()
if "mac_hosts" not in cfg:
cfg["mac_hosts"] = list(_FALLBACK_MAC_HOSTS)
_save(cfg)
+11 -5
View File
@@ -16,7 +16,13 @@ from .._df import _apply_bar_time_align_df, _period_to_minutes, _to_df
from .._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin
from ..codec.bitmap import Fields, PresetField
from ..commands.base import BaseCommand
from ..config import get_best_host, get_mac_hosts, get_port, get_timeout, save_best_host
from ..config import (
get_best_mac_host,
get_mac_hosts,
get_port,
get_timeout,
save_best_mac_host,
)
from ..exceptions import TdxConnectionError
from ..transport.async_ import AsyncTdxConnection
from ..transport.sync import TdxConnection, ping_mac_all
@@ -147,7 +153,7 @@ class MacClient:
auto_reconnect: bool = True,
heartbeat_interval: float = 15.0,
) -> None:
self._host = host if host is not None else get_best_host()
self._host = host if host is not None else get_best_mac_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
@@ -180,7 +186,7 @@ class MacClient:
timeout = get_timeout()
ranked = ping_mac_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_host(best)
save_best_mac_host(best)
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
@staticmethod
@@ -1156,7 +1162,7 @@ class AsyncMacClient(AsyncHeartbeatMixin):
auto_reconnect: bool = True,
heartbeat_interval: float = 15.0,
) -> None:
self._host = host if host is not None else get_best_host()
self._host = host if host is not None else get_best_mac_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
@@ -1191,7 +1197,7 @@ class AsyncMacClient(AsyncHeartbeatMixin):
timeout = get_timeout()
ranked = ping_mac_all(hosts, port, ping_timeout)
best = ranked[0][0] if ranked else hosts[0]
save_best_host(best)
save_best_mac_host(best)
return cls(best, port, timeout, auto_reconnect, heartbeat_interval)
@staticmethod
+74 -1
View File
@@ -72,8 +72,17 @@ class TestConfigReadWrite:
assert cfg.get_known_hosts() == list(cfg._FALLBACK_HOSTS)
def test_config_json_host(self, isolated_config: Path) -> None:
# best_host 必须在 known_hosts 里才能通过污染校验(v1.19.4 新增)。
# 用 known_hosts 里的一个 host,确保不被重置。
(isolated_config / "config.json").write_text(
json.dumps({"best_host": "203.0.0.1", "port": 7709, "timeout": 12.0}),
json.dumps(
{
"best_host": "203.0.0.1",
"known_hosts": ["203.0.0.1"],
"port": 7709,
"timeout": 12.0,
}
),
"utf-8",
)
assert cfg.get_best_host() == "203.0.0.1"
@@ -126,3 +135,67 @@ class TestSaveBestHost:
cfg.save_best_host("x.host")
assert not (isolated_config / "config.json.tmp").exists()
assert (isolated_config / "config.json").exists()
# --------------------------------------------------------------------------- #
# best_host 交叉污染校验(v1.19.4 回归守卫)
# --------------------------------------------------------------------------- #
class TestBestHostPollutionGuard:
"""MacClient.from_best_host 曾误调 save_best_host 把 MAC 服务器写入
best_host,导致标准 TdxClient 用错协议请求 MAC 服务器返回空 body。
get_best_host 现在含校验:缓存 host 不在标准列表里时自动重置。
"""
def test_mac_host_in_best_host_gets_reset(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""best_host 被污染成 MAC host(不在标准列表)→ 自动重置。"""
polluted = {
"best_host": "121.36.248.138", # MAC host
"known_hosts": ["180.153.18.170", "115.238.56.198"],
}
(isolated_config / "config.json").write_text(json.dumps(polluted), "utf-8")
monkeypatch.delenv("EASY_TDX_HOST", raising=False)
result = cfg.get_best_host()
assert result != "121.36.248.138", "MAC host 应被重置"
assert result in polluted["known_hosts"] or result in cfg._FALLBACK_HOSTS
def test_valid_host_not_reset(
self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""best_host 是合法标准 host → 不重置。"""
(isolated_config / "config.json").write_text(
json.dumps({"best_host": "180.153.18.170", "known_hosts": ["180.153.18.170"]}),
"utf-8",
)
monkeypatch.delenv("EASY_TDX_HOST", raising=False)
assert cfg.get_best_host() == "180.153.18.170"
def test_reset_persists_to_config(self, isolated_config: Path) -> None:
"""重置后的 host 应写回 config.json,下次读不需再校验。"""
(isolated_config / "config.json").write_text(
json.dumps({"best_host": "121.36.248.138"}), "utf-8"
)
cfg.get_best_host() # 触发重置
data = json.loads((isolated_config / "config.json").read_text("utf-8"))
assert data["best_host"] != "121.36.248.138"
class TestMacHostSeparation:
"""MAC 协议的 best host 应独立于标准 TDX 的 best_host。"""
def test_save_best_mac_host_does_not_touch_best_host(self, isolated_config: Path) -> None:
"""save_best_mac_host 不应修改 best_host 字段。"""
cfg.save_best_host("180.153.18.170")
cfg.save_best_mac_host("121.36.248.138")
data = json.loads((isolated_config / "config.json").read_text("utf-8"))
assert data["best_host"] == "180.153.18.170"
assert data["best_mac_host"] == "121.36.248.138"
def test_get_best_mac_host_returns_mac_host(self, isolated_config: Path) -> None:
"""get_best_mac_host 返回的是 MAC host 字段,不是标准 host。"""
cfg.save_best_mac_host("123.60.47.136")
assert cfg.get_best_mac_host() == "123.60.47.136"