mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
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:
@@ -0,0 +1,93 @@
|
|||||||
|
name: Release EXE
|
||||||
|
|
||||||
|
# 打 tag 时构建 Windows 单 EXE 并发布到 GitHub Release。
|
||||||
|
# 与 publish.yml(PyPI)并行独立:即使 PyPI 发布失败,EXE 仍可发布,
|
||||||
|
# 提高发布韧性。Phase 1 产物未签名,老人首次运行会被 SmartScreen 拦截,
|
||||||
|
# 需手动"更多信息 → 仍要运行"——Phase 2 引入代码签名后解决。
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # softprops/action-gh-release 创建 Release 需要
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows EXE
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: web-ui/package-lock.json
|
||||||
|
|
||||||
|
# 安装后端(含 [web,packaging] extras:fastapi/uvicorn + pystray/Pillow)
|
||||||
|
- name: Install Python deps
|
||||||
|
run: |
|
||||||
|
pip install -e ".[web,packaging]"
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# 构建前端 → web-ui/dist/,spec 把它打到 EXE 内的 web_dist/
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
working-directory: web-ui
|
||||||
|
|
||||||
|
- name: Build EXE
|
||||||
|
run: pyinstaller easy_tdx.spec --noconfirm
|
||||||
|
|
||||||
|
# 重命名为带版本号的文件名,方便老人下载时识别
|
||||||
|
- name: Rename EXE with version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
mv dist/easy-tdx.exe "dist/easy-tdx-${VERSION}-windows.exe"
|
||||||
|
ls -lh dist/
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: easy-tdx-windows-exe
|
||||||
|
path: dist/easy-tdx-*-windows.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Publish GitHub Release
|
||||||
|
needs: build-windows
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: easy-tdx-windows-exe
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/easy-tdx-*-windows.exe
|
||||||
|
generate_release_notes: true
|
||||||
|
body: |
|
||||||
|
## 下载使用
|
||||||
|
|
||||||
|
1. 下载下方 `easy-tdx-*-windows.exe`(约 80-150MB)
|
||||||
|
2. 双击运行
|
||||||
|
3. 浏览器会自动打开回测界面(地址 `http://localhost:8000`)
|
||||||
|
|
||||||
|
## ⚠️ SmartScreen 提示
|
||||||
|
|
||||||
|
本版本未做代码签名,首次运行 Windows 会弹出"已保护你的电脑":
|
||||||
|
|
||||||
|
1. 点击 **更多信息**
|
||||||
|
2. 点击 **仍要运行**
|
||||||
|
|
||||||
|
Phase 2 将引入代码签名消除此提示。
|
||||||
@@ -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 位代码输入(市场自动识别)。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
打开浏览器后,顶部导航栏有五个页面:
|
打开浏览器后,顶部导航栏有五个页面:
|
||||||
|
|
||||||
|
|||||||
@@ -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/11(PyInstaller 不支持跨平台编译)
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 20+
|
||||||
|
- 项目已 `pip install -e ".[web]"` 安装到当前环境
|
||||||
|
|
||||||
|
### 步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 PyInstaller
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
# 2. 构建前端
|
||||||
|
cd web-ui
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 3. 构建 EXE
|
||||||
|
pyinstaller easy_tdx.spec --noconfirm
|
||||||
|
|
||||||
|
# 4. 产物
|
||||||
|
ls -lh dist/easy-tdx.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
双击 `dist/easy-tdx.exe` 验证:浏览器自动打开,能跑通一次内置策略回测。
|
||||||
|
|
||||||
|
### 调试
|
||||||
|
|
||||||
|
`.spec` 默认 `console=False`(无黑窗)。排查启动失败时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 临时改 console=True 重新打包,或在命令行运行看 stderr
|
||||||
|
dist/easy-tdx.exe serve --no-open-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GitHub Actions 自动发版
|
||||||
|
|
||||||
|
打 tag 触发:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.19.0
|
||||||
|
git push origin v1.19.0
|
||||||
|
```
|
||||||
|
|
||||||
|
`.github/workflows/release.yml` 会自动:
|
||||||
|
|
||||||
|
1. 在 `windows-latest` runner 上构建前端 + EXE
|
||||||
|
2. 重命名为 `easy-tdx-<版本>-windows.exe`
|
||||||
|
3. 创建 GitHub Release 并上传 EXE
|
||||||
|
|
||||||
|
该 workflow 与 `publish.yml`(PyPI)**完全独立**:即使 PyPI 发布失败,EXE
|
||||||
|
照样能发布。两个 workflow 共享 `v*` tag 触发器但互不依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前限制(Phase 1)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows EXE | ✅ | 单文件,双击即用 |
|
||||||
|
| 代码签名 | ❌ | 未签名,SmartScreen 会拦截,需手动绕过 |
|
||||||
|
| macOS | ❌ | 延后到 Phase 3(需 Apple 开发者账号 + 公证) |
|
||||||
|
| 自动更新 | ❌ | 老人需手动下载新版本 |
|
||||||
|
| EXE 体积 | ~80-150MB | pandas/numpy/scipy/uvicorn/Vue 全包 |
|
||||||
|
|
||||||
|
Phase 2 计划:购买 OV/EV 代码签名证书,在 GitHub Actions 中签名 EXE,
|
||||||
|
消除 SmartScreen 提示。
|
||||||
|
|
||||||
|
Phase 3 计划:macOS 构建 + Apple 公证。
|
||||||
+277
-110
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""PyInstaller spec — 把 easy-tdx + Vue 前端打包成单一 Windows EXE。
|
||||||
|
|
||||||
|
构建前提(CI 会自动完成,本地手动构建需自行执行)::
|
||||||
|
|
||||||
|
1. pip install -e ".[web]" pyinstaller
|
||||||
|
2. cd web-ui && npm ci && npm run build # 产出 web-ui/dist/
|
||||||
|
3. pyinstaller easy_tdx.spec # 产出 dist/easy-tdx.exe
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- ``--onefile``:单 EXE,老人双击即用。首次启动解压到临时目录需 2-5 秒。
|
||||||
|
- ``console=False``:无黑窗(Windows GUI 子系统)。
|
||||||
|
- ``collect_submodules('uvicorn')``:uvicorn 用 importlib 动态加载协议
|
||||||
|
实现,PyInstaller 静态分析漏掉会导致启动报 ModuleNotFoundError。
|
||||||
|
- ``collect_submodules('easy_tdx')``:routers/backtest.strategies 等大量
|
||||||
|
延迟 import,全量收集避免遗漏。
|
||||||
|
- ``web-ui/dist → web_dist``:app.py 的 ``_resolve_web_dist_dir`` 会从
|
||||||
|
``sys._MEIPASS/web_dist`` 读取前端资源。
|
||||||
|
- ``~/.easy_tdx`` 的 SQLite 不在打包范围(用户运行时写入家目录),无需处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
|
||||||
|
|
||||||
|
hiddenimports: list[str] = []
|
||||||
|
hiddenimports += collect_submodules("uvicorn")
|
||||||
|
hiddenimports += collect_submodules("easy_tdx")
|
||||||
|
# pandas / numpy / scipy 由 PyInstaller 自带 hook 处理(见
|
||||||
|
# PyInstaller/hooks/hook-pandas.* 等),无需手动 collect_submodules——
|
||||||
|
# 手动全量收集会把 numpy.typing.tests / pandas._numba.kernels 等可选/测试
|
||||||
|
# 模块也拖进来,既增加体积又制造噪音 WARNING。
|
||||||
|
# 系统托盘(打包态专用):pystray 在 Windows 用 win32 API,Pillow 的
|
||||||
|
# 图像格式插件用 importlib 动态加载,两者都需要显式声明。
|
||||||
|
hiddenimports += collect_submodules("pystray")
|
||||||
|
|
||||||
|
datas: list[tuple[str, str]] = []
|
||||||
|
datas += collect_data_files("tzdata")
|
||||||
|
# Pillow 的图像格式插件(PngImagePlugin 等)随数据文件一起收集
|
||||||
|
datas += collect_data_files("PIL")
|
||||||
|
# 前端 dist 打包到运行时 sys._MEIPASS/web_dist
|
||||||
|
datas += [("web-ui/dist", "web_dist")]
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
["src/easy_tdx/__main__.py"],
|
||||||
|
pathex=["src"],
|
||||||
|
binaries=[],
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[
|
||||||
|
# matplotlib 仅 CLI 表格输出用,EXE 形态不需要
|
||||||
|
"matplotlib",
|
||||||
|
"tkinter",
|
||||||
|
"PyQt5",
|
||||||
|
"PyQt6",
|
||||||
|
"PySide2",
|
||||||
|
"PySide6",
|
||||||
|
# 测试框架不打进生产 EXE
|
||||||
|
"pytest",
|
||||||
|
"IPython",
|
||||||
|
"jupyter",
|
||||||
|
"notebook",
|
||||||
|
],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="easy-tdx",
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
# GUI 子系统:双击不弹控制台黑窗。排查问题时改 console=True 重新打包。
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
# icon="assets/easy-tdx.ico", # 暂无图标资源;后续补
|
||||||
|
)
|
||||||
+9
-1
@@ -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 stubs;tray.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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -63,8 +63,14 @@ 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
|
||||||
|
try:
|
||||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
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)
|
||||||
@@ -74,6 +80,10 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
|
|||||||
|
|
||||||
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,8 +126,11 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
|
|||||||
pre_diff_base = 0
|
pre_diff_base = 0
|
||||||
cat = int(self.category)
|
cat = int(self.category)
|
||||||
|
|
||||||
|
# 同 GetSecurityBarsCmd:ret_count 与 body 实际长度偶发不匹配,
|
||||||
|
# 残缺尾记录提前 break,详见父类同名注释。
|
||||||
for _ in range(ret_count):
|
for _ in range(ret_count):
|
||||||
record_start = pos
|
record_start = pos
|
||||||
|
try:
|
||||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
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)
|
||||||
@@ -130,6 +143,8 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
|
|||||||
|
|
||||||
# 指数记录额外 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
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""系统托盘(仅打包态使用)。
|
||||||
|
|
||||||
|
双击 ``easy-tdx.exe`` 后:uvicorn 跑在后台线程,主线程跑 pystray 托盘
|
||||||
|
图标,右键菜单提供"打开浏览器 / 退出"。老人不用学任务管理器,右下角
|
||||||
|
图标右键 → 退出即可干净关闭。
|
||||||
|
|
||||||
|
**为什么需要独立模块**:
|
||||||
|
- uvicorn 的 ``server.run()`` 是阻塞调用。常规 CLI 路径(``cmd_web.py``)
|
||||||
|
让 uvicorn 占主线程;但 pystray 在 Windows 上需要主线程的消息泵,所以
|
||||||
|
打包态必须把 uvicorn 挪到后台线程。
|
||||||
|
- 本模块仅在 PyInstaller frozen 模式下由 ``__main__.py`` 调用,开发态
|
||||||
|
``easy-tdx serve`` 走原 CLI 路径,不引入托盘。
|
||||||
|
|
||||||
|
依赖:``pystray`` + ``Pillow``(纯 Python wheel,PyInstaller 打包无坑)。
|
||||||
|
两者仅在打包态 import,开发态不强制安装。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import PIL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_icon_image() -> PIL.Image.Image:
|
||||||
|
"""画一个简单的"K 线图"风格图标(红涨绿跌的简化样式)。
|
||||||
|
|
||||||
|
用 Pillow 代码生成,避免在仓库里维护二进制 .ico 文件。32×32 是
|
||||||
|
Windows 系统托盘的标准尺寸。
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
size = 64 # 高分辨率,pystray 会自动缩放到托盘尺寸
|
||||||
|
img = Image.new("RGBA", (size, size), (30, 30, 40, 255)) # 深色背景
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 三根简化 K 线:红涨两根 + 绿跌一根
|
||||||
|
bars = [
|
||||||
|
# (x, y_top, y_bottom, color) —— y 越大越往下
|
||||||
|
(16, 18, 44, (231, 76, 60)), # 红
|
||||||
|
(30, 12, 38, (231, 76, 60)), # 红(更高的高点)
|
||||||
|
(44, 22, 50, (46, 204, 113)), # 绿
|
||||||
|
]
|
||||||
|
for x, top, bottom, color in bars:
|
||||||
|
# 影线(细竖线)
|
||||||
|
draw.line([(x + 3, top - 4), (x + 3, bottom + 4)], fill=color, width=1)
|
||||||
|
# 实体(矩形)
|
||||||
|
draw.rectangle([(x, top), (x + 6, bottom)], fill=color)
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def run_with_tray(
|
||||||
|
app_factory: Callable[[], Any],
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
open_browser: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""启动 uvicorn(后台线程)+ 系统托盘(主线程阻塞)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_factory: 返回配置好的 ASGI app 的零参 callable(惰性调用,
|
||||||
|
避免本模块顶层 import fastapi/uvicorn)。
|
||||||
|
host: 监听地址。
|
||||||
|
port: 监听端口。
|
||||||
|
open_browser: 启动后是否自动开浏览器。
|
||||||
|
"""
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from pystray import Icon, Menu, MenuItem
|
||||||
|
|
||||||
|
app = app_factory()
|
||||||
|
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
|
||||||
|
# uvicorn 跑在后台线程:server.run() 阻塞,由 server.should_exit 通知退出
|
||||||
|
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn")
|
||||||
|
server_thread.start()
|
||||||
|
|
||||||
|
# 启动后延迟开浏览器(等端口就绪)
|
||||||
|
if open_browser:
|
||||||
|
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
|
||||||
|
url = f"http://{display_host}:{port}"
|
||||||
|
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
|
||||||
|
|
||||||
|
def _open_browser() -> None:
|
||||||
|
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
|
||||||
|
webbrowser.open(f"http://{display_host}:{port}")
|
||||||
|
|
||||||
|
def _quit(icon: Icon, item: MenuItem) -> None:
|
||||||
|
logger.info("Tray quit clicked — shutting down uvicorn")
|
||||||
|
server.should_exit = True
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
menu = Menu(
|
||||||
|
MenuItem("打开浏览器", _open_browser, default=True), # default = 双击图标触发
|
||||||
|
Menu.SEPARATOR,
|
||||||
|
MenuItem("退出", _quit),
|
||||||
|
)
|
||||||
|
|
||||||
|
icon = Icon("easy-tdx", _make_icon_image(), "easy-tdx 回测服务", menu)
|
||||||
|
|
||||||
|
# Ctrl+C 兜底(console=False 下其实收不到,但开发态调试时有用)
|
||||||
|
def _signal_handler(signum: int, frame: object) -> None:
|
||||||
|
server.should_exit = True
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
signal.signal(signal.SIGINT, _signal_handler)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
# 非 main 线程或 Windows GUI 子系统下会失败,可忽略
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info("Starting tray icon (main thread blocks here)")
|
||||||
|
icon.run() # 阻塞主线程,直到 icon.stop() 被调用
|
||||||
|
|
||||||
|
# 托盘退出后,等 uvicorn 线程收尾(最多 5 秒)
|
||||||
|
server_thread.join(timeout=5.0)
|
||||||
|
logger.info("uvicorn thread joined — process exiting")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user