fix: K线响应截断容错 + 一键寻优并发默认8进程

- security_bars: parse_response 遇末尾残缺记录时丢弃并返回前 N-1 条,
  避免 TDX 服务端截断响应导致整页 500(如 000408 count=800 日线)
- OptimizeView: 一键寻优并发默认 8 进程,用户选择持久化到 localStorage

bump version to 1.18.3
This commit is contained in:
GitHub
2026-07-06 13:23:37 +08:00
parent c901d198ca
commit e3e8dd492e
5 changed files with 127 additions and 23 deletions
+12
View File
@@ -2,6 +2,18 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [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
**回退拼音声母搜索功能,回到稳定的 6 位代码输入** —— v1.19.0 引入的拼音声母搜索(输 `zjxc` 命中中际旭创)因底层依赖过重被移除。该功能首次使用时需从通达信服务器爬取沪深 A 股约 5000 条完整名单(几十次协议往返,慢机器耗时几十秒到超时),且与共享的 TDX 连接耦合——爬名单期间会阻塞行情请求。虽经多轮优化(按需加载 / 全站遮罩 / 单飞去重 / 后台预热),均无法兼顾"不阻塞核心行情"与"首次可用"。本次回到 v1.18.1 的干净基线,代码输入框恢复为纯 6 位代码输入(市场自动识别)。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.18.2"
version = "1.18.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+52 -18
View File
@@ -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,17 +67,33 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
pre_diff_base = 0
cat = int(self.category)
for _ in range(ret_count):
for i 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 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
@@ -116,21 +136,35 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
pre_diff_base = 0
cat = int(self.category)
for _ in range(ret_count):
for i 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 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
+36
View File
@@ -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
# ---------------------------------------------------------------------------
+26 -4
View File
@@ -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: '开盘价' },