From 0b4ed9af62e78eac0d05a3716576a7d6c8ab3d6d Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Tue, 7 Jul 2026 01:48:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(packaging):=20v1.19.1=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Windows=20=E5=8D=95=20EXE=20=E6=89=93=E5=8C=85=20+=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=89=98=E7=9B=98=20+=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8F=91=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向零基础老年用户,easy-tdx 可打包成单一 Windows EXE,双击即用。 新增: - 后端同源托管前端 dist(app.py 三级探测:env → _MEIPASS → web-ui/dist) - easy-tdx serve 默认 --open-browser,启动后自动开浏览器 - PyInstaller 打包入口(__main__.py)+ spec 配置(easy_tdx.spec) - 系统托盘(tray.py):右下角图标,右键"打开浏览器/退出" 解决老人不会用任务管理器关闭的问题 - GitHub Actions release.yml:打 v* tag 自动构建并发布 EXE 到 Releases - docs/packaging.md 打包使用文档 修复: - K 线残缺尾记录导致 500(security_bars.py):通达信服务器偶发 ret_count 与 body 长度不匹配,改为 try/except 优雅降级丢弃残缺尾, 返回已解析的完整记录。GetIndexBarsCmd 同改。加 4 个回归测试。 - PyInstaller frozen 模式三个坑: 1. console=False 下 stdout/stderr 为 None → 重定向到日志文件 2. multiprocessing spawn 子进程重新 import __main__ → freeze_support + 子进程检测 3. 系统托盘需主线程消息泵 → uvicorn 挪到后台线程 文档: - README/手册改为三档分流:EXE(零基础)/ Python(一条命令)/ 源码(打包) - 删除 npm run dev / 5173 / 两个终端的过时说明 - 手册补虚拟环境配置 + EXE 打包附录 + EXE 排错 FAQ --- .github/workflows/release.yml | 93 ++++++ CHANGELOG.md | 22 ++ README.md | 31 +- docs/packaging.md | 123 ++++++++ docs/回测系统完全上手手册.html | 387 ++++++++++++++++++------- easy_tdx.spec | 95 ++++++ pyproject.toml | 10 +- src/easy_tdx/__main__.py | 145 +++++++++ src/easy_tdx/cli/cmd_web.py | 26 +- src/easy_tdx/commands/security_bars.py | 47 ++- src/easy_tdx/tray.py | 127 ++++++++ src/easy_tdx/web/app.py | 50 ++++ tests/unit/test_decode_errors.py | 50 +++- 13 files changed, 1065 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/packaging.md create mode 100644 easy_tdx.spec create mode 100644 src/easy_tdx/__main__.py create mode 100644 src/easy_tdx/tray.py 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 \ Web UI 截图 3 -不想写命令行?用浏览器。`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 @@

目录

    -
  1. 第一章 准备工作:安装两个软件
  2. -
  3. 第二章 下载项目并启动系统
  4. +
  5. 第一章 准备工作:选一种方式开始(EXE / Python / 源码)
  6. +
  7. 第二章 下载项目并启动系统(方式二/三用户)
  8. 第三章 第一次回测:验证策略靠不靠谱
  9. 第四章 参数寻优:让电脑帮你找最佳参数
  10. 第五章 怎么确认寻优结果是不是真的好
  11. @@ -283,26 +283,67 @@
  12. 第十章补 充:保存策略组合 + 一键看今日信号(新)
  13. 第十一章 常见问题与排错
  14. 第十二章 重要提醒(必读)
  15. +
  16. 附录:自己打包 EXE 分发给朋友(方式三)
-

第一章 准备工作:安装两个软件

+

第一章 准备工作:选一种方式开始

-

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 还没有花钱买"数字签名"。处理方法:

+
    +
  1. 点蓝色窗口里的 "更多信息"
  2. +
  3. 会出现 "仍要运行" 按钮,点它
  4. +
+
+ +
+[此处应放截图: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 长期支持版)

+
+选了方式二? 接下来按 第二章 下载项目并启动系统 操作。不需要装 Node.js。 +
+ +

方式三:从源码运行 + 自己打包 EXE(开发者)

+ +

如果你想看代码、改代码,或者想自己打包一个 EXE 分发给朋友,用这种方式。需要同时装 PythonNode.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 服务依赖 -
# 一次性安装项目本体和 Web 服务所需的所有 Python 依赖
+2创建虚拟环境(强烈推荐)
+

虚拟环境可以把这个项目的依赖和系统里其他 Python 程序隔离开,避免冲突。在项目目录下输入:

+
# 创建一个叫 venv 的虚拟环境(只做一次)
+python -m venv venv
+
+# 激活虚拟环境(每次开新窗口都要做)
+# Windows cmd 用这句:
+venv\Scripts\activate
+
+# Windows Git Bash 用这句:
+# source venv/Scripts/activate
+

激活成功后,命令行最前面会多出一个 (venv) 标记,说明你已经在虚拟环境里了。

+
+ +
+为什么要虚拟环境? 如果直接用系统 Python 装依赖,以后装别的 Python 软件可能版本冲突报错。虚拟环境就像给这个项目单独开了一个"小房间",干净不干扰。每次重新开 cmd 窗口,都要先 venv\Scripts\activate 激活,看到 (venv) 才说明进对了。 +
+ +
+3安装项目本体 + Web 服务依赖 +
# 确认命令行前面有 (venv) 后执行
+# 一次性安装项目本体和 Web 服务所需的所有 Python 依赖
 # 包括 FastAPI、Uvicorn、pandas、numpy 等
 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
-
-# 安装前端依赖(第一次会下载约 200MB,耐心等 5 分钟)
-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_tdxvenv\Scripts\activate(激活虚拟环境)
+3. easy-tdx serve
+浏览器会自动弹出来。不用再跑 npm,不用开两个窗口了
+

2.4 常用启动参数(可选)

+ +
# 自定义端口(默认 8000,被占用时换一个)
+easy-tdx serve --port 8080
+
+# 不自动开浏览器(比如想用别的浏览器手动打开)
+easy-tdx serve --no-open-browser
+
+# 让局域网其他电脑也能访问(比如手机同 WiFi 访问)
+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 秒还没反应:

    -
  1. 后端窗口是否还开着?如果关了,重新跑 easy-tdx serve --port 8000
  2. -
  3. 看后端窗口有没有报错。如果有红色错误,截图找老师
  4. +
  5. 看屏幕右下角任务栏有没有出现 K 线图标——有的话说明后台已启动,手动打开浏览器访问 http://localhost:8000
  6. +
  7. 没有图标的话,打开任务管理器(Ctrl+Shift+Esc)看有没有 easy-tdx.exe 进程
  8. +
  9. 还不行就在 cmd 里拖入 EXE 加 serve --no-open-browser 跑,看报错信息
-Q5: 回测时提示"取行情失败"或"连接通达信服务器超时" +Q2: 双击 EXE 提示"SmartScreen 已保护你的电脑" +

这是正常的。我们的 EXE 没有花钱买"数字签名",Windows 会拦截。处理方法:

+
    +
  1. 点蓝色窗口里的 "更多信息"
  2. +
  3. 会出现 "仍要运行" 按钮,点它
  4. +
+

只需第一次这样做,之后 Windows 会记住。

+
+ +
+Q3: 怎么完全退出 EXE? +

直接关浏览器标签页不会停止后台服务。完整退出看屏幕右下角任务栏:

+
    +
  1. 找到 K 线图样式的小图标
  2. +
  3. 右键 → 退出
  4. +
+

或者打开任务管理器(Ctrl+Shift+Esc),结束 easy-tdx.exe 进程。

+
+ +
+Q4: Python 方式启动报错 "easy-tdx 不是内部或外部命令" +

两种可能:

+
    +
  1. 没激活虚拟环境:命令行前面没有 (venv) 标记。先 cd \easy_tdxvenv\Scripts\activate
  2. +
  3. Python 没装好或 PATH 没配:回到第一章"方式二"重装 Python,务必勾选 "Add Python to PATH"。
  4. +
+

如果已装,在项目目录下(激活 venv 后)重新执行 pip install -e ".[web]"

+
+ +
+Q5: 浏览器打开 localhost:8000 显示空白或报错 +

检查两点:

+
    +
  1. 后端窗口是否还开着?如果关了,重新跑 easy-tdx serve
  2. +
  3. 看后端窗口有没有报错。如果有红色错误,截图反馈
  4. +
  5. EXE 用户看 %USERPROFILE%\.easy_tdx\easy_tdx_runtime.log 日志文件
  6. +
+
+ +
+Q6: 回测时提示"取行情失败"或"连接通达信服务器超时"

easy-tdx 需要连接通达信的行情服务器取数据。可能原因:

可以换个时间再试,或者用其他股票代码试试。

-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 打包前提

+ + + +

A.2 安装打包所需的额外依赖

+ +

在项目目录、激活虚拟环境后,执行:

+
# web 依赖 + 打包依赖(系统托盘 pystray + 图标 Pillow)一起装
+pip install -e ".[web,packaging]"
+
+# 装 PyInstaller 打包工具
+pip install pyinstaller
+ +

A.3 构建前端

+ +
# 进入前端目录
+cd web-ui
+
+# 装前端依赖(首次需要)
+npm install
+
+# 编译前端到 web-ui/dist/
+npm run build
+
+# 回到项目根目录
+cd ..
+ +
+这步必须做! 如果不编译前端,打包出来的 EXE 启动后浏览器会显示空白——因为前端文件还没有。 +
+ +

A.4 打包 EXE

+ +
# 在项目根目录执行(venv 已激活)
+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 这个单文件直接发给朋友即可(可以通过微信传文件、网盘等)。对方:

+
    +
  1. 下载这个 EXE
  2. +
  3. 双击运行(首次 SmartScreen 拦截,点"更多信息 → 仍要运行")
  4. +
  5. 等 2-5 秒,浏览器自动打开
  6. +
+ +

不用装任何东西,不用敲任何命令。

+ +

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.ymldocs/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("