feat(packaging): v1.19.1 支持 Windows 单 EXE 打包 + 系统托盘 + 自动发版

面向零基础老年用户,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
This commit is contained in:
Justin Gu
2026-07-07 01:48:07 +08:00
parent c901d198ca
commit 0b4ed9af62
13 changed files with 1065 additions and 141 deletions
+93
View File
@@ -0,0 +1,93 @@
name: Release EXE
# 打 tag 时构建 Windows 单 EXE 并发布到 GitHub Release。
# 与 publish.ymlPyPI)并行独立:即使 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] extrasfastapi/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 将引入代码签名消除此提示。
+22
View File
@@ -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 位代码输入(市场自动识别)。
+19 -12
View File
@@ -446,30 +446,37 @@ easy-tdx portfolio --stocks SZ:000001,SH:600519 \
<img src="./docs/web-ui-page-3.png" alt="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)。
打开浏览器后,顶部导航栏有五个页面:
+123
View File
@@ -0,0 +1,123 @@
# 打包为 Windows EXE(面向老年用户)
本文档说明如何把 easy-tdx + Vue 前端打包成单一 Windows EXE,让老人双击即可
使用量化回测界面,无需安装 Python、Node 或任何依赖。
## 给最终用户(老人 / 量化初学者)
### 下载
到 [Releases 页面](https://github.com/<owner>/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/11PyInstaller 不支持跨平台编译)
- 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 公证。
+277 -110
View File
@@ -261,7 +261,7 @@
<h1>easy-tdx 回测系统完全上手手册</h1>
<p class="subtitle">从零开始,手把手教你在浏览器里完成股票策略回测</p>
<div class="meta">
<span>适用版本 v1.18.0</span>
<span>适用版本 v1.19.1</span>
<span>适用系统 Windows 10 / 11</span>
<span>读者:零基础新手</span>
</div>
@@ -270,8 +270,8 @@
<div class="toc">
<h2>目录</h2>
<ol>
<li><a href="#ch1">第一章 准备工作:安装两个软件</a></li>
<li><a href="#ch2">第二章 下载项目并启动系统</a></li>
<li><a href="#ch1">第一章 准备工作:选一种方式开始(EXE / Python / 源码)</a></li>
<li><a href="#ch2">第二章 下载项目并启动系统(方式二/三用户)</a></li>
<li><a href="#ch3">第三章 第一次回测:验证策略靠不靠谱</a></li>
<li><a href="#ch4">第四章 参数寻优:让电脑帮你找最佳参数</a></li>
<li><a href="#ch5">第五章 怎么确认寻优结果是不是真的好</a></li>
@@ -283,26 +283,67 @@
<li><a href="#ch10b">第十章补 充:保存策略组合 + 一键看今日信号(新)</a></li>
<li><a href="#ch11">第十一章 常见问题与排错</a></li>
<li><a href="#ch12">第十二章 重要提醒(必读)</a></li>
<li><a href="#appendix-packaging">附录:自己打包 EXE 分发给朋友(方式三)</a></li>
</ol>
</div>
<!-- ==================== 第一章 ==================== -->
<section class="chapter" id="ch1">
<h2>第一章 准备工作:安装两个软件</h2>
<h2>第一章 准备工作:选一种方式开始</h2>
<p>easy-tdx 的回测系统需要两个软件配合:<strong>Python</strong>(负责算数据和回测)和 <strong>Node.js</strong>(负责显示网页界面)。这两个软件都是免费的,我们一步一步来</p>
<p>easy-tdx 提供三种使用方式,难度从低到高。<strong>请根据自己的情况选一种</strong>,不需要三种都试</p>
<div class="warn">
<strong>为什么需要两个?</strong> 简单理解:Python 是"厨房",负责做菜(算数据、跑回测);Node.js 是"服务员",负责把菜端到你面前(显示网页)。缺一个都不行
<div class="tip">
<strong>不知道选哪个?</strong> <strong>方式一(下载 EXE)</strong>。它不用装任何软件,双击就能用,最适合零基础的朋友
</div>
<h3>1.1 安装 Python(必须 3.10 或更高版本)</h3>
<h3>方式一:下载 EXE,双击即用(零基础首选)</h3>
<p>这是最简单的方式。我们提供了打包好的单个 EXE 文件(约 80-150MB),里面已经包含了所有需要的东西,<strong>不用装 Python、不用装 Node.js、不用敲命令</strong></p>
<div class="step">
<span class="step-num">1</span><strong>下载 EXE</strong>
<p>用浏览器打开项目主页的 <strong>Releases(发布)</strong> 页面:<br>
<code>https://github.com/handsomejustin/easy_tdx/releases</code></p>
<p>找到最新版本,下载那个名字像 <code>easy-tdx-1.19.0-windows.exe</code> 的文件。</p>
</div>
<div class="step">
<span class="step-num">2</span><strong>双击运行</strong>
<p>下载后直接双击这个 EXE。</p>
<p><strong>第一次运行会被 Windows 拦截</strong>(弹出蓝色"已保护你的电脑"窗口),这是正常的,因为我们的 EXE 还没有花钱买"数字签名"。处理方法:</p>
<ol>
<li>点蓝色窗口里的 <strong>"更多信息"</strong></li>
<li>会出现 <strong>"仍要运行"</strong> 按钮,点它</li>
</ol>
</div>
<div class="screenshot-ph">
[此处应放截图:SmartScreen 蓝色窗口 → 点"更多信息" → 点"仍要运行"]
</div>
<div class="step">
<span class="step-num">3</span><strong>等待 2-5 秒,浏览器自动打开</strong>
<p>EXE 首次启动需要解压(所以会慢几秒),然后浏览器会自动打开回测界面。看到顶部导航栏有"单标的回测 / 组合回测 / 参数寻优 / 结果对比 / 策略库"五个标签,就大功告成了!</p>
</div>
<div class="warn">
<strong>怎么关闭?</strong> 直接关浏览器标签页<strong>不会</strong>停止后台服务。完整退出请看屏幕<strong>右下角任务栏的小图标</strong>(一个 K 线图样式的图标),<strong>右键 → 退出</strong>即可干净关闭。
</div>
<div class="tip">
<strong>选了方式一?</strong> 直接跳到 <a href="#ch3">第三章 第一次回测</a> 开始用。第二、十一章可以跳过(那是给方式二/三的用户看的)。
</div>
<h3>方式二:装 Python,一条命令启动(会点电脑的)</h3>
<p>如果你愿意装一个软件(Python),用一条命令启动,这种方式更灵活,启动也更快。只需要装 <strong>Python</strong>,<strong>不需要 Node.js</strong>(网页界面已经预先编译好了,由后端自动提供)。</p>
<div class="step">
<span class="step-num">1</span><strong>下载 Python</strong>
<p>打开浏览器,访问官网下载页:<br>
<code>https://www.python.org/downloads/</code></p>
<p>页面会自动推荐 Windows 版本,点黄色按钮 <strong>"Download Python 3.12.x"</strong>(3.12 是目前最稳定的版本)。如果推荐的是 3.13 也可以。</p>
<p>页面会自动推荐 Windows 版本,点黄色按钮 <strong>"Download Python 3.12.x"</strong>(3.12 是目前最稳定的版本)。如果推荐的是 3.13 也可以。<strong>必须 3.10 或更高版本</strong></p>
</div>
<div class="step">
@@ -322,38 +363,45 @@
<p>如果显示 <code>Python 3.12.x</code>(数字无所谓,3.10 以上都行),就成功了。如果提示"不是内部或外部命令",说明上一步 PATH 没勾,请卸载重装一次。</p>
</div>
<h3>1.2 安装 Node.js(选 LTS 长期支持版)</h3>
<div class="tip">
<strong>选了方式二?</strong> 接下来按 <a href="#ch2">第二章 下载项目并启动系统</a> 操作。不需要装 Node.js。
</div>
<h3>方式三:从源码运行 + 自己打包 EXE(开发者)</h3>
<p>如果你想看代码、改代码,或者<strong>想自己打包一个 EXE 分发给朋友</strong>,用这种方式。需要同时装 <strong>Python</strong><strong>Node.js</strong>(Node.js 仅用于修改前端界面后重新编译,日常使用不需要)。</p>
<div class="step">
<span class="step-num">1</span><strong>下载 Node.js</strong>
<p>访问官网:<br>
<code>https://nodejs.org/zh-cn</code></p>
<p>下载左边那个 <strong>"LTS"(长期支持版)</strong>,不要下右边那个最新尝鲜版。LTS 更稳定,新手用它不会出怪问题。版本号一般是 20.x 或 22.x。</p>
<span class="step-num">1</span><strong>装 Python</strong>
<p>按上面"方式二"的第 1-3 步装好 Python 3.10+。</p>
</div>
<div class="step">
<span class="step-num">2</span><strong>一路下一步安装</strong>
<p>双击安装包,全部点"下一步",不用改任何选项,点"Install"等完成</p>
<span class="step-num">2</span><strong>装 Node.js(LTS 长期支持版)</strong>
<p>访问官网 <code>https://nodejs.org/zh-cn</code>,下载左边那个 <strong>"LTS"</strong> 版本。双击安装包,全部点"下一步"即可</p>
<p>验证:打开命令行输入 <code>node --version</code>,显示 <code>v20.x.x</code> 或更高就成功了。</p>
</div>
<div class="step">
<span class="step-num">3</span><strong>验证安装</strong>
<p>再次打开命令行(<span class="keyboard">Win</span> + <span class="keyboard">R</span> 输入 <code>cmd</code>),输入:</p>
<pre><code>node --version</code></pre>
<p>显示 <code>v20.x.x</code> 或更高版本就成功了。</p>
<span class="step-num">3</span><strong>按第二章操作,然后看附录"自己打包 EXE"</strong>
<p><a href="#ch2">第二章</a> 把项目跑起来。打包 EXE 的方法见第十二章附录 <a href="#appendix-packaging">"自己打包 EXE 分发"</a></p>
</div>
<div class="tip">
<strong>两个软件都装好了?恭喜!最难的安装部分已经过去了一大半。</strong>
<strong>三个方式都介绍完了。</strong> 方式一最简单,方式二最常用,方式三适合爱折腾的人。选好后翻到下一章。
</div>
</section>
<!-- ==================== 第二章 ==================== -->
<section class="chapter" id="ch2">
<h2>第二章 下载项目并启动系统</h2>
<h2>第二章 下载项目并启动系统(方式二/三用户)</h2>
<p>easy-tdx 是一个开源项目,代码托管在 GitHub。我们需要把代码下载到本地,然后启动两个服务。</p>
<div class="tip">
<strong>选了"方式一(下载 EXE)"的朋友请跳过这一章</strong>,直接翻到 <a href="#ch3">第三章 第一次回测</a>。这一章是给用 Python 启动的朋友准备的。
</div>
<p>easy-tdx 是一个开源项目,代码托管在 GitHub。我们需要把代码下载到本地,装好 Python 依赖,然后<strong>一条命令启动</strong></p>
<h3>2.1 下载项目代码</h3>
@@ -368,7 +416,7 @@
<strong>路径千万别用中文!</strong> 不要解压到"我的文档"或"桌面",因为 Windows 用户名如果是中文,后续安装会出各种莫名其妙的错。直接放 <code>D:\easy_tdx</code> 最稳。
</div>
<h3>2.2 安装 Python 依赖(只一次)</h3>
<h3>2.2 创建虚拟环境并安装依赖(只一次)</h3>
<div class="step">
<span class="step-num">1</span><strong>打开命令行,进入项目目录</strong>
@@ -379,8 +427,28 @@ cd \easy_tdx</code></pre>
</div>
<div class="step">
<span class="step-num">2</span><strong>安装项目本体 + Web 服务依赖</strong>
<pre><code><span class="comment"># 一次性安装项目本体和 Web 服务所需的所有 Python 依赖</span>
<span class="step-num">2</span><strong>创建虚拟环境(强烈推荐)</strong>
<p>虚拟环境可以把这个项目的依赖和系统里其他 Python 程序隔离开,避免冲突。在项目目录下输入:</p>
<pre><code><span class="comment"># 创建一个叫 venv 的虚拟环境(只做一次)</span>
python -m venv venv
<span class="comment"># 激活虚拟环境(每次开新窗口都要做)</span>
<span class="comment"># Windows cmd 用这句:</span>
venv\Scripts\activate
<span class="comment"># Windows Git Bash 用这句:</span>
<span class="comment"># source venv/Scripts/activate</span></code></pre>
<p>激活成功后,命令行最前面会多出一个 <code>(venv)</code> 标记,说明你已经在虚拟环境里了。</p>
</div>
<div class="warn">
<strong>为什么要虚拟环境?</strong> 如果直接用系统 Python 装依赖,以后装别的 Python 软件可能版本冲突报错。虚拟环境就像给这个项目单独开了一个"小房间",干净不干扰。<strong>每次重新开 cmd 窗口,都要先 <code>venv\Scripts\activate</code> 激活</strong>,看到 <code>(venv)</code> 才说明进对了。
</div>
<div class="step">
<span class="step-num">3</span><strong>安装项目本体 + Web 服务依赖</strong>
<pre><code><span class="comment"># 确认命令行前面有 (venv) 后执行</span>
<span class="comment"># 一次性安装项目本体和 Web 服务所需的所有 Python 依赖</span>
<span class="comment"># 包括 FastAPI、Uvicorn、pandas、numpy 等</span>
pip install -e ".[web]"</code></pre>
<p>这步会下载很多文件(约 100MB),需要等 3 到 5 分钟。看到最后有 <code>Successfully installed ...</code> 就成功了。</p>
@@ -392,80 +460,56 @@ pip install -e ".[web]"</code></pre>
</div>
<div class="step">
<span class="step-num">3</span><strong>验证安装成功</strong>
<span class="step-num">4</span><strong>验证安装成功</strong>
<pre><code><span class="comment"># 看到帮助信息就说明安装好了</span>
easy-tdx --help</code></pre>
</div>
<h3>2.3 安装前端依赖(只装一次)</h3>
<h3>2.3 启动系统(每次使用都要做)</h3>
<p>装好之后,<strong>每次用只需要一条命令</strong>。打开命令行,激活虚拟环境,进入项目目录,然后:</p>
<div class="step">
<span class="step-num">1</span><strong>进入 web-ui 目录,装依赖</strong>
<pre><code><span class="comment"># 还在项目目录里,进入前端目录</span>
cd web-ui
<span class="comment"># 安装前端依赖(第一次会下载约 200MB,耐心等 5 分钟)</span>
npm install</code></pre>
</div>
<div class="warn">
<strong>npm install 很慢怎么办?</strong> 用淘宝镜像加速(只执行一次,以后都快):
<pre><code>npm config set registry https://registry.npmmirror.com
npm install</code></pre>
</div>
<h3>2.4 启动系统(每次使用都要做)</h3>
<p>这是新手最容易迷糊的地方:<strong>这个系统需要同时开两个命令行窗口</strong>,一个跑后端(算数据的厨房),一个跑前端(显示网页的服务员)。</p>
<h4>第一个窗口:启动后端</h4>
<div class="step">
<span class="step-num">1</span><strong>新开一个命令行窗口</strong>
<p><span class="keyboard">Win</span> + <span class="keyboard">R</span> 输入 <code>cmd</code> 回车,进入项目根目录:</p>
<pre><code>D:
cd \easy_tdx</code></pre>
<span class="step-num">1</span><strong>激活虚拟环境 + 进入项目目录</strong>
<pre><code><span class="comment"># 每次开新窗口都先做这两步</span>
D:
cd \easy_tdx
venv\Scripts\activate</code></pre>
</div>
<div class="step">
<span class="step-num">2</span><strong>启动后端服务</strong>
<pre><code>easy-tdx serve --port 8000</code></pre>
<span class="step-num">2</span><strong>一条命令启动</strong>
<pre><code>easy-tdx serve</code></pre>
<p>看到类似下面的输出就成功了:</p>
<pre><code>INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.</code></pre>
<p><strong>这个窗口不要关!</strong> 它一直开着,前端才能取到数据</p>
</div>
<h4>第二个窗口:启动前端</h4>
<div class="step">
<span class="step-num">1</span><strong>再开一个命令行窗口</strong>
<p><span class="keyboard">Win</span> + <span class="keyboard">R</span> 输入 <code>cmd</code> 回车,进入前端目录:</p>
<pre><code>D:
cd \easy_tdx\web-ui</code></pre>
<p><strong>大约 1-2 秒后,浏览器会自动打开</strong> <code>http://localhost:8000</code>,直接看到回测界面。不用再手动开浏览器,也不用跑第二个命令</p>
</div>
<div class="step">
<span class="step-num">2</span><strong>启动前端开发服务器</strong>
<pre><code>npm run dev</code></pre>
<p>看到这样的输出:</p>
<pre><code> VITE v8.x.x ready in 500 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose</code></pre>
</div>
<div class="step">
<span class="step-num">3</span><strong>打开浏览器,访问网页</strong>
<p>打开 Chrome 或 Edge 浏览器,地址栏输入:<br>
<code>http://localhost:5173</code></p>
<p>看到顶部导航栏有"单标的回测 / 组合回测 / 参数寻优 / 结果对比 / 策略库"五个标签,就大功告成了!</p>
<span class="step-num">3</span><strong>关闭系统</strong>
<p>用完后,在跑 <code>easy-tdx serve</code> 的命令行窗口里按 <span class="keyboard">Ctrl</span> + <span class="keyboard">C</span> 就能停止服务。然后关掉窗口即可。</p>
</div>
<div class="tip">
<strong>关机后下次怎么用?</strong> 不用重新装,只要重复 2.4 节这两个步骤:开两个 cmd 窗口,一个跑 <code>easy-tdx serve --port 8000</code>,一个跑 <code>npm run dev</code>,然后浏览器打开 <code>http://localhost:5173</code> 就行。
<strong>关机后下次怎么用?</strong> 不用重新装,只要:<br>
1. 开一个 cmd 窗口<br>
2. <code>D:</code><code>cd \easy_tdx</code><code>venv\Scripts\activate</code>(激活虚拟环境)<br>
3. <code>easy-tdx serve</code><br>
浏览器会自动弹出来。<strong>不用再跑 npm,不用开两个窗口了</strong>
</div>
<h3>2.4 常用启动参数(可选)</h3>
<pre><code><span class="comment"># 自定义端口(默认 8000,被占用时换一个)</span>
easy-tdx serve --port 8080
<span class="comment"># 不自动开浏览器(比如想用别的浏览器手动打开)</span>
easy-tdx serve --no-open-browser
<span class="comment"># 让局域网其他电脑也能访问(比如手机同 WiFi 访问)</span>
easy-tdx serve --host 0.0.0.0</code></pre>
</section>
<!-- ==================== 第三章 ==================== -->
@@ -1260,64 +1304,88 @@ SZ:000858 五粮液</code></pre>
<h2>第十一章 常见问题与排错</h2>
<details>
<summary>Q1: 启动后端报错 "easy-tdx 不是内部或外部命令"</summary>
<p>说明 Python 没装好或 PATH 没配置。回到第一章 1.1 重装 Python,务必勾选 "Add Python to PATH"。如果已装,在命令行输入 <code>pip install -e ".[web]"</code>(在项目目录下)重新安装。</p>
</details>
<details>
<summary>Q2: 启动前端报错 "npm 不是内部或外部命令"</summary>
<p>Node.js 没装或没重启命令行。回到第一章 1.2 重装 Node.js,然后<strong>关闭所有 cmd 窗口重新打开</strong></p>
</details>
<details>
<summary>Q3: npm install 卡住不动或报错</summary>
<p>网络问题。换淘宝镜像:<br>
<pre><code>npm config set registry https://registry.npmmirror.com</code></pre>
然后重新 <code>npm install</code></p>
</details>
<details>
<summary>Q4: 浏览器打开 localhost:5173 显示空白或报错</summary>
<p>检查两点:</p>
<summary>Q1: 双击 EXE 没反应 / 浏览器没打开</summary>
<p>EXE 启动需要 2-5 秒解压,请耐心等待。如果超过 30 秒还没反应:</p>
<ol>
<li>后端窗口是否还开着?如果关了,重新跑 <code>easy-tdx serve --port 8000</code></li>
<li>看后端窗口有没有报错。如果有红色错误,截图找老师</li>
<li>看屏幕右下角任务栏有没有出现 K 线图标——有的话说明后台已启动,手动打开浏览器访问 <code>http://localhost:8000</code></li>
<li>没有图标的话,打开任务管理器(Ctrl+Shift+Esc)看有没有 <code>easy-tdx.exe</code> 进程</li>
<li>还不行就在 cmd 里拖入 EXE 加 <code> serve --no-open-browser</code> 跑,看报错信息</li>
</ol>
</details>
<details>
<summary>Q5: 回测时提示"取行情失败"或"连接通达信服务器超时"</summary>
<summary>Q2: 双击 EXE 提示"SmartScreen 已保护你的电脑"</summary>
<p>这是正常的。我们的 EXE 没有花钱买"数字签名",Windows 会拦截。处理方法:</p>
<ol>
<li>点蓝色窗口里的 <strong>"更多信息"</strong></li>
<li>会出现 <strong>"仍要运行"</strong> 按钮,点它</li>
</ol>
<p>只需第一次这样做,之后 Windows 会记住。</p>
</details>
<details>
<summary>Q3: 怎么完全退出 EXE?</summary>
<p>直接关浏览器标签页<strong>不会</strong>停止后台服务。完整退出看屏幕<strong>右下角任务栏</strong>:</p>
<ol>
<li>找到 K 线图样式的小图标</li>
<li><strong>右键 → 退出</strong></li>
</ol>
<p>或者打开任务管理器(Ctrl+Shift+Esc),结束 <code>easy-tdx.exe</code> 进程。</p>
</details>
<details>
<summary>Q4: Python 方式启动报错 "easy-tdx 不是内部或外部命令"</summary>
<p>两种可能:</p>
<ol>
<li><strong>没激活虚拟环境</strong>:命令行前面没有 <code>(venv)</code> 标记。先 <code>cd \easy_tdx</code><code>venv\Scripts\activate</code></li>
<li><strong>Python 没装好或 PATH 没配</strong>:回到第一章"方式二"重装 Python,务必勾选 "Add Python to PATH"。</li>
</ol>
<p>如果已装,在项目目录下(激活 venv 后)重新执行 <code>pip install -e ".[web]"</code></p>
</details>
<details>
<summary>Q5: 浏览器打开 localhost:8000 显示空白或报错</summary>
<p>检查两点:</p>
<ol>
<li>后端窗口是否还开着?如果关了,重新跑 <code>easy-tdx serve</code></li>
<li>看后端窗口有没有报错。如果有红色错误,截图反馈</li>
<li>EXE 用户看 <code>%USERPROFILE%\.easy_tdx\easy_tdx_runtime.log</code> 日志文件</li>
</ol>
</details>
<details>
<summary>Q6: 回测时提示"取行情失败"或"连接通达信服务器超时"</summary>
<p>easy-tdx 需要连接通达信的行情服务器取数据。可能原因:</p>
<ul>
<li>网络问题:换网络或等一会再试</li>
<li>防火墙拦截:检查是否有安全软件拦截了 Python</li>
<li>防火墙拦截:检查是否有安全软件拦截了 Python 或 EXE</li>
<li>非交易时段:周末和晚上有时连接不稳定</li>
</ul>
<p>可以换个时间再试,或者用其他股票代码试试。</p>
</details>
<details>
<summary>Q6: 回测结果关机后就没了</summary>
<summary>Q7: 回测结果关机后就没了</summary>
<p>这是正常的。回测结果存在后端进程内存,重启就清空。<strong>重要的策略一定要点"保存策略"存进策略库</strong>,策略库的数据存在 SQLite 文件里,重启不丢。</p>
</details>
<details>
<summary>Q7: 评级徽章显示 "⚠ 交易样本有限" 是什么意思</summary>
<summary>Q8: 评级徽章显示 "⚠ 交易样本有限" 是什么意思</summary>
<p>这只股票/策略在回测期间交易笔数少于 10 笔。系统已经把胜率和利润因子的权重降到 0,只看净值类指标(夏普/卡玛/回撤)。长线策略常常这样,不一定是坏事。详见第七章 7.5 节。</p>
</details>
<details>
<summary>Q8: 寻优网格点数提示超过 200</summary>
<summary>Q9: 寻优网格点数提示超过 200</summary>
<p>参数取值组合太多。减少参数取值的数量,比如 fast 从 10 个值减到 5 个值,或者只寻优一个参数(不勾另一个)。</p>
</details>
<details>
<summary>Q9: 评级看起来不准,某个明显好的策略却是 C 档</summary>
<p>评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 <code>web-ui/src/grading/thresholds.ts</code> 文件里,如果你懂技术可以微调。或者截图给老师反馈。</p>
<summary>Q10: 评级看起来不准,某个明显好的策略却是 C 档</summary>
<p>评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 <code>web-ui/src/grading/thresholds.ts</code> 文件里,如果你懂技术可以微调。或者截图反馈。</p>
</details>
<details>
<summary>Q10: 我想用分钟线或周线回测</summary>
<summary>Q11: 我想用分钟线或周线回测</summary>
<p>在行情数据区的"周期"下拉里选 MIN_5(5 分钟)、WEEK(周线)等。注意:分钟线数据量很大,回测会慢很多,新手建议只用日线(DAY)。</p>
</details>
@@ -1392,8 +1460,107 @@ SZ:000858 五粮液</code></pre>
</section>
<!-- ==================== 附录:打包 EXE ==================== -->
<section class="chapter" id="appendix-packaging">
<h2>附录:自己打包 EXE 分发给朋友(方式三)</h2>
<p>如果你想把 easy-tdx 打包成单个 EXE,发给不会装 Python 的朋友/家人用,按下面的步骤操作。打包后的 EXE 双击即用,对方不用装任何东西。</p>
<h3>A.1 打包前提</h3>
<ul>
<li>Windows 10/11(PyInstaller 不能跨系统打包,Mac 用户打不了 Windows EXE)</li>
<li>已按"方式三"装好 Python 3.10+ 和 Node.js 20+</li>
<li>已按第二章下载项目代码、创建虚拟环境、安装 <code>[web,packaging]</code> 依赖</li>
</ul>
<h3>A.2 安装打包所需的额外依赖</h3>
<p>在项目目录、激活虚拟环境后,执行:</p>
<pre><code><span class="comment"># web 依赖 + 打包依赖(系统托盘 pystray + 图标 Pillow)一起装</span>
pip install -e ".[web,packaging]"
<span class="comment"># 装 PyInstaller 打包工具</span>
pip install pyinstaller</code></pre>
<h3>A.3 构建前端</h3>
<pre><code><span class="comment"># 进入前端目录</span>
cd web-ui
<span class="comment"># 装前端依赖(首次需要)</span>
npm install
<span class="comment"># 编译前端到 web-ui/dist/</span>
npm run build
<span class="comment"># 回到项目根目录</span>
cd ..</code></pre>
<div class="warn">
<strong>这步必须做!</strong> 如果不编译前端,打包出来的 EXE 启动后浏览器会显示空白——因为前端文件还没有。
</div>
<h3>A.4 打包 EXE</h3>
<pre><code><span class="comment"># 在项目根目录执行(venv 已激活)</span>
pyinstaller easy_tdx.spec --noconfirm</code></pre>
<p>打包过程约 1-3 分钟。完成后,在 <code>dist\</code> 目录下会出现 <code>easy-tdx.exe</code>(约 80-150MB)。</p>
<h3>A.5 验证</h3>
<div class="step">
<span class="step-num">1</span><strong>双击 <code>dist\easy-tdx.exe</code></strong>
<p>等待 2-5 秒,浏览器应自动打开 <code>http://localhost:8000</code></p>
</div>
<div class="step">
<span class="step-num">2</span><strong>检查右下角任务栏</strong>
<p>应出现 K 线图样式的小图标,右键有"打开浏览器 / 退出"菜单。</p>
</div>
<div class="step">
<span class="step-num">3</span><strong>跑一次完整回测</strong>
<p>选一只股票 + 一个策略,确认能完整出图、出指标。再试一次"一键寻优"(选 4 或 8 进程),确认多进程正常。</p>
</div>
<h3>A.6 分发给朋友</h3>
<p><code>dist\easy-tdx.exe</code> 这个<strong>单文件</strong>直接发给朋友即可(可以通过微信传文件、网盘等)。对方:</p>
<ol>
<li>下载这个 EXE</li>
<li>双击运行(首次 SmartScreen 拦截,点"更多信息 → 仍要运行")</li>
<li>等 2-5 秒,浏览器自动打开</li>
</ol>
<p><strong>不用装任何东西,不用敲任何命令。</strong></p>
<h3>A.7 常见打包问题</h3>
<details>
<summary>打包后双击 EXE 报错 "NoneType object has no attribute 'isatty'"</summary>
<p>这是因为 Windows GUI 模式下标准流为 None,而 uvicorn 假设它们存在。我们的代码已经在 <code>__main__.py</code> 里做了重定向处理,如果还报这个错,说明你用的是旧版入口代码。请拉取最新代码后重新打包。</p>
</details>
<details>
<summary>双击 EXE 后一键寻优报错 "day datetime: 数据不足"</summary>
<p>多进程在打包模式下需要特殊处理。我们的代码已经在 <code>__main__.py</code> 加了 <code>freeze_support()</code> 和子进程检测。如果还报错,确认你拉的是最新代码。</p>
</details>
<details>
<summary>打包过程 WARNING 一堆 "not found"</summary>
<p>大部分是无害的:numba(可选加速库,没装正常)、jinja2(模板引擎,本项目用不到)、scipy 内部模块改名。只要最后 <code>dist\easy-tdx.exe</code> 能正常启动,这些 WARNING 都可以忽略。</p>
</details>
<div class="tip">
<strong>想自动发版?</strong> 如果你在维护这个项目,可以打 git tag 触发 GitHub Actions 自动打包并发布到 Releases 页。详见项目根目录的 <code>.github/workflows/release.yml</code><code>docs/packaging.md</code>
</div>
</section>
<footer>
easy-tdx 回测系统完全上手手册 · 适用版本 v1.18.0 · 内部教学资料,请勿外传<br>
easy-tdx 回测系统完全上手手册 · 适用版本 v1.19.1 · 内部教学资料,请勿外传<br>
本手册不构成任何投资建议,市场有风险,投资需谨慎
</footer>
+95
View File
@@ -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 APIPillow 的
# 图像格式插件用 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", # 暂无图标资源;后续补
)
+9 -1
View File
@@ -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 stubstray.py 已用 type: ignore 兜底关键调用
module = ["pystray", "pystray.*", "PIL", "PIL.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["scipy", "scipy.*"]
ignore_missing_imports = true
+145
View File
@@ -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()
+25 -1
View File
@@ -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",
+31 -16
View File
@@ -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)
# 同 GetSecurityBarsCmdret_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
+127
View File
@@ -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 wheelPyInstaller 打包无坑)。
两者仅在打包态 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")
+50
View File
@@ -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
+49 -1
View File
@@ -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("<I", yyyymmdd) + b"\x00" * 4 + b"\x00\x00\x00\x00" * 2
def test_security_bars_truncated_tail_returns_partial_results() -> None:
"""ret_count=3 但 body 只够 1 条 + 残渣 → 返回 1 条,不抛异常。"""
body = struct.pack("<H", 3) + _make_day_record(20240101) + b"\x00\x00"
cmd = GetSecurityBarsCmd(Market.SZ, "000001", KlineCategory.DAY, 0, 800)
bars = cmd.parse_response(body)
assert len(bars) == 1
assert (bars[0].year, bars[0].month, bars[0].day) == (2024, 1, 1)
def test_security_bars_ret_count_lies_returns_actual_count() -> None:
"""ret_count 撒大谎(说 100 条实际 2 条)→ 按 body 实际长度截断。"""
body = struct.pack("<H", 100) + _make_day_record(20240101) + _make_day_record(20240102)
cmd = GetSecurityBarsCmd(Market.SH, "600519", KlineCategory.DAY, 0, 800)
bars = cmd.parse_response(body)
assert len(bars) == 2
def test_security_bars_empty_body_returns_empty_list() -> None:
"""ret_count=0 → 空列表,不抛异常。"""
cmd = GetSecurityBarsCmd(Market.SZ, "000001", KlineCategory.DAY, 0, 800)
bars = cmd.parse_response(struct.pack("<H", 0))
assert bars == []
def test_security_bars_complete_body_not_affected() -> None:
"""正常完整 body 不受 graceful degradation 影响(不能误伤)。"""
body = struct.pack("<H", 2) + _make_day_record(20240101) + _make_day_record(20240102)
cmd = GetSecurityBarsCmd(Market.SZ, "000001", KlineCategory.DAY, 0, 800)
bars = cmd.parse_response(body)
assert len(bars) == 2
assert (bars[1].year, bars[1].month, bars[1].day) == (2024, 1, 2)