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/)。 本文件记录 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 ## [1.18.2] — 2026-07-06
**回退拼音声母搜索功能,回到稳定的 6 位代码输入** —— v1.19.0 引入的拼音声母搜索(输 `zjxc` 命中中际旭创)因底层依赖过重被移除。该功能首次使用时需从通达信服务器爬取沪深 A 股约 5000 条完整名单(几十次协议往返,慢机器耗时几十秒到超时),且与共享的 TDX 连接耦合——爬名单期间会阻塞行情请求。虽经多轮优化(按需加载 / 全站遮罩 / 单飞去重 / 后台预热),均无法兼顾"不阻塞核心行情"与"首次可用"。本次回到 v1.18.1 的干净基线,代码输入框恢复为纯 6 位代码输入(市场自动识别)。 **回退拼音声母搜索功能,回到稳定的 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" /> <img src="./docs/web-ui-page-3.png" alt="Web UI 截图 3" />
不想写命令行?用浏览器。`easy-tdx serve` 启动后端,`web-ui/` 目录跑前端,浏览器打开就是完整的回测可视化界面。 不想写命令行?用浏览器。`easy-tdx serve` 一条命令启动,浏览器自动打开 `http://localhost:8000`就是完整的回测可视化界面。
**前置条件:** **前置条件:**
```bash ```bash
# 后端需安装 web 可选依赖(FastAPI + Uvicorn # 需安装 web 可选依赖(FastAPI + Uvicorn
pip install -e ".[web]" pip install -e ".[web]"
# 前端需 Node.js 18+(首次运行需装依赖)
cd web-ui && npm install
``` ```
**启动(两个终端):** **启动(一条命令):**
```bash ```bash
# 终端 1:启动后端 API 服务(提供行情数据 + 回测计算 # 启动后端 + 自动打开浏览器(默认 http://localhost:8000
easy-tdx serve --port 8000 easy-tdx serve
# 终端 2:启动前端开发服务器(web-ui/ 目录) # 自定义端口/不自动开浏览器
cd web-ui && npm run dev easy-tdx serve --port 8080 --no-open-browser
# 浏览器打开 http://localhost:5173
``` ```
> 前端开发服务器通过 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> <h1>easy-tdx 回测系统完全上手手册</h1>
<p class="subtitle">从零开始,手把手教你在浏览器里完成股票策略回测</p> <p class="subtitle">从零开始,手把手教你在浏览器里完成股票策略回测</p>
<div class="meta"> <div class="meta">
<span>适用版本 v1.18.0</span> <span>适用版本 v1.19.1</span>
<span>适用系统 Windows 10 / 11</span> <span>适用系统 Windows 10 / 11</span>
<span>读者:零基础新手</span> <span>读者:零基础新手</span>
</div> </div>
@@ -270,8 +270,8 @@
<div class="toc"> <div class="toc">
<h2>目录</h2> <h2>目录</h2>
<ol> <ol>
<li><a href="#ch1">第一章 准备工作:安装两个软件</a></li> <li><a href="#ch1">第一章 准备工作:选一种方式开始(EXE / Python / 源码)</a></li>
<li><a href="#ch2">第二章 下载项目并启动系统</a></li> <li><a href="#ch2">第二章 下载项目并启动系统(方式二/三用户)</a></li>
<li><a href="#ch3">第三章 第一次回测:验证策略靠不靠谱</a></li> <li><a href="#ch3">第三章 第一次回测:验证策略靠不靠谱</a></li>
<li><a href="#ch4">第四章 参数寻优:让电脑帮你找最佳参数</a></li> <li><a href="#ch4">第四章 参数寻优:让电脑帮你找最佳参数</a></li>
<li><a href="#ch5">第五章 怎么确认寻优结果是不是真的好</a></li> <li><a href="#ch5">第五章 怎么确认寻优结果是不是真的好</a></li>
@@ -283,26 +283,67 @@
<li><a href="#ch10b">第十章补 充:保存策略组合 + 一键看今日信号(新)</a></li> <li><a href="#ch10b">第十章补 充:保存策略组合 + 一键看今日信号(新)</a></li>
<li><a href="#ch11">第十一章 常见问题与排错</a></li> <li><a href="#ch11">第十一章 常见问题与排错</a></li>
<li><a href="#ch12">第十二章 重要提醒(必读)</a></li> <li><a href="#ch12">第十二章 重要提醒(必读)</a></li>
<li><a href="#appendix-packaging">附录:自己打包 EXE 分发给朋友(方式三)</a></li>
</ol> </ol>
</div> </div>
<!-- ==================== 第一章 ==================== --> <!-- ==================== 第一章 ==================== -->
<section class="chapter" id="ch1"> <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"> <div class="tip">
<strong>为什么需要两个?</strong> 简单理解:Python 是"厨房",负责做菜(算数据、跑回测);Node.js 是"服务员",负责把菜端到你面前(显示网页)。缺一个都不行 <strong>不知道选哪个?</strong> <strong>方式一(下载 EXE)</strong>。它不用装任何软件,双击就能用,最适合零基础的朋友
</div> </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"> <div class="step">
<span class="step-num">1</span><strong>下载 Python</strong> <span class="step-num">1</span><strong>下载 Python</strong>
<p>打开浏览器,访问官网下载页:<br> <p>打开浏览器,访问官网下载页:<br>
<code>https://www.python.org/downloads/</code></p> <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>
<div class="step"> <div class="step">
@@ -322,38 +363,45 @@
<p>如果显示 <code>Python 3.12.x</code>(数字无所谓,3.10 以上都行),就成功了。如果提示"不是内部或外部命令",说明上一步 PATH 没勾,请卸载重装一次。</p> <p>如果显示 <code>Python 3.12.x</code>(数字无所谓,3.10 以上都行),就成功了。如果提示"不是内部或外部命令",说明上一步 PATH 没勾,请卸载重装一次。</p>
</div> </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"> <div class="step">
<span class="step-num">1</span><strong>下载 Node.js</strong> <span class="step-num">1</span><strong>装 Python</strong>
<p>访问官网:<br> <p>按上面"方式二"的第 1-3 步装好 Python 3.10+。</p>
<code>https://nodejs.org/zh-cn</code></p>
<p>下载左边那个 <strong>"LTS"(长期支持版)</strong>,不要下右边那个最新尝鲜版。LTS 更稳定,新手用它不会出怪问题。版本号一般是 20.x 或 22.x。</p>
</div> </div>
<div class="step"> <div class="step">
<span class="step-num">2</span><strong>一路下一步安装</strong> <span class="step-num">2</span><strong>装 Node.js(LTS 长期支持版)</strong>
<p>双击安装包,全部点"下一步",不用改任何选项,点"Install"等完成</p> <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>
<div class="step"> <div class="step">
<span class="step-num">3</span><strong>验证安装</strong> <span class="step-num">3</span><strong>按第二章操作,然后看附录"自己打包 EXE"</strong>
<p>再次打开命令行(<span class="keyboard">Win</span> + <span class="keyboard">R</span> 输入 <code>cmd</code>),输入:</p> <p><a href="#ch2">第二章</a> 把项目跑起来。打包 EXE 的方法见第十二章附录 <a href="#appendix-packaging">"自己打包 EXE 分发"</a></p>
<pre><code>node --version</code></pre>
<p>显示 <code>v20.x.x</code> 或更高版本就成功了。</p>
</div> </div>
<div class="tip"> <div class="tip">
<strong>两个软件都装好了?恭喜!最难的安装部分已经过去了一大半。</strong> <strong>三个方式都介绍完了。</strong> 方式一最简单,方式二最常用,方式三适合爱折腾的人。选好后翻到下一章。
</div> </div>
</section> </section>
<!-- ==================== 第二章 ==================== --> <!-- ==================== 第二章 ==================== -->
<section class="chapter" id="ch2"> <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> <h3>2.1 下载项目代码</h3>
@@ -368,7 +416,7 @@
<strong>路径千万别用中文!</strong> 不要解压到"我的文档"或"桌面",因为 Windows 用户名如果是中文,后续安装会出各种莫名其妙的错。直接放 <code>D:\easy_tdx</code> 最稳。 <strong>路径千万别用中文!</strong> 不要解压到"我的文档"或"桌面",因为 Windows 用户名如果是中文,后续安装会出各种莫名其妙的错。直接放 <code>D:\easy_tdx</code> 最稳。
</div> </div>
<h3>2.2 安装 Python 依赖(只一次)</h3> <h3>2.2 创建虚拟环境并安装依赖(只一次)</h3>
<div class="step"> <div class="step">
<span class="step-num">1</span><strong>打开命令行,进入项目目录</strong> <span class="step-num">1</span><strong>打开命令行,进入项目目录</strong>
@@ -379,8 +427,28 @@ cd \easy_tdx</code></pre>
</div> </div>
<div class="step"> <div class="step">
<span class="step-num">2</span><strong>安装项目本体 + Web 服务依赖</strong> <span class="step-num">2</span><strong>创建虚拟环境(强烈推荐)</strong>
<pre><code><span class="comment"># 一次性安装项目本体和 Web 服务所需的所有 Python 依赖</span> <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> <span class="comment"># 包括 FastAPI、Uvicorn、pandas、numpy 等</span>
pip install -e ".[web]"</code></pre> pip install -e ".[web]"</code></pre>
<p>这步会下载很多文件(约 100MB),需要等 3 到 5 分钟。看到最后有 <code>Successfully installed ...</code> 就成功了。</p> <p>这步会下载很多文件(约 100MB),需要等 3 到 5 分钟。看到最后有 <code>Successfully installed ...</code> 就成功了。</p>
@@ -392,80 +460,56 @@ pip install -e ".[web]"</code></pre>
</div> </div>
<div class="step"> <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> <pre><code><span class="comment"># 看到帮助信息就说明安装好了</span>
easy-tdx --help</code></pre> easy-tdx --help</code></pre>
</div> </div>
<h3>2.3 安装前端依赖(只装一次)</h3> <h3>2.3 启动系统(每次使用都要做)</h3>
<p>装好之后,<strong>每次用只需要一条命令</strong>。打开命令行,激活虚拟环境,进入项目目录,然后:</p>
<div class="step"> <div class="step">
<span class="step-num">1</span><strong>进入 web-ui 目录,装依赖</strong> <span class="step-num">1</span><strong>激活虚拟环境 + 进入项目目录</strong>
<pre><code><span class="comment"># 还在项目目录里,进入前端目录</span> <pre><code><span class="comment"># 每次开新窗口都先做这两步</span>
cd web-ui D:
cd \easy_tdx
<span class="comment"># 安装前端依赖(第一次会下载约 200MB,耐心等 5 分钟)</span> venv\Scripts\activate</code></pre>
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>
</div> </div>
<div class="step"> <div class="step">
<span class="step-num">2</span><strong>启动后端服务</strong> <span class="step-num">2</span><strong>一条命令启动</strong>
<pre><code>easy-tdx serve --port 8000</code></pre> <pre><code>easy-tdx serve</code></pre>
<p>看到类似下面的输出就成功了:</p> <p>看到类似下面的输出就成功了:</p>
<pre><code>INFO: Uvicorn running on http://0.0.0.0:8000 <pre><code>INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Application startup complete.</code></pre> INFO: Application startup complete.</code></pre>
<p><strong>这个窗口不要关!</strong> 它一直开着,前端才能取到数据</p> <p><strong>大约 1-2 秒后,浏览器会自动打开</strong> <code>http://localhost:8000</code>,直接看到回测界面。不用再手动开浏览器,也不用跑第二个命令</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>
</div> </div>
<div class="step"> <div class="step">
<span class="step-num">2</span><strong>启动前端开发服务器</strong> <span class="step-num">3</span><strong>关闭系统</strong>
<pre><code>npm run dev</code></pre> <p>用完后,在跑 <code>easy-tdx serve</code> 的命令行窗口里按 <span class="keyboard">Ctrl</span> + <span class="keyboard">C</span> 就能停止服务。然后关掉窗口即可。</p>
<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>
</div> </div>
<div class="tip"> <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> </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> </section>
<!-- ==================== 第三章 ==================== --> <!-- ==================== 第三章 ==================== -->
@@ -1260,64 +1304,88 @@ SZ:000858 五粮液</code></pre>
<h2>第十一章 常见问题与排错</h2> <h2>第十一章 常见问题与排错</h2>
<details> <details>
<summary>Q1: 启动后端报错 "easy-tdx 不是内部或外部命令"</summary> <summary>Q1: 双击 EXE 没反应 / 浏览器没打开</summary>
<p>说明 Python 没装好或 PATH 没配置。回到第一章 1.1 重装 Python,务必勾选 "Add Python to PATH"。如果已装,在命令行输入 <code>pip install -e ".[web]"</code>(在项目目录下)重新安装。</p> <p>EXE 启动需要 2-5 秒解压,请耐心等待。如果超过 30 秒还没反应:</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>
<ol> <ol>
<li>后端窗口是否还开着?如果关了,重新跑 <code>easy-tdx serve --port 8000</code></li> <li>看屏幕右下角任务栏有没有出现 K 线图标——有的话说明后台已启动,手动打开浏览器访问 <code>http://localhost:8000</code></li>
<li>看后端窗口有没有报错。如果有红色错误,截图找老师</li> <li>没有图标的话,打开任务管理器(Ctrl+Shift+Esc)看有没有 <code>easy-tdx.exe</code> 进程</li>
<li>还不行就在 cmd 里拖入 EXE 加 <code> serve --no-open-browser</code> 跑,看报错信息</li>
</ol> </ol>
</details> </details>
<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> <p>easy-tdx 需要连接通达信的行情服务器取数据。可能原因:</p>
<ul> <ul>
<li>网络问题:换网络或等一会再试</li> <li>网络问题:换网络或等一会再试</li>
<li>防火墙拦截:检查是否有安全软件拦截了 Python</li> <li>防火墙拦截:检查是否有安全软件拦截了 Python 或 EXE</li>
<li>非交易时段:周末和晚上有时连接不稳定</li> <li>非交易时段:周末和晚上有时连接不稳定</li>
</ul> </ul>
<p>可以换个时间再试,或者用其他股票代码试试。</p> <p>可以换个时间再试,或者用其他股票代码试试。</p>
</details> </details>
<details> <details>
<summary>Q6: 回测结果关机后就没了</summary> <summary>Q7: 回测结果关机后就没了</summary>
<p>这是正常的。回测结果存在后端进程内存,重启就清空。<strong>重要的策略一定要点"保存策略"存进策略库</strong>,策略库的数据存在 SQLite 文件里,重启不丢。</p> <p>这是正常的。回测结果存在后端进程内存,重启就清空。<strong>重要的策略一定要点"保存策略"存进策略库</strong>,策略库的数据存在 SQLite 文件里,重启不丢。</p>
</details> </details>
<details> <details>
<summary>Q7: 评级徽章显示 "⚠ 交易样本有限" 是什么意思</summary> <summary>Q8: 评级徽章显示 "⚠ 交易样本有限" 是什么意思</summary>
<p>这只股票/策略在回测期间交易笔数少于 10 笔。系统已经把胜率和利润因子的权重降到 0,只看净值类指标(夏普/卡玛/回撤)。长线策略常常这样,不一定是坏事。详见第七章 7.5 节。</p> <p>这只股票/策略在回测期间交易笔数少于 10 笔。系统已经把胜率和利润因子的权重降到 0,只看净值类指标(夏普/卡玛/回撤)。长线策略常常这样,不一定是坏事。详见第七章 7.5 节。</p>
</details> </details>
<details> <details>
<summary>Q8: 寻优网格点数提示超过 200</summary> <summary>Q9: 寻优网格点数提示超过 200</summary>
<p>参数取值组合太多。减少参数取值的数量,比如 fast 从 10 个值减到 5 个值,或者只寻优一个参数(不勾另一个)。</p> <p>参数取值组合太多。减少参数取值的数量,比如 fast 从 10 个值减到 5 个值,或者只寻优一个参数(不勾另一个)。</p>
</details> </details>
<details> <details>
<summary>Q9: 评级看起来不准,某个明显好的策略却是 C 档</summary> <summary>Q10: 评级看起来不准,某个明显好的策略却是 C 档</summary>
<p>评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 <code>web-ui/src/grading/thresholds.ts</code> 文件里,如果你懂技术可以微调。或者截图给老师反馈。</p> <p>评级阈值是基于金融惯例校准的,可能在某些边界情况不完美。每个维度的打分逻辑在 <code>web-ui/src/grading/thresholds.ts</code> 文件里,如果你懂技术可以微调。或者截图反馈。</p>
</details> </details>
<details> <details>
<summary>Q10: 我想用分钟线或周线回测</summary> <summary>Q11: 我想用分钟线或周线回测</summary>
<p>在行情数据区的"周期"下拉里选 MIN_5(5 分钟)、WEEK(周线)等。注意:分钟线数据量很大,回测会慢很多,新手建议只用日线(DAY)。</p> <p>在行情数据区的"周期"下拉里选 MIN_5(5 分钟)、WEEK(周线)等。注意:分钟线数据量很大,回测会慢很多,新手建议只用日线(DAY)。</p>
</details> </details>
@@ -1392,8 +1460,107 @@ SZ:000858 五粮液</code></pre>
</section> </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> <footer>
easy-tdx 回测系统完全上手手册 · 适用版本 v1.18.0 · 内部教学资料,请勿外传<br> easy-tdx 回测系统完全上手手册 · 适用版本 v1.19.1 · 内部教学资料,请勿外传<br>
本手册不构成任何投资建议,市场有风险,投资需谨慎 本手册不构成任何投资建议,市场有风险,投资需谨慎
</footer> </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] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.18.2" version = "1.19.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" 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"] 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"] science = ["scipy>=1.10,<1.16"]
web = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.29"] 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] [tool.hatch.build.targets.wheel]
packages = ["src/easy_tdx"] packages = ["src/easy_tdx"]
@@ -39,6 +42,11 @@ module = "easy_tdx.MyTT"
module = ["fastapi.*", "uvicorn.*", "pydantic.*"] module = ["fastapi.*", "uvicorn.*", "pydantic.*"]
ignore_missing_imports = true 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]] [[tool.mypy.overrides]]
module = ["scipy", "scipy.*"] module = ["scipy", "scipy.*"]
ignore_missing_imports = true 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 from __future__ import annotations
import threading
import webbrowser
import click import click
@@ -11,7 +14,19 @@ import click
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)") @click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口") @click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
@click.option("--reload", is_flag=True, help="开发模式(自动重载)") @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])。""" """启动 Web API 服务器(需要安装 easy-tdx[web])。"""
try: try:
import uvicorn 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 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: if reload:
uvicorn.run( uvicorn.run(
"easy_tdx.web:app_factory", "easy_tdx.web:app_factory",
+31 -16
View File
@@ -63,17 +63,27 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
pre_diff_base = 0 pre_diff_base = 0
cat = int(self.category) 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): for _ in range(ret_count):
record_start = pos 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) open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos) close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos) high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos) low_diff, pos = get_price(body, pos)
vol, pos = get_volume(body, pos) vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos) amount, pos = get_volume(body, pos)
except Exception:
# 残缺尾记录:body 已读到底或字段不完整,丢弃本条并停止。
# 不重新抛出—— degrade gracefully,返回已解析的完整记录。
break
# 差分还原(与 pytdx 完全一致) # 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base open_abs = open_diff + pre_diff_base
@@ -116,20 +126,25 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
pre_diff_base = 0 pre_diff_base = 0
cat = int(self.category) cat = int(self.category)
# 同 GetSecurityBarsCmdret_count 与 body 实际长度偶发不匹配,
# 残缺尾记录提前 break,详见父类同名注释。
for _ in range(ret_count): for _ in range(ret_count):
record_start = pos 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) open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos) close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos) high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos) low_diff, pos = get_price(body, pos)
vol, pos = get_volume(body, pos) vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos) amount, pos = get_volume(body, pos)
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE # 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE
pos += 4 pos += 4
except Exception:
break
open_abs = open_diff + pre_diff_base open_abs = open_diff + pre_diff_base
close_abs = open_abs + close_diff 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 from __future__ import annotations
import logging import logging
import os
import sys
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
@@ -15,6 +18,41 @@ from easy_tdx.web.errors import register_exception_handlers
logger = logging.getLogger(__name__) 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 @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。""" """管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
@@ -191,4 +229,16 @@ def _create_app(
# 策略库路由(SQLite 持久化,纯数据 CRUD # 策略库路由(SQLite 持久化,纯数据 CRUD
app.include_router(strategies_router, prefix="/api/v1") 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 return app
+49 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
import struct
from pathlib import Path from pathlib import Path
import pytest import pytest
from easy_tdx.codec.frame import FrameHeader, decompress_body from easy_tdx.codec.frame import FrameHeader, decompress_body
from easy_tdx.commands.company_info import GetCompanyInfoCategoryCmd 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.security_count import GetSecurityCountCmd
from easy_tdx.commands.xdxr_info import GetXdxrInfoCmd from easy_tdx.commands.xdxr_info import GetXdxrInfoCmd
from easy_tdx.exceptions import TdxDecodeError 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" 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: def test_frame_unzipsize_mismatch_raises_tdxdecodeerror() -> None:
with pytest.raises(TdxDecodeError): with pytest.raises(TdxDecodeError):
decompress_body(FrameHeader(0, 0, 0, 3, 4), b"abc") 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)