mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
Merge commit 'e3e8dd4'
# Conflicts: # CHANGELOG.md # pyproject.toml # src/easy_tdx/commands/security_bars.py
This commit is contained in:
+17
-4
@@ -10,19 +10,32 @@
|
||||
|
||||
- **后端同源托管前端 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`。
|
||||
- **PyInstaller 打包入口**(`src/easy_tdx/__main__.py` + `easy_tdx.spec`)—— `python -m easy_tdx` 等价 CLI;无参数时默认走 `serve`。`.spec` 用 `--onefile` + `console=False`(无黑窗)+ `collect_submodules('uvicorn' / 'easy_tdx')` 收集动态 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 发布。
|
||||
- **系统托盘图标**(`src/easy_tdx/tray.py`)—— 打包态双击 EXE 后右下角任务栏出现 K 线风格图标,右键"打开浏览器 / 退出"可干净关闭,老人无需学任务管理器。
|
||||
- **打包使用文档**(`docs/packaging.md`)—— 老人下载/运行/绕过 SmartScreen 图文说明 + 开发者本地构建步骤 + Phase 1/2/3 路线图。
|
||||
|
||||
### 已知约束(非 bug)
|
||||
|
||||
- **EXE 未签名,SmartScreen 会拦截** —— 老人首次运行需手动"更多信息 → 仍要运行"。这是 Phase 1 的明确取舍(你已确认"先打未签名包自测"),Phase 2 引入 OV/EV 代码签名证书后消除。
|
||||
- **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 打包无关**,是项目既有问题,只是打包版运行更频繁把它暴露了出来。
|
||||
- **README + 上手手册大改** —— 删除"两个终端 + npm run dev + 5173"的过时流程,改为三档分流:① 下载 EXE(零基础首选)② 装 Python 一条命令启动(会点电脑的)③ 源码运行 + 自己打包(开发者)。手册补虚拟环境配置、EXE 打包附录、EXE 排错 FAQ。
|
||||
|
||||
## [1.18.3] — 2026-07-06
|
||||
|
||||
**K 线响应截断容错 + 一键寻优并发默认值优化** —— 两个小修复合并发布。(1) 修复 `000408` 等标的请求 `count=800` 日线时,TDX 服务端返回截断响应(响应头声称有数据但 body 末尾若干条记录被切掉)导致整页 500 的问题:解析器现在丢弃残缺的末条记录,返回已成功解析的前 N-1 条,避免一条坏数据让整页请求失败。(2) 一键寻优并发默认值从「串行」改为「8 进程」,并把用户选择持久化到 `localStorage`,下次以其最后一次选择为默认。
|
||||
|
||||
### 修复
|
||||
|
||||
- **K 线响应截断容错**(`src/easy_tdx/commands/security_bars.py`)—— `GetSecurityBarsCmd` / `GetIndexBarsCmd` 的 `parse_response` 在逐条解析时,若某条记录的 datetime/price/volume 字段因数据不足抛 `TdxDecodeError`,改为丢弃残缺的末条并返回已成功解析的前若干条(记 warning 日志),而非整体抛 500。仅当连第一条都无法解析时才继续抛错(说明是真正的坏包而非尾部截断)。覆盖日线/分钟线/指数 K 线全部分支。
|
||||
|
||||
### 变更
|
||||
|
||||
- **一键寻优并发默认 8 进程 + 持久化**(`web-ui/src/views/OptimizeView.vue`)—— 「一键寻优并发」工作进程默认值从串行(0)改为 8 进程;用户修改后写入 `localStorage`(key `optimize.workers`),下次打开以其最后一次选择为默认。无历史记录或值非法时回退默认 8;`localStorage` 不可用时(隐私模式等)静默回退,不影响使用。
|
||||
|
||||
## [1.18.2] — 2026-07-06
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"""获取 K 线数据命令(支持全部周期)。"""
|
||||
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from .._binary import unpack_from
|
||||
from ..codec.datetime_ import get_datetime
|
||||
from ..codec.price import get_price
|
||||
from ..codec.volume import get_volume
|
||||
from ..exceptions import TdxDecodeError
|
||||
from ..models.bar import SecurityBar
|
||||
from ..models.enums import KlineCategory, Market
|
||||
from .base import BaseCommand
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
|
||||
"""获取指定股票的 K 线数据。
|
||||
@@ -63,12 +67,7 @@ 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):
|
||||
for i in range(ret_count):
|
||||
record_start = pos
|
||||
try:
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
@@ -80,10 +79,21 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
|
||||
|
||||
vol, pos = get_volume(body, pos)
|
||||
amount, pos = get_volume(body, pos)
|
||||
except Exception:
|
||||
# 残缺尾记录:body 已读到底或字段不完整,丢弃本条并停止。
|
||||
# 不重新抛出—— degrade gracefully,返回已解析的完整记录。
|
||||
break
|
||||
except TdxDecodeError as e:
|
||||
# TDX 服务端偶发截断:响应头声称有 N 条,但 body 末尾若干条
|
||||
# 被切掉(停牌/退市/分页边界常见)。丢弃残缺的末条,保留已
|
||||
# 成功解析的前若干条,避免一条坏数据让整页 500。
|
||||
if bars:
|
||||
_log.warning(
|
||||
"K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,"
|
||||
"返回前 %d 条",
|
||||
i + 1,
|
||||
ret_count,
|
||||
e,
|
||||
len(bars),
|
||||
)
|
||||
return bars
|
||||
raise
|
||||
|
||||
# 差分还原(与 pytdx 完全一致)
|
||||
open_abs = open_diff + pre_diff_base
|
||||
@@ -126,9 +136,7 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
|
||||
pre_diff_base = 0
|
||||
cat = int(self.category)
|
||||
|
||||
# 同 GetSecurityBarsCmd:ret_count 与 body 实际长度偶发不匹配,
|
||||
# 残缺尾记录提前 break,详见父类同名注释。
|
||||
for _ in range(ret_count):
|
||||
for i in range(ret_count):
|
||||
record_start = pos
|
||||
try:
|
||||
year, month, day, hour, minute, pos = get_datetime(cat, body, pos)
|
||||
@@ -143,9 +151,20 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
|
||||
|
||||
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE)
|
||||
pos += 4
|
||||
except Exception:
|
||||
break
|
||||
except TdxDecodeError as e:
|
||||
if bars:
|
||||
_log.warning(
|
||||
"指数K线响应在第 %d/%d 条处被截断(%s),已丢弃末尾残缺记录,"
|
||||
"返回前 %d 条",
|
||||
i + 1,
|
||||
ret_count,
|
||||
e,
|
||||
len(bars),
|
||||
)
|
||||
return bars
|
||||
raise
|
||||
|
||||
# 差分还原(与 pytdx 完全一致)
|
||||
open_abs = open_diff + pre_diff_base
|
||||
close_abs = open_abs + close_diff
|
||||
high_abs = open_abs + high_diff
|
||||
|
||||
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
import pathlib
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
|
||||
|
||||
|
||||
@@ -125,6 +127,40 @@ def test_security_bars_parse():
|
||||
assert len(bar._raw) > 0
|
||||
|
||||
|
||||
def test_security_bars_truncated_drops_partial_last_record():
|
||||
"""TDX 服务端偶发截断:响应头声称有 N 条,但末尾记录被切。
|
||||
|
||||
解析器应丢弃残缺的末条,返回已成功解析的前若干条,而非整体抛 500。
|
||||
"""
|
||||
from easy_tdx.commands.security_bars import GetSecurityBarsCmd
|
||||
from easy_tdx.models.enums import KlineCategory, Market
|
||||
|
||||
body = load_hex("security_bars") # 完整 5 条
|
||||
# 把最后一条的 body 切掉 3 字节 → 末条 zipday 4 字节不够,触发截断
|
||||
truncated = body[:-3]
|
||||
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
|
||||
bars = cmd.parse_response(truncated)
|
||||
|
||||
assert len(bars) == 4 # 前 4 条完整,末条残缺被丢弃
|
||||
|
||||
|
||||
def test_security_bars_truncated_first_record_still_raises():
|
||||
"""若连第一条都无法解析(body 完全没有记录数据),仍抛 TdxDecodeError。"""
|
||||
from easy_tdx.commands.security_bars import GetSecurityBarsCmd
|
||||
from easy_tdx.exceptions import TdxDecodeError
|
||||
from easy_tdx.models.enums import KlineCategory, Market
|
||||
|
||||
body = load_hex("security_bars")
|
||||
# 构造 header 声称 5 条但 body 只有 header(2 字节)+1 字节 → 第一条就截断
|
||||
truncated = body[:3]
|
||||
# 强行把 ret_count 写成 5
|
||||
truncated = struct.pack("<H", 5) + truncated[2:]
|
||||
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 5)
|
||||
|
||||
with pytest.raises(TdxDecodeError):
|
||||
cmd.parse_response(truncated)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# security_quotes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 参数寻优主页面:左配置(选标的 + 策略 + 寻优参数)/ 右报告(排名表 + 热力图)。
|
||||
// 取行情已整合进「开始寻优」。另有「一键寻优所有策略」:用各策略预设网格逐策略寻优再全局排名。
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import GradeBadge from '../components/GradeBadge.vue'
|
||||
@@ -44,16 +44,38 @@ const cpuCount = (() => {
|
||||
const n = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined
|
||||
return n && n > 0 ? n : 4
|
||||
})()
|
||||
// 推荐档:min(cpu, 8)。默认串行(workers=0),让用户实测后再开并发——
|
||||
// 小机器上 Windows spawn 子进程开销可能反而拖慢单个寻优任务。
|
||||
// 推荐档:min(cpu, 8)。默认 8 进程,若用户改过则记到 localStorage,
|
||||
// 下次以其最后一次选择为默认。
|
||||
const recommendedWorkers = Math.min(cpuCount, 8)
|
||||
const workers = ref(0)
|
||||
const DEFAULT_WORKERS = 8
|
||||
const WORKERS_STORAGE_KEY = 'optimize.workers'
|
||||
const WORKER_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: '串行(不并发)' },
|
||||
{ value: 4, label: '4 进程' },
|
||||
{ value: 8, label: '8 进程' },
|
||||
{ value: 16, label: '16 进程' },
|
||||
]
|
||||
function loadWorkersFromStorage(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKERS_STORAGE_KEY)
|
||||
if (raw == null) return DEFAULT_WORKERS
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) && WORKER_OPTIONS.some((o) => o.value === n)
|
||||
? n
|
||||
: DEFAULT_WORKERS
|
||||
} catch {
|
||||
return DEFAULT_WORKERS
|
||||
}
|
||||
}
|
||||
const workers = ref(loadWorkersFromStorage())
|
||||
// 用户修改后持久化,下次以最后选择为默认
|
||||
watch(workers, (v) => {
|
||||
try {
|
||||
localStorage.setItem(WORKERS_STORAGE_KEY, String(v))
|
||||
} catch {
|
||||
/* localStorage 不可用时静默忽略 */
|
||||
}
|
||||
})
|
||||
// 成交价模式(精简为 开盘价/收盘价)
|
||||
const EXECUTIONS: { value: ExecutionMode; label: string }[] = [
|
||||
{ value: 'next_open', label: '开盘价' },
|
||||
|
||||
Reference in New Issue
Block a user