mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 13:24:15 +08:00
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:
@@ -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 找不到文件返回 404(SPA fallback 缺失)。
|
||||
|
||||
+1
-1
@@ -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
@@ -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 host(121.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 服务器,返回空 body(v1.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user