From c9d80617e815e8f547a8ffb5f1444091ecfcc52a Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Mon, 6 Jul 2026 02:50:51 +0800 Subject: [PATCH] =?UTF-8?q?revert:=20=E7=A7=BB=E9=99=A4=E6=8B=BC=E9=9F=B3?= =?UTF-8?q?=E5=A3=B0=E6=AF=8D=E6=90=9C=E7=B4=A2=EF=BC=8C=E5=9B=9E=E5=88=B0?= =?UTF-8?q?=206=20=E4=BD=8D=E4=BB=A3=E7=A0=81=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拼音搜索的底层依赖(首次需爬沪深 A 股 5000 条全名单,几十次 TDX 协议 往返,慢机器几十秒到超时)太重,反复优化(按需加载/遮罩/单飞/预热) 都无法兼顾'不阻塞核心行情请求'与'首次可用'。用户决定放弃此功能, 回到简单稳定的 6 位代码输入。 回退 e2bf29e..2523605 共 6 个 commit 的全部改动: - 删除 StockSearchInput / AppInitOverlay / useStockSearch - 移除 pypinyin 依赖、/security/search-index 端点、lifespan 预热 - SymbolPicker / StocksPicker 恢复为纯 6 位代码输入 - README / CHANGELOG 同步回退 代码状态等同 v1.18.1(一键寻优多进程并发)发布后的干净基线 --- CHANGELOG.md | 22 -- README.md | 6 +- pyproject.toml | 4 +- src/easy_tdx/web/app.py | 18 -- src/easy_tdx/web/routers/market.py | 86 -------- web-ui/src/App.vue | 10 +- web-ui/src/api.ts | 17 -- web-ui/src/components/AppInitOverlay.vue | 130 ----------- web-ui/src/components/StockSearchInput.vue | 242 --------------------- web-ui/src/components/StocksPicker.vue | 36 ++- web-ui/src/components/SymbolPicker.vue | 31 ++- web-ui/src/composables/useStockSearch.ts | 99 --------- web-ui/src/types.ts | 15 -- 13 files changed, 48 insertions(+), 668 deletions(-) delete mode 100644 web-ui/src/components/AppInitOverlay.vue delete mode 100644 web-ui/src/components/StockSearchInput.vue delete mode 100644 web-ui/src/composables/useStockSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c3022f7..5d1bade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,6 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 -## [1.19.0] — 2026-07-06 - -**Web UI 股票代码输入支持拼音声母搜索(zjxc → 中际旭创)** —— 此前所有股票代码输入框(回测页、寻优页、组合页)都只能输入 6 位纯数字代码,用户必须先记住代码才能搜,不符合中文用户的肌肉记忆(同花顺/东财/通达信都支持声母搜索)。本次给代码输入框加上"代码 / 中文名 / 拼音声母"三路匹配的下拉联想:输 `zjxc` 命中中际旭创、输 `gzmt` 命中贵州茅台、输 `旭创` 也能命中。复用项目里**早已有但从未被前端消费**的 `/security/list-all` 数据(沪深 A 股 5206 只完整中文名表,本地日级缓存),后端只需新增一个预计算声母的轻量端点。**关键认知**:声母在后端用 `pypinyin` 一次算好下发,前端零拼音依赖(JS 拼音词典 200KB+ 比数据还大)。 - -### 新增 - -- **股票搜索索引端点**(`src/easy_tdx/web/routers/market.py`)—— 新增 `GET /api/v1/security/search-index`,返回 `[{code, name, initials}]`(声母用 `pypinyin` 的 `FIRST_LETTER` 样式预计算)。数据源复用 `get_security_list_all`(沪深 A 股 5206 只,已有本地日级缓存)。进程内缓存,首次请求算一次后常驻,热重启即丢。 -- **三路匹配 composable**(`web-ui/src/composables/useStockSearch.ts`,新文件)—— 模块级缓存索引(整会话只拉一次 ~150KB),按 `代码前缀 / 名字包含 / 声母包含` 三路过滤,防抖 120ms。5000 条本地过滤 <5ms。 -- **股票搜索输入组件**(`web-ui/src/components/StockSearchInput.vue`,新文件)—— 输入框 + 下拉建议列表,支持键盘导航(↑↓/Enter/Esc)、市场标签实时显示、加载错误提示。v-model 绑定 6 位代码,选中后自动回填。保留"直接敲 6 位代码"的快路径(输满 6 位纯数字时跳过下拉)。 -- **接入两个代码输入场景** —— `SymbolPicker`(回测/寻优页单标的)和 `StocksPicker`(组合页多标的)均接入新组件。组合页选中下拉项即自动添加到列表。 - -### 变更 - -- **`web` extra 新增 `pypinyin>=0.50` 依赖**(`pyproject.toml`)—— 声母提取用,纯 Python 无 C 扩展,维护成熟。放在 `web` extra 而非核心依赖,因为只有搜索端点用它。 -- **`SymbolPicker` / `StocksPicker` 清理冗余** —— 原来各自实现的 `detectedMarket` computed 和市场标签样式移入 `StockSearchInput` 统一维护,消除两处重复。 - -### 已知约束(非 bug) - -- **搜索索引仅沪深 A 股** —— 数据源 `get_security_list_all` 不含北交所(`Market.BJ` 的证券列表请求长期服务器超时),故北交所股票搜不到,仍需手输 6 位代码。这与其他页面的标的范围一致。 -- **索引在服务进程内常驻** —— 长期运行的服务不会自动纳入新上市股票,需重启 `easy-tdx serve` 刷新。A 股新股上市频率低(每周个位数),影响可忽略。 -- **仅支持声母,不支持全文拼音** —— 输 `zjxc` 命中,但输 `zhongji` 不命中。声母覆盖 95% 搜索场景,复杂度低一个数量级;全文拼音可后扩。 - ## [1.18.1] — 2026-07-06 **Web UI 一键寻优多进程并发 + 策略库组合评级 + 市场前缀纠正** —— 两个独立主题合并发布。(1) 「一键寻优所有策略」此前串行跑 17 个策略的预设网格(共约 182 个网格点),在中大型机器上动辄几十秒到几分钟。本次引入 `ProcessPoolExecutor` 多进程并发,配置区新增并发数选择器(串行 / 4 / 8 / 16 进程,自动检测 CPU 核数并标注推荐档),实测 8 进程可提速 4-6×。**关键认知**:回测是 numpy/pandas 的 CPU 密集计算并持有 GIL,多线程无加速,必须用多进程;照搬项目里已跑通的 `screen/scanner.py` 进程池模板。(2) 策略库「组合回测」结果区补上组合评级徽章(与单标的回测/组合页同口径的 5 维度评分),同时修复历史保存策略的市场前缀错配(5 开头的沪市基金/ETF 曾被误判为深市)。 diff --git a/README.md b/README.md index 5f3c660..914b135 100644 --- a/README.md +++ b/README.md @@ -451,15 +451,13 @@ easy-tdx portfolio --stocks SZ:000001,SH:600519 \ **前置条件:** ```bash -# 后端需安装 web 可选依赖(FastAPI + Uvicorn + pypinyin) +# 后端需安装 web 可选依赖(FastAPI + Uvicorn) pip install -e ".[web]" # 前端需 Node.js 18+(首次运行需装依赖) cd web-ui && npm install ``` -> ⚠️ **升级注意(v1.19.0+)**:股票代码输入框的**拼音声母搜索**(如输 `zjxc` 命中中际旭创)依赖 `pypinyin`,已包含在 `[web]` extra 里。**从旧版本升级时必须重新运行 `pip install -e ".[web]"`**,否则搜索端点会报 500(其他功能不受影响,只是代码输入框退化为只能输 6 位数字)。 - **启动(两个终端):** ```bash @@ -479,7 +477,7 @@ cd web-ui && npm run dev 左侧配置面板从上到下填写,右侧自动出图: -- **取行情**:填 6 位代码,选周期(日线/周线/分钟线),设日期范围(默认最近 3 年),点「取行情」。超过 800 根会自动翻页拼接。代码输入框支持**拼音声母搜索**(v1.19.0+):输 `zjxc` 命中中际旭创、输 `gzmt` 命中贵州茅台、输中文名片段也行,下拉联想 + 键盘 ↑↓/Enter 选择 +- **取行情**:选市场(深/沪/北),填 6 位代码,选周期(日线/周线/分钟线),设日期范围(默认最近 3 年),点「取行情」。超过 800 根会自动翻页拼接 - **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参 - **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交) - 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细 diff --git a/pyproject.toml b/pyproject.toml index 84f2a36..616e6a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.19.0" +version = "1.18.1" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" @@ -16,7 +16,7 @@ easy-tdx = "easy_tdx.cli:cli" # cli/__init__.py exposes the click group [project.optional-dependencies] 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"] -web = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.29", "pypinyin>=0.50"] +web = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.29"] [tool.hatch.build.targets.wheel] packages = ["src/easy_tdx"] diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index d937ec0..75aa12f 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -64,24 +64,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: ex_client = None app.state.ex_client = ex_client - # --- 股票搜索索引预热(后台,不阻塞服务启动) --- - # 首次 get_security_list_all("all") 要几十次 TDX 协议往返(几十秒), - # 后台提前跑,让本地缓存尽早建立。用户打开页面时大概率已就绪。 - # 仅触发构建(fire-and-forget),不 await 不超时——预热是 best effort, - # 没有权力取消构建 task(否则会波及同时到达的 /security/search-index 请求)。 - import asyncio - - async def _warmup_security_list() -> None: - try: - from easy_tdx.web.routers.market import _build_search_index - - await _build_search_index(client) - logger.info("Security list warmup done") - except Exception: - logger.warning("Security list warmup failed (non-fatal)", exc_info=True) - - asyncio.create_task(_warmup_security_list()) - yield # --- 依次关闭 --- diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index e7444b2..ef16663 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -53,92 +53,6 @@ async def security_list_all( return _df_response(df) -# ── 股票搜索索引(声母检索) ─────────────────────────────────────────────────── -# 进程级缓存:5206 条记录的 {code, name, initials} 只算一次,热重启即丢。 -# 前端拉一次后模块级缓存,按 code/name.includes/initials.includes 三路过滤。 -_SEARCH_INDEX: list[dict[str, str]] | None = None -# 单飞标记:True 表示后台构建 task 正在跑。调用方据此判断是否需要启动新 task。 -# 注意:不持有 task/future 引用,避免调用方被 cancel 时波及后台构建。 -_SEARCH_BUILDING: bool = False -# 后台构建失败的最近一次异常(供等待中的调用方读取;None 表示无错或未发生) -_SEARCH_BUILD_ERROR: BaseException | None = None - - -async def _build_search_index(client: Any) -> list[dict[str, str]]: - """构建搜索索引:拉全名单 + pypinyin 预计算声母。耗时几十秒(首次)。 - - 单飞 + 轮询设计:后台构建 task 与调用方解耦,调用方被 cancel(如预热超时) - 不会波及正在跑的构建 task,也不会让其他等待的请求收到 CancelledError。 - """ - import asyncio - - global _SEARCH_INDEX, _SEARCH_BUILDING, _SEARCH_BUILD_ERROR - - # 已就绪:直接返回 - if _SEARCH_INDEX is not None: - return _SEARCH_INDEX - - # 未启动构建:启动后台 task(fire and forget,调用方不持有它的引用) - if not _SEARCH_BUILDING: - _SEARCH_BUILDING = True - _SEARCH_BUILD_ERROR = None - - async def _do_build() -> None: - global _SEARCH_INDEX, _SEARCH_BUILDING, _SEARCH_BUILD_ERROR - from pypinyin import Style, lazy_pinyin - - try: - df = await client.get_security_list_all(pages="all") - index: list[dict[str, str]] = [] - for row in df.itertuples(index=False): - name = str(getattr(row, "name", "") or "") - if not name: - continue - code = str(getattr(row, "code", "")) - initials = "".join(lazy_pinyin(name, style=Style.FIRST_LETTER)) - index.append({"code": code, "name": name, "initials": initials}) - _SEARCH_INDEX = index - except BaseException as e: - _SEARCH_BUILD_ERROR = e - finally: - _SEARCH_BUILDING = False - - asyncio.create_task(_do_build()) - - # 轮询等待结果(每 0.5s 检查一次)。 - # 这样调用方被 cancel 时,只是退出轮询,不影响后台 _do_build task。 - # 用 asyncio.shield 保护轮询本身不被取消传播,并在每次循环检查错误。 - for _ in range(600): # 上限 300 秒(600 × 0.5s) - if _SEARCH_INDEX is not None: - return _SEARCH_INDEX - if not _SEARCH_BUILDING and _SEARCH_BUILD_ERROR is not None: - # 构建已结束但失败:抛错给调用方(下次调用会重新触发构建) - err = _SEARCH_BUILD_ERROR - _SEARCH_BUILD_ERROR = None - raise err - await asyncio.sleep(0.5) - raise TimeoutError("搜索索引构建超时(300s)") - - -@router.get("/security/search-index") -async def security_search_index( - client: Any = Depends(get_client), -) -> dict[str, Any]: - """返回股票搜索索引 ``[{code, name, initials}]``(供前端声母/代码/名字搜索)。 - - 数据源复用 :meth:`get_security_list_all`(沪深 A 股,已有本地日级缓存)。 - 声母用 pypinyin ``FIRST_LETTER`` 预计算(如 中际旭创→zjxc)。 - 进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。 - - 与 lifespan 预热共享同一单飞任务(``_build_search_index``), - 避免预热和首次端点请求并发各爬一次全名单。 - """ - if _SEARCH_INDEX is not None: - return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX} - index = await _build_search_index(client) - return {"count": len(index), "data": index} - - @router.post("/quotes", response_model=DataFrameResponse) async def security_quotes( req: QuoteRequest, diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index af66c7b..cc63149 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -1,16 +1,8 @@