revert: 移除拼音声母搜索,回到 6 位代码输入

拼音搜索的底层依赖(首次需爬沪深 A 股 5000 条全名单,几十次 TDX 协议
往返,慢机器几十秒到超时)太重,反复优化(按需加载/遮罩/单飞/预热)
都无法兼顾'不阻塞核心行情请求'与'首次可用'。用户决定放弃此功能,
回到简单稳定的 6 位代码输入。

回退 e2bf29e..2523605 共 6 个 commit 的全部改动:
- 删除 StockSearchInput / AppInitOverlay / useStockSearch
- 移除 pypinyin 依赖、/security/search-index 端点、lifespan 预热
- SymbolPicker / StocksPicker 恢复为纯 6 位代码输入
- README / CHANGELOG 同步回退

代码状态等同 v1.18.1(一键寻优多进程并发)发布后的干净基线
This commit is contained in:
Justin Gu
2026-07-06 02:50:51 +08:00
parent 25236056f7
commit c9d80617e8
13 changed files with 48 additions and 668 deletions
-22
View File
@@ -2,28 +2,6 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 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 ## [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 曾被误判为深市)。 **Web UI 一键寻优多进程并发 + 策略库组合评级 + 市场前缀纠正** —— 两个独立主题合并发布。(1) 「一键寻优所有策略」此前串行跑 17 个策略的预设网格(共约 182 个网格点),在中大型机器上动辄几十秒到几分钟。本次引入 `ProcessPoolExecutor` 多进程并发,配置区新增并发数选择器(串行 / 4 / 8 / 16 进程,自动检测 CPU 核数并标注推荐档),实测 8 进程可提速 4-6×。**关键认知**:回测是 numpy/pandas 的 CPU 密集计算并持有 GIL,多线程无加速,必须用多进程;照搬项目里已跑通的 `screen/scanner.py` 进程池模板。(2) 策略库「组合回测」结果区补上组合评级徽章(与单标的回测/组合页同口径的 5 维度评分),同时修复历史保存策略的市场前缀错配(5 开头的沪市基金/ETF 曾被误判为深市)。
+2 -4
View File
@@ -451,15 +451,13 @@ easy-tdx portfolio --stocks SZ:000001,SH:600519 \
**前置条件:** **前置条件:**
```bash ```bash
# 后端需安装 web 可选依赖(FastAPI + Uvicorn + pypinyin # 后端需安装 web 可选依赖(FastAPI + Uvicorn
pip install -e ".[web]" pip install -e ".[web]"
# 前端需 Node.js 18+(首次运行需装依赖) # 前端需 Node.js 18+(首次运行需装依赖)
cd web-ui && npm install cd web-ui && npm install
``` ```
> ⚠️ **升级注意(v1.19.0+)**:股票代码输入框的**拼音声母搜索**(如输 `zjxc` 命中中际旭创)依赖 `pypinyin`,已包含在 `[web]` extra 里。**从旧版本升级时必须重新运行 `pip install -e ".[web]"`**,否则搜索端点会报 500(其他功能不受影响,只是代码输入框退化为只能输 6 位数字)。
**启动(两个终端):** **启动(两个终端):**
```bash ```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 等),选中后参数表单自动出现,按推荐范围调参 - **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参
- **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交) - **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交)
- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细 - 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.19.0" version = "1.18.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -16,7 +16,7 @@ easy-tdx = "easy_tdx.cli:cli" # cli/__init__.py exposes the click group
[project.optional-dependencies] [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"] 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"] 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] [tool.hatch.build.targets.wheel]
packages = ["src/easy_tdx"] packages = ["src/easy_tdx"]
-18
View File
@@ -64,24 +64,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
ex_client = None ex_client = None
app.state.ex_client = ex_client 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 yield
# --- 依次关闭 --- # --- 依次关闭 ---
-86
View File
@@ -53,92 +53,6 @@ async def security_list_all(
return _df_response(df) 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
# 未启动构建:启动后台 taskfire 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) @router.post("/quotes", response_model=DataFrameResponse)
async def security_quotes( async def security_quotes(
req: QuoteRequest, req: QuoteRequest,
+1 -9
View File
@@ -1,16 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
// 根组件:顶部标题栏 + 路由出口 + 全局初始化遮罩 // 根组件:顶部标题栏 + 路由出口。
import AppInitOverlay from './components/AppInitOverlay.vue'
import { useStockSearch } from './composables/useStockSearch'
// App 挂载时立即开始拉取股票搜索索引,遮罩会在加载期间盖住页面。
// 后端 lifespan 也会后台预热 get_security_list_all 缓存,多数情况下秒回。
const { eagerLoad } = useStockSearch()
eagerLoad()
</script> </script>
<template> <template>
<AppInitOverlay />
<div class="app"> <div class="app">
<header class="app-header"> <header class="app-header">
<h1>easy-tdx 回测</h1> <h1>easy-tdx 回测</h1>
-17
View File
@@ -14,7 +14,6 @@ import type {
SavedStrategy, SavedStrategy,
SavedStrategyCreate, SavedStrategyCreate,
SavedStrategyListResponse, SavedStrategyListResponse,
StockSearchIndex,
StrategiesResponse, StrategiesResponse,
TaskListResponse, TaskListResponse,
TaskState, TaskState,
@@ -103,22 +102,6 @@ export async function fetchBars(
return bars return bars
} }
/** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。
* 前端 useStockSearch 会模块级缓存,整个会话只拉一次。
* 超时 120 秒——后端首次构建索引要走全量 get_security_list_all(几十秒),
* AppInitOverlay 遮罩期间用户本就在等待,不能过早 abort。 */
export async function fetchSearchIndex(): Promise<StockSearchIndex> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 120_000)
try {
const resp = await fetch(`${BASE}/security/search-index`, { signal: controller.signal })
if (!resp.ok) await throwError(resp)
return (await resp.json()) as StockSearchIndex
} finally {
clearTimeout(timer)
}
}
/** 把后端 bars 的单条记录归一化为统一 Bardatetime 字段)。 */ /** 把后端 bars 的单条记录归一化为统一 Bardatetime 字段)。 */
function normalizeBar(row: Record<string, unknown>): Bar { function normalizeBar(row: Record<string, unknown>): Bar {
const raw = (row.datetime ?? row.date) as string | undefined const raw = (row.datetime ?? row.date) as string | undefined
-130
View File
@@ -1,130 +0,0 @@
<script setup lang="ts">
// 全局初始化遮罩:股票搜索索引加载期间盖住全站,给用户明确的初始化反馈。
// App 根挂载时调 eagerLoad(),本组件监听 loading/ready/failed 三态:
// - loading:全屏灰底 + spinner + "正在初始化股票列表…"
// - ready / failedfade-out 消失(失败也消失,避免卡死;搜索降级为只能输代码)
//
// 方案 A:无"跳过"按钮,强制等待(索引构建是一次性代价,之后整会话秒回)。
// 唯一的"逃逸阀"是加载失败——失败时遮罩消失,搜索不可用但不阻塞其他功能。
import { useStockSearch } from '../composables/useStockSearch'
const { loading, ready, failed, loadError } = useStockSearch()
// 是否显示遮罩:加载中显示;就绪或失败后淡出
// ready 已就绪过的会话(缓存命中)loading 不会变 true,遮罩根本不显示
function shouldShow() {
return loading.value && !ready.value
}
</script>
<template>
<Transition name="overlay-fade">
<div v-if="shouldShow()" class="app-init-overlay">
<div class="init-card">
<div class="spinner" />
<h2>正在初始化股票列表</h2>
<p class="sub">首次加载需从通达信服务器拉取沪深 A 股全名单 5000 </p>
<p class="sub">预计 30-60 请稍候</p>
<p class="hint">完成后即可使用拼音声母搜索 zjxc 中际旭创</p>
</div>
</div>
</Transition>
<!-- 失败提示条非阻塞顶部小横幅 -->
<Transition name="banner-slide">
<div v-if="failed" class="init-failed-banner">
股票搜索初始化失败{{ loadError }}拼音搜索不可用但仍可手动输入 6 位代码
</div>
</Transition>
</template>
<style scoped>
.app-init-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 17, 23, 0.88);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
}
.init-card {
text-align: center;
padding: 40px 48px;
max-width: 440px;
}
.spinner {
width: 48px;
height: 48px;
margin: 0 auto 24px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.init-card h2 {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
}
.sub {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
margin: 2px 0;
}
.hint {
font-size: 12px;
color: var(--text-dim);
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* 淡出过渡 */
.overlay-fade-leave-active {
transition: opacity 0.4s ease;
}
.overlay-fade-leave-to {
opacity: 0;
}
/* 失败横幅 */
.init-failed-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9998;
padding: 10px 20px;
background: rgba(239, 65, 70, 0.12);
border-bottom: 1px solid var(--up);
color: var(--up);
font-size: 13px;
text-align: center;
}
.banner-slide-enter-active,
.banner-slide-leave-active {
transition: all 0.3s ease;
}
.banner-slide-enter-from,
.banner-slide-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
-242
View File
@@ -1,242 +0,0 @@
<script setup lang="ts">
// 股票搜索输入框:支持 6 位代码 / 中文名 / 拼音声母(如 zjxc→中际旭创)。
// 下拉建议 + 键盘导航(↑↓/Enter/Esc)。v-model 绑定 6 位代码。
// 数据源:useStockSearch composable(模块级缓存索引,整会话拉一次)。
import { computed, nextTick, ref, watch } from 'vue'
import { useStockSearch } from '../composables/useStockSearch'
import { detectMarket, marketLabel } from '../market'
import type { StockSearchEntry } from '../types'
const code = defineModel<string>({ default: '' })
const props = withDefaults(defineProps<{ placeholder?: string }>(), {
placeholder: '代码 / 拼音 / 名字',
})
const emit = defineEmits<{
/** 选中某只股票(code + name)时触发,供父组件做额外处理(如回填名称) */
select: [entry: StockSearchEntry]
/** 无下拉时按 Enter 触发(输入满 6 位代码的"确认"场景,供组合页接"添加" */
confirm: [code: string]
}>()
const { ready, loadError, search } = useStockSearch()
// 输入框文本(可能是代码片段、拼音、中文)。与 code 解耦:
// code 是最终选定的 6 位代码,inputText 是用户正在敲的内容
const inputText = ref(code.value)
const suggestions = ref<StockSearchEntry[]>([])
const showDropdown = ref(false)
const activeIndex = ref(-1) // 键盘高亮项,-1 表示不高亮
const inputRef = ref<HTMLInputElement | null>(null)
// 输入满 6 位纯数字 → 直接当成选定代码(保留"直接敲代码"的老习惯)
const isFullCode = computed(() => /^\d{6}$/.test(inputText.value.trim()))
// 智能识别的市场(用于提示展示)
const detectedMarket = computed(() =>
code.value && /^\d{6}$/.test(code.value) ? marketLabel(detectMarket(code.value)) : '',
)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function refreshSuggestions() {
const q = inputText.value.trim().toLowerCase()
// 满 6 位纯数字:清空下拉(已经是有效代码,无需搜索)
if (/^\d{6}$/.test(q)) {
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
return
}
if (!q || q.length < 1) {
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
return
}
if (!ready.value) return // 索引未就绪,等加载完再过滤
suggestions.value = await search(q, 30)
showDropdown.value = suggestions.value.length > 0
activeIndex.value = suggestions.value.length > 0 ? 0 : -1
}
watch(inputText, () => {
// 同步纯数字输入到 code(边敲代码边更新市场标签)
if (/^\d{6}$/.test(inputText.value.trim())) {
code.value = inputText.value.trim()
}
// 防抖 120ms
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(refreshSuggestions, 120)
})
function selectEntry(entry: StockSearchEntry) {
inputText.value = entry.code
code.value = entry.code
suggestions.value = []
showDropdown.value = false
activeIndex.value = -1
emit('select', entry)
inputRef.value?.focus()
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown.value || suggestions.value.length === 0) {
// 无下拉时,Enter 且输入是有效代码 → 通知父组件"确认"(如组合页添加标的)
if (e.key === 'Enter' && isFullCode.value) {
emit('confirm', code.value)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % suggestions.value.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value =
(activeIndex.value - 1 + suggestions.value.length) % suggestions.value.length
} else if (e.key === 'Enter') {
if (activeIndex.value >= 0 && activeIndex.value < suggestions.value.length) {
e.preventDefault()
selectEntry(suggestions.value[activeIndex.value])
}
} else if (e.key === 'Escape') {
showDropdown.value = false
activeIndex.value = -1
}
}
function onBlur() {
// 延迟关闭,给 click 事件时间触发(mousedown 在 blur 前,但 click 在后)
setTimeout(() => {
showDropdown.value = false
}, 150)
}
function onFocus() {
// 索引由 App 根挂载时 eagerLoad 触发(详见 App.vue),这里聚焦时无需再触发。
// 聚焦时若已有输入且非完整代码,重新展示建议
nextTick(() => {
if (inputText.value.trim() && !isFullCode.value && suggestions.value.length > 0) {
showDropdown.value = true
}
})
}
// 父组件外部更新 code 时(如 URL 回填),同步到输入框
watch(code, (newCode) => {
if (newCode !== inputText.value) {
inputText.value = newCode
}
})
</script>
<template>
<div class="stock-search-input">
<input
ref="inputRef"
v-model="inputText"
type="text"
autocomplete="off"
:placeholder="props.placeholder"
@keydown="onKeydown"
@blur="onBlur"
@focus="onFocus"
/>
<span v-if="detectedMarket" class="market-tag">{{ detectedMarket }}</span>
<span v-if="loadError" class="load-err" :title="loadError"></span>
<ul v-if="showDropdown" class="suggestions">
<li
v-for="(s, i) in suggestions"
:key="s.code"
:class="{ active: i === activeIndex }"
@mousedown.prevent="selectEntry(s)"
@mouseenter="activeIndex = i"
>
<span class="code">{{ s.code }}</span>
<span class="name">{{ s.name }}</span>
<span v-if="s.initials" class="initials">{{ s.initials }}</span>
</li>
</ul>
</div>
</template>
<style scoped>
.stock-search-input {
position: relative;
width: 100%;
}
.stock-search-input input {
width: 100%;
padding-right: 70px;
}
.market-tag {
position: absolute;
right: 8px;
bottom: 8px;
font-size: 11px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
}
.load-err {
position: absolute;
right: 8px;
top: 8px;
color: var(--up);
font-size: 14px;
}
.suggestions {
position: absolute;
z-index: 100;
top: calc(100% + 2px);
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
list-style: none;
margin: 0;
padding: 0;
}
.suggestions li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
cursor: pointer;
font-size: 13px;
}
.suggestions li:hover,
.suggestions li.active {
background: var(--bg-elevated);
}
.suggestions .code {
font-family: var(--font-mono);
color: var(--text-dim);
width: 64px;
flex-shrink: 0;
}
.suggestions .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggestions .initials {
font-size: 11px;
color: var(--text-dim);
opacity: 0.7;
text-transform: lowercase;
}
</style>
+16 -20
View File
@@ -2,11 +2,9 @@
// 多标的输入(组合回测用)。逐个添加 6 位代码,市场自动识别。 // 多标的输入(组合回测用)。逐个添加 6 位代码,市场自动识别。
// 删除手动市场选择(沪市/深市/北交所),由 detectMarket 智能匹配。 // 删除手动市场选择(沪市/深市/北交所),由 detectMarket 智能匹配。
import { ref } from 'vue' import { computed, ref } from 'vue'
import { detectMarket } from '../market' import { detectMarket, marketLabel } from '../market'
import StockSearchInput from './StockSearchInput.vue'
import type { StockSearchEntry } from '../types'
const props = defineProps<{ const props = defineProps<{
modelValue: string[] modelValue: string[]
@@ -14,6 +12,9 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>() const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const code = ref('') const code = ref('')
const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value)
? marketLabel(detectMarket(code.value))
: ''))
function add() { function add() {
if (!/^\d{6}$/.test(code.value)) return if (!/^\d{6}$/.test(code.value)) return
@@ -24,15 +25,6 @@ function add() {
code.value = '' code.value = ''
} }
/** 选中下拉建议时,直接添加并清空输入框(组合页"选中即添加"的快捷流) */
function onSelectEntry(entry: StockSearchEntry) {
const sym = `${detectMarket(entry.code)}:${entry.code}`
if (!props.modelValue.includes(sym)) {
emit('update:modelValue', [...props.modelValue, sym])
}
code.value = ''
}
function remove(sym: string) { function remove(sym: string) {
emit('update:modelValue', props.modelValue.filter((s) => s !== sym)) emit('update:modelValue', props.modelValue.filter((s) => s !== sym))
} }
@@ -41,14 +33,15 @@ function remove(sym: string) {
<template> <template>
<div class="stocks-picker"> <div class="stocks-picker">
<div class="row add-row"> <div class="row add-row">
<StockSearchInput <input
v-model="code" v-model="code"
placeholder="6位代码 / 拼音 / 名字" maxlength="6"
@select="onSelectEntry" placeholder="6位代码(市场自动识别)"
@confirm="add" @keyup.enter="add"
/> />
<button @click="add">添加</button> <button @click="add">添加</button>
</div> </div>
<p v-if="detectedMarket" class="market-hint">将识别为{{ detectedMarket }}</p>
<div v-if="modelValue.length" class="stock-list"> <div v-if="modelValue.length" class="stock-list">
<span v-for="s in modelValue" :key="s" class="stock-tag"> <span v-for="s in modelValue" :key="s" class="stock-tag">
@@ -64,12 +57,15 @@ function remove(sym: string) {
.add-row { .add-row {
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center;
} }
/* StockSearchInput 根元素填满剩余宽度 */ .add-row input {
.add-row :deep(.stock-search-input) {
flex: 1; flex: 1;
} }
.market-hint {
color: var(--text-dim);
font-size: 11px;
margin-top: 4px;
}
.stock-list { .stock-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+27 -4
View File
@@ -4,11 +4,10 @@
// 后端 /bars 仅支持 count(上限 800,约 3.2 年),固定拉满后前端按日期过滤。 // 后端 /bars 仅支持 count(上限 800,约 3.2 年),固定拉满后前端按日期过滤。
// 默认:结束日=今天(最近交易日),开始日=2020-01-06。 // 默认:结束日=今天(最近交易日),开始日=2020-01-06。
import { ref } from 'vue' import { computed, ref } from 'vue'
import { fetchBars, formatError } from '../api' import { fetchBars, formatError } from '../api'
import { detectMarket } from '../market' import { detectMarket, marketLabel } from '../market'
import StockSearchInput from './StockSearchInput.vue'
import { useBacktestStore } from '../stores/backtest' import { useBacktestStore } from '../stores/backtest'
import type { Category } from '../types' import type { Category } from '../types'
@@ -37,6 +36,11 @@ const loading = ref(false)
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60'] const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60']
// 智能识别的市场(用于提示展示)
const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value)
? marketLabel(detectMarket(code.value))
: ''))
/** 取行情(由父组件在点击「开始回测/开始寻优」时调用)。 /** 取行情(由父组件在点击「开始回测/开始寻优」时调用)。
* 成功返回 true,失败返回 false(并把错误写入 store.error 供父组件感知)。 */ * 成功返回 true,失败返回 false(并把错误写入 store.error 供父组件感知)。 */
async function loadBars(): Promise<boolean> { async function loadBars(): Promise<boolean> {
@@ -89,7 +93,12 @@ defineExpose({ loadBars, loading })
<div class="symbol-picker"> <div class="symbol-picker">
<div class="field code-field"> <div class="field code-field">
<label>代码</label> <label>代码</label>
<StockSearchInput v-model="code" placeholder="6位代码 / 拼音 / 名字" /> <input
v-model="code"
maxlength="6"
placeholder="6位代码(市场自动识别)"
/>
<span v-if="detectedMarket" class="market-tag">{{ detectedMarket }}</span>
</div> </div>
<div class="field"> <div class="field">
@@ -121,6 +130,20 @@ defineExpose({ loadBars, loading })
.code-field { .code-field {
position: relative; position: relative;
} }
.code-field input {
padding-right: 70px;
}
.market-tag {
position: absolute;
right: 8px;
bottom: 8px;
font-size: 11px;
color: var(--text-dim);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 3px;
}
.err { .err {
color: var(--up); color: var(--up);
font-size: 12px; font-size: 12px;
-99
View File
@@ -1,99 +0,0 @@
// 股票搜索 composable:模块级缓存搜索索引 + 按代码/名字/声母三路过滤。
// 索引整会话只拉一次(~150KB / 5000 条),后续过滤纯本地计算(<5ms)。
//
// 加载策略:App 根组件挂载时调 eagerLoad() 立即开始拉取,期间 AppInitOverlay
// 全局遮罩盖住页面("正在初始化股票列表…"),就绪后遮罩消失。这样既保证
// 用户进入页面时搜索已可用,又给了清晰的初始化反馈。
// 后端 lifespan 也会后台预热 get_security_list_all 缓存,多数情况下 eagerLoad
// 能秒回(命中后端已建好的缓存)。
import { ref } from 'vue'
import { fetchSearchIndex, formatError } from '../api'
import type { StockSearchEntry } from '../types'
// ── 模块级状态(所有组件实例共享) ───────────────────────────────────────────
let cachedIndex: StockSearchEntry[] | null = null
let loadPromise: Promise<StockSearchEntry[]> | null = null
/** 全局响应式状态:所有 useStockSearch 实例共享同一组 ref,保证遮罩与输入框一致 */
const ready = ref(false)
const loading = ref(false)
const failed = ref(false)
const loadError = ref('')
/** 三路匹配:代码前缀 / 名字包含 / 声母包含。 */
function matchEntry(entry: StockSearchEntry, q: string): boolean {
if (entry.code.startsWith(q)) return true
if (entry.name.includes(q)) return true
if (entry.initials.includes(q)) return true
return false
}
/** 拉索引(去重并发请求;成功后常驻模块级缓存)。失败抛错。 */
async function ensureIndex(): Promise<StockSearchEntry[]> {
if (cachedIndex) return cachedIndex
if (!loadPromise) {
loadPromise = (async () => {
const { data } = await fetchSearchIndex()
cachedIndex = data
return data
})().catch((e) => {
loadPromise = null // 失败清空,允许下次重试
throw e
})
}
return loadPromise
}
export interface UseStockSearch {
/** 索引是否已加载就绪 */
ready: typeof ready
/** 是否正在加载(遮罩用) */
loading: typeof loading
/** 是否加载失败(遮罩用:失败也要消失,不能卡死) */
failed: typeof failed
/** 加载错误信息 */
loadError: typeof loadError
/** App 根挂载时调用,立即开始拉取索引 */
eagerLoad: () => void
/** 按输入过滤,返回最多 limit 条(默认 30)。索引未就绪时返回空数组。 */
search: (query: string, limit?: number) => Promise<StockSearchEntry[]>
}
/** 股票搜索:模块级共享状态 + 本地三路过滤。 */
export function useStockSearch(): UseStockSearch {
function eagerLoad() {
if (cachedIndex || loadPromise) return
loading.value = true
failed.value = false
loadError.value = ''
ensureIndex()
.then(() => {
ready.value = true
})
.catch((e) => {
failed.value = true
loadError.value = formatError(e)
})
.finally(() => {
loading.value = false
})
}
async function search(query: string, limit = 30): Promise<StockSearchEntry[]> {
const q = query.trim().toLowerCase()
if (!q) return []
const index = await ensureIndex()
const out: StockSearchEntry[] = []
for (const entry of index) {
if (matchEntry(entry, q)) {
out.push(entry)
if (out.length >= limit) break
}
}
return out
}
return { ready, loading, failed, loadError, eagerLoad, search }
}
-15
View File
@@ -330,18 +330,3 @@ export interface MultiStrategyBacktestRequest {
slippage?: number slippage?: number
execution?: ExecutionMode execution?: ExecutionMode
} }
// ── 股票搜索索引(GET /api/v1/security/search-index ────────────────────────
/** 搜索索引单条:code/name/initials(声母,如 中际旭创→zjxc)。 */
export interface StockSearchEntry {
code: string
name: string
initials: string
}
/** 搜索索引响应。 */
export interface StockSearchIndex {
count: number
data: StockSearchEntry[]
}