diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..f9c4bf4
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,93 @@
+name: Release EXE
+
+# 打 tag 时构建 Windows 单 EXE 并发布到 GitHub Release。
+# 与 publish.yml(PyPI)并行独立:即使 PyPI 发布失败,EXE 仍可发布,
+# 提高发布韧性。Phase 1 产物未签名,老人首次运行会被 SmartScreen 拦截,
+# 需手动"更多信息 → 仍要运行"——Phase 2 引入代码签名后解决。
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write # softprops/action-gh-release 创建 Release 需要
+
+jobs:
+ build-windows:
+ name: Build Windows EXE
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: web-ui/package-lock.json
+
+ # 安装后端(含 [web,packaging] extras:fastapi/uvicorn + pystray/Pillow)
+ - name: Install Python deps
+ run: |
+ pip install -e ".[web,packaging]"
+ pip install pyinstaller
+
+ # 构建前端 → web-ui/dist/,spec 把它打到 EXE 内的 web_dist/
+ - name: Build frontend
+ run: |
+ npm ci
+ npm run build
+ working-directory: web-ui
+
+ - name: Build EXE
+ run: pyinstaller easy_tdx.spec --noconfirm
+
+ # 重命名为带版本号的文件名,方便老人下载时识别
+ - name: Rename EXE with version
+ shell: bash
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ mv dist/easy-tdx.exe "dist/easy-tdx-${VERSION}-windows.exe"
+ ls -lh dist/
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: easy-tdx-windows-exe
+ path: dist/easy-tdx-*-windows.exe
+ if-no-files-found: error
+
+ release:
+ name: Publish GitHub Release
+ needs: build-windows
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: easy-tdx-windows-exe
+ path: dist
+
+ - name: Create release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/easy-tdx-*-windows.exe
+ generate_release_notes: true
+ body: |
+ ## 下载使用
+
+ 1. 下载下方 `easy-tdx-*-windows.exe`(约 80-150MB)
+ 2. 双击运行
+ 3. 浏览器会自动打开回测界面(地址 `http://localhost:8000`)
+
+ ## ⚠️ SmartScreen 提示
+
+ 本版本未做代码签名,首次运行 Windows 会弹出"已保护你的电脑":
+
+ 1. 点击 **更多信息**
+ 2. 点击 **仍要运行**
+
+ Phase 2 将引入代码签名消除此提示。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2660d97..78b0b5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,28 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
+## [1.19.1] — 2026-07-07
+
+**支持打包成单一 Windows EXE + GitHub Actions 自动发版** —— 面向"一点都不懂的老年"用户群,让 easy-tdx 能从"开发者双进程"形态变成"双击 EXE → 浏览器自动打开 → 看到回测界面"的零门槛形态。本版为 Phase 1(未签名自测版);Phase 2 引入代码签名消除 SmartScreen 提示,Phase 3 加 macOS。
+
+### 新增
+
+- **后端同源托管前端 dist**(`src/easy_tdx/web/app.py`)—— `_resolve_web_dist_dir()` 三级探测(环境变量 → PyInstaller `_MEIPASS/web_dist` → 仓库根 `web-ui/dist`),在所有 API 路由注册后 `app.mount("/", StaticFiles(..., html=True))`。开发态可缺省(仅 API),打包态同源服务前端。**前置条件**:此前前端 Vite 单独跑、靠 CORS 跨端口,老人无法双进程操作;现单进程同源解决。
+- **`--open-browser` 启动选项**(`src/easy_tdx/cli/cmd_web.py`)—— uvicorn 启动后 `threading.Timer(1.5, ...)` 延迟开浏览器(等端口就绪),默认开、`--no-open-browser` 关闭、`--reload` 模式禁用(开发态不抢焦点)。
+- **PyInstaller 打包入口**(`src/easy_tdx/__main__.py` + `easy_tdx.spec`)—— `python -m easy_tdx` 等价 CLI;无参数时默认走 `serve`。`.spec` 用 `--onefile` + `console=False`(无黑窗)+ `collect_submodules('uvicorn' / 'easy_tdx' / 'pandas' / 'numpy')` 收集动态 import + 前端 dist 打到 `web_dist`。
+- **GitHub Actions 发版工作流**(`.github/workflows/release.yml`)—— `v*` tag 触发,`windows-latest` 构建前端 + EXE,重命名为 `easy-tdx-<版本>-windows.exe`,`softprops/action-gh-release` 上传。与 `publish.yml`(PyPI)完全独立并行,PyPI 失败不影响 EXE 发布。
+- **打包使用文档**(`docs/packaging.md`)—— 老人下载/运行/绕过 SmartScreen 图文说明 + 开发者本地构建步骤 + Phase 1/2/3 路线图。
+
+### 已知约束(非 bug)
+
+- **EXE 未签名,SmartScreen 会拦截** —— 老人首次运行需手动"更多信息 → 仍要运行"。这是 Phase 1 的明确取舍(你已确认"先打未签名包自测"),Phase 2 引入 OV/EV 代码签名证书后消除。
+- **EXE 体积 80-150MB** —— pandas/numpy/uvicorn/Vue dist 全量打包的必然结果。`--onefile` 首次启动解压需 2-5 秒。
+- **离线 .day 读取需要通达信** —— 老人若未安装 Windows 版通达信,离线读取本地数据功能不可用;在线行情不受影响。
+
+### 修复(打包过程暴露的既有 bug)
+
+- **K 线残缺尾记录导致 500**(`src/easy_tdx/commands/security_bars.py`)—— 通达信服务器偶发返回的 `ret_count`(K 线条数)字段与 body 实际字节数不匹配(pytdx/mootdx 均有同类报告),循环到中途 `pos` 读到底,下一条 datetime 解析抛 `TdxDecodeError: day datetime: 数据不足`,整批数据 500。实测日志证据:SH600519 首次请求 500、重试即 200,同一只股票时好时坏。改为把 `TdxDecodeError` 当记录边界——`try/except` 包住单条记录解析,异常时 `break` 退出循环,丢弃残缺尾记录,返回已成功解析的完整记录(少几根 K 线比整批 500 好)。`GetIndexBarsCmd`(指数 K 线)同改。`tests/unit/test_decode_errors.py` 加 4 个回归测试守卫。**此 bug 与 PyInstaller 打包无关**,是项目既有问题,只是打包版运行更频繁把它暴露了出来。
+
## [1.18.2] — 2026-07-06
**回退拼音声母搜索功能,回到稳定的 6 位代码输入** —— v1.19.0 引入的拼音声母搜索(输 `zjxc` 命中中际旭创)因底层依赖过重被移除。该功能首次使用时需从通达信服务器爬取沪深 A 股约 5000 条完整名单(几十次协议往返,慢机器耗时几十秒到超时),且与共享的 TDX 连接耦合——爬名单期间会阻塞行情请求。虽经多轮优化(按需加载 / 全站遮罩 / 单飞去重 / 后台预热),均无法兼顾"不阻塞核心行情"与"首次可用"。本次回到 v1.18.1 的干净基线,代码输入框恢复为纯 6 位代码输入(市场自动识别)。
diff --git a/README.md b/README.md
index 914b135..1244051 100644
--- a/README.md
+++ b/README.md
@@ -446,30 +446,37 @@ easy-tdx portfolio --stocks SZ:000001,SH:600519 \
-不想写命令行?用浏览器。`easy-tdx serve` 启动后端,`web-ui/` 目录跑前端,浏览器打开就是完整的回测可视化界面。
+不想写命令行?用浏览器。`easy-tdx serve` 一条命令启动,浏览器自动打开 `http://localhost:8000`,就是完整的回测可视化界面。
**前置条件:**
```bash
-# 后端需安装 web 可选依赖(FastAPI + Uvicorn)
+# 需安装 web 可选依赖(FastAPI + Uvicorn)
pip install -e ".[web]"
-
-# 前端需 Node.js 18+(首次运行需装依赖)
-cd web-ui && npm install
```
-**启动(两个终端):**
+**启动(一条命令):**
```bash
-# 终端 1:启动后端 API 服务(提供行情数据 + 回测计算)
-easy-tdx serve --port 8000
+# 启动后端 + 自动打开浏览器(默认 http://localhost:8000)
+easy-tdx serve
-# 终端 2:启动前端开发服务器(web-ui/ 目录)
-cd web-ui && npm run dev
-# 浏览器打开 http://localhost:5173
+# 自定义端口/不自动开浏览器
+easy-tdx serve --port 8080 --no-open-browser
```
-> 前端开发服务器通过 Vite proxy 把 `/api` 请求转发到后端 `127.0.0.1:8000`,无需处理跨域。后端行情连接失败时回测路由仍可用(用内联数据),但取行情功能需要后端连通通达信服务器。
+> 后端启动后约 1-2 秒会自动弹出浏览器。前端界面已编译进 `web-ui/dist/`,由后端同源托管,无需单独跑前端开发服务器。后端行情连接失败时回测路由仍可用(用内联数据),但取行情功能需要后端连通通达信服务器。
+
+**不想装 Python?下载 EXE 直接用(面向零基础用户):**
+
+Windows 用户可以下载打包好的单一 EXE(约 80-150MB),双击即可使用,无需安装 Python/Node 或任何依赖:
+
+1. 到 [Releases 页面](../../releases) 下载最新的 `easy-tdx-<版本>-windows.exe`
+2. 双击运行(首次会被 SmartScreen 拦截,点"更多信息 → 仍要运行")
+3. 等待 2-5 秒,浏览器自动打开回测界面
+4. 右下角任务栏出现小图标,右键 → "退出" 可关闭
+
+EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。
打开浏览器后,顶部导航栏有五个页面:
diff --git a/docs/packaging.md b/docs/packaging.md
new file mode 100644
index 0000000..664ab7e
--- /dev/null
+++ b/docs/packaging.md
@@ -0,0 +1,123 @@
+# 打包为 Windows EXE(面向老年用户)
+
+本文档说明如何把 easy-tdx + Vue 前端打包成单一 Windows EXE,让老人双击即可
+使用量化回测界面,无需安装 Python、Node 或任何依赖。
+
+## 给最终用户(老人 / 量化初学者)
+
+### 下载
+
+到 [Releases 页面](https://github.com//easy_tdx/releases) 下载最新的
+`easy-tdx-<版本号>-windows.exe`(约 80-150MB)。
+
+### 运行
+
+1. 双击 `easy-tdx-<版本号>-windows.exe`
+2. 首次运行 Windows 会弹"已保护你的电脑"(蓝色 SmartScreen 窗口):
+ - 点击 **更多信息**
+ - 点击 **仍要运行**
+ - (Phase 2 引入代码签名后会消除此提示)
+3. 等待 2-5 秒(EXE 首次解压),浏览器会自动打开
+ `http://localhost:8000`
+4. 即可看到回测界面,开始使用
+
+### 关闭
+
+直接关闭浏览器标签页**不会**停止后台服务。完整退出请:
+
+- 在任务管理器结束 `easy-tdx.exe` 进程,或
+- 在命令行运行 `taskkill /IM easy-tdx.exe /F`
+
+### 已知限制
+
+- **必须联网**:在线行情数据需要连接通达信服务器。
+- **离线 .day 读取需要通达信**:若没安装 Windows 版通达信,离线读取本地
+ 数据功能不可用;在线行情不受影响。
+- **收藏的策略不会丢**:策略保存在 `~/.easy_tdx/strategies.db`,跨重启保留。
+ 升级 EXE 时该文件不会被覆盖。
+
+### 排查问题
+
+双击后没反应(浏览器没打开):
+
+1. 打开命令提示符(Win+R 输入 `cmd`)
+2. 拖拽 EXE 到命令行,加 ` serve`,回车
+3. 查看报错信息(通常是端口 8000 被占用,改用 `--port 8001`)
+
+---
+
+## 给开发者:本地构建 EXE
+
+### 前置
+
+- Windows 10/11(PyInstaller 不支持跨平台编译)
+- Python 3.10+
+- Node.js 20+
+- 项目已 `pip install -e ".[web]"` 安装到当前环境
+
+### 步骤
+
+```bash
+# 1. 安装 PyInstaller
+pip install pyinstaller
+
+# 2. 构建前端
+cd web-ui
+npm ci
+npm run build
+cd ..
+
+# 3. 构建 EXE
+pyinstaller easy_tdx.spec --noconfirm
+
+# 4. 产物
+ls -lh dist/easy-tdx.exe
+```
+
+双击 `dist/easy-tdx.exe` 验证:浏览器自动打开,能跑通一次内置策略回测。
+
+### 调试
+
+`.spec` 默认 `console=False`(无黑窗)。排查启动失败时:
+
+```bash
+# 临时改 console=True 重新打包,或在命令行运行看 stderr
+dist/easy-tdx.exe serve --no-open-browser
+```
+
+---
+
+## GitHub Actions 自动发版
+
+打 tag 触发:
+
+```bash
+git tag v1.19.0
+git push origin v1.19.0
+```
+
+`.github/workflows/release.yml` 会自动:
+
+1. 在 `windows-latest` runner 上构建前端 + EXE
+2. 重命名为 `easy-tdx-<版本>-windows.exe`
+3. 创建 GitHub Release 并上传 EXE
+
+该 workflow 与 `publish.yml`(PyPI)**完全独立**:即使 PyPI 发布失败,EXE
+照样能发布。两个 workflow 共享 `v*` tag 触发器但互不依赖。
+
+---
+
+## 当前限制(Phase 1)
+
+| 项 | 状态 | 说明 |
+|---|---|---|
+| Windows EXE | ✅ | 单文件,双击即用 |
+| 代码签名 | ❌ | 未签名,SmartScreen 会拦截,需手动绕过 |
+| macOS | ❌ | 延后到 Phase 3(需 Apple 开发者账号 + 公证) |
+| 自动更新 | ❌ | 老人需手动下载新版本 |
+| EXE 体积 | ~80-150MB | pandas/numpy/scipy/uvicorn/Vue 全包 |
+
+Phase 2 计划:购买 OV/EV 代码签名证书,在 GitHub Actions 中签名 EXE,
+消除 SmartScreen 提示。
+
+Phase 3 计划:macOS 构建 + Apple 公证。
diff --git a/docs/回测系统完全上手手册.html b/docs/回测系统完全上手手册.html
index 24b8985..d4ccd3b 100644
--- a/docs/回测系统完全上手手册.html
+++ b/docs/回测系统完全上手手册.html
@@ -261,7 +261,7 @@
easy-tdx 回测系统完全上手手册
从零开始,手把手教你在浏览器里完成股票策略回测
- 适用版本 v1.18.0
+ 适用版本 v1.19.1
适用系统 Windows 10 / 11
读者:零基础新手
@@ -270,8 +270,8 @@
-第一章 准备工作:安装两个软件
+第一章 准备工作:选一种方式开始
-easy-tdx 的回测系统需要两个软件配合:Python(负责算数据和回测)和 Node.js(负责显示网页界面)。这两个软件都是免费的,我们一步一步来。
+easy-tdx 提供三种使用方式,难度从低到高。请根据自己的情况选一种,不需要三种都试。
-
-
为什么需要两个? 简单理解:Python 是"厨房",负责做菜(算数据、跑回测);Node.js 是"服务员",负责把菜端到你面前(显示网页)。缺一个都不行。
+
+不知道选哪个? 选方式一(下载 EXE)。它不用装任何软件,双击就能用,最适合零基础的朋友。
-
1.1 安装 Python(必须 3.10 或更高版本)
+
方式一:下载 EXE,双击即用(零基础首选)
+
+
这是最简单的方式。我们提供了打包好的单个 EXE 文件(约 80-150MB),里面已经包含了所有需要的东西,不用装 Python、不用装 Node.js、不用敲命令。
+
+
+
1下载 EXE
+
用浏览器打开项目主页的 Releases(发布) 页面:
+https://github.com/handsomejustin/easy_tdx/releases
+
找到最新版本,下载那个名字像 easy-tdx-1.19.0-windows.exe 的文件。
+
+
+
+
2双击运行
+
下载后直接双击这个 EXE。
+
第一次运行会被 Windows 拦截(弹出蓝色"已保护你的电脑"窗口),这是正常的,因为我们的 EXE 还没有花钱买"数字签名"。处理方法:
+
+ - 点蓝色窗口里的 "更多信息"
+ - 会出现 "仍要运行" 按钮,点它
+
+
+
+
+[此处应放截图:SmartScreen 蓝色窗口 → 点"更多信息" → 点"仍要运行"]
+
+
+
+
3等待 2-5 秒,浏览器自动打开
+
EXE 首次启动需要解压(所以会慢几秒),然后浏览器会自动打开回测界面。看到顶部导航栏有"单标的回测 / 组合回测 / 参数寻优 / 结果对比 / 策略库"五个标签,就大功告成了!
+
+
+
+怎么关闭? 直接关浏览器标签页不会停止后台服务。完整退出请看屏幕右下角任务栏的小图标(一个 K 线图样式的图标),右键 → 退出即可干净关闭。
+
+
+
+
选了方式一? 直接跳到
第三章 第一次回测 开始用。第二、十一章可以跳过(那是给方式二/三的用户看的)。
+
+
+
方式二:装 Python,一条命令启动(会点电脑的)
+
+
如果你愿意装一个软件(Python),用一条命令启动,这种方式更灵活,启动也更快。只需要装 Python,不需要 Node.js(网页界面已经预先编译好了,由后端自动提供)。
1下载 Python
打开浏览器,访问官网下载页:
https://www.python.org/downloads/
-
页面会自动推荐 Windows 版本,点黄色按钮 "Download Python 3.12.x"(3.12 是目前最稳定的版本)。如果推荐的是 3.13 也可以。
+
页面会自动推荐 Windows 版本,点黄色按钮 "Download Python 3.12.x"(3.12 是目前最稳定的版本)。如果推荐的是 3.13 也可以。必须 3.10 或更高版本。
@@ -322,38 +363,45 @@
如果显示 Python 3.12.x(数字无所谓,3.10 以上都行),就成功了。如果提示"不是内部或外部命令",说明上一步 PATH 没勾,请卸载重装一次。
-
1.2 安装 Node.js(选 LTS 长期支持版)
+
+
+
方式三:从源码运行 + 自己打包 EXE(开发者)
+
+
如果你想看代码、改代码,或者想自己打包一个 EXE 分发给朋友,用这种方式。需要同时装 Python 和 Node.js(Node.js 仅用于修改前端界面后重新编译,日常使用不需要)。
-
1下载 Node.js
-
访问官网:
-https://nodejs.org/zh-cn
-
下载左边那个 "LTS"(长期支持版),不要下右边那个最新尝鲜版。LTS 更稳定,新手用它不会出怪问题。版本号一般是 20.x 或 22.x。
+
1装 Python
+
按上面"方式二"的第 1-3 步装好 Python 3.10+。
-
2一路下一步安装
-
双击安装包,全部点"下一步",不用改任何选项,点"Install"等完成。
+
2装 Node.js(LTS 长期支持版)
+
访问官网 https://nodejs.org/zh-cn,下载左边那个 "LTS" 版本。双击安装包,全部点"下一步"即可。
+
验证:打开命令行输入 node --version,显示 v20.x.x 或更高就成功了。
-
3验证安装
-
再次打开命令行(按 Win + R 输入 cmd),输入:
-
node --version
-
显示 v20.x.x 或更高版本就成功了。
+
3按第二章操作,然后看附录"自己打包 EXE"
+
先按 第二章 把项目跑起来。打包 EXE 的方法见第十二章附录 "自己打包 EXE 分发"。
-两个软件都装好了?恭喜!最难的安装部分已经过去了一大半。
+三个方式都介绍完了。 方式一最简单,方式二最常用,方式三适合爱折腾的人。选好后翻到下一章。
-第二章 下载项目并启动系统
+第二章 下载项目并启动系统(方式二/三用户)
-easy-tdx 是一个开源项目,代码托管在 GitHub。我们需要把代码下载到本地,然后启动两个服务。
+
+
选了"方式一(下载 EXE)"的朋友请跳过这一章,直接翻到
第三章 第一次回测。这一章是给用 Python 启动的朋友准备的。
+
+
+easy-tdx 是一个开源项目,代码托管在 GitHub。我们需要把代码下载到本地,装好 Python 依赖,然后一条命令启动。
2.1 下载项目代码
@@ -368,7 +416,7 @@
路径千万别用中文! 不要解压到"我的文档"或"桌面",因为 Windows 用户名如果是中文,后续安装会出各种莫名其妙的错。直接放 D:\easy_tdx 最稳。
-2.2 安装 Python 依赖(只装一次)
+2.2 创建虚拟环境并安装依赖(只做一次)
1打开命令行,进入项目目录
@@ -379,8 +427,28 @@ cd \easy_tdx
-
2安装项目本体 + Web 服务依赖
-
+2创建虚拟环境(强烈推荐)
+虚拟环境可以把这个项目的依赖和系统里其他 Python 程序隔离开,避免冲突。在项目目录下输入:
+
+python -m venv venv
+
+
+
+venv\Scripts\activate
+
+
+
+激活成功后,命令行最前面会多出一个 (venv) 标记,说明你已经在虚拟环境里了。
+
+
+
+为什么要虚拟环境? 如果直接用系统 Python 装依赖,以后装别的 Python 软件可能版本冲突报错。虚拟环境就像给这个项目单独开了一个"小房间",干净不干扰。每次重新开 cmd 窗口,都要先 venv\Scripts\activate 激活,看到 (venv) 才说明进对了。
+
+
+
+
3安装项目本体 + Web 服务依赖
+
+
pip install -e ".[web]"
这步会下载很多文件(约 100MB),需要等 3 到 5 分钟。看到最后有 Successfully installed ... 就成功了。
@@ -392,80 +460,56 @@ pip install -e ".[web]"
-
3验证安装成功
+
4验证安装成功
easy-tdx --help
-2.3 安装前端依赖(只装一次)
+2.3 启动系统(每次使用都要做)
+
+装好之后,每次用只需要一条命令。打开命令行,激活虚拟环境,进入项目目录,然后:
-
1进入 web-ui 目录,装依赖
-
-cd web-ui
-
-
-npm install
-
-
-
-
npm install 很慢怎么办? 用淘宝镜像加速(只执行一次,以后都快):
-
npm config set registry https://registry.npmmirror.com
-npm install
-
-
-2.4 启动系统(每次使用都要做)
-
-这是新手最容易迷糊的地方:这个系统需要同时开两个命令行窗口,一个跑后端(算数据的厨房),一个跑前端(显示网页的服务员)。
-
-第一个窗口:启动后端
-
-
-
1新开一个命令行窗口
-
按 Win + R 输入 cmd 回车,进入项目根目录:
-
D:
-cd \easy_tdx
+
1激活虚拟环境 + 进入项目目录
+
+D:
+cd \easy_tdx
+venv\Scripts\activate
-
2启动后端服务
-
easy-tdx serve --port 8000
+
2一条命令启动
+
easy-tdx serve
看到类似下面的输出就成功了:
INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.
-
这个窗口不要关! 它一直开着,前端才能取到数据。
-
-
-第二个窗口:启动前端
-
-
-
1再开一个命令行窗口
-
按 Win + R 输入 cmd 回车,进入前端目录:
-
D:
-cd \easy_tdx\web-ui
+
大约 1-2 秒后,浏览器会自动打开 http://localhost:8000,直接看到回测界面。不用再手动开浏览器,也不用跑第二个命令。
-
2启动前端开发服务器
-
npm run dev
-
看到这样的输出:
-
VITE v8.x.x ready in 500 ms
-
- ➜ Local: http://localhost:5173/
- ➜ Network: use --host to expose
-
-
-
-
3打开浏览器,访问网页
-
打开 Chrome 或 Edge 浏览器,地址栏输入:
-http://localhost:5173
-
看到顶部导航栏有"单标的回测 / 组合回测 / 参数寻优 / 结果对比 / 策略库"五个标签,就大功告成了!
+
3关闭系统
+
用完后,在跑 easy-tdx serve 的命令行窗口里按 Ctrl + C 就能停止服务。然后关掉窗口即可。
-关机后下次怎么用? 不用重新装,只要重复 2.4 节这两个步骤:开两个 cmd 窗口,一个跑 easy-tdx serve --port 8000,一个跑 npm run dev,然后浏览器打开 http://localhost:5173 就行。
+关机后下次怎么用? 不用重新装,只要:
+1. 开一个 cmd 窗口
+2. D: → cd \easy_tdx → venv\Scripts\activate(激活虚拟环境)
+3. easy-tdx serve
+浏览器会自动弹出来。不用再跑 npm,不用开两个窗口了。
+2.4 常用启动参数(可选)
+
+
+easy-tdx serve --port 8080
+
+
+easy-tdx serve --no-open-browser
+
+
+easy-tdx serve --host 0.0.0.0
+
@@ -1260,64 +1304,88 @@ SZ:000858 五粮液
第十一章 常见问题与排错
-Q1: 启动后端报错 "easy-tdx 不是内部或外部命令"
-说明 Python 没装好或 PATH 没配置。回到第一章 1.1 重装 Python,务必勾选 "Add Python to PATH"。如果已装,在命令行输入 pip install -e ".[web]"(在项目目录下)重新安装。
-
-
-
-Q2: 启动前端报错 "npm 不是内部或外部命令"
-Node.js 没装或没重启命令行。回到第一章 1.2 重装 Node.js,然后关闭所有 cmd 窗口重新打开。
-
-
-
-Q3: npm install 卡住不动或报错
-网络问题。换淘宝镜像:
-
npm config set registry https://registry.npmmirror.com
-然后重新 npm install。
-
-
-
-Q4: 浏览器打开 localhost:5173 显示空白或报错
-检查两点:
+Q1: 双击 EXE 没反应 / 浏览器没打开
+EXE 启动需要 2-5 秒解压,请耐心等待。如果超过 30 秒还没反应:
- - 后端窗口是否还开着?如果关了,重新跑
easy-tdx serve --port 8000
- - 看后端窗口有没有报错。如果有红色错误,截图找老师
+ - 看屏幕右下角任务栏有没有出现 K 线图标——有的话说明后台已启动,手动打开浏览器访问
http://localhost:8000
+ - 没有图标的话,打开任务管理器(Ctrl+Shift+Esc)看有没有
easy-tdx.exe 进程
+ - 还不行就在 cmd 里拖入 EXE 加
serve --no-open-browser 跑,看报错信息
-Q5: 回测时提示"取行情失败"或"连接通达信服务器超时"
+Q2: 双击 EXE 提示"SmartScreen 已保护你的电脑"
+这是正常的。我们的 EXE 没有花钱买"数字签名",Windows 会拦截。处理方法:
+
+ - 点蓝色窗口里的 "更多信息"
+ - 会出现 "仍要运行" 按钮,点它
+
+只需第一次这样做,之后 Windows 会记住。
+
+
+
+Q3: 怎么完全退出 EXE?
+直接关浏览器标签页不会停止后台服务。完整退出看屏幕右下角任务栏:
+
+ - 找到 K 线图样式的小图标
+ - 右键 → 退出
+
+或者打开任务管理器(Ctrl+Shift+Esc),结束 easy-tdx.exe 进程。
+
+
+
+Q4: Python 方式启动报错 "easy-tdx 不是内部或外部命令"
+两种可能:
+
+ - 没激活虚拟环境:命令行前面没有
(venv) 标记。先 cd \easy_tdx 再 venv\Scripts\activate。
+ - Python 没装好或 PATH 没配:回到第一章"方式二"重装 Python,务必勾选 "Add Python to PATH"。
+
+如果已装,在项目目录下(激活 venv 后)重新执行 pip install -e ".[web]"。
+
+
+
+Q5: 浏览器打开 localhost:8000 显示空白或报错
+检查两点:
+
+ - 后端窗口是否还开着?如果关了,重新跑
easy-tdx serve
+ - 看后端窗口有没有报错。如果有红色错误,截图反馈
+ - EXE 用户看
%USERPROFILE%\.easy_tdx\easy_tdx_runtime.log 日志文件
+
+
+
+
+Q6: 回测时提示"取行情失败"或"连接通达信服务器超时"
easy-tdx 需要连接通达信的行情服务器取数据。可能原因:
- 网络问题:换网络或等一会再试
- - 防火墙拦截:检查是否有安全软件拦截了 Python
+ - 防火墙拦截:检查是否有安全软件拦截了 Python 或 EXE
- 非交易时段:周末和晚上有时连接不稳定
可以换个时间再试,或者用其他股票代码试试。
-Q6: 回测结果关机后就没了
+Q7: 回测结果关机后就没了
这是正常的。回测结果存在后端进程内存,重启就清空。重要的策略一定要点"保存策略"存进策略库,策略库的数据存在 SQLite 文件里,重启不丢。
-Q7: 评级徽章显示 "⚠ 交易样本有限" 是什么意思
+Q8: 评级徽章显示 "⚠ 交易样本有限" 是什么意思
这只股票/策略在回测期间交易笔数少于 10 笔。系统已经把胜率和利润因子的权重降到 0,只看净值类指标(夏普/卡玛/回撤)。长线策略常常这样,不一定是坏事。详见第七章 7.5 节。
-Q8: 寻优网格点数提示超过 200
+Q9: 寻优网格点数提示超过 200
参数取值组合太多。减少参数取值的数量,比如 fast 从 10 个值减到 5 个值,或者只寻优一个参数(不勾另一个)。
-Q9: 评级看起来不准,某个明显好的策略却是 C 档
-评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 web-ui/src/grading/thresholds.ts 文件里,如果你懂技术可以微调。或者截图给老师反馈。
+Q10: 评级看起来不准,某个明显好的策略却是 C 档
+评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 web-ui/src/grading/thresholds.ts 文件里,如果你懂技术可以微调。或者截图反馈。
-Q10: 我想用分钟线或周线回测
+Q11: 我想用分钟线或周线回测
在行情数据区的"周期"下拉里选 MIN_5(5 分钟)、WEEK(周线)等。注意:分钟线数据量很大,回测会慢很多,新手建议只用日线(DAY)。
@@ -1392,8 +1460,107 @@ SZ:000858 五粮液
+
+
+附录:自己打包 EXE 分发给朋友(方式三)
+
+如果你想把 easy-tdx 打包成单个 EXE,发给不会装 Python 的朋友/家人用,按下面的步骤操作。打包后的 EXE 双击即用,对方不用装任何东西。
+
+A.1 打包前提
+
+
+ - Windows 10/11(PyInstaller 不能跨系统打包,Mac 用户打不了 Windows EXE)
+ - 已按"方式三"装好 Python 3.10+ 和 Node.js 20+
+ - 已按第二章下载项目代码、创建虚拟环境、安装
[web,packaging] 依赖
+
+
+A.2 安装打包所需的额外依赖
+
+在项目目录、激活虚拟环境后,执行:
+
+pip install -e ".[web,packaging]"
+
+
+pip install pyinstaller
+
+A.3 构建前端
+
+
+cd web-ui
+
+
+npm install
+
+
+npm run build
+
+
+cd ..
+
+
+这步必须做! 如果不编译前端,打包出来的 EXE 启动后浏览器会显示空白——因为前端文件还没有。
+
+
+A.4 打包 EXE
+
+
+pyinstaller easy_tdx.spec --noconfirm
+
+打包过程约 1-3 分钟。完成后,在 dist\ 目录下会出现 easy-tdx.exe(约 80-150MB)。
+
+A.5 验证
+
+
+
1双击 dist\easy-tdx.exe
+
等待 2-5 秒,浏览器应自动打开 http://localhost:8000。
+
+
+
+
2检查右下角任务栏
+
应出现 K 线图样式的小图标,右键有"打开浏览器 / 退出"菜单。
+
+
+
+
3跑一次完整回测
+
选一只股票 + 一个策略,确认能完整出图、出指标。再试一次"一键寻优"(选 4 或 8 进程),确认多进程正常。
+
+
+A.6 分发给朋友
+
+把 dist\easy-tdx.exe 这个单文件直接发给朋友即可(可以通过微信传文件、网盘等)。对方:
+
+ - 下载这个 EXE
+ - 双击运行(首次 SmartScreen 拦截,点"更多信息 → 仍要运行")
+ - 等 2-5 秒,浏览器自动打开
+
+
+不用装任何东西,不用敲任何命令。
+
+A.7 常见打包问题
+
+
+打包后双击 EXE 报错 "NoneType object has no attribute 'isatty'"
+这是因为 Windows GUI 模式下标准流为 None,而 uvicorn 假设它们存在。我们的代码已经在 __main__.py 里做了重定向处理,如果还报这个错,说明你用的是旧版入口代码。请拉取最新代码后重新打包。
+
+
+
+双击 EXE 后一键寻优报错 "day datetime: 数据不足"
+多进程在打包模式下需要特殊处理。我们的代码已经在 __main__.py 加了 freeze_support() 和子进程检测。如果还报错,确认你拉的是最新代码。
+
+
+
+打包过程 WARNING 一堆 "not found"
+大部分是无害的:numba(可选加速库,没装正常)、jinja2(模板引擎,本项目用不到)、scipy 内部模块改名。只要最后 dist\easy-tdx.exe 能正常启动,这些 WARNING 都可以忽略。
+
+
+
+想自动发版? 如果你在维护这个项目,可以打 git tag 触发 GitHub Actions 自动打包并发布到 Releases 页。详见项目根目录的 .github/workflows/release.yml 和 docs/packaging.md。
+
+
+
+
diff --git a/easy_tdx.spec b/easy_tdx.spec
new file mode 100644
index 0000000..e739074
--- /dev/null
+++ b/easy_tdx.spec
@@ -0,0 +1,95 @@
+# -*- mode: python ; coding: utf-8 -*-
+"""PyInstaller spec — 把 easy-tdx + Vue 前端打包成单一 Windows EXE。
+
+构建前提(CI 会自动完成,本地手动构建需自行执行)::
+
+ 1. pip install -e ".[web]" pyinstaller
+ 2. cd web-ui && npm ci && npm run build # 产出 web-ui/dist/
+ 3. pyinstaller easy_tdx.spec # 产出 dist/easy-tdx.exe
+
+设计要点:
+- ``--onefile``:单 EXE,老人双击即用。首次启动解压到临时目录需 2-5 秒。
+- ``console=False``:无黑窗(Windows GUI 子系统)。
+- ``collect_submodules('uvicorn')``:uvicorn 用 importlib 动态加载协议
+ 实现,PyInstaller 静态分析漏掉会导致启动报 ModuleNotFoundError。
+- ``collect_submodules('easy_tdx')``:routers/backtest.strategies 等大量
+ 延迟 import,全量收集避免遗漏。
+- ``web-ui/dist → web_dist``:app.py 的 ``_resolve_web_dist_dir`` 会从
+ ``sys._MEIPASS/web_dist`` 读取前端资源。
+- ``~/.easy_tdx`` 的 SQLite 不在打包范围(用户运行时写入家目录),无需处理。
+"""
+
+from PyInstaller.utils.hooks import collect_data_files, collect_submodules
+
+hiddenimports: list[str] = []
+hiddenimports += collect_submodules("uvicorn")
+hiddenimports += collect_submodules("easy_tdx")
+# pandas / numpy / scipy 由 PyInstaller 自带 hook 处理(见
+# PyInstaller/hooks/hook-pandas.* 等),无需手动 collect_submodules——
+# 手动全量收集会把 numpy.typing.tests / pandas._numba.kernels 等可选/测试
+# 模块也拖进来,既增加体积又制造噪音 WARNING。
+# 系统托盘(打包态专用):pystray 在 Windows 用 win32 API,Pillow 的
+# 图像格式插件用 importlib 动态加载,两者都需要显式声明。
+hiddenimports += collect_submodules("pystray")
+
+datas: list[tuple[str, str]] = []
+datas += collect_data_files("tzdata")
+# Pillow 的图像格式插件(PngImagePlugin 等)随数据文件一起收集
+datas += collect_data_files("PIL")
+# 前端 dist 打包到运行时 sys._MEIPASS/web_dist
+datas += [("web-ui/dist", "web_dist")]
+
+block_cipher = None
+
+a = Analysis(
+ ["src/easy_tdx/__main__.py"],
+ pathex=["src"],
+ binaries=[],
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[
+ # matplotlib 仅 CLI 表格输出用,EXE 形态不需要
+ "matplotlib",
+ "tkinter",
+ "PyQt5",
+ "PyQt6",
+ "PySide2",
+ "PySide6",
+ # 测试框架不打进生产 EXE
+ "pytest",
+ "IPython",
+ "jupyter",
+ "notebook",
+ ],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ cipher=block_cipher,
+ noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.zipfiles,
+ a.datas,
+ [],
+ name="easy-tdx",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ # GUI 子系统:双击不弹控制台黑窗。排查问题时改 console=True 重新打包。
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ # icon="assets/easy-tdx.ico", # 暂无图标资源;后续补
+)
diff --git a/pyproject.toml b/pyproject.toml
index c01f5d7..67d122a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
-version = "1.18.2"
+version = "1.19.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
@@ -17,6 +17,9 @@ easy-tdx = "easy_tdx.cli:cli" # cli/__init__.py exposes the click group
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov", "mypy>=1.9", "ruff>=0.4", "scipy>=1.10,<1.16", "httpx>=0.27"]
science = ["scipy>=1.10,<1.16"]
web = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.29"]
+# 打包成桌面 EXE 用:系统托盘(pystray)+ 图标生成(Pillow)。
+# 仅 PyInstaller 打包态需要,开发态 ``pip install -e .[web]`` 不强制装。
+packaging = ["pystray>=0.19", "Pillow>=10.0"]
[tool.hatch.build.targets.wheel]
packages = ["src/easy_tdx"]
@@ -39,6 +42,11 @@ module = "easy_tdx.MyTT"
module = ["fastapi.*", "uvicorn.*", "pydantic.*"]
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+# pystray / Pillow 无 type stubs;tray.py 已用 type: ignore 兜底关键调用
+module = ["pystray", "pystray.*", "PIL", "PIL.*"]
+ignore_missing_imports = true
+
[[tool.mypy.overrides]]
module = ["scipy", "scipy.*"]
ignore_missing_imports = true
diff --git a/src/easy_tdx/__main__.py b/src/easy_tdx/__main__.py
new file mode 100644
index 0000000..d9e38e6
--- /dev/null
+++ b/src/easy_tdx/__main__.py
@@ -0,0 +1,145 @@
+"""``python -m easy_tdx`` 入口 + PyInstaller 打包入口(``easy-tdx.exe``)。
+
+**两种启动形态**:
+
+- **开发态**(``python -m easy_tdx``):等价于 ``easy-tdx`` CLI,无托盘。
+- **打包态**(双击 EXE):uvicorn 后台线程 + 右下角系统托盘图标,老人右键
+ → 退出即可关闭,无需任务管理器。
+
+**三个关键坑(PyInstaller frozen 模式)**:
+
+1. **``console=False`` 下标准流为 None**:Windows GUI 子系统下
+ ``sys.stdout`` / ``sys.stderr`` 都是 ``None``。uvicorn 的
+ ``DefaultFormatter.__init__`` 会调 ``sys.stderr.isatty()``,对 ``None`` 取
+ 属性直接报 ``AttributeError: 'NoneType' object has no attribute 'isatty'``,
+ 导致启动即崩。解决:把 None 流重定向到家目录下的日志文件。
+
+2. **``multiprocessing`` spawn 子进程会重新 import ``__main__``**:Windows
+ 下 ``ProcessPoolExecutor`` 用 spawn 方式启动子进程,子进程会重新执行
+ ``__main__`` 模块来重建执行环境。如果不拦截,子进程会再次执行 ``cli()``
+ → 又启动一个 uvicorn server → 子进程死循环 + 抢占端口 + 各种莫名其妙的
+ 错误。解决:(a) 调 ``freeze_support()``;(b) 在 ``__main__`` 里判断如果是
+ 子进程(argv 含 ``--multiprocessing-fork`` 等标记)就直接 return,不跑 CLI。
+ 一键寻优、screen scanner 等所有用多进程的功能都依赖这个保护。
+
+3. **托盘需要主线程消息泵**:pystray 在 Windows 上需要主线程跑消息循环,
+ 而 uvicorn 的 ``server.run()`` 是阻塞调用。解决:打包态把 uvicorn 挪到
+ 后台线程,主线程跑托盘(见 ``_run_tray_server`` / ``easy_tdx.tray``)。
+ 开发态走原 CLI 路径,不引入托盘。
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+def _is_multiprocessing_child() -> bool:
+ """判断当前进程是否为 multiprocessing fork 出来的子进程。
+
+ Windows spawn 模式下,子进程的 argv[0] 是父 EXE 路径,但会带特殊 flag:
+ ``--multiprocessing-fork``(带或不带 ``=``)或新版 Python 的
+ ``--mp-main`` / ``-c`` 等。检测到这些就说明本进程是子进程,不应启动 CLI。
+ """
+ if len(sys.argv) < 2:
+ return False
+ first_arg = sys.argv[1]
+ # multiprocessing 标准标记(不同 Python 版本略有差异)
+ return first_arg.startswith("--multiprocessing-fork") or first_arg == "--mp-main"
+
+
+def _redirect_std_streams_to_log() -> None:
+ """``console=False`` 下把 None 的 stdout/stderr 重定向到日志文件。
+
+ PyInstaller ``--windowed``(Windows GUI 子系统)下 Python 的 sys.stdout /
+ sys.stderr 为 ``None``。许多库(uvicorn/click/logging)假设它们存在,
+ 调 ``.isatty()`` 或 ``.write()`` 即崩。本函数把它们重定向到家目录下的
+ ``easy_tdx_runtime.log``,并设 ``PYTHONUNBUFFERED=1`` 保证日志实时落盘。
+
+ 只在 ``sys.stdout is None``(打包态)时启用;开发态(有真实终端)不动。
+ """
+ if sys.stdout is not None and sys.stderr is not None:
+ return # 开发态:有真实终端,不重定向
+
+ # 落在 ~/.easy_tdx/,与 strategy_store 的 SQLite 同目录,便于一键收集诊断
+ config_dir = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
+ config_dir.mkdir(parents=True, exist_ok=True)
+ log_path = config_dir / "easy_tdx_runtime.log"
+
+ # 用 "a" 追加而非覆盖:老人多次启动的日志都保留,便于复盘
+ try:
+ f = open(log_path, "a", encoding="utf-8", buffering=1) # noqa: SIM115
+ except OSError:
+ # 家目录不可写(极罕见),退化到 os.devnull——至少不崩
+ f = open(os.devnull, "w", encoding="utf-8") # noqa: SIM115
+
+ if sys.stdout is None:
+ sys.stdout = f
+ if sys.stderr is None:
+ sys.stderr = f
+
+
+def _run_tray_server() -> None:
+ """打包态双击启动专用:uvicorn(后台线程)+ 系统托盘(主线程)。
+
+ 提取为函数避免 ``__main__`` 顶层 import 拖累开发态启动——pystray/Pillow
+ 只在打包态才需要。
+ """
+ from easy_tdx.tray import run_with_tray
+ from easy_tdx.web import create_app
+
+ run_with_tray(
+ app_factory=create_app,
+ host="127.0.0.1",
+ port=8000,
+ open_browser=True,
+ )
+
+
+def main() -> None:
+ """进程入口:区分子进程 / 开发态 / 打包态三条路径。"""
+ # 1. 必须最先:multiprocessing 子进程保护。
+ # Windows spawn 子进程会重新 import __main__;如果不拦截,子进程会
+ # 再次跑 cli() 启动 uvicorn,导致死循环 + 端口冲突 + 数据错乱。
+ if _is_multiprocessing_child():
+ # 子进程:只跑 freeze_support(处理 multiprocessing 协议),
+ # 不启动 CLI / uvicorn / 浏览器。子进程的 mainloop 由
+ # multiprocessing 内部接管,会执行被 pickle 过来的任务函数。
+ import multiprocessing
+
+ multiprocessing.freeze_support()
+ # freeze_support 在子进程里会阻塞到任务完成后 exit,理论不会走到这里;
+ # 但防御性 return,避免任何情况下的 CLI 重复启动。
+ sys.exit(0)
+
+ # 2. 标准流重定向(必须在 import click/uvicorn 之前)。
+ _redirect_std_streams_to_log()
+
+ # 3. freeze_support 即便在主进程也建议调(无害,防御未来加的子进程触发点)。
+ import multiprocessing
+
+ multiprocessing.freeze_support()
+
+ from easy_tdx.cli import cli
+
+ is_frozen = getattr(sys, "frozen", False)
+
+ # 双击启动(无参 + 打包态)→ 走托盘路径:uvicorn 后台 + 右下角图标可退出。
+ # 命令行带参(含 ``serve``)→ 走原 CLI,行为不变(开发/调试场景)。
+ if is_frozen and len(sys.argv) <= 1:
+ _run_tray_server()
+ return
+
+ # 命令行显式调用:保持原行为。
+ # 显式传 ``serve --no-open-browser`` 可关闭浏览器自动打开,
+ # 传其他子命令(如 ``server-info``)走原 CLI 行为。
+ if len(sys.argv) <= 1:
+ # 开发态无参 python -m easy_tdx:默认走 serve(无托盘,开发态不需要)。
+ sys.argv = [sys.argv[0], "serve"]
+
+ cli()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/easy_tdx/cli/cmd_web.py b/src/easy_tdx/cli/cmd_web.py
index b5676e9..ac2d780 100644
--- a/src/easy_tdx/cli/cmd_web.py
+++ b/src/easy_tdx/cli/cmd_web.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+import threading
+import webbrowser
+
import click
@@ -11,7 +14,19 @@ import click
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
@click.option("--reload", is_flag=True, help="开发模式(自动重载)")
-def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, reload: bool) -> None:
+@click.option(
+ "--open-browser/--no-open-browser",
+ default=True,
+ help="启动后自动打开浏览器(默认开启,PyInstaller 打包后老人双击即用)",
+)
+def serve(
+ host: str,
+ port: int,
+ tdx_host: str | None,
+ tdx_port: int | None,
+ reload: bool,
+ open_browser: bool,
+) -> None:
"""启动 Web API 服务器(需要安装 easy-tdx[web])。"""
try:
import uvicorn
@@ -22,6 +37,15 @@ def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, relo
)
raise SystemExit(1) from None
+ # 启动后延迟打开浏览器:uvicorn 需要约 1-2 秒绑定端口,过早打开会
+ # 命中 connection refused。用后台 Timer 而非阻塞主线程。
+ if open_browser and not reload:
+ # 0.0.0.0 / 127.0.0.1 在浏览器里用 localhost 打开(更友好)。
+ display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
+ url = f"http://{display_host}:{port}"
+ # 1.5 秒通常足够本地端口就绪;uvicorn 启动慢的机器可适当延长。
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()
+
if reload:
uvicorn.run(
"easy_tdx.web:app_factory",
diff --git a/src/easy_tdx/commands/security_bars.py b/src/easy_tdx/commands/security_bars.py
index b61f557..9c6c92f 100644
--- a/src/easy_tdx/commands/security_bars.py
+++ b/src/easy_tdx/commands/security_bars.py
@@ -63,17 +63,27 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
pre_diff_base = 0
cat = int(self.category)
+ # 服务器偶发返回的 ret_count 与 body 实际长度不匹配(pytdx/mootdx 均
+ # 有类似报告):ret_count 撒谎或网络帧粘包/截断,循环到中途 pos 已
+ # 读到底("剩余 0 字节")。改用"取 min(ret_count, body 可解析条数)"
+ # 策略——TdxDecodeError 视为记录边界,提前结束循环并丢弃残缺尾记录,
+ # 而不是让整批数据 500。调用方拿到的是完整记录(少几根 K 线比全崩好)。
for _ in range(ret_count):
record_start = pos
- year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
+ try:
+ year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
- open_diff, pos = get_price(body, pos)
- close_diff, pos = get_price(body, pos)
- high_diff, pos = get_price(body, pos)
- low_diff, pos = get_price(body, pos)
+ open_diff, pos = get_price(body, pos)
+ close_diff, pos = get_price(body, pos)
+ high_diff, pos = get_price(body, pos)
+ low_diff, pos = get_price(body, pos)
- vol, pos = get_volume(body, pos)
- amount, pos = get_volume(body, pos)
+ vol, pos = get_volume(body, pos)
+ amount, pos = get_volume(body, pos)
+ except Exception:
+ # 残缺尾记录:body 已读到底或字段不完整,丢弃本条并停止。
+ # 不重新抛出—— degrade gracefully,返回已解析的完整记录。
+ break
# 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base
@@ -116,20 +126,25 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
pre_diff_base = 0
cat = int(self.category)
+ # 同 GetSecurityBarsCmd:ret_count 与 body 实际长度偶发不匹配,
+ # 残缺尾记录提前 break,详见父类同名注释。
for _ in range(ret_count):
record_start = pos
- year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
+ try:
+ year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
- open_diff, pos = get_price(body, pos)
- close_diff, pos = get_price(body, pos)
- high_diff, pos = get_price(body, pos)
- low_diff, pos = get_price(body, pos)
+ open_diff, pos = get_price(body, pos)
+ close_diff, pos = get_price(body, pos)
+ high_diff, pos = get_price(body, pos)
+ low_diff, pos = get_price(body, pos)
- vol, pos = get_volume(body, pos)
- amount, pos = get_volume(body, pos)
+ vol, pos = get_volume(body, pos)
+ amount, pos = get_volume(body, pos)
- # 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
- pos += 4
+ # 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
+ pos += 4
+ except Exception:
+ break
open_abs = open_diff + pre_diff_base
close_abs = open_abs + close_diff
diff --git a/src/easy_tdx/tray.py b/src/easy_tdx/tray.py
new file mode 100644
index 0000000..23c4dbc
--- /dev/null
+++ b/src/easy_tdx/tray.py
@@ -0,0 +1,127 @@
+"""系统托盘(仅打包态使用)。
+
+双击 ``easy-tdx.exe`` 后:uvicorn 跑在后台线程,主线程跑 pystray 托盘
+图标,右键菜单提供"打开浏览器 / 退出"。老人不用学任务管理器,右下角
+图标右键 → 退出即可干净关闭。
+
+**为什么需要独立模块**:
+- uvicorn 的 ``server.run()`` 是阻塞调用。常规 CLI 路径(``cmd_web.py``)
+ 让 uvicorn 占主线程;但 pystray 在 Windows 上需要主线程的消息泵,所以
+ 打包态必须把 uvicorn 挪到后台线程。
+- 本模块仅在 PyInstaller frozen 模式下由 ``__main__.py`` 调用,开发态
+ ``easy-tdx serve`` 走原 CLI 路径,不引入托盘。
+
+依赖:``pystray`` + ``Pillow``(纯 Python wheel,PyInstaller 打包无坑)。
+两者仅在打包态 import,开发态不强制安装。
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+import webbrowser
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ import PIL
+
+logger = logging.getLogger(__name__)
+
+
+def _make_icon_image() -> PIL.Image.Image:
+ """画一个简单的"K 线图"风格图标(红涨绿跌的简化样式)。
+
+ 用 Pillow 代码生成,避免在仓库里维护二进制 .ico 文件。32×32 是
+ Windows 系统托盘的标准尺寸。
+ """
+ from PIL import Image, ImageDraw
+
+ size = 64 # 高分辨率,pystray 会自动缩放到托盘尺寸
+ img = Image.new("RGBA", (size, size), (30, 30, 40, 255)) # 深色背景
+ draw = ImageDraw.Draw(img)
+
+ # 三根简化 K 线:红涨两根 + 绿跌一根
+ bars = [
+ # (x, y_top, y_bottom, color) —— y 越大越往下
+ (16, 18, 44, (231, 76, 60)), # 红
+ (30, 12, 38, (231, 76, 60)), # 红(更高的高点)
+ (44, 22, 50, (46, 204, 113)), # 绿
+ ]
+ for x, top, bottom, color in bars:
+ # 影线(细竖线)
+ draw.line([(x + 3, top - 4), (x + 3, bottom + 4)], fill=color, width=1)
+ # 实体(矩形)
+ draw.rectangle([(x, top), (x + 6, bottom)], fill=color)
+
+ return img
+
+
+def run_with_tray(
+ app_factory: Callable[[], Any],
+ host: str,
+ port: int,
+ open_browser: bool = True,
+) -> None:
+ """启动 uvicorn(后台线程)+ 系统托盘(主线程阻塞)。
+
+ Args:
+ app_factory: 返回配置好的 ASGI app 的零参 callable(惰性调用,
+ 避免本模块顶层 import fastapi/uvicorn)。
+ host: 监听地址。
+ port: 监听端口。
+ open_browser: 启动后是否自动开浏览器。
+ """
+ import signal
+
+ import uvicorn
+ from pystray import Icon, Menu, MenuItem
+
+ app = app_factory()
+ config = uvicorn.Config(app, host=host, port=port, log_level="info")
+ server = uvicorn.Server(config)
+
+ # uvicorn 跑在后台线程:server.run() 阻塞,由 server.should_exit 通知退出
+ server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn")
+ server_thread.start()
+
+ # 启动后延迟开浏览器(等端口就绪)
+ if open_browser:
+ display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
+ url = f"http://{display_host}:{port}"
+ threading.Timer(1.5, lambda: webbrowser.open(url)).start()
+
+ def _open_browser() -> None:
+ display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
+ webbrowser.open(f"http://{display_host}:{port}")
+
+ def _quit(icon: Icon, item: MenuItem) -> None:
+ logger.info("Tray quit clicked — shutting down uvicorn")
+ server.should_exit = True
+ icon.stop()
+
+ menu = Menu(
+ MenuItem("打开浏览器", _open_browser, default=True), # default = 双击图标触发
+ Menu.SEPARATOR,
+ MenuItem("退出", _quit),
+ )
+
+ icon = Icon("easy-tdx", _make_icon_image(), "easy-tdx 回测服务", menu)
+
+ # Ctrl+C 兜底(console=False 下其实收不到,但开发态调试时有用)
+ def _signal_handler(signum: int, frame: object) -> None:
+ server.should_exit = True
+ icon.stop()
+
+ try:
+ signal.signal(signal.SIGINT, _signal_handler)
+ except (ValueError, OSError):
+ # 非 main 线程或 Windows GUI 子系统下会失败,可忽略
+ pass
+
+ logger.info("Starting tray icon (main thread blocks here)")
+ icon.run() # 阻塞主线程,直到 icon.stop() 被调用
+
+ # 托盘退出后,等 uvicorn 线程收尾(最多 5 秒)
+ server_thread.join(timeout=5.0)
+ logger.info("uvicorn thread joined — process exiting")
diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py
index 75aa12f..511c833 100644
--- a/src/easy_tdx/web/app.py
+++ b/src/easy_tdx/web/app.py
@@ -3,8 +3,11 @@
from __future__ import annotations
import logging
+import os
+import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
+from pathlib import Path
from typing import Any
from fastapi import FastAPI
@@ -15,6 +18,41 @@ from easy_tdx.web.errors import register_exception_handlers
logger = logging.getLogger(__name__)
+def _resolve_web_dist_dir() -> Path | None:
+ """定位前端构建产物目录(Vite build 输出的 ``web-ui/dist``)。
+
+ 依次探测三处,命中即返回,全部缺失时返回 ``None``(开发期未构建前端
+ 时正常,路由层照常工作,仅前端页面 404):
+
+ 1. ``EASY_TDX_WEB_DIST`` 环境变量——部署/调试时显式指定。
+ 2. PyInstaller 运行态:``sys._MEIPASS / "web_dist"``——单 EXE 解压
+ 后的临时目录(``--onefile`` 模式)。开发态无 ``_MEIPASS`` 属性,
+ 此分支自动跳过。
+ 3. 开发态:仓库根目录的 ``web-ui/dist``——支持 ``pip install -e .``
+ 后直接 ``easy-tdx serve`` 调试,无需打包。
+ """
+ env_dir = os.environ.get("EASY_TDX_WEB_DIST")
+ if env_dir:
+ p = Path(env_dir)
+ if p.is_dir():
+ return p
+
+ # PyInstaller --onefile 解压目录(frozen 运行态)
+ meipass = getattr(sys, "_MEIPASS", None)
+ if meipass is not None:
+ p = Path(meipass) / "web_dist"
+ if p.is_dir():
+ return p
+
+ # 开发态:从 src/easy_tdx/web/app.py 回溯到仓库根的 web-ui/dist
+ repo_root = Path(__file__).resolve().parents[3]
+ p = repo_root / "web-ui" / "dist"
+ if p.is_dir():
+ return p
+
+ return None
+
+
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
@@ -191,4 +229,16 @@ def _create_app(
# 策略库路由(SQLite 持久化,纯数据 CRUD)
app.include_router(strategies_router, prefix="/api/v1")
+ # --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
+ # 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
+ # 未匹配路径,放最后保证 /api/v1/* 优先命中路由表。
+ from fastapi.staticfiles import StaticFiles
+
+ dist_dir = _resolve_web_dist_dir()
+ if dist_dir is not None:
+ app.mount("/", StaticFiles(directory=str(dist_dir), html=True), name="web-ui")
+ logger.info("Web UI mounted from %s", dist_dir)
+ else:
+ logger.info("Web UI dist not found — serving API only")
+
return app
diff --git a/tests/unit/test_decode_errors.py b/tests/unit/test_decode_errors.py
index 1770752..7a9c952 100644
--- a/tests/unit/test_decode_errors.py
+++ b/tests/unit/test_decode_errors.py
@@ -2,16 +2,18 @@
from __future__ import annotations
+import struct
from pathlib import Path
import pytest
from easy_tdx.codec.frame import FrameHeader, decompress_body
from easy_tdx.commands.company_info import GetCompanyInfoCategoryCmd
+from easy_tdx.commands.security_bars import GetSecurityBarsCmd
from easy_tdx.commands.security_count import GetSecurityCountCmd
from easy_tdx.commands.xdxr_info import GetXdxrInfoCmd
from easy_tdx.exceptions import TdxDecodeError
-from easy_tdx.models.enums import Market
+from easy_tdx.models.enums import KlineCategory, Market
FIXTURES = Path(__file__).parent.parent / "fixtures"
@@ -49,3 +51,49 @@ def test_frame_bad_zlib_raises_tdxdecodeerror() -> None:
def test_frame_unzipsize_mismatch_raises_tdxdecodeerror() -> None:
with pytest.raises(TdxDecodeError):
decompress_body(FrameHeader(0, 0, 0, 3, 4), b"abc")
+
+
+# --------------------------------------------------------------------------- #
+# security_bars 残缺尾记录:服务器 ret_count 与 body 实际长度偶发不匹配
+# (实测日志:SH600519 报"day datetime: 数据不足,需要 4 字节,偏移 2,
+# 实际剩余 0 字节")。修复策略是丢弃残缺尾记录、返回已解析的完整记录,
+# 而不是让整批数据 500。
+# --------------------------------------------------------------------------- #
+
+
+def _make_day_record(yyyymmdd: int) -> bytes:
+ """构造一条合法日线记录:4B datetime + 4 price(0x00) + 8B volume(0)。"""
+ return struct.pack(" None:
+ """ret_count=3 但 body 只够 1 条 + 残渣 → 返回 1 条,不抛异常。"""
+ body = struct.pack(" None:
+ """ret_count 撒大谎(说 100 条实际 2 条)→ 按 body 实际长度截断。"""
+ body = struct.pack(" None:
+ """ret_count=0 → 空列表,不抛异常。"""
+ cmd = GetSecurityBarsCmd(Market.SZ, "000001", KlineCategory.DAY, 0, 800)
+ bars = cmd.parse_response(struct.pack(" None:
+ """正常完整 body 不受 graceful degradation 影响(不能误伤)。"""
+ body = struct.pack("