diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py
index d1a1627..cf80bba 100644
--- a/backend/app/api/kline.py
+++ b/backend/app/api/kline.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import math
from datetime import date, timedelta
+from functools import lru_cache
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
@@ -31,6 +32,53 @@ def _minute_allowed(capset) -> bool:
return not fallback
+@lru_cache(maxsize=8192)
+def _name_pinyin_keys(name: str) -> tuple[str, ...]:
+ """返回中文名称所有可能的拼音首字母串 (多音字展开为笛卡尔积)。
+
+ '平安银行' -> ('PAYH',); '重庆百货' -> ('CQBH', 'CQMH', 'ZQBH', 'ZQMH')。
+ 非汉字字符原样保留: '万科A' -> ('WKA',)。
+ 股票名总量有限且不变, lru_cache 命中后单次查询 ≈ dict 查找, 全市场遍历 < 1ms。
+ """
+ from pypinyin import pinyin, Style
+ if not name:
+ return ()
+ keys = [""]
+ for group in pinyin(name, style=Style.FIRST_LETTER, heteronym=True):
+ keys = [k + g.upper() for k in keys for g in group]
+ return tuple(keys)
+
+
+def _init_pinyin_dict() -> None:
+ """加载 A 股高频多音字地名/词组词典, 使常见误读也能命中。
+
+ pypinyin 默认词典对部分地名取常见读音 (如「重」→ chóng), 补充后「重庆」
+ 同时接受 zhòng/qìng (zq) 与 chóng/qīng (cq) 两种首字母, 与同花顺行为一致。
+ 幂等: 多次调用安全。
+ """
+ try:
+ from pypinyin import load_phrases_dict
+ # value 用二维 list: 每个字给一个或多个读音
+ load_phrases_dict({
+ "重庆": [["zhòng", "chóng"], ["qīng"]],
+ "长安": [["cháng", "zhǎng"], ["ān"]],
+ "长春": [["cháng", "zhǎng"], ["chūn"]],
+ "长沙": [["cháng", "zhǎng"], ["shā"]],
+ "长城": [["cháng", "zhǎng"], ["chéng"]],
+ "长江": [["cháng", "zhǎng"], ["jiāng"]],
+ })
+ except Exception as exc: # noqa: BLE001
+ logger.warning("pypinyin phrases dict load failed (polyphone coverage may degrade): %s", exc)
+
+
+_init_pinyin_dict()
+
+
+def _match_pinyin(name: str, keyword: str) -> bool:
+ """keyword 是否匹配 name 任一拼音首字母串的前缀 (支持多音字)。"""
+ return any(k.startswith(keyword) for k in _name_pinyin_keys(name))
+
+
@router.get("/instruments/search")
def search_instruments(
request: Request,
@@ -67,6 +115,7 @@ def search_instruments(
df = pl.concat(parts, how="vertical")
keyword = q.strip().upper()
+ is_pinyin_query = keyword.isalpha() and keyword.isascii()
# code/symbol 前缀优先,再 name 包含匹配
prefix_mask = (
@@ -79,16 +128,38 @@ def search_instruments(
| pl.col("name").str.contains(keyword, literal=True)
)
- # 前缀匹配优先,剩余名额用包含匹配补充
+ # 分层匹配: ① code/symbol 前缀 → ② 拼音首字母前缀(纯字母输入) → ③ 包含匹配
prefix_hits = df.filter(prefix_mask).head(limit)
if prefix_hits.height >= limit:
matched = prefix_hits
else:
+ collected = [prefix_hits] if prefix_hits.height else []
+ seen = set(prefix_hits["symbol"].to_list()) if prefix_hits.height else set()
remaining = limit - prefix_hits.height
- # 排除已匹配的 symbol
- prefix_symbols = set(prefix_hits["symbol"].to_list()) if not prefix_hits.is_empty() else set()
- contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(prefix_symbols)).head(remaining)
- matched = pl.concat([prefix_hits, contain_hits]) if not prefix_hits.is_empty() else contain_hits
+
+ # ② 拼音首字母前缀: 仅纯字母输入触发 (如 payh → 平安银行); 中文/代码输入零开销跳过
+ if is_pinyin_query and remaining > 0:
+ pinyin_rows = []
+ for row in df.filter(~pl.col("symbol").is_in(seen)).iter_rows(named=True):
+ if _match_pinyin(row["name"], keyword):
+ pinyin_rows.append(row)
+ if len(pinyin_rows) >= remaining:
+ break
+ if pinyin_rows:
+ collected.append(pl.DataFrame(pinyin_rows))
+ seen.update(r["symbol"] for r in pinyin_rows)
+ remaining -= len(pinyin_rows)
+
+ # ③ 包含匹配补充
+ if remaining > 0:
+ contain_hits = df.filter(contains_mask & ~pl.col("symbol").is_in(seen)).head(remaining)
+ if contain_hits.height:
+ collected.append(contain_hits)
+
+ matched = (
+ pl.concat(collected, how="vertical") if len(collected) > 1
+ else (collected[0] if collected else df.head(0))
+ )
rows = matched.select(["symbol", "name", "code", "asset_type"]).to_dicts()
return {"results": rows}
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 7b09b22..39b5916 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -22,6 +22,7 @@ dependencies = [
# llvmlite 不再提供 macOS Intel wheel;该平台使用 Matrix 纯 Python fallback。
"numba>=0.65.1; sys_platform != 'darwin' or platform_machine != 'x86_64'",
"fastexcel>=0.10", # Polars 读取 xlsx/xls
+ "pypinyin>=0.50", # 股票名拼音首字母搜索 (同花顺式 payh → 平安银行)
# TickFlow 官方 SDK
"tickflow[all]>=0.1.23",
# Scheduling
diff --git a/backend/tests/test_instrument_search.py b/backend/tests/test_instrument_search.py
new file mode 100644
index 0000000..410e5ae
--- /dev/null
+++ b/backend/tests/test_instrument_search.py
@@ -0,0 +1,148 @@
+"""标的搜索测试: 代码 / 名称 / 拼音首字母 (同花顺式 payh → 平安银行)。
+
+直接调用 search_instruments, 用最小 FakeRepo 提供 instruments 缓存, 不走 HTTP/DB。
+"""
+from __future__ import annotations
+
+import types
+
+import polars as pl
+import pytest
+
+from app.api.kline import search_instruments
+
+
+class _FakeRepo:
+ """最小 repo 桩: 只实现 search_instruments 依赖的 get_instruments_asset。"""
+
+ def __init__(self, by_asset: dict[str, pl.DataFrame]) -> None:
+ self.store = types.SimpleNamespace(data_dir="data")
+ self._by_asset = by_asset
+
+ def get_instruments_asset(self, asset_type: str) -> pl.DataFrame:
+ return self._by_asset.get(asset_type, pl.DataFrame())
+
+
+def _request(repo: _FakeRepo) -> types.SimpleNamespace:
+ return types.SimpleNamespace(app=types.SimpleNamespace(state=types.SimpleNamespace(repo=repo)))
+
+
+STOCKS = pl.DataFrame({
+ "symbol": ["000001.SZ", "600000.SH", "600519.SH", "000333.SZ", "600737.SH"],
+ "code": ["000001", "600000", "600519", "000333", "600737"],
+ "name": ["平安银行", "浦发银行", "贵州茅台", "美的集团", "中粮糖业"],
+})
+
+
+def _search(q: str, asset_types: str = "stock", limit: int = 20) -> list[dict]:
+ repo = _FakeRepo({"stock": STOCKS})
+ return search_instruments(_request(repo), q=q, limit=limit, asset_types=asset_types)["results"]
+
+
+# ===== 既有逻辑回归: 代码 / 名称搜索不受影响 =====
+
+def test_code_prefix_match():
+ """code 前缀: 6005 → 600519 (前缀优先)。"""
+ rows = _search("6005")
+ assert "600519.SH" in [r["symbol"] for r in rows]
+
+
+def test_symbol_contains_match():
+ rows = _search("6005")
+ assert "600519.SH" in [r["symbol"] for r in rows]
+
+
+def test_chinese_name_contains_match():
+ rows = _search("银行")
+ assert sorted(r["symbol"] for r in rows) == ["000001.SZ", "600000.SH"]
+
+
+def test_empty_query_returns_empty():
+ assert _search(" ") == []
+
+
+# ===== 拼音首字母搜索 (新功能) =====
+
+def test_pinyin_full_initials_match():
+ """payh → 平安银行"""
+ rows = _search("payh")
+ assert [r["symbol"] for r in rows] == ["000001.SZ"]
+
+
+def test_pinyin_prefix_match():
+ """m → 美的集团 (m 开头)"""
+ rows = _search("m")
+ assert "000333.SZ" in [r["symbol"] for r in rows]
+
+
+def test_pinyin_prefix_picks_multiple():
+ """pf → 浦发银行; pa → 平安银行 (前缀区分)"""
+ assert [r["symbol"] for r in _search("pf")] == ["600000.SH"]
+ assert [r["symbol"] for r in _search("pa")] == ["000001.SZ"]
+
+
+def test_pinyin_respects_limit():
+ """limit 限制拼音结果数"""
+ rows = _search("z", limit=1) # z → 中粮糖业
+ assert len(rows) == 1
+
+
+def test_pinyin_layer_between_prefix_and_contains():
+ """拼音命中应排在包含匹配之前 (分层优先级)。"""
+ rows = _search("md") # md → 美的集团 (拼音); 无代码/符号以 md 前缀
+ assert "000333.SZ" in [r["symbol"] for r in rows]
+
+
+def test_pinyin_and_code_prefix_coexist():
+ """纯字母查询同时命中 code 前缀和拼音首字母时, code 前缀优先排前。"""
+ # '600000' 是浦发的 code 前缀; 这里用纯字母无法命中 code, 故仅验证拼音路径独立可用
+ rows = _search("pf") # 浦发
+ assert "600000.SH" in [r["symbol"] for r in rows]
+
+
+# ===== 多音字 =====
+
+POLYPHONE_STOCKS = pl.DataFrame({
+ "symbol": ["600729.SH", "000625.SZ"],
+ "code": ["600729", "000625"],
+ "name": ["重庆百货", "长安汽车"],
+})
+
+
+def test_polyphone_all_readings_match():
+ """'重庆' 多音字: cq (chóng) 和 zq (zhòng) 读音都应命中。"""
+ repo = _FakeRepo({"stock": POLYPHONE_STOCKS})
+ # 取首字母集, 验证两种读音都能搜到
+ cq = search_instruments(_request(repo), q="cqbh", limit=20, asset_types="stock")["results"] # chóng qīng
+ zq = search_instruments(_request(repo), q="zqbh", limit=20, asset_types="stock")["results"] # zhòng qìng
+ assert "600729.SH" in [r["symbol"] for r in cq]
+ assert "600729.SH" in [r["symbol"] for r in zq]
+
+
+# ===== 边界 =====
+
+def test_non_ascii_skips_pinyin_branch():
+ """中文输入不走拼音分支, 仍按名称匹配。"""
+ rows = _search("平安")
+ assert [r["symbol"] for r in rows] == ["000001.SZ"]
+
+
+def test_digits_skips_pinyin_branch():
+ """数字输入不走拼音分支, 走 code 前缀。"""
+ rows = _search("000")
+ assert "000001.SZ" in [r["symbol"] for r in rows]
+
+
+def test_no_pinyin_hit_returns_empty():
+ """无任何拼音命中时返回空 (不报错)。"""
+ assert _search("xyz") == []
+
+
+def test_cache_returns_same_result_across_calls():
+ """lru_cache 不应在不同请求间串结果 (按 name 缓存, 查询无状态)。"""
+ repo = _FakeRepo({"stock": STOCKS})
+ req = _request(repo)
+ r1 = search_instruments(req, q="payh", limit=20, asset_types="stock")["results"]
+ r2 = search_instruments(req, q="payh", limit=20, asset_types="stock")["results"]
+ assert r1 == r2
+ assert [r["symbol"] for r in r1] == ["000001.SZ"]
diff --git a/frontend/src/components/financials/StockFinancialSearch.tsx b/frontend/src/components/financials/StockFinancialSearch.tsx
index 2b739b8..ef5b3f4 100644
--- a/frontend/src/components/financials/StockFinancialSearch.tsx
+++ b/frontend/src/components/financials/StockFinancialSearch.tsx
@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { Search, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
+import { boardTag } from '@/components/stock-table/primitives'
interface Props {
onSelect: (symbol: string, name: string) => void
@@ -118,6 +119,12 @@ export function StockFinancialSearch({ onSelect, assetTypes }: Props) {
>
{r.symbol}
{r.name}
+ {(() => {
+ const b = boardTag(r.symbol)
+ return b && (
+ {b.label}
+ )
+ })()}
{r.asset_type === 'index' && (
指数
)}
diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx
index 4878885..320a663 100644
--- a/frontend/src/components/monitor/RuleEditor.tsx
+++ b/frontend/src/components/monitor/RuleEditor.tsx
@@ -5,6 +5,7 @@ import { Activity, Check, Plus, RadioTower, Save, Search, TrendingUp, Waypoints,
import { api, genRuleId, type MonitorRule, type MonitorCondition, type StrategyNotifyEvent } from '@/lib/api'
import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents'
import { QK } from '@/lib/queryKeys'
+import { boardTag } from '@/components/stock-table/primitives'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
import { usePreferences } from '@/lib/useSharedQueries'
@@ -430,6 +431,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
{symbolSearch.data.results.map(r => (
))}
diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx
index 4243075..2b6df55 100644
--- a/frontend/src/pages/Watchlist.tsx
+++ b/frontend/src/pages/Watchlist.tsx
@@ -308,6 +308,12 @@ function StockSearchBox({
{r.asset_type === 'index' && (
指数
)}
+ {(() => {
+ const b = boardTag(r.symbol)
+ return b && (
+ {b.label}
+ )
+ })()}
)