mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
feat(web-ui): 股票代码输入支持拼音声母搜索(zjxc→中际旭创)
所有股票代码输入框(回测/寻优/组合页)支持代码/中文名/拼音声母三路
匹配的下拉联想。复用项目里早已有但从未被前端消费的 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
This commit is contained in:
@@ -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 曾被误判为深市)。
|
||||
|
||||
+2
-2
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StockSearchIndex> {
|
||||
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<string, unknown>): Bar {
|
||||
const raw = (row.datetime ?? row.date) as string | undefined
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<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() {
|
||||
// 聚焦时若已有输入且非完整代码,重新展示建议
|
||||
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>
|
||||
@@ -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) {
|
||||
<template>
|
||||
<div class="stocks-picker">
|
||||
<div class="row add-row">
|
||||
<input
|
||||
<StockSearchInput
|
||||
v-model="code"
|
||||
maxlength="6"
|
||||
placeholder="6位代码(市场自动识别)"
|
||||
@keyup.enter="add"
|
||||
placeholder="6位代码 / 拼音 / 名字"
|
||||
@select="onSelectEntry"
|
||||
@confirm="add"
|
||||
/>
|
||||
<button @click="add">添加</button>
|
||||
</div>
|
||||
<p v-if="detectedMarket" class="market-hint">将识别为:{{ detectedMarket }}</p>
|
||||
|
||||
<div v-if="modelValue.length" class="stock-list">
|
||||
<span v-for="s in modelValue" :key="s" class="stock-tag">
|
||||
@@ -57,15 +64,12 @@ function remove(sym: string) {
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.add-row input {
|
||||
/* StockSearchInput 根元素填满剩余宽度 */
|
||||
.add-row :deep(.stock-search-input) {
|
||||
flex: 1;
|
||||
}
|
||||
.market-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stock-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
// 后端 /bars 仅支持 count(上限 800,约 3.2 年),固定拉满后前端按日期过滤。
|
||||
// 默认:结束日=今天(最近交易日),开始日=2020-01-06。
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchBars, formatError } from '../api'
|
||||
import { detectMarket, marketLabel } from '../market'
|
||||
import { detectMarket } from '../market'
|
||||
import StockSearchInput from './StockSearchInput.vue'
|
||||
import { useBacktestStore } from '../stores/backtest'
|
||||
import type { Category } from '../types'
|
||||
|
||||
@@ -36,11 +37,6 @@ const loading = ref(false)
|
||||
|
||||
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 供父组件感知)。 */
|
||||
async function loadBars(): Promise<boolean> {
|
||||
@@ -93,12 +89,7 @@ defineExpose({ loadBars, loading })
|
||||
<div class="symbol-picker">
|
||||
<div class="field code-field">
|
||||
<label>代码</label>
|
||||
<input
|
||||
v-model="code"
|
||||
maxlength="6"
|
||||
placeholder="6位代码(市场自动识别)"
|
||||
/>
|
||||
<span v-if="detectedMarket" class="market-tag">{{ detectedMarket }}</span>
|
||||
<StockSearchInput v-model="code" placeholder="6位代码 / 拼音 / 名字" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -130,20 +121,6 @@ defineExpose({ loadBars, loading })
|
||||
.code-field {
|
||||
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 {
|
||||
color: var(--up);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 股票搜索 composable:模块级缓存搜索索引 + 按代码/名字/声母三路过滤。
|
||||
// 索引整会话只拉一次(~150KB / 5000 条),后续过滤纯本地计算(<5ms)。
|
||||
|
||||
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
|
||||
|
||||
/** 三路匹配:代码前缀 / 名字包含 / 声母包含。
|
||||
* query 纯数字时优先按代码前缀(照顾"直接输 6 位代码"的老习惯);
|
||||
* 含字母时按声母;任何情况都叠加名字包含(输"旭创"也能命中)。 */
|
||||
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) => {
|
||||
// 失败清空 promise,允许下次重试
|
||||
loadPromise = null
|
||||
throw e
|
||||
})
|
||||
}
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export interface UseStockSearch {
|
||||
/** 索引是否已加载就绪 */
|
||||
ready: ReturnType<typeof ref<boolean>>
|
||||
/** 加载错误信息(空串表示无错) */
|
||||
loadError: ReturnType<typeof ref<string>>
|
||||
/** 按输入过滤,返回最多 limit 条(默认 30) */
|
||||
search: (query: string, limit?: number) => Promise<StockSearchEntry[]>
|
||||
}
|
||||
|
||||
/** 股票搜索:懒加载索引 + 本地三路过滤。 */
|
||||
export function useStockSearch(): UseStockSearch {
|
||||
const ready = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
// 首次调用即触发后台拉取(不阻塞,失败记错)
|
||||
ensureIndex()
|
||||
.then(() => {
|
||||
ready.value = true
|
||||
})
|
||||
.catch((e) => {
|
||||
loadError.value = formatError(e)
|
||||
})
|
||||
|
||||
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, loadError, search }
|
||||
}
|
||||
@@ -330,3 +330,18 @@ export interface MultiStrategyBacktestRequest {
|
||||
slippage?: number
|
||||
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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user