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
+145
View File
@@ -0,0 +1,145 @@
"""``python -m easy_tdx`` 入口 + PyInstaller 打包入口(``easy-tdx.exe``)。
**两种启动形态**
- **开发态**``python -m easy_tdx``):等价于 ``easy-tdx`` CLI,无托盘。
- **打包态**(双击 EXE):uvicorn 后台线程 + 右下角系统托盘图标,老人右键
→ 退出即可关闭,无需任务管理器。
**三个关键坑(PyInstaller frozen 模式)**
1. **``console=False`` 下标准流为 None**Windows GUI 子系统下
``sys.stdout`` / ``sys.stderr`` 都是 ``None``。uvicorn 的
``DefaultFormatter.__init__`` 会调 ``sys.stderr.isatty()``,对 ``None`` 取
属性直接报 ``AttributeError: 'NoneType' object has no attribute 'isatty'``
导致启动即崩。解决:把 None 流重定向到家目录下的日志文件。
2. **``multiprocessing`` spawn 子进程会重新 import ``__main__``**Windows
下 ``ProcessPoolExecutor`` 用 spawn 方式启动子进程,子进程会重新执行
``__main__`` 模块来重建执行环境。如果不拦截,子进程会再次执行 ``cli()``
→ 又启动一个 uvicorn server → 子进程死循环 + 抢占端口 + 各种莫名其妙的
错误。解决:(a) 调 ``freeze_support()``(b) 在 ``__main__`` 里判断如果是
子进程(argv 含 ``--multiprocessing-fork`` 等标记)就直接 return,不跑 CLI。
一键寻优、screen scanner 等所有用多进程的功能都依赖这个保护。
3. **托盘需要主线程消息泵**pystray 在 Windows 上需要主线程跑消息循环,
而 uvicorn 的 ``server.run()`` 是阻塞调用。解决:打包态把 uvicorn 挪到
后台线程,主线程跑托盘(见 ``_run_tray_server`` / ``easy_tdx.tray``)。
开发态走原 CLI 路径,不引入托盘。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _is_multiprocessing_child() -> bool:
"""判断当前进程是否为 multiprocessing fork 出来的子进程。
Windows spawn 模式下,子进程的 argv[0] 是父 EXE 路径,但会带特殊 flag:
``--multiprocessing-fork``(带或不带 ``=``)或新版 Python 的
``--mp-main`` / ``-c`` 等。检测到这些就说明本进程是子进程,不应启动 CLI。
"""
if len(sys.argv) < 2:
return False
first_arg = sys.argv[1]
# multiprocessing 标准标记(不同 Python 版本略有差异)
return first_arg.startswith("--multiprocessing-fork") or first_arg == "--mp-main"
def _redirect_std_streams_to_log() -> None:
"""``console=False`` 下把 None 的 stdout/stderr 重定向到日志文件。
PyInstaller ``--windowed``Windows GUI 子系统)下 Python 的 sys.stdout /
sys.stderr 为 ``None``。许多库(uvicorn/click/logging)假设它们存在,
调 ``.isatty()`` 或 ``.write()`` 即崩。本函数把它们重定向到家目录下的
``easy_tdx_runtime.log``,并设 ``PYTHONUNBUFFERED=1`` 保证日志实时落盘。
只在 ``sys.stdout is None``(打包态)时启用;开发态(有真实终端)不动。
"""
if sys.stdout is not None and sys.stderr is not None:
return # 开发态:有真实终端,不重定向
# 落在 ~/.easy_tdx/,与 strategy_store 的 SQLite 同目录,便于一键收集诊断
config_dir = Path(os.environ.get("EASY_TDX_CONFIG_DIR", str(Path.home() / ".easy_tdx")))
config_dir.mkdir(parents=True, exist_ok=True)
log_path = config_dir / "easy_tdx_runtime.log"
# 用 "a" 追加而非覆盖:老人多次启动的日志都保留,便于复盘
try:
f = open(log_path, "a", encoding="utf-8", buffering=1) # noqa: SIM115
except OSError:
# 家目录不可写(极罕见),退化到 os.devnull——至少不崩
f = open(os.devnull, "w", encoding="utf-8") # noqa: SIM115
if sys.stdout is None:
sys.stdout = f
if sys.stderr is None:
sys.stderr = f
def _run_tray_server() -> None:
"""打包态双击启动专用:uvicorn(后台线程)+ 系统托盘(主线程)。
提取为函数避免 ``__main__`` 顶层 import 拖累开发态启动——pystray/Pillow
只在打包态才需要。
"""
from easy_tdx.tray import run_with_tray
from easy_tdx.web import create_app
run_with_tray(
app_factory=create_app,
host="127.0.0.1",
port=8000,
open_browser=True,
)
def main() -> None:
"""进程入口:区分子进程 / 开发态 / 打包态三条路径。"""
# 1. 必须最先:multiprocessing 子进程保护。
# Windows spawn 子进程会重新 import __main__;如果不拦截,子进程会
# 再次跑 cli() 启动 uvicorn,导致死循环 + 端口冲突 + 数据错乱。
if _is_multiprocessing_child():
# 子进程:只跑 freeze_support(处理 multiprocessing 协议),
# 不启动 CLI / uvicorn / 浏览器。子进程的 mainloop 由
# multiprocessing 内部接管,会执行被 pickle 过来的任务函数。
import multiprocessing
multiprocessing.freeze_support()
# freeze_support 在子进程里会阻塞到任务完成后 exit,理论不会走到这里;
# 但防御性 return,避免任何情况下的 CLI 重复启动。
sys.exit(0)
# 2. 标准流重定向(必须在 import click/uvicorn 之前)。
_redirect_std_streams_to_log()
# 3. freeze_support 即便在主进程也建议调(无害,防御未来加的子进程触发点)。
import multiprocessing
multiprocessing.freeze_support()
from easy_tdx.cli import cli
is_frozen = getattr(sys, "frozen", False)
# 双击启动(无参 + 打包态)→ 走托盘路径:uvicorn 后台 + 右下角图标可退出。
# 命令行带参(含 ``serve``)→ 走原 CLI,行为不变(开发/调试场景)。
if is_frozen and len(sys.argv) <= 1:
_run_tray_server()
return
# 命令行显式调用:保持原行为。
# 显式传 ``serve --no-open-browser`` 可关闭浏览器自动打开,
# 传其他子命令(如 ``server-info``)走原 CLI 行为。
if len(sys.argv) <= 1:
# 开发态无参 python -m easy_tdx:默认走 serve(无托盘,开发态不需要)。
sys.argv = [sys.argv[0], "serve"]
cli()
if __name__ == "__main__":
main()
+25 -1
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import threading
import webbrowser
import click
@@ -11,7 +14,19 @@ import click
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
@click.option("--reload", is_flag=True, help="开发模式(自动重载)")
def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, reload: bool) -> None:
@click.option(
"--open-browser/--no-open-browser",
default=True,
help="启动后自动打开浏览器(默认开启,PyInstaller 打包后老人双击即用)",
)
def serve(
host: str,
port: int,
tdx_host: str | None,
tdx_port: int | None,
reload: bool,
open_browser: bool,
) -> None:
"""启动 Web API 服务器(需要安装 easy-tdx[web])。"""
try:
import uvicorn
@@ -22,6 +37,15 @@ def serve(host: str, port: int, tdx_host: str | None, tdx_port: int | None, relo
)
raise SystemExit(1) from None
# 启动后延迟打开浏览器:uvicorn 需要约 1-2 秒绑定端口,过早打开会
# 命中 connection refused。用后台 Timer 而非阻塞主线程。
if open_browser and not reload:
# 0.0.0.0 / 127.0.0.1 在浏览器里用 localhost 打开(更友好)。
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
url = f"http://{display_host}:{port}"
# 1.5 秒通常足够本地端口就绪;uvicorn 启动慢的机器可适当延长。
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
if reload:
uvicorn.run(
"easy_tdx.web:app_factory",
+31 -16
View File
@@ -63,17 +63,27 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
pre_diff_base = 0
cat = int(self.category)
# 服务器偶发返回的 ret_count 与 body 实际长度不匹配(pytdx/mootdx 均
# 有类似报告):ret_count 撒谎或网络帧粘包/截断,循环到中途 pos 已
# 读到底("剩余 0 字节")。改用"取 min(ret_count, body 可解析条数)"
# 策略——TdxDecodeError 视为记录边界,提前结束循环并丢弃残缺尾记录,
# 而不是让整批数据 500。调用方拿到的是完整记录(少几根 K 线比全崩好)。
for _ in range(ret_count):
record_start = pos
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
try:
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
except Exception:
# 残缺尾记录:body 已读到底或字段不完整,丢弃本条并停止。
# 不重新抛出—— degrade gracefully,返回已解析的完整记录。
break
# 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base
@@ -116,20 +126,25 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
pre_diff_base = 0
cat = int(self.category)
# 同 GetSecurityBarsCmdret_count 与 body 实际长度偶发不匹配,
# 残缺尾记录提前 break,详见父类同名注释。
for _ in range(ret_count):
record_start = pos
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
try:
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
open_diff, pos = get_price(body, pos)
close_diff, pos = get_price(body, pos)
high_diff, pos = get_price(body, pos)
low_diff, pos = get_price(body, pos)
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
vol, pos = get_volume(body, pos)
amount, pos = get_volume(body, pos)
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE
pos += 4
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE
pos += 4
except Exception:
break
open_abs = open_diff + pre_diff_base
close_abs = open_abs + close_diff
+127
View File
@@ -0,0 +1,127 @@
"""系统托盘(仅打包态使用)。
双击 ``easy-tdx.exe`` 后:uvicorn 跑在后台线程,主线程跑 pystray 托盘
图标,右键菜单提供"打开浏览器 / 退出"。老人不用学任务管理器,右下角
图标右键 → 退出即可干净关闭。
**为什么需要独立模块**
- uvicorn 的 ``server.run()`` 是阻塞调用。常规 CLI 路径(``cmd_web.py``
让 uvicorn 占主线程;但 pystray 在 Windows 上需要主线程的消息泵,所以
打包态必须把 uvicorn 挪到后台线程。
- 本模块仅在 PyInstaller frozen 模式下由 ``__main__.py`` 调用,开发态
``easy-tdx serve`` 走原 CLI 路径,不引入托盘。
依赖:``pystray`` + ``Pillow``(纯 Python wheelPyInstaller 打包无坑)。
两者仅在打包态 import,开发态不强制安装。
"""
from __future__ import annotations
import logging
import threading
import webbrowser
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import PIL
logger = logging.getLogger(__name__)
def _make_icon_image() -> PIL.Image.Image:
"""画一个简单的"K 线图"风格图标(红涨绿跌的简化样式)。
用 Pillow 代码生成,避免在仓库里维护二进制 .ico 文件。32×32 是
Windows 系统托盘的标准尺寸。
"""
from PIL import Image, ImageDraw
size = 64 # 高分辨率,pystray 会自动缩放到托盘尺寸
img = Image.new("RGBA", (size, size), (30, 30, 40, 255)) # 深色背景
draw = ImageDraw.Draw(img)
# 三根简化 K 线:红涨两根 + 绿跌一根
bars = [
# (x, y_top, y_bottom, color) —— y 越大越往下
(16, 18, 44, (231, 76, 60)), # 红
(30, 12, 38, (231, 76, 60)), # 红(更高的高点)
(44, 22, 50, (46, 204, 113)), # 绿
]
for x, top, bottom, color in bars:
# 影线(细竖线)
draw.line([(x + 3, top - 4), (x + 3, bottom + 4)], fill=color, width=1)
# 实体(矩形)
draw.rectangle([(x, top), (x + 6, bottom)], fill=color)
return img
def run_with_tray(
app_factory: Callable[[], Any],
host: str,
port: int,
open_browser: bool = True,
) -> None:
"""启动 uvicorn(后台线程)+ 系统托盘(主线程阻塞)。
Args:
app_factory: 返回配置好的 ASGI app 的零参 callable(惰性调用,
避免本模块顶层 import fastapi/uvicorn)。
host: 监听地址。
port: 监听端口。
open_browser: 启动后是否自动开浏览器。
"""
import signal
import uvicorn
from pystray import Icon, Menu, MenuItem
app = app_factory()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
# uvicorn 跑在后台线程:server.run() 阻塞,由 server.should_exit 通知退出
server_thread = threading.Thread(target=server.run, daemon=True, name="uvicorn")
server_thread.start()
# 启动后延迟开浏览器(等端口就绪)
if open_browser:
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
url = f"http://{display_host}:{port}"
threading.Timer(1.5, lambda: webbrowser.open(url)).start()
def _open_browser() -> None:
display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1") else host
webbrowser.open(f"http://{display_host}:{port}")
def _quit(icon: Icon, item: MenuItem) -> None:
logger.info("Tray quit clicked — shutting down uvicorn")
server.should_exit = True
icon.stop()
menu = Menu(
MenuItem("打开浏览器", _open_browser, default=True), # default = 双击图标触发
Menu.SEPARATOR,
MenuItem("退出", _quit),
)
icon = Icon("easy-tdx", _make_icon_image(), "easy-tdx 回测服务", menu)
# Ctrl+C 兜底(console=False 下其实收不到,但开发态调试时有用)
def _signal_handler(signum: int, frame: object) -> None:
server.should_exit = True
icon.stop()
try:
signal.signal(signal.SIGINT, _signal_handler)
except (ValueError, OSError):
# 非 main 线程或 Windows GUI 子系统下会失败,可忽略
pass
logger.info("Starting tray icon (main thread blocks here)")
icon.run() # 阻塞主线程,直到 icon.stop() 被调用
# 托盘退出后,等 uvicorn 线程收尾(最多 5 秒)
server_thread.join(timeout=5.0)
logger.info("uvicorn thread joined — process exiting")
+50
View File
@@ -3,8 +3,11 @@
from __future__ import annotations
import logging
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI
@@ -15,6 +18,41 @@ from easy_tdx.web.errors import register_exception_handlers
logger = logging.getLogger(__name__)
def _resolve_web_dist_dir() -> Path | None:
"""定位前端构建产物目录(Vite build 输出的 ``web-ui/dist``)。
依次探测三处,命中即返回,全部缺失时返回 ``None``(开发期未构建前端
时正常,路由层照常工作,仅前端页面 404):
1. ``EASY_TDX_WEB_DIST`` 环境变量——部署/调试时显式指定。
2. PyInstaller 运行态:``sys._MEIPASS / "web_dist"``——单 EXE 解压
后的临时目录(``--onefile`` 模式)。开发态无 ``_MEIPASS`` 属性,
此分支自动跳过。
3. 开发态:仓库根目录的 ``web-ui/dist``——支持 ``pip install -e .``
后直接 ``easy-tdx serve`` 调试,无需打包。
"""
env_dir = os.environ.get("EASY_TDX_WEB_DIST")
if env_dir:
p = Path(env_dir)
if p.is_dir():
return p
# PyInstaller --onefile 解压目录(frozen 运行态)
meipass = getattr(sys, "_MEIPASS", None)
if meipass is not None:
p = Path(meipass) / "web_dist"
if p.is_dir():
return p
# 开发态:从 src/easy_tdx/web/app.py 回溯到仓库根的 web-ui/dist
repo_root = Path(__file__).resolve().parents[3]
p = repo_root / "web-ui" / "dist"
if p.is_dir():
return p
return None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
@@ -191,4 +229,16 @@ def _create_app(
# 策略库路由(SQLite 持久化,纯数据 CRUD
app.include_router(strategies_router, prefix="/api/v1")
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
# 未匹配路径,放最后保证 /api/v1/* 优先命中路由表。
from fastapi.staticfiles import StaticFiles
dist_dir = _resolve_web_dist_dir()
if dist_dir is not None:
app.mount("/", StaticFiles(directory=str(dist_dir), html=True), name="web-ui")
logger.info("Web UI mounted from %s", dist_dir)
else:
logger.info("Web UI dist not found — serving API only")
return app