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/)。 本文件记录 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 ## [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 位代码输入(市场自动识别)。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.18.2" version = "1.18.3"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+36 -2
View File
@@ -1,15 +1,19 @@
"""获取 K 线数据命令(支持全部周期)。""" """获取 K 线数据命令(支持全部周期)。"""
import logging
import struct import struct
from .._binary import unpack_from from .._binary import unpack_from
from ..codec.datetime_ import get_datetime from ..codec.datetime_ import get_datetime
from ..codec.price import get_price from ..codec.price import get_price
from ..codec.volume import get_volume from ..codec.volume import get_volume
from ..exceptions import TdxDecodeError
from ..models.bar import SecurityBar from ..models.bar import SecurityBar
from ..models.enums import KlineCategory, Market from ..models.enums import KlineCategory, Market
from .base import BaseCommand from .base import BaseCommand
_log = logging.getLogger(__name__)
class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]): class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
"""获取指定股票的 K 线数据。 """获取指定股票的 K 线数据。
@@ -63,8 +67,9 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
pre_diff_base = 0 pre_diff_base = 0
cat = int(self.category) cat = int(self.category)
for _ in range(ret_count): for i 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 +79,21 @@ 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 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 完全一致) # 差分还原(与 pytdx 完全一致)
open_abs = open_diff + pre_diff_base open_abs = open_diff + pre_diff_base
@@ -116,8 +136,9 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
pre_diff_base = 0 pre_diff_base = 0
cat = int(self.category) cat = int(self.category)
for _ in range(ret_count): for i 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,7 +151,20 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
# 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE # 指数记录额外 4 字节:上涨家数 + 下跌家数(各 uint16 LE
pos += 4 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 open_abs = open_diff + pre_diff_base
close_abs = open_abs + close_diff close_abs = open_abs + close_diff
high_abs = open_abs + high_diff high_abs = open_abs + high_diff
+36
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import pathlib import pathlib
import struct import struct
import pytest
FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures" FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures"
@@ -125,6 +127,40 @@ def test_security_bars_parse():
assert len(bar._raw) > 0 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 # 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 { useRouter } from 'vue-router'
import GradeBadge from '../components/GradeBadge.vue' import GradeBadge from '../components/GradeBadge.vue'
@@ -44,16 +44,38 @@ const cpuCount = (() => {
const n = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined const n = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined
return n && n > 0 ? n : 4 return n && n > 0 ? n : 4
})() })()
// 推荐档:min(cpu, 8)。默认串行(workers=0),让用户实测后再开并发—— // 推荐档:min(cpu, 8)。默认 8 进程,若用户改过则记到 localStorage
// 小机器上 Windows spawn 子进程开销可能反而拖慢单个寻优任务 // 下次以其最后一次选择为默认
const recommendedWorkers = Math.min(cpuCount, 8) 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 }[] = [ const WORKER_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: '串行(不并发)' }, { value: 0, label: '串行(不并发)' },
{ value: 4, label: '4 进程' }, { value: 4, label: '4 进程' },
{ value: 8, label: '8 进程' }, { value: 8, label: '8 进程' },
{ value: 16, label: '16 进程' }, { 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 }[] = [ const EXECUTIONS: { value: ExecutionMode; label: string }[] = [
{ value: 'next_open', label: '开盘价' }, { value: 'next_open', label: '开盘价' },