# 更新日志 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 ## [1.20.5] — 2026-08-05 **资金流空数据故障转移**(Issue #41)—— 用户反馈 `get_history_fund_flow(SH, "600519")` 返回空 DataFrame,日志显示"K线响应为空(声称 800 条但首条即解析失败...)"。排查定位:当前 host 对常见标的也返回 `ret_count` 撒谎的空 body,但资金流这条兼容回退路径(直连空 → 拉 K 线 + 历史逐笔重算)**未接入 v1.20.4 的空数据故障转移**,"服务器回包正常但内容是假的空"既非 `TdxConnectionError` 也不触发换台,用户卡在坏服务器上拿不到数据。本次将资金流路径接入与 K 线同源的空数据故障转移。 ### 修复 - **资金流空数据故障转移**(`src/easy_tdx/client.py`)—— `get_history_fund_flow`(sync+async)当前 host 直连(Category 22)与 K 线回退均空时,按延迟顺序逐台实测找首台返回有效数据的服务器(与 `get_security_bars`/`get_index_bars` 同源逻辑)。因资金流获取涉及多命令(直连 / K 线 + 逐笔),无法用单 cmd 复用泛化版 `_find_host_returning_data`,故内联 `_fund_flow_failover`:每台候选上跑完整 `_fetch_fund_flow_records`,返回首台非空结果。全空返回空 DataFrame(不 raise,区分"真无历史数据"与"服务器缺数据")。`auto_reconnect=False` 时不触发。 ### 重构 - **提取 `_fetch_fund_flow_records`**(`src/easy_tdx/client.py`)—— 将"直连 + K 线回退"逻辑从 `get_history_fund_flow` 抽出为独立方法(sync+async 对称),便于故障转移在内联 `_try` 中复用。行为不变。 ### 测试 - 新增 6 个测试:`test_failover.py` 的 `TestFundFlowEmptyFailover`(4 个 sync:空数据切台命中 / 全空返回空 df / 首次非空不触发 / `auto_reconnect=False` 不触发)+ `TestAsyncFundFlowEmptyFailover`(2 个 async:空数据切台命中 / 首次非空不触发)。全套 989 passed;ruff/mypy 改动文件零错误。 ## [1.20.4] — 2026-07-13 **引入服务器健康分引擎 + K线空数据故障转移**(PR #37)—— 彻底解决用户反馈的通达信服务器"跳来跳去"且指数 K 线取不到数据问题。此前代码库零服务器健康记忆(失败的服务器下次又会被低延迟选中),且指数 K 线空数据不触发故障转移(直接返回空 DataFrame)。本次新增进程级健康分引擎 + 泛化空数据转移 + 8 个 client 统一健康分联动。 ### 新增 - **服务器健康分引擎**(`src/easy_tdx/_health.py`)—— 为每台候选主机维护 `score ∈ (0, 1.0]`:失败乘性降权(×0.5)、连续失败 ≥3 次进 120s 冷却期、成功加性恢复(+0.2,上限 1.0)。`rank_by_health` 按 `latency/score`(有效延迟)重排候选列表,冷却中的主机直接剔除。全健康时近似恒等映射,对既有测试零影响。频繁断连或数据不全的服务器会自动靠后,不再被低延迟反复选中又反复触发空数据转移。 - **K线空数据故障转移**(`src/easy_tdx/client.py`)—— `get_index_bars`/`get_security_bars`(sync+async)空结果时自动逐台换台(此前直接返回空 DataFrame,是日志"指数K线响应在第1/800条处被截断"后用户拿不到数据的根因)。泛化 `_find_host_returning_quotes` → `_find_host_returning_data[T]`,支持 quotes/K线/未来任意命令,原 quotes 方法保留薄封装保兼容。全空返回空 DataFrame(不 raise,区分"真无历史数据"与"服务器缺数据")。 - **8 个 client 统一健康分联动**(`src/easy_tdx/{client,mac/client,ex/client,ex/mac_client}.py`)—— A股/MAC/EX/MAC-EX × sync/async 的 `_execute` 全部注入:成功 `record_success`、连接失败 `record_failure`。此前仅 A 股 client 写健康分,MAC/EX 的 6 个 `_execute` 漏改(审核发现并修复)。 ### 改进 - **故障转移感知健康分**(`src/easy_tdx/_reconnect.py`)—— `select_best_host_*`/`find_working_host_*` 调 `rank_by_health` 重排候选;空数据验证失败/异常时调 `record_failure`,命中调 `record_success`。 - **截断日志区分**(`src/easy_tdx/commands/security_bars.py`)—— 区分"首条即空(服务器无数据,该换台)"与"末尾截断(部分可用)",便于人工排查。 ### 测试 - 新增 26 个测试:`test_health.py`(15 个健康分引擎单测)+ `test_failover.py` 扩展(7 个健康分感知 + K线空数据转移)+ `test_ex_reconnect.py` 扩展(4 个 MAC/EX client 健康分追踪,防 pattern-fix 回归)。全量 reconnect/failover/decode/config 回归通过(70+ tests),ruff/mypy 全绿,CI 8/8 通过。 ## [1.20.3] — 2026-07-10 **修复回测绩效统计两个准确性 bug**(issues #30 / #31)—— 用户反馈升级到 1.20.2 后回测数据仍然不对:#31 调仓回测最大回撤荒谬(-92%),#30 单标的回测总收益恒为 0(但交易表有盈亏)。排查后定位为两处独立缺陷,逐一修复并补回归测试。 ### 修复 - **RebalanceEngine 缺失价格导致净值假崩塌**(`src/easy_tdx/portfolio/rebalance.py`)—— 已持仓标的当日缺 K 线(停牌/上市晚/日历错位)时,`prices.get(code, 0)` 返回 0,该标的持仓市值被记为 0,净值单日暴跌(issue #31:159915 在 20210208 缺一天数据,持仓占 ~93%,净值从 1.1M 瞬跌至 91,845,全期最大回撤 -92%)。新增 `last_known_price` forward-fill:缺失日沿用最近已知收盘价估值(停牌标的的标准做法)。修复后真实 ETF 数据最大回撤 24.01%(用户 backtrader 基准 27%),总收益 220.56% 不变。 - **RebalanceEngine 最大回撤符号口径**(`src/easy_tdx/portfolio/rebalance.py`)—— `_compute_performance` 此前用 `(total-peak)/peak + np.min` 返回**负**最大回撤,与 `BacktestEngine.PerformanceAnalyzer`(正值 `[0,1]`)、CLI/文档约定不一致。改为 `(peak-total)/peak + np.max` 正值口径。 - **PortfolioTracker 交易静默漏单**(`src/easy_tdx/backtest/portfolio.py`)—— `apply_trades` 用 `trade.datetime` 作 dict key、用 `df["datetime"].to_numpy()[i]` 查找;两端类型不一致(int YYYYMMDD vs datetime64)时 `trade_map.get(dt)` 永不命中,全部交易被静默丢弃,净值恒等于初始资金(issue #30:`total_return=0, volatility=0, end_value=100000`,但 trades 表有 PnL,因 `_compute_pnls` 不依赖 df 查找)。改为按"位置索引"匹配:预构建归一化 datetime→位置映射,trade.datetime 无论 Timestamp/int/datetime64 都能正确命中,彻底消除该静默失败。 ### 测试 - 新增 4 个回归测试:`test_apply_trades_int_datetime_vs_datetime64_df` / `test_apply_trades_timestamp_vs_int_df`(#30,int↔datetime64 类型不一致仍正确撮合);`test_missing_price_does_not_collapse_equity` / `test_max_drawdown_sign_positive`(#31,缺数据不假崩塌 + 回撤正值)。四测试在未修复代码上**均失败**,修复后通过。全套 936 passed;mypy 改动文件零错误;ruff 全绿。 ## [1.20.2] — 2026-07-09 **修复 v1.20.1 引入的 CI mypy 失败** —— v1.20.1 把 `BacktestResult.performance` 类型扩大为 `dict[str, float | str]`(为塞进 `diagnostic_warning` 字符串),破坏了 6 处下游消费方(portfolio/combo/optimizer/ranker 假设 `dict[str, float]` 做算术比较),CI mypy job 转红。本次重构为更干净的设计:诊断信息走独立的 `BacktestResult.diagnostic` 字段,performance 字典恢复 `dict[str, float]` 类型契约。顺手修复 `optimizer.py` 的 3 个既有 ndarray type-arg 错误。 ### 修复 - **诊断信息独立字段**(`src/easy_tdx/backtest/{performance,engine,types,cli}.py`)—— `PerformanceAnalyzer.diagnostic` 属性承载数据异常提示,`BacktestResult.diagnostic: str | None` 透出,`to_dict()` 含该字段,CLI 表格显示。performance 字典回归 `dict[str, float]`(含 `sharpe_ratio`/`start_cash`/`end_value` 别名键),下游算术/比较不再类型报错。 - **`optimizer.py` ndarray 类型标注**(`src/easy_tdx/portfolio/optimizer.py`)—— `FactorWeightedOptimizer`/`RiskParityOptimizer` 的 `scores`/`vol` 局部变量、`MeanVarianceOptimizer.objective` 参数补 `npt.NDArray[np.float64]` 标注,消除 3 个既有 mypy type-arg 错误。 ## [1.20.1] — 2026-07-09 **修复回测引擎 3 个用户高频踩坑的 bug**(issues #22 / #23 / #25)—— 用户最初反馈"回测统计数据缺失/异常",排查后发现并非服务器连接问题(已建议 `easy-tdx ping`),而是回测引擎与组合优化器自身的代码缺陷:首根 bar 访问历史数据崩溃、再平衡 `n_stocks` 被无视、交易笔数统计成天数。本次逐一修复并补回归测试,同时在数据异常时给出诊断提示而非静默返回全 0。 ### 修复 - **首根 bar 回溯访问不再崩溃**(`src/easy_tdx/backtest/strategy.py` + `engine.py`)—— 文档示例 `self.data.close[-1]` / `[-2]` 在 `bar_index=0` 时越界抛 `IndexError`(issue #23)。`_SeriesAccessor` 负向越界改为返回 `NaN`;`BacktestEngine` 新增 `warmup_bars` 参数,预热期前 N 根不调用 `next()`、不产生信号。含回归测试。 - **`FactorWeightedOptimizer` 权重坍缩**(`src/easy_tdx/portfolio/optimizer.py`)—— `n_stocks=2` 且因子得分接近时,"减最小值 + 1e-8"把低分标的权重压到 ~`6e-8`,等于单股满仓,`n_stocks` 被实际无视,进而出现"持仓 1 只"、`-99.98%` 回撤等荒谬结果(issue #25)。新增 `_apply_weight_floor` 权重下限(每只 ≥ `1/(N*10)`),保证入选标的都有实质权重且和仍为 1。 - **再平衡 `total_trades` 统计错误**(`src/easy_tdx/portfolio/rebalance.py`)—— `_compute_performance` 把 `total_trades` 设成 `len(equity_curve)`(天数),而非真实交易笔数(issue #25,56 笔交易显示为 500)。改为 `len(trades_df)`。 ### 新增 - **绩效指标别名键 + 数据异常诊断**(`src/easy_tdx/backtest/performance.py` + `cli.py` + `types.py`)—— performance dict 新增 `sharpe_ratio` / `start_cash` / `end_value` 别名键,避免用户 `.get('sharpe_ratio')` 误用返回 0(issue #22 body)。资金曲线不足 2 点或有效日收益 < 2 时,`BacktestResult.diagnostic` 字段填充提示(可能数据不全、建议 `easy-tdx ping`),CLI 表格输出显示该提示,不再静默返回全 0。诊断信息独立于数值型 performance 字典,不破坏 `dict[str, float]` 类型契约。 ### 文档 - **README 回测手册导航**(`README.md`)—— 在「回测引擎」章节顶部加入 `docs/backtest_usage.md` 完整使用手册的醒目提示。 - **`backtest_usage.md` 补充 warmup 与回溯容错说明**(`docs/backtest_usage.md`)—— 记录 `warmup_bars` 参数语义、负向索引越界返回 `NaN` 的行为。 ## [1.20.0] — 2026-07-08 **服务器失败时自动 ping 切换,无需手动 `easy-tdx ping`** —— 解决普通用户最困惑的痛点:连不上服务器或返回空数据时,之前必须手动跑 `easy-tdx ping` 才能恢复,普通人根本不知道该这么做。现在 Python API / CLI / Web API **三入口全部自动**——服务器连不上或返回空统计指数时,自动测速、切到延迟最低的可用服务器、重试,全程对用户透明。收敛在 `_reconnect.py` 单点注入 8 个 client 的 `_execute`,零冗余、不新增配置开关。 ### 新增 - **跨主机故障转移(连接失败)**(`src/easy_tdx/_reconnect.py`)—— 8 个 client(TdxClient / MacClient / ExTdxClient / MacExClient,各 sync+async)的 `_execute` 在同主机重试耗尽(`_RETRY_DELAYS` 4 次指数退避)后,自动调 `select_best_host_sync/async` 重新测速、切到延迟最低的**另一台**服务器再试一轮。复用 `auto_reconnect` 开关(`False` 时不触发),内置 30s 节流防惊群。 - **空数据故障转移(`get_market_stat`)**(`src/easy_tdx/_reconnect.py` + `client.py`)—— 880005/880001/880006 统计指数并非所有服务器都提供,返回空 quotes 时触发 `find_working_host_sync/async`:按延迟顺序逐台实测(最多 5 台),找到第一台返回有效数据的服务器。这是 v1.20.0 的核心场景——延迟最低的服务器不一定服务统计指数,必须逐台实测。 - **统一重建 helper**(`client.py` / `mac/client.py` / `ex/client.py` / `ex/mac_client.py`)—— 新增 `_reconnect`/`_areconnect` 收敛各 client 内"重建连接 + 起心跳"的副本(原 `_execute` / `ensure_connected` 各有一份),消除 4 处重复,保证 failover 与重试逻辑一致。 ### 修复 - **MacClient failover 不污染标准 best_host**(`src/easy_tdx/mac/client.py`)—— MAC 客户端的 failover 用 `save_best_mac_host`(写入独立配置项),而非 `save_best_host`。延续 v1.19.4 的修复(MAC 服务器不再写进标准 best_host),含防回归测试锁定。 ## [1.19.7] — 2026-07-07 **新增「服务器设置」页面:web UI 上测速 + 切换通达信服务器** —— 解决"有些用户获取到的 IP 能连通、有些不能"的问题。不同地区/运营商对通达信各服务器连通性不同,之前用户只能碰运气或手动改 config.json。现在在 web UI 上新增第六个页面「服务器设置」,列出全部 50+ 候选服务器、一键并发测速、点选切换——切换后立即生效(热重连),无需重启服务。 ### 新增 - **`AsyncTdxClient.reconnect_to(host)`**(`src/easy_tdx/client.py`)—— 热切换 host 的核心方法:复用 `_execute_lock` 保证切换期间无并发请求撞半开连接,关旧连接→换 host→建新连接→重启心跳。切换失败抛异常(client 断开,路由层捕获返回友好提示)。 - **服务器设置路由**(`src/easy_tdx/web/routers/server.py`)—— 3 个端点: - `GET /api/v1/server/hosts`:列出候选 host + 当前 host(不测速,首屏秒开) - `POST /api/v1/server/test`:并发 ping 测速,返回延迟(ms)和可达性,按延迟排序 - `POST /api/v1/server/switch`:切换到指定 host(先 reconnect 成功再 save_best_host,避免连接失败污染 config) - **服务器设置页面**(`web-ui/src/views/ServerSettingsView.vue`)—— 左侧当前 host + 测速按钮,右侧 host 列表表格(IP/延迟颜色编码/状态徽章/使用按钮)。延迟 <100ms 绿色、<300ms 蓝色、≥300ms 红色、不可达灰色。 - **导航入口**:顶部导航栏新增「服务器设置」(第 6 个页面)。 ### 设计决策 - **不自动测速**:页面加载只列 host,点按钮才测速(50+ host 全 ping 要几秒,自动测速会卡首屏)。 - **切换顺序**:先 `reconnect_to` 成功 → 再 `save_best_host` 持久化(v1.19.4 host 污染 bug 的教训)。 - **host 校验**:只允许切换到候选列表里的 IP,防止任意地址注入。 ## [1.19.6] — 2026-07-07 **修复 EXE 丢失所有第三方依赖(pandas/numpy/uvicorn 等)** —— v1.19.5 的 EXE 只有 11MB(正常 44MB),双击报 `ModuleNotFoundError: No module named 'pandas'`。根因:`release.yml` 的步骤顺序是先 `pip install -e ".[web,packaging]"` 再 `Build frontend`,但 `pyproject.toml` 的 `force-include` 要求 `web-ui/dist` 在 `pip install` 时就存在——install 阶段 dist 不存在导致 editable install 静默降级,PyInstaller 收集不到第三方包。修复:调换 `release.yml` 步骤顺序,先 `npm run build` 再 `pip install`。 ### 修复 - **release.yml 步骤顺序**(`.github/workflows/release.yml`)—— `Build frontend` 移到 `Install Python deps` 之前,确保 `pip install -e .` 时 `web-ui/dist` 已存在。 ## [1.19.5] — 2026-07-07 **修复 PyPI 安装后 `localhost:8000` 返回 404** —— `pip install easy-tdx[web]` 后启动 `easy-tdx serve`,浏览器打开 `localhost:8000` 直接 404。根因:PyPI wheel 不含前端 dist(只有 Python 包),`_resolve_web_dist_dir()` 三级探测全失败返回 None,StaticFiles 不挂载。v1.19.2 的 MIME 修复、v1.19.3 的 SPA fallback 都只对 EXE 打包态生效——PyPI 安装态连 dist 都没有,更谈不上 MIME 或 SPA。 ### 修复 - **前端 dist 打进 wheel**(`pyproject.toml` + `.github/workflows/publish.yml`)—— hatchling 配置 `force-include` 把 `web-ui/dist` 映射到包内 `easy_tdx/web/dist`;`publish.yml` 在 `python -m build` 前先 `npm ci && npm run build` 构建前端。PyPI 用户 `pip install easy-tdx[web]` 后开箱即用 UI,无需 clone 仓库或手动构建前端。 - **`_resolve_web_dist_dir()` 第 4 级探测**(`src/easy_tdx/web/app.py`)—— 新增"包内 `easy_tdx/web/dist`"分支,在环境变量 / _MEIPASS / 仓库根三级都失败后,回退到 PyPI 安装的包内 dist。 ## [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 缺失)。 ### 修复 - **K 线空 body 不再 500**(`src/easy_tdx/commands/security_bars.py`)—— 移除 `if bars:` 条件,无论已解析条数多少,`TdxDecodeError` 都 `return bars`(空列表让前端分页重试比直接 500 友好)。日志证据:`偏移 2,实际剩余 0 字节` = body 只有 ret_count 头、第 1 条 datetime 就崩。`GetIndexBarsCmd`(指数 K 线)同改。更新 `test_security_bars_truncated_first_record_still_raises`(原断言 raise,现断言返回空列表)+ 新增 `test_security_bars_ret_count_lies_body_completely_empty` 回归守卫。 - **SPA fallback**(`src/easy_tdx/web/app.py`)—— 子类化 `StaticFiles` 为 `SPAStaticFiles`,404 时返回 `index.html` 让 Vue Router 接管。修复 `/optimize`、`/portfolio`、`/compare`、`/strategies` 等前端路由刷新 404。API 路径(`/api/v1/*`)已在路由表注册,不受影响。 ## [1.19.2] — 2026-07-07 **修复干净 Windows 上 EXE 双击后页面纯黑** —— v1.19.1 在没装开发工具的 Windows(如老人电脑)上双击 EXE,浏览器打开 `localhost:8000` 后页面纯黑、`/docs` 却能正常打开。根因:干净 Windows 的注册表里没有 `.js` 文件的 `Content Type` 映射,Python 的 `mimetypes.guess_type('.js')` 返回 `None`,FastAPI/Starlette 的 `StaticFiles` 回退到 `text/plain`。但 `index.html` 里的 `