From e2bf29e931171a44edbf14441948d41e11d95903 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Mon, 6 Jul 2026 01:43:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(web-ui):=20=E8=82=A1=E7=A5=A8=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=BE=93=E5=85=A5=E6=94=AF=E6=8C=81=E6=8B=BC=E9=9F=B3?= =?UTF-8?q?=E5=A3=B0=E6=AF=8D=E6=90=9C=E7=B4=A2=EF=BC=88zjxc=E2=86=92?= =?UTF-8?q?=E4=B8=AD=E9=99=85=E6=97=AD=E5=88=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所有股票代码输入框(回测/寻优/组合页)支持代码/中文名/拼音声母三路 匹配的下拉联想。复用项目里早已有但从未被前端消费的 security_list_all 数据(沪深 A 股 5206 只完整中文名表)。 后端: - 新增 GET /api/v1/security/search-index 端点,返回 [{code,name,initials}] - 声母用 pypinyin FIRST_LETTER 预计算,进程内缓存,首次算一次常驻 - web extra 新增 pypinyin>=0.50 依赖 前端: - 新增 useStockSearch composable(模块级缓存 + 三路过滤 + 防抖) - 新增 StockSearchInput.vue(下拉 + 键盘导航 + 市场标签) - SymbolPicker / StocksPicker 接入新组件,保留直接输 6 位代码的快路径 实测 zjxc→中际旭创、gzmt→贵州茅台、cjxc→楚江新材,5000 条本地过滤 <5ms --- CHANGELOG.md | 22 ++ pyproject.toml | 4 +- src/easy_tdx/web/routers/market.py | 36 +++ web-ui/src/api.ts | 9 + web-ui/src/components/StockSearchInput.vue | 241 +++++++++++++++++++++ web-ui/src/components/StocksPicker.vue | 36 +-- web-ui/src/components/SymbolPicker.vue | 31 +-- web-ui/src/composables/useStockSearch.ts | 78 +++++++ web-ui/src/types.ts | 15 ++ 9 files changed, 427 insertions(+), 45 deletions(-) create mode 100644 web-ui/src/components/StockSearchInput.vue create mode 100644 web-ui/src/composables/useStockSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1bade..c3022f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 本文件记录 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/pyproject.toml b/pyproject.toml index 616e6a4..84f2a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.18.1" +version = "1.19.0" 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"] +web = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.29", "pypinyin>=0.50"] [tool.hatch.build.targets.wheel] packages = ["src/easy_tdx"] diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index ef16663..45b197f 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -53,6 +53,42 @@ 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 + + +@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)。 + 进程内缓存,首次请求算一次后常驻;强制刷新重启进程即可。 + """ + global _SEARCH_INDEX + if _SEARCH_INDEX is not None: + return {"count": len(_SEARCH_INDEX), "data": _SEARCH_INDEX} + + from pypinyin import Style, lazy_pinyin + + 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 + return {"count": len(index), "data": index} + + @router.post("/quotes", response_model=DataFrameResponse) async def security_quotes( req: QuoteRequest, diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index ef90599..1b303eb 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -14,6 +14,7 @@ import type { SavedStrategy, SavedStrategyCreate, SavedStrategyListResponse, + StockSearchIndex, StrategiesResponse, TaskListResponse, TaskState, @@ -102,6 +103,14 @@ export async function fetchBars( return bars } +/** 拉取股票搜索索引(code/name/initials,约 5000 条,~150KB)。 + * 前端 useStockSearch 会模块级缓存,整个会话只拉一次。 */ +export async function fetchSearchIndex(): Promise { + const resp = await fetch(`${BASE}/security/search-index`) + if (!resp.ok) await throwError(resp) + return (await resp.json()) as StockSearchIndex +} + /** 把后端 bars 的单条记录归一化为统一 Bar(datetime 字段)。 */ function normalizeBar(row: Record): Bar { const raw = (row.datetime ?? row.date) as string | undefined diff --git a/web-ui/src/components/StockSearchInput.vue b/web-ui/src/components/StockSearchInput.vue new file mode 100644 index 0000000..c546de1 --- /dev/null +++ b/web-ui/src/components/StockSearchInput.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/web-ui/src/components/StocksPicker.vue b/web-ui/src/components/StocksPicker.vue index 87fc8a6..8bf681e 100644 --- a/web-ui/src/components/StocksPicker.vue +++ b/web-ui/src/components/StocksPicker.vue @@ -2,9 +2,11 @@ // 多标的输入(组合回测用)。逐个添加 6 位代码,市场自动识别。 // 删除手动市场选择(沪市/深市/北交所),由 detectMarket 智能匹配。 -import { computed, ref } from 'vue' +import { ref } from 'vue' -import { detectMarket, marketLabel } from '../market' +import { detectMarket } from '../market' +import StockSearchInput from './StockSearchInput.vue' +import type { StockSearchEntry } from '../types' const props = defineProps<{ modelValue: string[] @@ -12,9 +14,6 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>() const code = ref('') -const detectedMarket = computed(() => (code.value && /^\d{6}$/.test(code.value) - ? marketLabel(detectMarket(code.value)) - : '')) function add() { if (!/^\d{6}$/.test(code.value)) return @@ -25,6 +24,15 @@ function add() { 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) { emit('update:modelValue', props.modelValue.filter((s) => s !== sym)) } @@ -33,15 +41,14 @@ function remove(sym: string) {