#730 [PR1] 首页股票分析输入框自动补全 AutoComplete (#775)

* feat(autocomplete): rebuild web autocomplete mvp

* feat(autocomplete): add error boundary and runtime fallback

Add error boundary and runtime error handling to improve autocomplete stability:

- Add StockAutocompleteBoundary class component to catch render errors
- Add FallbackInput component for graceful degradation
- Add runtimeFallback state and error handling in useAutocomplete hook
- Wrap search logic with try-catch to prevent crashes
- Add comprehensive tests for error scenarios

This ensures the autocomplete degrades to a plain input when:
- Index loading fails (existing behavior)
- Runtime search throws (new)
- Component tree throws during render (new)

* feat(autocomplete): add input validation and optimize name resolution

- Add early rejection for obviously invalid mixed alphanumeric input
- Skip expensive AkShare/fuzzy fallback for non-CJK free text
- Enhance frontend validation with isObviouslyInvalidStockQuery
- Increase minimum query length from 1 to 2 characters
- Add comprehensive test coverage for validation logic
- Update E2E test placeholder text to reflect new capabilities

These changes address code review feedback about:
- Preventing abuse from invalid input patterns
- Improving performance by avoiding unnecessary network calls
- Ensuring consistent validation between frontend and backend

* fix(autocomplete): harden input validation and runtime guards

* update doc

* style(autocomplete): adjust suggestion item hover effect

- Reduce hover background opacity from 35% to 25% for lighter visual effect
- Brighten hover color in dark theme from #257280 to #2dcae6

* fix(autocomplete): do not auto-highlight first suggestion to preserve raw input on Enter

* test(home): align report fixture with report language metadata

* fix: unify task queue stock dedupe key
This commit is contained in:
LouisHong
2026-03-20 11:35:00 +08:00
committed by GitHub
parent 590a61f033
commit 73d524a186
43 changed files with 5239 additions and 205 deletions

View File

@@ -41,6 +41,7 @@
| 基本面 | 结构化聚合 | 新增 `fundamental_context`valuation/growth/earnings/institution/capital_flow/dragon_tiger/boards其中 `earnings.data` 新增 `financial_report``dividend``boards` 表示板块涨跌榜),主链路 fail-open 降级 |
| 策略 | 市场策略系统 | 内置 A股「三段式复盘策略」与美股「Regime Strategy」输出进攻/均衡/防守或 risk-on/neutral/risk-off 计划,并附“仅供参考,不构成投资建议”提示 |
| 复盘 | 大盘复盘 | 每日市场概览、板块涨跌;支持 cn(A股)/us(美股)/both(两者) 切换 |
| 补全 | 智能补全 (MVP) | **[测试阶段]** 首页搜索框支持代码/名称/拼音/别名联想;**第一阶段仅限 A 股**,其他市场自动降级为手动输入 |
| 智能导入 | 多源导入 | 支持图片、CSV/Excel 文件、剪贴板粘贴Vision LLM 提取代码+名称;置信度分层确认;名称→代码解析(本地+拼音+AkShare |
| 历史记录 | 批量管理 | 支持多选、全选及批量删除历史分析记录,优化管理效率与 UI/UX 体验 |
| 回测 | AI 回测验证 | 自动评估历史分析准确率,方向胜率、止盈止损命中率 |
@@ -345,6 +346,18 @@ LITELLM_MODEL=openai/deepseek-chat
**API**`POST /api/v1/stocks/extract-from-image`(图片)、`POST /api/v1/stocks/parse-import`(文件/粘贴)。详见 [完整指南](docs/full-guide.md)。
### 智能搜索补全 (MVP)
首页分析输入框已升级为“类搜索引擎”补全框,显著降低记忆负担:
- **多维匹配**:支持输入股票代码、中文名、拼音缩写或别名(如 `gzmt` -> 贵州茅台)。
- **分阶段支持**:目前处于 **MVP 测试阶段**,本地索引优先覆盖 **A 股市场**
- **自动降级逻辑**
- **美股/港股**:若搜索未命中,用户直接按回车即可走原有手动输入流程,完全不影响分析。
- **新股/异常**:若索引未及时更新或加载失败,系统将无缝退回普通输入模式,确保分析链路 100% 可用。
> 💡 **搜索范围提示**:自动补全目前仅针对 A 股进行试点优化。对于港股、美股或新上市标的,请继续直接输入代码(如 `AAPL`、`00700.HK`)并回车发起分析,系统会自动解析并处理。
**LLM 用量查询**`GET /api/v1/usage/summary?period=today|month|all`,返回按调用类型和模型分组的 token 消耗汇总(`total_calls``total_tokens``by_call_type``by_model`)。
**分析 API 说明**`POST /api/v1/analysis/analyze``async_mode=false` 时仅支持单只股票;批量 `stock_codes` 需要 `async_mode=true`。异步 `202` 响应对单股返回 `task_id`,对批量返回 `accepted` / `duplicates` 汇总结构;空白股票代码会在服务端过滤,若过滤后为空则返回 `400`。未知 `/api` 路径(含 `/api` 本身)返回 JSON `404`,不再回退到前端页面。详见 [API 规范](docs/architecture/api_spec.json)。

View File

@@ -19,6 +19,7 @@
import asyncio
import json
import logging
import re
from datetime import datetime
from typing import Optional, Union, Dict, Any
@@ -46,9 +47,11 @@ from api.v1.schemas.history import (
ReportStrategy,
ReportDetails,
)
from data_provider.base import canonical_stock_code
from data_provider.base import canonical_stock_code, normalize_stock_code
from src.config import Config
from src.report_language import get_localized_stock_name, normalize_report_language
from src.services.name_to_code_resolver import resolve_name_to_code
from src.services.stock_code_utils import is_code_like
from src.services.task_queue import (
get_task_queue,
DuplicateTaskError,
@@ -64,6 +67,56 @@ logger = logging.getLogger(__name__)
router = APIRouter()
_SUPPORTED_FREE_TEXT_RE = re.compile(r"^[A-Za-z0-9.*\-+\u3400-\u9fff\s]+$")
def _invalid_analysis_input_error() -> HTTPException:
return HTTPException(
status_code=400,
detail={
"error": "validation_error",
"message": "请输入有效的股票代码或股票名称",
},
)
def _is_obviously_invalid_analysis_input(text: str) -> bool:
"""Reject mixed alphanumeric noise and unsupported symbols early."""
if not text or is_code_like(text):
return False
if not _SUPPORTED_FREE_TEXT_RE.fullmatch(text):
return True
has_letters = any(ch.isalpha() and ch.isascii() for ch in text)
has_digits = any(ch.isdigit() for ch in text)
return has_letters and has_digits
def _resolve_and_normalize_input(raw_value: str) -> str:
"""
Resolve and normalize a stock input for analysis requests.
Code-like values keep the existing canonical path.
Non-code inputs must resolve to a known stock code. Obvious garbage
input is rejected before expensive resolver and task-queue work.
"""
text = (raw_value or "").strip()
if not text:
return ""
if is_code_like(text):
return canonical_stock_code(text)
if _is_obviously_invalid_analysis_input(text):
raise _invalid_analysis_input_error()
resolved = resolve_name_to_code(text)
if resolved:
return canonical_stock_code(resolved)
raise _invalid_analysis_input_error()
# ============================================================
# POST /analyze - 触发股票分析
@@ -128,10 +181,32 @@ def trigger_analysis(
}
)
# 统一大小写后去重,确保 ['aapl', 'AAPL'] 被识别为同一股票Issue #355
stock_codes = [canonical_stock_code(c) for c in stock_codes]
stock_codes = [c for c in stock_codes if c]
stock_codes = list(dict.fromkeys(stock_codes))
# Normalize and de-duplicate inputs while preserving compatibility.
resolved = [_resolve_and_normalize_input(c) for c in stock_codes]
seen = set()
unique_codes = []
for code in resolved:
if not code:
continue
# Use normalize_stock_code to ensure '600519' and '600519.SH' are merged
norm = normalize_stock_code(code)
if norm not in seen:
seen.add(norm)
unique_codes.append(code)
stock_codes = unique_codes
# Limit the number of stocks in a single request to prevent DoS
MAX_BATCH_SIZE = 50
if len(stock_codes) > MAX_BATCH_SIZE:
raise HTTPException(
status_code=400,
detail={
"error": "validation_error",
"message": f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票"
}
)
if not stock_codes:
raise HTTPException(
@@ -142,7 +217,7 @@ def trigger_analysis(
}
)
# 同步模式仅支持单只股票
# Sync mode only supports single-stock analysis.
if not request.async_mode:
if len(stock_codes) > 1:
raise HTTPException(
@@ -154,7 +229,7 @@ def trigger_analysis(
)
return _handle_sync_analysis(stock_codes[0], request)
# 异步模式:为每只股票提交任务
# Async mode submits one task per stock.
return _handle_async_analysis_batch(stock_codes, request)
@@ -163,15 +238,25 @@ def _handle_async_analysis_batch(
request: AnalyzeRequest
) -> JSONResponse:
"""
处理异步分析请求(支持批量)
为每只股票提交任务到队列,立即返回 202
如果仅一只股票且正在分析中,返回 409
Handle asynchronous analysis requests, including batch submission.
"""
task_queue = get_task_queue()
# Preserve metadata for single-stock requests. For batch requests,
# only carry through metadata that semantically applies to the whole
# batch, such as import/image source tracking.
is_single = len(stock_codes) == 1
preserve_batch_metadata = request.selection_source in {"import", "image"}
stock_name = request.stock_name if is_single else None
original_query = request.original_query if (is_single or preserve_batch_metadata) else None
selection_source = request.selection_source if (is_single or preserve_batch_metadata) else None
accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(
stock_codes=stock_codes,
stock_name=None,
stock_name=stock_name,
original_query=original_query,
selection_source=selection_source,
report_type=request.report_type,
force_refresh=request.force_refresh,
)
@@ -357,6 +442,8 @@ def get_task_list(
started_at=t.started_at.isoformat() if t.started_at else None,
completed_at=t.completed_at.isoformat() if t.completed_at else None,
error=t.error,
original_query=t.original_query,
selection_source=t.selection_source,
)
for t in all_tasks
]
@@ -491,8 +578,11 @@ def get_analysis_status(task_id: str) -> TaskStatus:
task_id=task.task_id,
status=task.status.value,
progress=task.progress,
result=None, # 进行中的任务没有结果
result=None, # In-progress tasks do not carry a result payload.
error=task.error,
stock_name=task.stock_name,
original_query=task.original_query,
selection_source=task.selection_source,
)
# 2. 从数据库查询已完成的记录

View File

@@ -14,6 +14,7 @@ from typing import Optional, List, Any
from enum import Enum
from pydantic import BaseModel, Field
from src.utils.analysis_metadata import SELECTION_SOURCE_PATTERN
class TaskStatusEnum(str, Enum):
@@ -25,7 +26,7 @@ class TaskStatusEnum(str, Enum):
class AnalyzeRequest(BaseModel):
"""分析请求模型"""
"""Analysis request parameters"""
stock_code: Optional[str] = Field(
None,
@@ -43,13 +44,29 @@ class AnalyzeRequest(BaseModel):
pattern="^(simple|detailed|full|brief)$",
)
force_refresh: bool = Field(
True,
False,
description="是否强制刷新(忽略缓存)"
)
async_mode: bool = Field(
False,
description="是否使用异步模式"
)
stock_name: Optional[str] = Field(
None,
description="用户选中的股票名称(自动补全时提供)",
example="贵州茅台"
)
original_query: Optional[str] = Field(
None,
description="用户原始输入如茅台、gzmt、600519",
example="茅台"
)
selection_source: Optional[str] = Field(
None,
description="股票选择来源manual(手动输入) | autocomplete(自动补全) | import(导入) | image(图片识别)",
pattern=SELECTION_SOURCE_PATTERN,
example="autocomplete"
)
class Config:
json_schema_extra = {
@@ -57,7 +74,10 @@ class AnalyzeRequest(BaseModel):
"stock_code": "600519",
"report_type": "detailed",
"force_refresh": False,
"async_mode": False
"async_mode": False,
"stock_name": "贵州茅台",
"original_query": "茅台",
"selection_source": "autocomplete"
}
}
@@ -180,7 +200,7 @@ class BatchTaskAcceptedResponse(BaseModel):
class TaskStatus(BaseModel):
"""任务状态模型"""
"""Task status model"""
task_id: str = Field(..., description="任务 ID")
status: str = Field(
@@ -202,6 +222,13 @@ class TaskStatus(BaseModel):
None,
description="错误信息(仅在 failed 时存在)"
)
stock_name: Optional[str] = Field(None, description="股票名称")
original_query: Optional[str] = Field(None, description="用户原始输入")
selection_source: Optional[str] = Field(
None,
description="选择来源",
pattern=SELECTION_SOURCE_PATTERN,
)
class Config:
json_schema_extra = {
@@ -210,16 +237,19 @@ class TaskStatus(BaseModel):
"status": "completed",
"progress": 100,
"result": None,
"error": None
"error": None,
"stock_name": "贵州茅台",
"original_query": "茅台",
"selection_source": "autocomplete"
}
}
class TaskInfo(BaseModel):
"""
任务详情模型
用于任务列表和 SSE 事件推送
Task details model
Used for task list and SSE event delivery
"""
task_id: str = Field(..., description="任务 ID")
@@ -233,6 +263,12 @@ class TaskInfo(BaseModel):
started_at: Optional[str] = Field(None, description="开始执行时间")
completed_at: Optional[str] = Field(None, description="完成时间")
error: Optional[str] = Field(None, description="错误信息(仅在 failed 时存在)")
original_query: Optional[str] = Field(None, description="用户原始输入")
selection_source: Optional[str] = Field(
None,
description="选择来源",
pattern=SELECTION_SOURCE_PATTERN,
)
class Config:
json_schema_extra = {
@@ -247,7 +283,9 @@ class TaskInfo(BaseModel):
"created_at": "2026-02-05T10:30:00",
"started_at": "2026-02-05T10:30:01",
"completed_at": None,
"error": None
"error": None,
"original_query": "茅台",
"selection_source": "autocomplete"
}
}

View File

@@ -52,7 +52,7 @@ test.describe('web smoke', () => {
test('home page shows analysis entry and history panel after login', async ({ page }) => {
await login(page);
const stockInput = page.getByPlaceholder('输入股票代码,如 600519、HK00700、AAPL');
const stockInput = page.getByPlaceholder('输入股票代码或名称,如 600519、贵州茅台、AAPL');
await expect(stockInput).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('link', { name: '首页' })).toBeVisible();
await expect(page.getByRole('link', { name: '问股' })).toBeVisible();

File diff suppressed because one or more lines are too long

View File

@@ -10,13 +10,13 @@ import type {
TaskListResponse,
} from '../types/analysis';
// ============ API 接口 ============
// ============ API Interfaces ============
export const analysisApi = {
/**
* 触发股票分析
* @param data 分析请求参数
* @returns 同步模式返回 AnalysisResult异步模式返回单任务或批量任务接受响应
* Trigger stock analysis.
* @param data Analysis request payload
* @returns Sync mode returns AnalysisResult; async mode returns accepted task payloads
*/
analyze: async (data: AnalysisRequest): Promise<AnalyzeResponse> => {
const requestData = {
@@ -25,6 +25,9 @@ export const analysisApi = {
report_type: data.reportType || 'detailed',
force_refresh: data.forceRefresh || false,
async_mode: data.asyncMode || false,
stock_name: data.stockName,
original_query: data.originalQuery,
selection_source: data.selectionSource,
};
const response = await apiClient.post<Record<string, unknown>>(
@@ -34,7 +37,7 @@ export const analysisApi = {
const result = toCamelCase<AnalyzeResponse>(response.data);
// 确保同步分析返回中的 report 字段正确转换
// Ensure the sync analysis report payload is converted recursively.
if ('report' in result && result.report) {
result.report = toCamelCase<AnalysisReport>(result.report);
}
@@ -43,10 +46,9 @@ export const analysisApi = {
},
/**
* 异步模式触发分析
* 返回 task_id通过 SSE 或轮询获取结果
* @param data 分析请求参数
* @returns 单任务或批量任务接受响应409 时抛出重复任务错误
* Trigger analysis in async mode.
* @param data Analysis request payload
* @returns Accepted task payloads; throws DuplicateTaskError on 409
*/
analyzeAsync: async (data: AnalysisRequest): Promise<AnalyzeAsyncResponse> => {
const requestData = {
@@ -55,18 +57,21 @@ export const analysisApi = {
report_type: data.reportType || 'detailed',
force_refresh: data.forceRefresh || false,
async_mode: true,
stock_name: data.stockName,
original_query: data.originalQuery,
selection_source: data.selectionSource,
};
const response = await apiClient.post<Record<string, unknown>>(
'/api/v1/analysis/analyze',
requestData,
{
// 允许 202 状态码
// Allow 202 accepted responses in addition to standard success codes.
validateStatus: (status) => status === 200 || status === 202 || status === 409,
}
);
// 处理 409 重复提交错误
// Handle duplicate submission compatibility.
if (response.status === 409) {
const errorData = toCamelCase<{
error: string;
@@ -81,8 +86,8 @@ export const analysisApi = {
},
/**
* 获取异步任务状态
* @param taskId 任务 ID
* Get async task status.
* @param taskId Task ID
*/
getStatus: async (taskId: string): Promise<TaskStatus> => {
const response = await apiClient.get<Record<string, unknown>>(
@@ -91,7 +96,7 @@ export const analysisApi = {
const data = toCamelCase<TaskStatus>(response.data);
// 确保嵌套的 result 也被正确转换
// Ensure nested result payloads are converted recursively.
if (data.result) {
data.result = toCamelCase<AnalysisResult>(data.result);
if (data.result.report) {
@@ -103,8 +108,8 @@ export const analysisApi = {
},
/**
* 获取任务列表
* @param params 筛选参数
* Get task list.
* @param params Filter parameters
*/
getTasks: async (params?: {
status?: string;
@@ -121,21 +126,19 @@ export const analysisApi = {
},
/**
* 获取 SSE 流 URL
* 用于 EventSource 连接
* Get the SSE stream URL.
*/
getTaskStreamUrl: (): string => {
// 获取 API base URL
// Read API base URL from the shared client.
const baseUrl = apiClient.defaults.baseURL || '';
return `${baseUrl}/api/v1/analysis/tasks/stream`;
},
};
// ============ 自定义错误类 ============
// ============ Custom Error Classes ============
/**
* 重复任务错误
* 当股票正在分析中时抛出
* Duplicate task error.
*/
export class DuplicateTaskError extends Error {
stockCode: string;

View File

@@ -0,0 +1,303 @@
/**
* StockAutocomplete Component
*
* Stock code/name autocomplete input box
* Supports keyboard navigation, IME input method, graceful degradation
*/
import { Component, useRef, useEffect, useState } from 'react';
import type { KeyboardEvent } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useStockIndex } from '../../hooks/useStockIndex';
import { useAutocomplete } from '../../hooks/useAutocomplete';
import { SuggestionsList } from './SuggestionsList';
import { cn } from '../../utils/cn';
export interface StockAutocompleteProps {
/** Input value */
value: string;
/** Value change callback */
onChange: (value: string) => void;
/** Submit callback (code, name, source) */
onSubmit: (code: string, name?: string, source?: 'manual' | 'autocomplete') => void;
/** Whether disabled */
disabled?: boolean;
/** Placeholder text */
placeholder?: string;
/** Additional CSS class name */
className?: string;
}
function FallbackInput({
value,
onChange,
onSubmit,
disabled = false,
placeholder = '输入股票代码或名称',
className,
}: StockAutocompleteProps) {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !disabled && value) {
onSubmit(value);
}
}}
placeholder={placeholder}
disabled={disabled}
className={cn('input-terminal w-full', className)}
data-autocomplete-mode="fallback"
/>
);
}
interface StockAutocompleteBoundaryProps extends StockAutocompleteProps {
children: ReactNode;
}
interface StockAutocompleteBoundaryState {
hasError: boolean;
}
class StockAutocompleteBoundary extends Component<
StockAutocompleteBoundaryProps,
StockAutocompleteBoundaryState
> {
override state: StockAutocompleteBoundaryState = { hasError: false };
static getDerivedStateFromError(): StockAutocompleteBoundaryState {
return { hasError: true };
}
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Autocomplete runtime error. Falling back to plain input.', error, errorInfo);
}
override render() {
if (this.state.hasError) {
const { children, ...fallbackProps } = this.props;
void children;
return <FallbackInput {...fallbackProps} />;
}
return this.props.children;
}
}
function StockAutocompleteInner({
value,
onChange,
onSubmit,
disabled = false,
placeholder = '输入股票代码或名称',
className,
}: StockAutocompleteProps) {
const { index, loading, fallback } = useStockIndex();
const {
// query,
setQuery,
suggestions,
isOpen,
highlightedIndex,
setHighlightedIndex,
highlightPrevious,
highlightNext,
close,
// reset,
isComposing,
setIsComposing,
runtimeFallback,
error: autocompleteError,
} = useAutocomplete(index);
const inputRef = useRef<HTMLInputElement>(null);
const prevValueRef = useRef(value);
const [dropdownStyle, setDropdownStyle] = useState<{ top: number; left: number; width: string } | null>(null);
const updateDropdownPosition = () => {
if (!inputRef.current) {
setDropdownStyle(null);
return;
}
const rect = inputRef.current.getBoundingClientRect();
setDropdownStyle({
top: rect.bottom,
left: rect.left,
width: `${rect.width}px`,
});
};
const closeSuggestions = () => {
close();
setDropdownStyle(null);
};
// Sync external value with internal query (only when value truly changes)
useEffect(() => {
if (prevValueRef.current !== value) {
setQuery(value);
prevValueRef.current = value;
}
}, [value, setQuery]);
// Calculate suggestion box position (using fixed positioning)
useEffect(() => {
if (!isOpen) {
return;
}
const frameId = window.requestAnimationFrame(updateDropdownPosition);
window.addEventListener('resize', updateDropdownPosition);
window.addEventListener('scroll', updateDropdownPosition, true);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', updateDropdownPosition);
window.removeEventListener('scroll', updateDropdownPosition, true);
};
}, [isOpen]);
useEffect(() => {
if (!autocompleteError) {
return;
}
console.error('Autocomplete runtime fallback activated.', autocompleteError);
}, [autocompleteError]);
// Keyboard event handling
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// Skip if composing (IME)
if (isComposing) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightNext();
break;
case 'ArrowUp':
e.preventDefault();
highlightPrevious();
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && suggestions[highlightedIndex]) {
// Select highlighted item
const selected = suggestions[highlightedIndex];
onChange(selected.displayCode);
closeSuggestions();
onSubmit(selected.canonicalCode, selected.nameZh, 'autocomplete');
} else {
// Submit directly
onSubmit(value);
}
break;
case 'Escape':
e.preventDefault();
closeSuggestions();
break;
}
};
// IME handling
const handleCompositionStart = () => {
setIsComposing(true);
};
const handleCompositionEnd = () => {
setIsComposing(false);
};
// Delay closing on blur (avoid immediate close when clicking suggestion items)
const handleBlur = () => {
setTimeout(() => closeSuggestions(), 200);
};
// Fallback mode: use normal input
if (fallback || loading || runtimeFallback) {
return (
<FallbackInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
disabled={disabled}
placeholder={placeholder}
className={className}
/>
);
}
return (
<div className="relative stock-autocomplete">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onFocus={() => {
if (isOpen) {
updateDropdownPosition();
}
}}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className={cn(
"input-terminal w-full",
"focus:outline-none",
isOpen && "rounded-b-none",
className
)}
aria-autocomplete="none"
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="suggestions-list"
/>
{/* Loading indicator */}
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-cyan/20 border-t-cyan rounded-full animate-spin" />
</div>
)}
{/* Suggestion dropdown list */}
{isOpen && dropdownStyle && createPortal(
<SuggestionsList
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={(s) => {
// Update external value (shown in input box)
onChange(s.displayCode);
// Close dropdown list
closeSuggestions();
// Submit analysis
onSubmit(s.canonicalCode, s.nameZh, 'autocomplete');
}}
onMouseEnter={(index) => setHighlightedIndex(index)}
style={{ position: 'fixed', ...dropdownStyle }}
/>,
document.body
)}
</div>
);
}
export function StockAutocomplete(props: StockAutocompleteProps) {
return (
<StockAutocompleteBoundary {...props}>
<StockAutocompleteInner {...props} />
</StockAutocompleteBoundary>
);
}
export default StockAutocomplete;

View File

@@ -0,0 +1,126 @@
/**
* SuggestionsList Component
*
* Stock search suggestion list
* Displays matched stock options
*/
import type { CSSProperties } from 'react';
import type { StockSuggestion } from '../../types/stockIndex';
import { cn } from '../../utils/cn';
export interface SuggestionsListProps {
/** Suggestion list */
suggestions: StockSuggestion[];
/** Highlighted index */
highlightedIndex: number;
/** Selection callback */
onSelect: (suggestion: StockSuggestion) => void;
/** Mouse hover callback */
onMouseEnter: (index: number) => void;
/** Custom style (for Portal fixed positioning) */
style?: CSSProperties;
}
export function SuggestionsList({
suggestions,
highlightedIndex,
onSelect,
onMouseEnter,
style,
}: SuggestionsListProps) {
if (suggestions.length === 0) {
return null;
}
return (
<ul
id="suggestions-list"
className="z-[100] border-x border-b rounded-b-lg rounded-t-none max-h-60 overflow-auto"
style={{
...style,
backgroundColor: 'hsl(var(--card) / 0.85)',
borderColor: 'var(--border-accent)',
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3), -4px 0 15px -3px rgba(0, 0, 0, 0.2), 4px 0 15px -3px rgba(0, 0, 0, 0.2)'
}}
role="listbox"
>
{suggestions.map((suggestion, index) => (
<li
key={suggestion.canonicalCode}
role="option"
aria-selected={index === highlightedIndex}
className={cn(
"px-4 py-1 cursor-pointer flex items-center justify-between",
"hover:bg-[var(--autocomplete-hover-bg)]/25",
index === highlightedIndex && "bg-[var(--autocomplete-hover-bg)]/25"
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => onMouseEnter(index)}
>
<div className="flex items-center gap-3">
{/* Market badge */}
<MarketBadge market={suggestion.market} />
{/* Name and code */}
<div className="flex flex-col">
<span className="text-sm font-medium text-primary-text">
{suggestion.nameZh}
</span>
<span className="text-sm text-secondary-text">
{suggestion.displayCode}
</span>
</div>
</div>
{/* Match type badge */}
<MatchTypeBadge matchType={suggestion.matchType} />
</li>
))}
</ul>
);
}
// Helper component: Market badge
const MARKET_BADGE_CONFIG = {
CN: { label: 'A股', className: 'text-red-500 bg-red-500/10' },
HK: { label: '港股', className: 'text-green-500 bg-green-500/10' },
US: { label: '美股', className: 'text-blue-500 bg-blue-500/10' },
INDEX: { label: '指数', className: 'text-purple-500 bg-purple-500/10' },
ETF: { label: 'ETF', className: 'text-yellow-500 bg-yellow-500/10' },
BSE: { label: '北交所', className: 'text-orange-500 bg-orange-500/10' },
} as const;
function MarketBadge({ market }: { market: string }) {
const config = MARKET_BADGE_CONFIG[market as keyof typeof MARKET_BADGE_CONFIG];
if (!config) {
throw new Error(`Unsupported market in stock suggestion: ${market}`);
}
return (
<span className={cn("text-xs px-2 py-0.5 rounded", config.className)}>
{config.label}
</span>
);
}
// Helper component: Match type badge
function MatchTypeBadge({ matchType }: { matchType: string }) {
const configMap = {
exact: { label: '精确', className: 'bg-cyan/10 text-cyan' },
prefix: { label: '前缀', className: 'bg-purple/10 text-purple' },
contains: { label: '包含', className: 'bg-yellow/10 text-yellow' },
fuzzy: { label: '模糊', className: 'bg-gray/10 text-gray' },
};
const config = configMap[matchType as keyof typeof configMap] || configMap.fuzzy;
return (
<span className={cn("text-xs px-1.5 py-0.5 rounded", config.className)}>
{config.label}
</span>
);
}
export default SuggestionsList;

View File

@@ -0,0 +1,442 @@
/**
* StockAutocomplete component tests.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StockAutocomplete } from '../StockAutocomplete';
import type { StockIndexItem } from '../../../types/stockIndex';
let stockIndexHookImpl: () => {
index: StockIndexItem[];
loading: boolean;
fallback: boolean;
error: Error | null;
loaded: boolean;
};
let autocompleteHookImpl: () => {
query: string;
setQuery: ReturnType<typeof vi.fn>;
suggestions: typeof mockSuggestions;
isOpen: boolean;
highlightedIndex: number;
setHighlightedIndex: ReturnType<typeof vi.fn>;
highlightPrevious: ReturnType<typeof vi.fn>;
highlightNext: ReturnType<typeof vi.fn>;
handleSelect: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
isComposing: boolean;
setIsComposing: ReturnType<typeof vi.fn>;
runtimeFallback: boolean;
error: Error | null;
};
// Mock the hooks
vi.mock('../../../hooks/useStockIndex', () => ({
useStockIndex: () => stockIndexHookImpl(),
}));
vi.mock('../../../hooks/useAutocomplete', () => ({
useAutocomplete: () => autocompleteHookImpl(),
}));
const mockIndex: StockIndexItem[] = [
{
canonicalCode: "600519.SH",
displayCode: "600519",
nameZh: "贵州茅台",
pinyinFull: "guizhoumaotai",
pinyinAbbr: "gzmt",
aliases: ["茅台"],
market: "CN",
assetType: "stock",
active: true,
popularity: 100,
},
];
const mockSuggestions = [
{
canonicalCode: "600519.SH",
displayCode: "600519",
nameZh: "贵州茅台",
market: "CN",
matchType: "exact" as const,
matchField: "code" as const,
score: 100,
},
];
describe('StockAutocomplete', () => {
const mockOnChange = vi.fn();
const mockOnSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
stockIndexHookImpl = () => ({
index: mockIndex,
loading: false,
fallback: false,
error: null,
loaded: true,
});
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: mockSuggestions,
isOpen: false,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: false,
error: null,
});
});
it('renders the input element', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByPlaceholderText(/输入股票代码或名称/);
expect(input).toBeInTheDocument();
});
it('renders a custom placeholder', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
placeholder="请输入代码"
/>
);
const input = screen.getByPlaceholderText(/请输入代码/);
expect(input).toBeInTheDocument();
});
it('renders the current value', () => {
render(
<StockAutocomplete
value="600519"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('600519');
expect(input).toBeInTheDocument();
});
it('supports the disabled state', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
disabled={true}
/>
);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
});
it('calls onChange when the input changes', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: '600519' } });
expect(mockOnChange).toHaveBeenCalledWith('600519');
});
it('applies a custom class name', () => {
const { container } = render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
className="custom-class"
/>
);
const input = container.querySelector('.custom-class');
expect(input).toBeInTheDocument();
});
it('exposes the expected accessibility attributes', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('aria-autocomplete', 'none');
expect(input).toHaveAttribute('role', 'combobox');
});
describe('fallback mode', () => {
it('renders a plain input when index loading fallback is active', () => {
stockIndexHookImpl = () => ({
index: [],
loading: false,
fallback: true,
error: new Error('Index load failed'),
loaded: false,
});
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByPlaceholderText(/输入股票代码或名称/);
expect(input).toHaveAttribute('data-autocomplete-mode', 'fallback');
});
it('renders a plain input when autocomplete runtime fallback is active', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [],
isOpen: false,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: true,
error: new Error('Search crashed'),
});
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByPlaceholderText(/输入股票代码或名称/);
expect(input).toHaveAttribute('data-autocomplete-mode', 'fallback');
});
it('submits manually when fallback input receives Enter', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [],
isOpen: false,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: true,
error: new Error('Search crashed'),
});
render(
<StockAutocomplete
value="600519"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('600519');
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnSubmit).toHaveBeenCalledWith('600519');
});
});
describe('IME support', () => {
it('handles composition start and end events', () => {
render(
<StockAutocomplete
value=""
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByRole('combobox');
fireEvent.compositionStart(input);
fireEvent.compositionEnd(input);
// The events should be handled without throwing.
expect(input).toBeInTheDocument();
});
});
describe('keyboard submission', () => {
it('submits the raw input when suggestions are open but nothing is highlighted', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: mockSuggestions,
isOpen: true,
highlightedIndex: -1,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: false,
error: null,
});
render(
<StockAutocomplete
value="6005"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('6005');
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnSubmit).toHaveBeenCalledWith('6005');
});
it('submits the highlighted suggestion when one is explicitly selected', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: mockSuggestions,
isOpen: true,
highlightedIndex: 0,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: false,
error: null,
});
render(
<StockAutocomplete
value="6005"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('6005');
fireEvent.keyDown(input, { key: 'Enter' });
expect(mockOnChange).toHaveBeenCalledWith('600519');
expect(mockOnSubmit).toHaveBeenCalledWith('600519.SH', '贵州茅台', 'autocomplete');
});
});
describe('runtime boundary', () => {
it('falls back to the plain input when the autocomplete tree throws during render', () => {
autocompleteHookImpl = () => {
throw new Error('Autocomplete render failed');
};
render(
<StockAutocomplete
value="META"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('META');
expect(input).toHaveAttribute('data-autocomplete-mode', 'fallback');
});
it('falls back to the plain input when a suggestion contains an unsupported market', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [
{
canonicalCode: 'TEST.OTC',
displayCode: 'TEST',
nameZh: '测试市场',
market: 'OTC' as never,
matchType: 'exact' as const,
matchField: 'code' as const,
score: 100,
},
],
isOpen: true,
highlightedIndex: 0,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: false,
error: null,
});
render(
<StockAutocomplete
value="TEST"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);
const input = screen.getByDisplayValue('TEST');
fireEvent.focus(input);
const fallbackInput = screen.getByDisplayValue('TEST');
expect(fallbackInput).toHaveAttribute('data-autocomplete-mode', 'fallback');
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,8 @@
/**
* StockAutocomplete component exports
*/
export { StockAutocomplete } from './StockAutocomplete';
export type { StockAutocompleteProps } from './StockAutocomplete';
export { SuggestionsList } from './SuggestionsList';
export type { SuggestionsListProps } from './SuggestionsList';

View File

@@ -0,0 +1,89 @@
/**
* useAutocomplete hook tests.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAutocomplete } from '../useAutocomplete';
import type { StockIndexItem } from '../../types/stockIndex';
const searchStocksMock = vi.fn();
vi.mock('../../utils/searchStocks', () => ({
searchStocks: (...args: unknown[]) => searchStocksMock(...args),
}));
const mockIndex: StockIndexItem[] = [
{
canonicalCode: '600519.SH',
displayCode: '600519',
nameZh: '贵州茅台',
pinyinFull: 'guizhoumaotai',
pinyinAbbr: 'gzmt',
aliases: ['茅台'],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 100,
},
];
describe('useAutocomplete', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('activates runtime fallback when search throws', () => {
searchStocksMock.mockImplementation(() => {
throw new Error('Search exploded');
});
const { result } = renderHook(() => useAutocomplete(mockIndex, { debounceMs: 10 }));
act(() => {
result.current.setQuery('600519');
});
act(() => {
vi.advanceTimersByTime(10);
});
expect(result.current.runtimeFallback).toBe(true);
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.isOpen).toBe(false);
expect(result.current.suggestions).toEqual([]);
});
it('keeps suggestions open without auto-highlighting the first result', () => {
searchStocksMock.mockReturnValue([
{
canonicalCode: '600519.SH',
displayCode: '600519',
nameZh: '贵州茅台',
market: 'CN',
matchType: 'exact',
matchField: 'code',
score: 100,
},
]);
const { result } = renderHook(() => useAutocomplete(mockIndex, { debounceMs: 10 }));
act(() => {
result.current.setQuery('600519');
});
act(() => {
vi.advanceTimersByTime(10);
});
expect(result.current.isOpen).toBe(true);
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.highlightedIndex).toBe(-1);
});
});

View File

@@ -0,0 +1,199 @@
/**
* useAutocomplete Hook
*
* Manage autocomplete interaction logic
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { StockIndexItem, StockSuggestion } from '../types/stockIndex';
import { searchStocks } from '../utils/searchStocks';
import { SEARCH_CONFIG } from '../utils/stockIndexFields';
export interface UseAutocompleteOptions {
/** Minimum query length */
minLength?: number;
/** Debounce delay (milliseconds) */
debounceMs?: number;
/** Limit on number of results to return */
limit?: number;
}
export interface UseAutocompleteResult {
/** Current query string */
query: string;
/** Set query string */
setQuery: (value: string) => void;
/** Search suggestions list */
suggestions: StockSuggestion[];
/** Whether to show suggestions list */
isOpen: boolean;
/** Highlighted item index */
highlightedIndex: number;
/** Set highlighted item index */
setHighlightedIndex: (index: number) => void;
/** Highlight previous item */
highlightPrevious: () => void;
/** Highlight next item */
highlightNext: () => void;
/** Select suggestion item */
handleSelect: (suggestion: StockSuggestion) => void;
/** Close suggestions list */
close: () => void;
/** Reset state */
reset: () => void;
/** Whether IME is composing */
isComposing: boolean;
/** Set IME composing state */
setIsComposing: (composing: boolean) => void;
/** Whether runtime fallback mode is active */
runtimeFallback: boolean;
/** Runtime error captured from search flow */
error: Error | null;
}
/**
* Autocomplete Hook
*
* @param index - Stock index
* @param options - Configuration options
* @returns Autocomplete state and methods
*/
export function useAutocomplete(
index: StockIndexItem[],
options: UseAutocompleteOptions = {}
): UseAutocompleteResult {
const {
minLength = SEARCH_CONFIG.MIN_QUERY_LENGTH,
debounceMs = SEARCH_CONFIG.DEBOUNCE_MS,
limit = SEARCH_CONFIG.DEFAULT_LIMIT,
} = options;
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState<StockSuggestion[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const [isComposing, setIsComposing] = useState(false);
const [runtimeFallback, setRuntimeFallback] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Use ref to store debounce timer
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Search function (debounced)
const search = useCallback((q: string) => {
if (runtimeFallback) {
return;
}
if (q.length < minLength) {
setSuggestions([]);
setIsOpen(false);
setHighlightedIndex(-1);
return;
}
try {
const results = searchStocks(q, index, { limit });
setSuggestions(results);
setIsOpen(results.length > 0);
setHighlightedIndex(-1);
} catch (caught) {
const runtimeError = caught instanceof Error ? caught : new Error('Autocomplete search failed');
console.error('Autocomplete search failed. Falling back to plain input.', runtimeError);
setError(runtimeError);
setRuntimeFallback(true);
setSuggestions([]);
setIsOpen(false);
setHighlightedIndex(-1);
}
}, [index, minLength, limit, runtimeFallback]);
// Input handling (with debounce)
const handleInputChange = useCallback((value: string) => {
setQuery(value);
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (runtimeFallback) {
return;
}
// Set new timer
debounceTimerRef.current = setTimeout(() => {
search(value);
}, debounceMs);
}, [search, debounceMs, runtimeFallback]);
// Select suggestion item
const handleSelect = useCallback((suggestion: StockSuggestion) => {
setQuery(suggestion.displayCode);
setIsOpen(false);
setSuggestions([]);
setHighlightedIndex(-1);
}, []);
// Highlight previous item
const highlightPrevious = useCallback(() => {
setHighlightedIndex(prev => {
if (prev <= 0) return suggestions.length - 1;
return prev - 1;
});
}, [suggestions.length]);
// Highlight next item
const highlightNext = useCallback(() => {
setHighlightedIndex(prev => {
if (prev >= suggestions.length - 1) return 0;
return prev + 1;
});
}, [suggestions.length]);
// Close dropdown
const close = useCallback(() => {
setIsOpen(false);
setHighlightedIndex(-1);
}, []);
// Reset
const reset = useCallback(() => {
setQuery('');
setSuggestions([]);
setIsOpen(false);
setHighlightedIndex(-1);
}, []);
// Cleanup timer (on component unmount)
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return {
query,
setQuery: handleInputChange,
suggestions,
isOpen,
highlightedIndex,
setHighlightedIndex,
highlightPrevious,
highlightNext,
handleSelect,
close,
reset,
isComposing,
setIsComposing,
runtimeFallback,
error,
};
}
/**
* Get default exported Hook
*/
export default useAutocomplete;

View File

@@ -0,0 +1,74 @@
/**
* useStockIndex Hook
*
* Manage stock index loading and state
*/
import { useState, useEffect } from 'react';
import type { StockIndexItem } from '../types/stockIndex';
import { loadStockIndex } from '../utils/stockIndexLoader';
import type { IndexLoadResult } from '../utils/stockIndexLoader';
export interface UseStockIndexResult {
/** Stock index data */
index: StockIndexItem[];
/** Is loading */
loading: boolean;
/** Load error */
error: Error | null;
/** Whether fallback mode is used */
fallback: boolean;
/** Is loaded */
loaded: boolean;
}
/**
* Stock index loading Hook
*
* @returns Index state and data
*/
export function useStockIndex(): UseStockIndexResult {
const [index, setIndex] = useState<StockIndexItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
setError(null);
const result: IndexLoadResult = await loadStockIndex();
if (mounted) {
setIndex(result.data);
setFallback(result.fallback);
if (result.error) {
setError(result.error);
}
setLoading(false);
}
}
load();
return () => {
mounted = false;
};
}, []);
return {
index,
loading,
error,
fallback, // Whether fallback
loaded: !loading,
};
}
/**
* Get default exported Hook
*/
export default useStockIndex;

View File

@@ -3,7 +3,7 @@ import { analysisApi } from '../api/analysis';
import type { TaskInfo } from '../types/analysis';
/**
* SSE 事件类型
* SSE event types.
*/
export type SSEEventType =
| 'connected'
@@ -14,7 +14,7 @@ export type SSEEventType =
| 'heartbeat';
/**
* SSE 事件数据
* SSE event payload.
*/
export interface SSEEvent {
type: SSEEventType;
@@ -23,57 +23,43 @@ export interface SSEEvent {
}
/**
* SSE Hook 配置
* SSE hook options.
*/
export interface UseTaskStreamOptions {
/** 任务创建回调 */
/** Task created callback */
onTaskCreated?: (task: TaskInfo) => void;
/** 任务开始回调 */
/** Task started callback */
onTaskStarted?: (task: TaskInfo) => void;
/** 任务完成回调 */
/** Task completed callback */
onTaskCompleted?: (task: TaskInfo) => void;
/** 任务失败回调 */
/** Task failed callback */
onTaskFailed?: (task: TaskInfo) => void;
/** 连接成功回调 */
/** Connected callback */
onConnected?: () => void;
/** 连接错误回调 */
/** Connection error callback */
onError?: (error: Event) => void;
/** 是否自动重连 */
/** Whether to reconnect automatically */
autoReconnect?: boolean;
/** 重连延迟(ms) */
/** Reconnect delay in milliseconds */
reconnectDelay?: number;
/** 是否启用 */
/** Whether the hook is enabled */
enabled?: boolean;
}
/**
* SSE Hook 返回值
* SSE hook result.
*/
export interface UseTaskStreamResult {
/** 是否已连接 */
/** Whether the stream is connected */
isConnected: boolean;
/** 手动重连 */
/** Reconnect manually */
reconnect: () => void;
/** 手动断开 */
/** Disconnect manually */
disconnect: () => void;
}
/**
* 任务流 SSE Hook
* 用于接收实时任务状态更新
*
* @example
* ```tsx
* const { isConnected } = useTaskStream({
* onTaskCompleted: (task) => {
* console.log('Task completed:', task);
* refreshHistory();
* },
* onTaskFailed: (task) => {
* showError(task.error);
* },
* });
* ```
* Task-stream SSE hook for realtime task status updates.
*/
export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStreamResult {
const {
@@ -93,7 +79,7 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const connectRef = useRef<() => void>(() => {});
// 使用 ref 存储回调,避免 SSE 连接因回调变化而频繁重连
// Store callbacks in a ref to avoid reconnecting on every render.
const callbacksRef = useRef({
onTaskCreated,
onTaskStarted,
@@ -103,7 +89,7 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
onError,
});
// 每次渲染时更新回调 ref确保事件处理使用最新回调
// Keep the latest callbacks available to the active SSE handlers.
useEffect(() => {
callbacksRef.current = {
onTaskCreated,
@@ -115,7 +101,7 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
};
});
// snake_case 转换为 camelCase
// Convert snake_case payloads into camelCase TaskInfo objects.
const toCamelCase = (data: Record<string, unknown>): TaskInfo => {
return {
taskId: data.task_id as string,
@@ -129,10 +115,12 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
startedAt: data.started_at as string | undefined,
completedAt: data.completed_at as string | undefined,
error: data.error as string | undefined,
originalQuery: data.original_query as string | undefined,
selectionSource: data.selection_source as string | undefined,
};
};
// 解析 SSE 数据
// Parse an SSE payload.
const parseEventData = useCallback((eventData: string): TaskInfo | null => {
try {
const data = JSON.parse(eventData);
@@ -143,7 +131,7 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
}
}, []);
// 创建 EventSource 连接
// Create an EventSource connection.
const connect = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
@@ -153,47 +141,47 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
const eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
// 连接成功
// Connected event
eventSource.addEventListener('connected', () => {
setIsConnected(true);
callbacksRef.current.onConnected?.();
});
// 任务创建
// Task created event
eventSource.addEventListener('task_created', (e) => {
const task = parseEventData(e.data);
if (task) callbacksRef.current.onTaskCreated?.(task);
});
// 任务开始
// Task started event
eventSource.addEventListener('task_started', (e) => {
const task = parseEventData(e.data);
if (task) callbacksRef.current.onTaskStarted?.(task);
});
// 任务完成
// Task completed event
eventSource.addEventListener('task_completed', (e) => {
const task = parseEventData(e.data);
if (task) callbacksRef.current.onTaskCompleted?.(task);
});
// 任务失败
// Task failed event
eventSource.addEventListener('task_failed', (e) => {
const task = parseEventData(e.data);
if (task) callbacksRef.current.onTaskFailed?.(task);
});
// 心跳 - 仅用于保持连接
// Heartbeat event used to keep the connection alive.
eventSource.addEventListener('heartbeat', () => {
// 可选:更新最后心跳时间
// Optional place to record the latest heartbeat timestamp.
});
// 错误处理
// Connection error handling
eventSource.onerror = (error) => {
setIsConnected(false);
callbacksRef.current.onError?.(error);
// 自动重连(通过 ref 避免闭包引用未声明的 connect
// Auto-reconnect via ref to avoid stale closure issues.
if (autoReconnect && enabled) {
eventSource.close();
reconnectTimeoutRef.current = setTimeout(() => {
@@ -212,7 +200,7 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
connectRef.current = connect;
}, [connect]);
// 断开连接setState 延后执行,避免 effect 内同步 setState 触发级联渲染)
// Disconnect and defer the state update to avoid nested renders.
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
@@ -225,13 +213,13 @@ export function useTaskStream(options: UseTaskStreamOptions = {}): UseTaskStream
queueMicrotask(() => setIsConnected(false));
}, []);
// 重连
// Reconnect
const reconnect = useCallback(() => {
disconnect();
connect();
}, [disconnect, connect]);
// 启用/禁用时连接/断开
// Connect or disconnect when the hook is enabled or disabled.
useEffect(() => {
if (enabled) {
connect();

View File

@@ -171,6 +171,7 @@
--home-prose-border-strong: hsl(var(--foreground) / 0.16);
--home-prose-blockquote-border: hsl(247 84% 66% / 0.28);
--home-prose-blockquote-bg: hsl(247 84% 66% / 0.08);
--autocomplete-hover-bg: #04d1f6;
/* Shadows (using HSL format) */
--shadow-soft-card: 0 18px 48px hsl(215 25% 27% / 0.08);
@@ -243,6 +244,7 @@
--color-success: 142 76% 54%;
--color-warning: 35 100% 50%;
--color-danger: 349 100% 63%;
--autocomplete-hover-bg: #2dcae6;
/* Tailwind aliases (for hsl(var(--success))) */
--success: var(--color-success);
--warning: var(--color-warning);

View File

@@ -2,6 +2,7 @@ import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiErrorAlert, ConfirmDialog, Button } from '../components/common';
import { StockAutocomplete } from '../components/StockAutocomplete';
import { HistoryList } from '../components/history';
import { ReportMarkdown, ReportSummary } from '../components/report';
import { TaskPanel } from '../components/tasks';
@@ -123,19 +124,20 @@ const HomePage: React.FC = () => {
</svg>
</button>
<div className="relative min-w-0 flex-1">
<input
type="text"
<StockAutocomplete
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && query && !isAnalyzing) {
event.preventDefault();
void submitAnalysis();
}
onChange={setQuery}
onSubmit={(stockCode, stockName, selectionSource) => {
void submitAnalysis({
stockCode,
stockName,
originalQuery: query,
selectionSource: selectionSource ?? 'manual',
});
}}
placeholder="输入股票代码,如 600519、HK00700、AAPL"
placeholder="输入股票代码或名称,如 600519、贵州茅台、AAPL"
disabled={isAnalyzing}
className={`input-terminal w-full ${inputError ? 'border-danger/50' : ''}`}
className={inputError ? 'border-danger/50' : undefined}
/>
{inputError ? (
<p className="absolute -bottom-4 left-0 text-xs text-danger">{inputError}</p>

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { analysisApi, DuplicateTaskError } from '../../api/analysis';
import { historyApi } from '../../api/history';
import { useStockPoolStore } from '../../stores';
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
import HomePage from '../HomePage';
const navigateMock = vi.fn();
@@ -57,6 +58,7 @@ const historyReport = {
stockCode: '600519',
stockName: '贵州茅台',
reportType: 'detailed' as const,
reportLanguage: 'zh' as const,
createdAt: '2026-03-18T08:00:00Z',
},
summary: {
@@ -97,9 +99,13 @@ describe('HomePage', () => {
expect(dashboard).toBeInTheDocument();
expect(dashboard.className).toContain('h-[calc(100vh-5rem)]');
expect(dashboard.className).toContain('lg:h-[calc(100vh-2rem)]');
expect(screen.getByPlaceholderText('输入股票代码,如 600519、HK00700、AAPL')).toBeInTheDocument();
expect(screen.getByPlaceholderText('输入股票代码或名称,如 600519、贵州茅台、AAPL')).toBeInTheDocument();
expect(await screen.findByText('趋势维持强势')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '详细报告' })).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: getReportText(normalizeReportLanguage(historyReport.meta.reportLanguage)).fullReport,
}),
).toBeInTheDocument();
});
it('shows the empty report workspace when history is empty', async () => {
@@ -138,7 +144,7 @@ describe('HomePage', () => {
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText('输入股票代码,如 600519、HK00700、AAPL');
const input = await screen.findByPlaceholderText('输入股票代码或名称,如 600519、贵州茅台、AAPL');
fireEvent.change(input, { target: { value: '600519' } });
fireEvent.click(screen.getByRole('button', { name: '分析' }));

View File

@@ -120,6 +120,17 @@ describe('stockPoolStore', () => {
expect(state.isAnalyzing).toBe(false);
});
it('rejects obviously invalid mixed alphanumeric input before calling the API', async () => {
useStockPoolStore.getState().setQuery('00aaaaa');
await useStockPoolStore.getState().submitAnalysis();
const state = useStockPoolStore.getState();
expect(state.inputError).toBe('请输入有效的股票代码或股票名称');
expect(state.isAnalyzing).toBe(false);
expect(analysisApi.analyzeAsync).not.toHaveBeenCalled();
});
it('merges newly discovered history items during silent refresh', async () => {
useStockPoolStore.setState({
historyItems: [historyItem],

View File

@@ -5,7 +5,7 @@ import { getParsedApiError } from '../api/error';
import { historyApi } from '../api/history';
import type { AnalysisReport, HistoryItem, HistoryListResponse, TaskInfo } from '../types/analysis';
import { getRecentStartDate, getTodayInShanghai } from '../utils/format';
import { validateStockCode } from '../utils/validation';
import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation';
const PAGE_SIZE = 20;
@@ -17,6 +17,13 @@ type FetchHistoryOptions = {
silent?: boolean;
};
type SubmitAnalysisOptions = {
stockCode?: string;
stockName?: string;
originalQuery?: string;
selectionSource?: SelectionSource;
};
let reportRequestSeq = 0;
let analyzeRequestSeq = 0;
let historyRequestSeq = 0;
@@ -52,7 +59,7 @@ export interface StockPoolState {
toggleHistorySelection: (recordId: number) => void;
toggleSelectAllVisible: () => void;
deleteSelectedHistory: () => Promise<void>;
submitAnalysis: () => Promise<void>;
submitAnalysis: (options?: SubmitAnalysisOptions) => Promise<void>;
syncTaskCreated: (task: TaskInfo) => void;
syncTaskUpdated: (task: TaskInfo) => void;
syncTaskFailed: (task: TaskInfo) => void;
@@ -167,7 +174,8 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
setQuery: (query) => {
set({
query: query.toUpperCase(),
query,
selectionSource: 'manual',
inputError: undefined,
duplicateError: null,
});
@@ -286,13 +294,34 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
}
},
submitAnalysis: async () => {
const { valid, message, normalized } = validateStockCode(get().query);
if (!valid) {
set({ inputError: message, duplicateError: null });
submitAnalysis: async (options) => {
const state = get();
const rawStockCode = options?.stockCode ?? state.query;
const stockCodeInput = rawStockCode.trim();
const stockName = options?.stockName;
const selectionSource = options?.selectionSource ?? state.selectionSource;
const originalQuery = (options?.originalQuery ?? state.query).trim();
if (!stockCodeInput) {
set({ inputError: '请输入股票代码', duplicateError: null });
return;
}
if (selectionSource !== 'autocomplete' && isObviouslyInvalidStockQuery(stockCodeInput)) {
set({ inputError: '请输入有效的股票代码或股票名称', duplicateError: null });
return;
}
let normalizedStockCode = stockCodeInput;
if (selectionSource === 'autocomplete' || looksLikeStockCode(stockCodeInput)) {
const { valid, message, normalized } = validateStockCode(stockCodeInput);
if (!valid) {
set({ inputError: message, duplicateError: null });
return;
}
normalizedStockCode = normalized;
}
set({
inputError: undefined,
duplicateError: null,
@@ -303,8 +332,11 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
const requestId = ++analyzeRequestSeq;
try {
await analysisApi.analyzeAsync({
stockCode: normalized,
stockCode: normalizedStockCode,
reportType: 'detailed',
stockName,
originalQuery: originalQuery || stockCodeInput,
selectionSource,
});
if (requestId !== analyzeRequestSeq) {

View File

@@ -1,9 +1,9 @@
/**
* 股票分析相关类型定义
* 与 API 规范 (api_spec.json) 对齐
* Analysis-related type definitions.
* Aligned with the API schema.
*/
// ============ 请求类型 ============
// ============ Request Types ============
export interface AnalysisRequest {
stockCode?: string;
@@ -11,15 +11,18 @@ export interface AnalysisRequest {
reportType?: 'simple' | 'detailed' | 'full' | 'brief';
forceRefresh?: boolean;
asyncMode?: boolean;
stockName?: string;
originalQuery?: string;
selectionSource?: 'manual' | 'autocomplete' | 'import' | 'image';
}
// ============ 报告类型 ============
// ============ Report Types ============
export type ReportLanguage = 'zh' | 'en';
/** 报告元信息 */
/** Report metadata */
export interface ReportMeta {
id?: number; // 分析历史记录主键 ID历史报告时有此字段
id?: number; // Analysis history record ID, present for persisted reports
queryId: string;
stockCode: string;
stockName: string;
@@ -28,10 +31,10 @@ export interface ReportMeta {
createdAt: string;
currentPrice?: number;
changePct?: number;
modelUsed?: string; // 分析使用的 LLM 模型Issue #528
modelUsed?: string; // LLM model used for analysis
}
/** 情绪标签 */
/** Sentiment label */
export type SentimentLabel =
| '极度悲观'
| '悲观'
@@ -44,7 +47,7 @@ export type SentimentLabel =
| 'Bullish'
| 'Very Bullish';
/** 报告概览区 */
/** Report summary section */
export interface ReportSummary {
analysisSummary: string;
operationAdvice: string;
@@ -53,7 +56,7 @@ export interface ReportSummary {
sentimentLabel?: SentimentLabel;
}
/** 策略点位区 */
/** Strategy section */
export interface ReportStrategy {
idealBuy?: string;
secondaryBuy?: string;
@@ -61,7 +64,7 @@ export interface ReportStrategy {
takeProfit?: string;
}
/** 详情区(可折叠) */
/** Details section */
export interface ReportDetails {
newsContent?: string;
rawResult?: Record<string, unknown>;
@@ -70,7 +73,7 @@ export interface ReportDetails {
dividendMetrics?: Record<string, unknown>;
}
/** 完整分析报告 */
/** Full analysis report */
export interface AnalysisReport {
meta: ReportMeta;
summary: ReportSummary;
@@ -78,9 +81,9 @@ export interface AnalysisReport {
details?: ReportDetails;
}
// ============ 分析结果类型 ============
// ============ Analysis Result Types ============
/** 同步分析返回结果 */
/** Sync analysis response */
export interface AnalysisResult {
queryId: string;
stockCode: string;
@@ -89,7 +92,7 @@ export interface AnalysisResult {
createdAt: string;
}
/** 异步任务接受响应 */
/** Async task accepted response */
export interface TaskAccepted {
taskId: string;
status: 'pending' | 'processing';
@@ -119,16 +122,19 @@ export type AnalyzeAsyncResponse = TaskAccepted | BatchTaskAcceptedResponse;
export type AnalyzeResponse = AnalysisResult | AnalyzeAsyncResponse;
/** 任务状态 */
/** Task status */
export interface TaskStatus {
taskId: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress?: number;
result?: AnalysisResult;
error?: string;
stockName?: string;
originalQuery?: string;
selectionSource?: string;
}
/** 任务详情(用于任务列表和 SSE 事件) */
/** Task details used by task list and SSE events */
export interface TaskInfo {
taskId: string;
stockCode: string;
@@ -141,9 +147,11 @@ export interface TaskInfo {
startedAt?: string;
completedAt?: string;
error?: string;
originalQuery?: string;
selectionSource?: string;
}
/** 任务列表响应 */
/** Task list response */
export interface TaskListResponse {
total: number;
pending: number;
@@ -151,7 +159,7 @@ export interface TaskListResponse {
tasks: TaskInfo[];
}
/** 重复任务错误响应 */
/** Duplicate task error response */
export interface DuplicateTaskError {
error: 'duplicate_task';
message: string;
@@ -159,12 +167,12 @@ export interface DuplicateTaskError {
existingTaskId: string;
}
// ============ 历史记录类型 ============
// ============ History Types ============
/** 历史记录摘要(列表展示用) */
/** History item summary */
export interface HistoryItem {
id: number; // Record primary key ID, always present for persisted history items
queryId: string; // 分析记录关联 query_id批量分析时重复
queryId: string; // Linked analysis query ID
stockCode: string;
stockName?: string;
reportType?: string;
@@ -173,7 +181,7 @@ export interface HistoryItem {
createdAt: string;
}
/** 历史记录列表响应 */
/** History list response */
export interface HistoryListResponse {
total: number;
page: number;
@@ -181,33 +189,33 @@ export interface HistoryListResponse {
items: HistoryItem[];
}
/** 新闻情报条目 */
/** News item */
export interface NewsIntelItem {
title: string;
snippet: string;
url: string;
}
/** 新闻情报响应 */
/** News response */
export interface NewsIntelResponse {
total: number;
items: NewsIntelItem[];
}
/** 历史列表筛选参数 */
/** History filter parameters */
export interface HistoryFilters {
stockCode?: string;
startDate?: string;
endDate?: string;
}
/** 历史列表分页参数 */
/** History pagination parameters */
export interface HistoryPagination {
page: number;
limit: number;
}
// ============ 错误类型 ============
// ============ Error Types ============
export interface ApiError {
error: string;
@@ -215,9 +223,9 @@ export interface ApiError {
detail?: Record<string, unknown>;
}
// ============ 辅助函数 ============
// ============ Helper Functions ============
/** 根据情绪评分获取情绪标签 */
/** Get sentiment label by score */
export const getSentimentLabel = (score: number, language: ReportLanguage = 'zh'): SentimentLabel => {
if (language === 'en') {
if (score <= 20) return 'Very Bearish';
@@ -226,7 +234,6 @@ export const getSentimentLabel = (score: number, language: ReportLanguage = 'zh'
if (score <= 80) return 'Bullish';
return 'Very Bullish';
}
if (score <= 20) return '极度悲观';
if (score <= 40) return '悲观';
if (score <= 60) return '中性';
@@ -234,7 +241,7 @@ export const getSentimentLabel = (score: number, language: ReportLanguage = 'zh'
return '极度乐观';
};
/** 根据情绪评分获取颜色 */
/** Get sentiment color by score */
export const getSentimentColor = (score: number): string => {
if (score <= 20) return '#ef4444'; // red-500
if (score <= 40) return '#f97316'; // orange-500

View File

@@ -0,0 +1,77 @@
/**
* Stock Index Type Definitions
*
* Stock data index for autocomplete functionality
*/
export type Market = 'CN' | 'HK' | 'US' | 'INDEX' | 'ETF' | 'BSE';
export type AssetType = 'stock' | 'index' | 'etf';
/**
* Stock index item (full format)
*/
export interface StockIndexItem {
/** Canonical code: 600519.SH */
canonicalCode: string;
/** Display code: 600519 */
displayCode: string;
/** Chinese name: 贵州茅台 */
nameZh: string;
/** English name: Kweichow Moutai */
nameEn?: string;
/** Pinyin full: guizhoumaotai */
pinyinFull?: string;
/** Pinyin abbreviation: gzmt */
pinyinAbbr?: string;
/** Aliases: ["茅台"] */
aliases?: string[];
/** Market */
market: Market;
/** Asset type */
assetType: AssetType;
/** Is active */
active: boolean;
/** Popularity */
popularity?: number;
}
/**
* Stock search suggestion item
*/
export interface StockSuggestion {
/** Canonical code */
canonicalCode: string;
/** Display code */
displayCode: string;
/** Chinese name */
nameZh: string;
/** Market */
market: Market;
/** Match type */
matchType: 'exact' | 'prefix' | 'contains' | 'fuzzy';
/** Match field */
matchField: 'code' | 'name' | 'pinyin' | 'alias';
/** Sort score */
score: number;
}
/**
* Compressed format stock index item (for reducing file size)
*/
export type StockIndexTuple = [
string, // canonicalCode
string, // displayCode
string, // nameZh
string | undefined, // pinyinFull
string | undefined, // pinyinAbbr
string[], // aliases (required, use empty array if none)
Market,
AssetType,
boolean, // active
number | undefined, // popularity
];
/**
* Stock index data (supports two formats)
*/
export type StockIndexData = StockIndexItem[] | StockIndexTuple[];

View File

@@ -0,0 +1,305 @@
/**
* normalizeQuery Unit Tests
*
* Test various edge cases for query string normalization functions
*/
import {
normalizeQuery,
isChineseChar,
containsChinese,
extractMarketSuffix,
removeMarketSuffix,
normalizeStockCode,
isStockCodeLike,
isStockNameLike,
isPinyinLike,
} from '../normalizeQuery';
import { describe, expect, test } from 'vitest';
describe('normalizeQuery', () => {
describe('normalizeQuery - Query normalization', () => {
test('removes leading and trailing spaces', () => {
expect(normalizeQuery(' 600519 ')).toBe('600519');
expect(normalizeQuery(' 茅台 ')).toBe('茅台');
});
test('converts to lowercase', () => {
expect(normalizeQuery('AAPL')).toBe('aapl');
expect(normalizeQuery('GZMT')).toBe('gzmt');
});
test('removes internal extra spaces', () => {
expect(normalizeQuery('600 519')).toBe('600519');
expect(normalizeQuery('gui zhou mao tai')).toBe('guizhoumaotai');
});
test('combines space and case operations', () => {
expect(normalizeQuery(' AAPL US ')).toBe('aaplus');
});
test('normalizes full-width latin characters to ASCII', () => {
expect(normalizeQuery('万科A')).toBe('万科a');
expect(normalizeQuery('wk')).toBe('wka');
});
test('handles empty strings', () => {
expect(normalizeQuery('')).toBe('');
expect(normalizeQuery(' ')).toBe('');
});
test('preserves special characters', () => {
expect(normalizeQuery('600519.SH')).toBe('600519.sh');
expect(normalizeQuery('00700.HK')).toBe('00700.hk');
});
});
describe('isChineseChar - Chinese character detection', () => {
test('identifies Chinese characters', () => {
expect(isChineseChar('茅')).toBe(true);
expect(isChineseChar('台')).toBe(true);
expect(isChineseChar('股')).toBe(true);
});
test('rejects non-Chinese characters', () => {
expect(isChineseChar('A')).toBe(false);
expect(isChineseChar('1')).toBe(false);
expect(isChineseChar('.')).toBe(false);
expect(isChineseChar(' ')).toBe(false);
});
test('boundary characters: CJK range', () => {
// 一 (\u4e00)
expect(isChineseChar('\u4e00')).toBe(true);
// 龥 (\u9fa5)
expect(isChineseChar('\u9fa5')).toBe(true);
// Out of range
expect(isChineseChar('\u9fa6')).toBe(false);
});
});
describe('containsChinese - Contains Chinese detection', () => {
test('pure Chinese strings', () => {
expect(containsChinese('贵州茅台')).toBe(true);
expect(containsChinese('腾讯')).toBe(true);
});
test('mixed Chinese-English strings', () => {
expect(containsChinese('600519贵州茅台')).toBe(true);
expect(containsChinese('AAPL苹果')).toBe(true);
});
test('pure English strings', () => {
expect(containsChinese('AAPL')).toBe(false);
expect(containsChinese('guizhoumaotai')).toBe(false);
});
test('pure numeric strings', () => {
expect(containsChinese('600519')).toBe(false);
expect(containsChinese('00700')).toBe(false);
});
test('empty strings', () => {
expect(containsChinese('')).toBe(false);
});
});
describe('extractMarketSuffix - Extract market suffix', () => {
test('extracts A-share market suffix', () => {
expect(extractMarketSuffix('600519.SH')).toBe('SH');
expect(extractMarketSuffix('000001.SZ')).toBe('SZ');
});
test('extracts HK stock market suffix', () => {
expect(extractMarketSuffix('00700.HK')).toBe('HK');
});
test('extracts US stock market suffix', () => {
expect(extractMarketSuffix('AAPL.US')).toBe('US');
});
test('returns null for no market suffix', () => {
expect(extractMarketSuffix('600519')).toBeNull();
expect(extractMarketSuffix('AAPL')).toBeNull();
expect(extractMarketSuffix('')).toBeNull();
});
test('handles multiple dots', () => {
expect(extractMarketSuffix('600519.SH.TEST')).toBe('TEST');
});
});
describe('removeMarketSuffix - Remove market suffix', () => {
test('removes A-share market suffix', () => {
expect(removeMarketSuffix('600519.SH')).toBe('600519');
expect(removeMarketSuffix('000001.SZ')).toBe('000001');
});
test('removes HK stock market suffix', () => {
expect(removeMarketSuffix('00700.HK')).toBe('00700');
});
test('removes US stock market suffix', () => {
expect(removeMarketSuffix('AAPL.US')).toBe('AAPL');
});
test('keeps unchanged without market suffix', () => {
expect(removeMarketSuffix('600519')).toBe('600519');
expect(removeMarketSuffix('AAPL')).toBe('AAPL');
});
test('handles empty strings', () => {
expect(removeMarketSuffix('')).toBe('');
});
});
describe('normalizeStockCode - Stock code normalization', () => {
test('converts to uppercase', () => {
expect(normalizeStockCode('aapl')).toBe('AAPL');
expect(normalizeStockCode('gzmt')).toBe('GZMT');
});
test('removes spaces', () => {
expect(normalizeStockCode('600 519')).toBe('600519');
expect(normalizeStockCode('AAPL US')).toBe('AAPLUS');
});
test('preserves market suffix', () => {
expect(normalizeStockCode('600519.SH')).toBe('600519.SH');
expect(normalizeStockCode('AAPL.US')).toBe('AAPL.US');
});
test('removes leading and trailing spaces', () => {
expect(normalizeStockCode(' 600519.SH ')).toBe('600519.SH');
});
test('combines operations', () => {
expect(normalizeStockCode(' aapl.us ')).toBe('AAPL.US');
});
});
describe('isStockCodeLike - Check if looks like stock code', () => {
test('identifies A-share codes', () => {
expect(isStockCodeLike('600519')).toBe(true);
expect(isStockCodeLike('000001')).toBe(true);
expect(isStockCodeLike('300001')).toBe(true);
});
test('identifies codes with market suffix', () => {
expect(isStockCodeLike('600519.SH')).toBe(true);
expect(isStockCodeLike('00700.HK')).toBe(true);
// US stock codes without numbers return false for isStockCodeLike
expect(isStockCodeLike('AAPL.US')).toBe(false);
});
test('handles US stock codes', () => {
// US stock codes without numbers, isStockCodeLike designed for A-share numeric codes
expect(isStockCodeLike('AAPL')).toBe(false);
expect(isStockCodeLike('TSLA')).toBe(false);
// But pure letters should be identified as pinyin
expect(isPinyinLike('AAPL')).toBe(true);
expect(isPinyinLike('TSLA')).toBe(true);
});
test('rejects Chinese names', () => {
expect(isStockCodeLike('贵州茅台')).toBe(false);
expect(isStockCodeLike('腾讯')).toBe(false);
});
test('rejects pinyin', () => {
expect(isStockCodeLike('gzmt')).toBe(false);
expect(isStockCodeLike('maotai')).toBe(false);
});
test('identifies pure numbers', () => {
expect(isStockCodeLike('12345')).toBe(true);
});
test('handles empty strings', () => {
expect(isStockCodeLike('')).toBe(false);
});
});
describe('isStockNameLike - Check if looks like stock name', () => {
test('identifies Chinese names', () => {
expect(isStockNameLike('贵州茅台')).toBe(true);
expect(isStockNameLike('腾讯控股')).toBe(true);
expect(isStockNameLike('平安银行')).toBe(true);
});
test('rejects English codes', () => {
expect(isStockNameLike('AAPL')).toBe(false);
expect(isStockNameLike('600519')).toBe(false);
});
test('rejects pinyin', () => {
expect(isStockNameLike('guizhoumaotai')).toBe(false);
expect(isStockNameLike('tengxun')).toBe(false);
});
test('identifies mixed Chinese-English', () => {
expect(isStockNameLike('贵州茅台600519')).toBe(true);
expect(isStockNameLike('AAPL苹果')).toBe(true);
});
test('handles empty strings', () => {
expect(isStockNameLike('')).toBe(false);
});
});
describe('isPinyinLike - Check if looks like pinyin', () => {
test('identifies pure pinyin', () => {
expect(isPinyinLike('guizhoumaotai')).toBe(true);
expect(isPinyinLike('tengxunkonggu')).toBe(true);
expect(isPinyinLike('pinganyinxing')).toBe(true);
});
test('identifies pinyin abbreviations', () => {
expect(isPinyinLike('gzmt')).toBe(true);
expect(isPinyinLike('txkg')).toBe(true);
expect(isPinyinLike('payh')).toBe(true);
});
test('identifies uppercase pinyin', () => {
expect(isPinyinLike('GZMT')).toBe(true);
expect(isPinyinLike('MAOTAI')).toBe(true);
});
test('rejects numbers', () => {
expect(isPinyinLike('guizhou123')).toBe(false);
expect(isPinyinLike('600519')).toBe(false);
});
test('rejects Chinese characters', () => {
expect(isPinyinLike('茅台maotai')).toBe(false);
expect(isPinyinLike('贵州')).toBe(false);
});
test('handles empty strings', () => {
expect(isPinyinLike('')).toBe(false);
});
test('rejects special characters', () => {
expect(isPinyinLike('maotai-sh')).toBe(false);
expect(isPinyinLike('ping.an')).toBe(false);
});
});
describe('Edge case comprehensive tests', () => {
test('null and undefined', () => {
// TypeScript should catch these at compile time, but runtime needs handling
expect(() => normalizeQuery(null as unknown as string)).toThrow();
expect(() => normalizeQuery(undefined as unknown as string)).toThrow();
});
test('extra long strings', () => {
const longString = 'a'.repeat(10000);
expect(() => normalizeQuery(longString)).not.toThrow();
});
test('special Unicode characters', () => {
expect(normalizeQuery('股票🚀')).toBe('股票🚀');
expect(normalizeQuery('©2023')).toBe('©2023');
});
});
});

View File

@@ -0,0 +1,418 @@
/**
* searchStocks unit tests.
*/
import { searchStocks } from '../searchStocks';
import type { StockIndexItem } from '../../types/stockIndex';
import { describe, expect, test } from 'vitest';
const mockIndex: StockIndexItem[] = [
{
canonicalCode: "600519.SH",
displayCode: "600519",
nameZh: "贵州茅台",
pinyinFull: "guizhoumaotai",
pinyinAbbr: "gzmt",
aliases: ["茅台"],
market: "CN",
assetType: "stock",
active: true,
popularity: 100,
},
{
canonicalCode: "000001.SZ",
displayCode: "000001",
nameZh: "平安银行",
pinyinFull: "pinganyinxing",
pinyinAbbr: "payh",
aliases: ["平银"],
market: "CN",
assetType: "stock",
active: true,
popularity: 90,
},
{
canonicalCode: "000002.SZ",
displayCode: "000002",
nameZh: "万科A",
pinyinFull: "wanke",
pinyinAbbr: "wk",
aliases: [],
market: "CN",
assetType: "stock",
active: true,
popularity: 92,
},
{
canonicalCode: "00700.HK",
displayCode: "00700",
nameZh: "腾讯控股",
pinyinFull: "tengxunkonggu",
pinyinAbbr: "txkg",
aliases: ["腾讯"],
market: "HK",
assetType: "stock",
active: true,
popularity: 95,
},
{
canonicalCode: "AAPL.US",
displayCode: "AAPL",
nameZh: "苹果",
pinyinFull: "pingguo",
pinyinAbbr: "pg",
aliases: [],
market: "US",
assetType: "stock",
active: true,
popularity: 98,
},
{
canonicalCode: "600000.SH",
displayCode: "600000",
nameZh: "浦发银行",
pinyinFull: "pufayinxing",
pinyinAbbr: "pfyh",
aliases: ["浦发"],
market: "CN",
assetType: "stock",
active: false, // Inactive
popularity: 80,
},
];
describe('searchStocks', () => {
test('精确匹配代码', () => {
const results = searchStocks('600519', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600519.SH');
expect(results[0].matchType).toBe('exact');
expect(results[0].matchField).toBe('code');
});
test('精确匹配中文名称', () => {
const results = searchStocks('贵州茅台', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600519.SH');
expect(results[0].matchType).toBe('exact');
expect(results[0].matchField).toBe('name');
});
test('拼音首字母匹配', () => {
const results = searchStocks('gzmt', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600519.SH');
expect(results[0].matchType).toBe('exact');
});
test('别名匹配', () => {
const results = searchStocks('茅台', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600519.SH');
expect(results[0].matchType).toBe('exact');
});
test('前缀匹配代码', () => {
const results = searchStocks('600', mockIndex);
expect(results.length).toBeGreaterThan(0);
expect(results[0].matchType).toBe('prefix');
expect(results[0].matchField).toBe('code');
});
test('前缀匹配名称', () => {
const results = searchStocks('贵州', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].matchType).toBe('prefix');
expect(results[0].matchField).toBe('name');
});
test('包含匹配拼音', () => {
const results = searchStocks('maotai', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600519.SH');
expect(results[0].matchType).toBe('contains');
});
test('active 优先于 inactive', () => {
// 600000 是不活跃的600519 是活跃的
const results = searchStocks('600', mockIndex);
const activeResults = results.filter(r => {
const item = mockIndex.find(i => i.canonicalCode === r.canonicalCode);
return item?.active;
});
// 活跃股票应该排在前面
if (results.length > 1) {
expect(activeResults.length).toBeGreaterThan(0);
}
});
test('activeOnly 选项过滤不活跃股票', () => {
const results = searchStocks('600', mockIndex, { activeOnly: true });
for (const result of results) {
const item = mockIndex.find(i => i.canonicalCode === result.canonicalCode);
expect(item?.active).toBe(true);
}
});
test('limit 选项限制返回数量', () => {
const results = searchStocks('600', mockIndex, { limit: 1 });
expect(results.length).toBeLessThanOrEqual(1);
});
test('无结果时返回空数组', () => {
const results = searchStocks('NOTFOUND', mockIndex);
expect(results).toHaveLength(0);
});
test('空查询返回空数组', () => {
const results = searchStocks('', mockIndex);
expect(results).toHaveLength(0);
});
test('大小写不敏感', () => {
const results1 = searchStocks('aapl', mockIndex);
const results2 = searchStocks('AAPL', mockIndex);
expect(results1).toHaveLength(1);
expect(results2).toHaveLength(1);
expect(results1[0].canonicalCode).toBe(results2[0].canonicalCode);
});
test('sorts by popularity when scores are tied', () => {
const results = searchStocks('600', mockIndex);
// When scores tie, popularity should decide the order.
if (results.length > 1) {
for (let index = 0; index < results.length - 1; index++) {
const currentItem = mockIndex.find((item) => item.canonicalCode === results[index].canonicalCode);
const nextItem = mockIndex.find((item) => item.canonicalCode === results[index + 1].canonicalCode);
if (results[index].score === results[index + 1].score) {
expect((currentItem?.popularity || 0)).toBeGreaterThanOrEqual(nextItem?.popularity || 0);
}
}
}
});
test('美股代码匹配', () => {
const results = searchStocks('AAPL', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('AAPL.US');
expect(results[0].market).toBe('US');
});
test('supports half-width queries for full-width A-share suffix names', () => {
const byName = searchStocks('万科A', mockIndex);
const byPinyin = searchStocks('wka', mockIndex);
expect(byName[0].canonicalCode).toBe('000002.SZ');
expect(byPinyin[0].canonicalCode).toBe('000002.SZ');
});
test('港股代码匹配', () => {
const results = searchStocks('00700', mockIndex);
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('00700.HK');
expect(results[0].market).toBe('HK');
});
describe('Edge case tests', () => {
test('special character query', () => {
const results = searchStocks('@#$%', mockIndex);
expect(results).toHaveLength(0);
});
test('pure space query', () => {
const results = searchStocks(' ', mockIndex);
expect(results).toHaveLength(0);
});
test('Unicode character query', () => {
const results = searchStocks('股票🚀', mockIndex);
expect(results).toHaveLength(0);
});
test('extra long query string', () => {
const longQuery = 'a'.repeat(1000);
const results = searchStocks(longQuery, mockIndex);
expect(results).toHaveLength(0);
});
test('partial pinyin match', () => {
const results = searchStocks('mao', mockIndex);
expect(results.length).toBeGreaterThan(0);
const hasMaoTai = results.some(r => r.canonicalCode === '600519.SH');
expect(hasMaoTai).toBe(true);
});
test('abbreviation prefix match', () => {
const results = searchStocks('gz', mockIndex);
expect(results.length).toBeGreaterThan(0);
expect(results[0].matchType).toBe('prefix');
});
test('alias match', () => {
const results = searchStocks('银', mockIndex);
expect(results.length).toBeGreaterThan(0);
// Should match 平安银行 and 浦发银行
const banks = results.filter(r => r.nameZh.includes('银行'));
expect(banks.length).toBeGreaterThan(0);
});
});
describe('Scoring system tests', () => {
test('exact match has highest score', () => {
const exactResults = searchStocks('600519', mockIndex);
const prefixResults = searchStocks('600', mockIndex);
expect(exactResults[0].score).toBeGreaterThan(prefixResults[0].score);
});
test('code match prioritized over name match', () => {
const codeResults = searchStocks('600519', mockIndex);
const nameResults = searchStocks('贵州', mockIndex);
// Code exact match should be 99 points (displayCode match)
expect(codeResults[0].score).toBe(99);
// Name prefix match should be less than 99 points
expect(nameResults[0].score).toBeLessThan(99);
});
test('sorts by popularity when scores are equal', () => {
// Add two stocks with same score
const tieIndex: StockIndexItem[] = [
{
canonicalCode: 'TEST1.SH',
displayCode: 'TEST1',
nameZh: '测试1',
pinyinFull: 'test1',
pinyinAbbr: 'ts1',
aliases: [],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 50,
},
{
canonicalCode: 'TEST2.SH',
displayCode: 'TEST2',
nameZh: '测试2',
pinyinFull: 'test2',
pinyinAbbr: 'ts2',
aliases: [],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 100,
},
];
const results = searchStocks('TEST', tieIndex);
if (results.length > 1) {
// TEST2 should rank first due to higher popularity
expect(results[0].canonicalCode).toBe('TEST2.SH');
}
});
});
describe('Inactive stock tests', () => {
test('filters out inactive stocks by default', () => {
const results = searchStocks('600000', mockIndex);
// 600000 is inactive, should not appear by default
expect(results).toHaveLength(0);
});
test('shows inactive stocks when activeOnly=false', () => {
const results = searchStocks('600000', mockIndex, { activeOnly: false });
expect(results).toHaveLength(1);
expect(results[0].canonicalCode).toBe('600000.SH');
});
test('active stocks prioritized over inactive stocks', () => {
const results = searchStocks('600', mockIndex, { activeOnly: false });
if (results.length > 1) {
// First result should be active
const firstItem = mockIndex.find(i => i.canonicalCode === results[0].canonicalCode);
expect(firstItem?.active).toBe(true);
}
});
});
describe('Performance tests', () => {
test('large index search performance', () => {
// Create a large index
const largeIndex: StockIndexItem[] = Array.from({ length: 5000 }, (_, i) => ({
canonicalCode: `${i}.SH`,
displayCode: `${i}`,
nameZh: `股票${i}`,
pinyinFull: `stock${i}`,
pinyinAbbr: `s${i}`,
aliases: [],
market: 'CN',
assetType: 'stock',
active: true,
popularity: i % 100,
}));
const startTime = Date.now();
const results = searchStocks('1', largeIndex);
const endTime = Date.now();
// Should complete in reasonable time (< 100ms)
expect(endTime - startTime).toBeLessThan(100);
expect(results.length).toBeGreaterThan(0);
});
test('multiple search performance', () => {
const iterations = 100;
const startTime = Date.now();
for (let i = 0; i < iterations; i++) {
searchStocks('600', mockIndex);
}
const endTime = Date.now();
const avgTime = (endTime - startTime) / iterations;
// Average search should be fast (< 10ms)
expect(avgTime).toBeLessThan(10);
});
});
describe('Match type tests', () => {
test('exact match type', () => {
const results = searchStocks('600519', mockIndex);
expect(results[0].matchType).toBe('exact');
});
test('prefix match type', () => {
const results = searchStocks('600', mockIndex);
expect(results[0].matchType).toBe('prefix');
});
test('contains match type', () => {
const results = searchStocks('maotai', mockIndex);
expect(results[0].matchType).toBe('contains');
});
});
describe('Match field tests', () => {
test('code field match', () => {
const results = searchStocks('600519', mockIndex);
expect(results[0].matchField).toBe('code');
});
test('name field match', () => {
const results = searchStocks('贵州', mockIndex);
expect(results[0].matchField).toBe('name');
});
test('pinyin field match', () => {
const results = searchStocks('gzmt', mockIndex);
expect(results[0].matchField).toBe('pinyin');
});
test('alias field match', () => {
const results = searchStocks('茅台', mockIndex);
// Should match 贵州茅台
expect(results.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,485 @@
/**
* stockIndexLoader Unit Tests
*
* Test stock index loading, parsing, compression, and other functions
*/
import {
loadStockIndex,
compressIndex,
findStockInIndex,
getPopularStocks,
groupStocksByMarket,
} from '../stockIndexLoader';
import type { StockIndexItem } from '../../types/stockIndex';
import { beforeEach, describe, expect, test, vi } from 'vitest';
// Mock fetch
const mockFetch = vi.fn();
globalThis.fetch = mockFetch as unknown as typeof fetch;
describe('stockIndexLoader', () => {
const mockIndexData: StockIndexItem[] = [
{
canonicalCode: '600519.SH',
displayCode: '600519',
nameZh: '贵州茅台',
pinyinFull: 'guizhoumaotai',
pinyinAbbr: 'gzmt',
aliases: ['茅台'],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 100,
},
{
canonicalCode: '000001.SZ',
displayCode: '000001',
nameZh: '平安银行',
pinyinFull: 'pinganyinxing',
pinyinAbbr: 'payh',
aliases: ['平银'],
market: 'CN',
assetType: 'stock',
active: true,
popularity: 90,
},
{
canonicalCode: '00700.HK',
displayCode: '00700',
nameZh: '腾讯控股',
pinyinFull: 'tengxunkonggu',
pinyinAbbr: 'txkg',
aliases: ['腾讯'],
market: 'HK',
assetType: 'stock',
active: true,
popularity: 95,
},
{
canonicalCode: 'AAPL.US',
displayCode: 'AAPL',
nameZh: '苹果',
pinyinFull: 'pingguo',
pinyinAbbr: 'pg',
aliases: [],
market: 'US',
assetType: 'stock',
active: true,
popularity: 98,
},
{
canonicalCode: '600000.SH',
displayCode: '600000',
nameZh: '浦发银行',
pinyinFull: 'pufayinxing',
pinyinAbbr: 'pfyh',
aliases: ['浦发'],
market: 'CN',
assetType: 'stock',
active: false,
popularity: 80,
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('loadStockIndex - Load stock index', () => {
test('successfully loads object format index', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockIndexData,
} as unknown as Response);
const result = await loadStockIndex();
expect(result.loaded).toBe(true);
expect(result.fallback).toBe(false);
expect(result.data).toEqual(mockIndexData);
expect(result.error).toBeUndefined();
});
test('successfully loads compressed format index (tuple format)', async () => {
const compressedData = [
['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100],
['000001.SZ', '000001', '平安银行', 'pinganyinxing', 'payh', ['平银'], 'CN', 'stock', true, 90],
];
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => compressedData,
} as unknown as Response);
const result = await loadStockIndex();
expect(result.loaded).toBe(true);
expect(result.fallback).toBe(false);
expect(result.data).toHaveLength(2);
expect(result.data[0].canonicalCode).toBe('600519.SH');
expect(result.data[0].nameZh).toBe('贵州茅台');
});
test('returns fallback mode on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const result = await loadStockIndex();
expect(result.loaded).toBe(false);
expect(result.fallback).toBe(true);
expect(result.data).toEqual([]);
expect(result.error).toBeInstanceOf(Error);
});
test('returns fallback mode on HTTP error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
} as unknown as Response);
const result = await loadStockIndex();
expect(result.loaded).toBe(false);
expect(result.fallback).toBe(true);
expect(result.data).toEqual([]);
expect(result.error).toBeDefined();
});
test('returns fallback mode on JSON parse error', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => {
throw new Error('Invalid JSON');
},
} as unknown as Response);
const result = await loadStockIndex();
expect(result.loaded).toBe(false);
expect(result.fallback).toBe(true);
expect(result.data).toEqual([]);
expect(result.error).toBeDefined();
});
test('handles empty array', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => [],
} as unknown as Response);
const result = await loadStockIndex();
expect(result.loaded).toBe(true);
expect(result.fallback).toBe(false);
expect(result.data).toEqual([]);
});
test('fetch call includes cache-busting parameter', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockIndexData,
} as unknown as Response);
await loadStockIndex();
const fetchCallArgs = mockFetch.mock.calls[0][0];
expect(fetchCallArgs).toContain('?_t=');
});
});
describe('compressIndex - Compress index', () => {
test('converts object format to tuple format', () => {
const compressed = compressIndex(mockIndexData);
expect(compressed).toHaveLength(mockIndexData.length);
expect(compressed[0]).toEqual([
'600519.SH',
'600519',
'贵州茅台',
'guizhoumaotai',
'gzmt',
['茅台'],
'CN',
'stock',
true,
100,
]);
});
test('handles empty aliases array', () => {
const itemWithoutAliases: StockIndexItem[] = [
{
canonicalCode: 'TEST.US',
displayCode: 'TEST',
nameZh: '测试',
pinyinFull: 'test',
pinyinAbbr: 'test',
aliases: [],
market: 'US',
assetType: 'stock',
active: true,
popularity: 50,
},
];
const compressed = compressIndex(itemWithoutAliases);
expect(compressed[0][5]).toEqual([]);
});
test('handles undefined aliases', () => {
const itemWithUndefinedAliases: StockIndexItem[] = [
{
canonicalCode: 'TEST.US',
displayCode: 'TEST',
nameZh: '测试',
pinyinFull: 'test',
pinyinAbbr: 'test',
aliases: undefined as unknown as string[],
market: 'US',
assetType: 'stock',
active: true,
popularity: 50,
},
];
const compressed = compressIndex(itemWithUndefinedAliases);
expect(compressed[0][5]).toEqual([]);
});
test('handles empty array', () => {
const compressed = compressIndex([]);
expect(compressed).toEqual([]);
});
});
describe('findStockInIndex - Find stock', () => {
test('finds existing stock', () => {
const result = findStockInIndex('600519.SH', mockIndexData);
expect(result).not.toBeNull();
expect(result?.canonicalCode).toBe('600519.SH');
expect(result?.nameZh).toBe('贵州茅台');
});
test('returns null for non-existent stock', () => {
const result = findStockInIndex('NOTFOUND.US', mockIndexData);
expect(result).toBeNull();
});
test('finds inactive stock', () => {
const result = findStockInIndex('600000.SH', mockIndexData);
expect(result).not.toBeNull();
expect(result?.active).toBe(false);
});
test('handles empty index', () => {
const result = findStockInIndex('600519.SH', []);
expect(result).toBeNull();
});
test('case-sensitive search', () => {
const result = findStockInIndex('600519.sh', mockIndexData);
expect(result).toBeNull();
});
});
describe('getPopularStocks - Get popular stocks', () => {
test('sorts by popularity descending', () => {
const result = getPopularStocks(mockIndexData, 3);
expect(result).toHaveLength(3);
expect(result[0].canonicalCode).toBe('600519.SH'); // popularity: 100
expect(result[1].canonicalCode).toBe('AAPL.US'); // popularity: 98
expect(result[2].canonicalCode).toBe('00700.HK'); // popularity: 95
});
test('filters out inactive stocks', () => {
const result = getPopularStocks(mockIndexData, 10);
// 600000.SH is inactive, should not appear
const hasInactive = result.some(item => !item.active);
expect(hasInactive).toBe(false);
});
test('limits return count', () => {
const result = getPopularStocks(mockIndexData, 2);
expect(result.length).toBeLessThanOrEqual(2);
});
test('defaults to limit of 20', () => {
const result = getPopularStocks(mockIndexData);
expect(result.length).toBeLessThanOrEqual(20);
});
test('handles empty index', () => {
const result = getPopularStocks([]);
expect(result).toEqual([]);
});
test('handles all inactive stocks', () => {
const inactiveOnly: StockIndexItem[] = [
{
canonicalCode: 'TEST.US',
displayCode: 'TEST',
nameZh: '测试',
pinyinFull: 'test',
pinyinAbbr: 'test',
aliases: [],
market: 'US',
assetType: 'stock',
active: false,
popularity: 100,
},
];
const result = getPopularStocks(inactiveOnly);
expect(result).toEqual([]);
});
test('maintains stable sorting for same popularity', () => {
const samePopularity: StockIndexItem[] = [
{
canonicalCode: 'A.US',
displayCode: 'A',
nameZh: 'A',
pinyinFull: 'a',
pinyinAbbr: 'a',
aliases: [],
market: 'US',
assetType: 'stock',
active: true,
popularity: 100,
},
{
canonicalCode: 'B.US',
displayCode: 'B',
nameZh: 'B',
pinyinFull: 'b',
pinyinAbbr: 'b',
aliases: [],
market: 'US',
assetType: 'stock',
active: true,
popularity: 100,
},
];
const result = getPopularStocks(samePopularity, 2);
expect(result).toHaveLength(2);
expect(result[0].popularity).toBe(100);
expect(result[1].popularity).toBe(100);
});
});
describe('groupStocksByMarket - Group stocks by market', () => {
test('groups different markets correctly', () => {
const result = groupStocksByMarket(mockIndexData);
expect(result.size).toBe(3); // CN, HK, US
expect(result.get('CN')).toHaveLength(2);
expect(result.get('HK')).toHaveLength(1);
expect(result.get('US')).toHaveLength(1);
});
test('filters out inactive stocks', () => {
const result = groupStocksByMarket(mockIndexData);
const cnStocks = result.get('CN')!;
const allActive = cnStocks.every(item => item.active);
expect(allActive).toBe(true);
});
test('handles empty index', () => {
const result = groupStocksByMarket([]);
expect(result.size).toBe(0);
});
test('handles all inactive stocks', () => {
const inactiveOnly: StockIndexItem[] = [
{
canonicalCode: 'A.US',
displayCode: 'A',
nameZh: 'A',
pinyinFull: 'a',
pinyinAbbr: 'a',
aliases: [],
market: 'US',
assetType: 'stock',
active: false,
popularity: 100,
},
];
const result = groupStocksByMarket(inactiveOnly);
expect(result.size).toBe(0);
});
test('returns independent arrays for groups', () => {
const result = groupStocksByMarket(mockIndexData);
const cnStocks = result.get('CN')!;
const originalLength = cnStocks.length;
// Modifying returned array should not affect original data
cnStocks.pop();
const result2 = groupStocksByMarket(mockIndexData);
const cnStocks2 = result2.get('CN')!;
expect(cnStocks2.length).toBe(originalLength);
});
test('maintains order within groups', () => {
const result = groupStocksByMarket(mockIndexData);
const cnStocks = result.get('CN')!;
expect(cnStocks[0].canonicalCode).toBe('600519.SH');
expect(cnStocks[1].canonicalCode).toBe('000001.SZ');
});
});
describe('Edge case comprehensive tests', () => {
test('handles very large datasets', () => {
const largeIndex: StockIndexItem[] = Array.from({ length: 10000 }, (_, i) => ({
canonicalCode: `TEST${i}.US`,
displayCode: `TEST${i}`,
nameZh: `测试${i}`,
pinyinFull: `test${i}`,
pinyinAbbr: `t${i}`,
aliases: [],
market: 'US',
assetType: 'stock',
active: i % 2 === 0,
popularity: i % 100,
}));
expect(() => compressIndex(largeIndex)).not.toThrow();
expect(() => findStockInIndex('TEST5000.US', largeIndex)).not.toThrow();
expect(() => getPopularStocks(largeIndex, 10)).not.toThrow();
});
test('handles special characters', () => {
const specialChars: StockIndexItem[] = [
{
canonicalCode: 'TEST.US',
displayCode: 'TEST',
nameZh: '测试·公司',
pinyinFull: 'test-gongsi',
pinyinAbbr: 'test',
aliases: ['测试(集团)'],
market: 'US',
assetType: 'stock',
active: true,
popularity: 50,
},
];
const compressed = compressIndex(specialChars);
expect(compressed[0][2]).toBe('测试·公司');
expect(compressed[0][5]).toEqual(['测试(集团)']);
});
});
});

View File

@@ -0,0 +1,87 @@
/**
* Query Normalization Utility Functions
*
* For processing user input stock codes or names
*/
/**
* Normalize query string
* - Remove leading/trailing spaces
* - Convert to lowercase
* - Remove internal extra spaces
*/
export function normalizeQuery(query: string): string {
return query
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/\s+/g, '');
}
/**
* Check if character is Chinese
*/
export function isChineseChar(char: string): boolean {
return /[\u4e00-\u9fa5]/.test(char);
}
/**
* Check if string contains Chinese characters
*/
export function containsChinese(query: string): boolean {
return Array.from(query).some(isChineseChar);
}
/**
* Extract market suffix from stock code
* Example: 600519.SH -> SH, 00700.HK -> HK
*/
export function extractMarketSuffix(code: string): string | null {
const match = code.match(/\.([A-Z]+)$/);
return match ? match[1] : null;
}
/**
* Remove market suffix from stock code
* Example: 600519.SH -> 600519, 00700.HK -> 00700
*/
export function removeMarketSuffix(code: string): string {
return code.replace(/\.[A-Z]+$/, '');
}
/**
* Normalize stock code
* - Convert to uppercase
* - Remove spaces
* - Keep market suffix
*/
export function normalizeStockCode(code: string): string {
return code.trim().toUpperCase().replace(/\s+/g, '');
}
/**
* Check if query looks like a stock code
* By detecting if it contains numbers or letter combinations
*/
export function isStockCodeLike(query: string): boolean {
const normalized = normalizeQuery(query);
// Contains numbers and no Chinese, possibly a stock code
return /\d/.test(normalized) && !containsChinese(normalized);
}
/**
* Check if query looks like a stock name
* By detecting if it contains Chinese
*/
export function isStockNameLike(query: string): boolean {
return containsChinese(query);
}
/**
* Check if query looks like pinyin
* By detecting if it only contains letters and no Chinese
*/
export function isPinyinLike(query: string): boolean {
const normalized = normalizeQuery(query);
return /^[a-z]+$/.test(normalized) && !containsChinese(query);
}

View File

@@ -0,0 +1,186 @@
/**
* Stock Search Algorithm
*
* Supports multiple matching methods:
* - Exact match: code, name, pinyin, alias
* - Prefix match: code prefix, name prefix, pinyin prefix
* - Contains match: code contains, name contains, pinyin contains
*/
import type { StockIndexItem, StockSuggestion } from '../types/stockIndex';
import { normalizeQuery } from './normalizeQuery';
import { MATCH_SCORE, SEARCH_CONFIG } from './stockIndexFields';
export interface SearchOptions {
/** Limit on number of results to return */
limit?: number;
/** Show only active stocks */
activeOnly?: boolean;
}
/**
* Search stock index
*
* @param query - Search query
* @param index - Stock index
* @param options - Search options
* @returns List of matched stock suggestions
*/
export function searchStocks(
query: string,
index: StockIndexItem[],
options: SearchOptions = {}
): StockSuggestion[] {
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) {
return [];
}
const limit = options.limit || SEARCH_CONFIG.DEFAULT_LIMIT;
const activeOnly = options.activeOnly !== false;
// Filter index
const filteredIndex = index.filter(item => {
if (activeOnly && !item.active) return false;
return true;
});
// Calculate match score for each item
const suggestions = filteredIndex.map(item => ({
item,
score: calculateMatchScore(normalizedQuery, item),
}));
// Filter out items with score of 0
const matched = suggestions.filter(s => s.score > 0);
// Sort: by score descending, then by popularity descending for same score
matched.sort((a, b) => {
if (a.score !== b.score) return b.score - a.score;
return (b.item.popularity || 0) - (a.item.popularity || 0);
});
// Return top N items
return matched.slice(0, limit).map(s => ({
canonicalCode: s.item.canonicalCode,
displayCode: s.item.displayCode,
nameZh: s.item.nameZh,
market: s.item.market,
matchType: determineMatchType(s.score),
matchField: determineMatchField(normalizedQuery, s.item),
score: s.score,
}));
}
/**
* Calculate match score
*
* Score rules:
* - 100: Exact match canonical code
* - 99: Exact match display code
* - 98: Exact match Chinese name
* - 97: Exact match alias
* - 96: Exact match pinyin abbreviation
* - 80-89: Prefix match
* - 60-69: Contains match
* - 0: No match
*/
function calculateMatchScore(query: string, item: StockIndexItem): number {
let score = 0;
const q = query.toLowerCase();
const normalizedCanonicalCode = normalizeQuery(item.canonicalCode);
const normalizedDisplayCode = normalizeQuery(item.displayCode);
const normalizedName = normalizeQuery(item.nameZh);
const normalizedPinyinFull = normalizeQuery(item.pinyinFull || '');
const normalizedPinyinAbbr = normalizeQuery(item.pinyinAbbr || '');
const normalizedAliases = item.aliases?.map(alias => normalizeQuery(alias)) || [];
// 1. Exact match (96-100 points)
if (q === normalizedCanonicalCode) return 100;
if (q === normalizedDisplayCode) return 99;
if (q === normalizedName) return 98;
if (normalizedAliases.some(a => a === q)) return 97;
if (q === normalizedPinyinAbbr) return 96;
// 2. Prefix match (77-80 points)
if (normalizedDisplayCode.startsWith(q)) score = Math.max(score, 80);
if (normalizedName.startsWith(q)) score = Math.max(score, 79);
if (normalizedPinyinAbbr.startsWith(q)) score = Math.max(score, 78);
if (normalizedAliases.some(a => a.startsWith(q))) score = Math.max(score, 77);
// 3. Contains match (57-60 points)
if (normalizedDisplayCode.includes(q)) score = Math.max(score, 60);
if (normalizedName.includes(q)) score = Math.max(score, 59);
if (normalizedPinyinFull.includes(q)) score = Math.max(score, 58);
if (normalizedAliases.some(a => a.includes(q))) score = Math.max(score, 57);
return score;
}
/**
* Determine match type based on score
*/
function determineMatchType(score: number): 'exact' | 'prefix' | 'contains' | 'fuzzy' {
if (score >= MATCH_SCORE.EXACT_MIN) return 'exact';
if (score >= MATCH_SCORE.PREFIX_MIN) return 'prefix';
if (score >= MATCH_SCORE.CONTAINS_MIN) return 'contains';
return 'fuzzy';
}
/**
* Determine match field
*/
function determineMatchField(query: string, item: StockIndexItem): 'code' | 'name' | 'pinyin' | 'alias' {
const q = query.toLowerCase();
const normalizedCanonicalCode = normalizeQuery(item.canonicalCode);
const normalizedDisplayCode = normalizeQuery(item.displayCode);
const normalizedName = normalizeQuery(item.nameZh);
const normalizedPinyinFull = normalizeQuery(item.pinyinFull || '');
const normalizedPinyinAbbr = normalizeQuery(item.pinyinAbbr || '');
const normalizedAliases = item.aliases?.map(alias => normalizeQuery(alias)) || [];
if (normalizedCanonicalCode.includes(q) ||
normalizedDisplayCode.includes(q)) {
return 'code';
}
if (normalizedName.includes(q)) return 'name';
if (normalizedPinyinFull.includes(q) ||
normalizedPinyinAbbr.includes(q)) {
return 'pinyin';
}
if (normalizedAliases.some(a => a.includes(q))) return 'alias';
return 'name';
}
/**
* Escape HTML entities
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Highlight matched text
*
* @param text - Original text
* @param query - Query string
* @returns Safe HTML string with highlight markers
*/
export function highlightMatch(text: string, query: string): string {
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) return escapeHtml(text);
const index = text.toLowerCase().indexOf(normalizedQuery);
if (index === -1) return escapeHtml(text);
const before = text.substring(0, index);
const match = text.substring(index, index + normalizedQuery.length);
const after = text.substring(index + normalizedQuery.length);
// Return escaped segments joined by safe <mark> tags
return `${escapeHtml(before)}<mark>${escapeHtml(match)}</mark>${escapeHtml(after)}`;
}

View File

@@ -0,0 +1,54 @@
/**
* Stock Index Field Constant Definitions
*
* For index data compression/decompression processing
*/
export const STOCK_INDEX_FIELDS = [
'canonicalCode',
'displayCode',
'nameZh',
'pinyinFull',
'pinyinAbbr',
'aliases',
'market',
'assetType',
'active',
'popularity',
] as const;
/**
* Field indices for compressed format
*/
export const INDEX_FIELD = {
CANONICAL_CODE: 0,
DISPLAY_CODE: 1,
NAME_ZH: 2,
PINYIN_FULL: 3,
PINYIN_ABBR: 4,
ALIASES: 5,
MARKET: 6,
ASSET_TYPE: 7,
ACTIVE: 8,
POPULARITY: 9,
} as const;
/**
* Match score thresholds
*/
export const MATCH_SCORE = {
EXACT_MIN: 96, // Minimum score for exact match
PREFIX_MIN: 77, // Minimum score for prefix match
CONTAINS_MIN: 57, // Minimum score for contains match
FUZZY_MIN: 1, // Minimum score for fuzzy match
} as const;
/**
* Search configuration
*/
export const SEARCH_CONFIG = {
DEFAULT_LIMIT: 10, // Default number of results to return
DEBOUNCE_MS: 200, // Debounce delay (milliseconds)
MIN_QUERY_LENGTH: 2, // Minimum query length
ACTIVE_ONLY: true, // Show only active stocks
} as const;

View File

@@ -0,0 +1,161 @@
/**
* Stock Index Loader
*
* Responsible for loading and parsing stock index data
*/
import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex';
import { INDEX_FIELD } from './stockIndexFields';
export interface IndexLoadResult {
/** Index data */
data: StockIndexItem[];
/** Successfully loaded */
loaded: boolean;
/** Error information */
error?: Error;
/** Whether fallback mode is used */
fallback: boolean;
}
/**
* Load stock index
*
* @returns Index load result
*/
export async function loadStockIndex(): Promise<IndexLoadResult> {
try {
// Add time parameter to bypass cache (in case the backend doesn't handle ETag/Cache-Control)
const response = await fetch(`/stocks.index.json?_t=${Math.floor(Date.now() / 3600000)}`);
if (!response.ok) {
throw new Error(`Failed to load index: ${response.status} ${response.statusText}`);
}
const data: StockIndexData = await response.json();
// Uncompress format (if array format)
const items = isCompressedFormat(data)
? unpackTuples(data as StockIndexTuple[])
: data as StockIndexItem[];
return {
data: items,
loaded: true,
fallback: false,
};
} catch (error) {
console.error('[StockIndexLoader] Failed to load stock index:', error);
return {
data: [],
loaded: false,
error: error as Error,
fallback: true, // Load failed, fallback to old mode
};
}
}
/**
* Check if data is in compressed format
*/
function isCompressedFormat(data: StockIndexData): data is StockIndexTuple[] {
if (!Array.isArray(data) || data.length === 0) return false;
const firstItem = data[0];
return Array.isArray(firstItem) && typeof firstItem[0] === 'string';
}
/**
* Uncompress tuple format to object format
*/
function unpackTuples(tuples: StockIndexTuple[]): StockIndexItem[] {
return tuples.map(tuple => ({
canonicalCode: tuple[INDEX_FIELD.CANONICAL_CODE],
displayCode: tuple[INDEX_FIELD.DISPLAY_CODE],
nameZh: tuple[INDEX_FIELD.NAME_ZH],
pinyinFull: tuple[INDEX_FIELD.PINYIN_FULL],
pinyinAbbr: tuple[INDEX_FIELD.PINYIN_ABBR],
aliases: tuple[INDEX_FIELD.ALIASES],
market: tuple[INDEX_FIELD.MARKET],
assetType: tuple[INDEX_FIELD.ASSET_TYPE],
active: tuple[INDEX_FIELD.ACTIVE],
popularity: tuple[INDEX_FIELD.POPULARITY],
}));
}
/**
* Compress object format to tuple format
*
* For reducing index file size
*/
export function compressIndex(items: StockIndexItem[]): StockIndexTuple[] {
return items.map(item => [
item.canonicalCode,
item.displayCode,
item.nameZh,
item.pinyinFull,
item.pinyinAbbr,
item.aliases || [],
item.market,
item.assetType,
item.active,
item.popularity,
]);
}
/**
* Find stock in index
*
* @param canonicalCode - Canonical code
* @param index - Stock index
* @returns Stock index item or null
*/
export function findStockInIndex(
canonicalCode: string,
index: StockIndexItem[]
): StockIndexItem | null {
return index.find(item => item.canonicalCode === canonicalCode) || null;
}
/**
* Get popular stocks list
*
* @param index - Stock index
* @param limit - Number of results to return
* @returns Popular stocks list
*/
export function getPopularStocks(
index: StockIndexItem[],
limit: number = 20
): StockIndexItem[] {
return [...index]
.filter(item => item.active)
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, limit);
}
/**
* Group stocks by market
*
* @param index - Stock index
* @returns Map of stocks grouped by market
*/
export function groupStocksByMarket(
index: StockIndexItem[]
): Map<string, StockIndexItem[]> {
const grouped = new Map<string, StockIndexItem[]>();
for (const item of index) {
if (!item.active) continue;
const market = item.market;
if (!grouped.has(market)) {
grouped.set(market, []);
}
const group = grouped.get(market);
if (group) {
group.push(item);
}
}
return grouped;
}

View File

@@ -4,7 +4,29 @@ interface ValidationResult {
normalized: string;
}
// 兼容 A/H/美股常见代码格式的基础校验
const SUPPORTED_QUERY_CHARACTERS = /^[A-Z0-9.\u3400-\u9FFF\s]+$/;
const STOCK_CODE_PATTERNS = [
/^\d{6}$/, // A-share 6-digit code
/^(SH|SZ|BJ)\d{6}$/, // A-share code with exchange prefix
/^\d{6}\.(SH|SZ|SS|BJ)$/, // A-share code with exchange suffix
/^\d{5}$/, // HK code without prefix
/^HK\d{1,5}$/, // HK-prefixed code, for example HK00700
/^\d{1,5}\.HK$/, // HK suffix format, for example 00700.HK
/^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$/, // Common US ticker format
];
/**
* Check whether the input looks like a stock code.
*/
export const looksLikeStockCode = (value: string): boolean => {
const normalized = value.trim().toUpperCase();
return STOCK_CODE_PATTERNS.some((regex) => regex.test(normalized));
};
/**
* Validate common A-share, HK, and US stock code formats.
*/
export const validateStockCode = (value: string): ValidationResult => {
const normalized = value.trim().toUpperCase();
@@ -12,16 +34,7 @@ export const validateStockCode = (value: string): ValidationResult => {
return { valid: false, message: '请输入股票代码', normalized };
}
const patterns = [
/^\d{6}$/, // A 股 6 位数字
/^(SH|SZ)\d{6}$/, // A 股带交易所前缀
/^\d{5}$/, // 港股 5 位数字(无前缀)
/^HK\d{1,5}$/, // 港股 HK 前缀格式,如 HK00700、HK01810、HK1810
/^\d{1,5}\.HK$/, // 港股 .HK 后缀格式,如 00700.HK、1810.HK
/^[A-Z]{1,6}(\.[A-Z]{1,2})?$/, // 美股常见 Ticker
];
const valid = patterns.some((regex) => regex.test(normalized));
const valid = looksLikeStockCode(normalized);
return {
valid,
@@ -29,3 +42,23 @@ export const validateStockCode = (value: string): ValidationResult => {
normalized,
};
};
/**
* Reject obviously invalid free-text queries before they reach the backend.
*/
export const isObviouslyInvalidStockQuery = (value: string): boolean => {
const normalized = value.trim().toUpperCase();
if (!normalized || looksLikeStockCode(normalized)) {
return false;
}
if (!SUPPORTED_QUERY_CHARACTERS.test(normalized)) {
return true;
}
const hasLetters = /[A-Z]/.test(normalized);
const hasDigits = /\d/.test(normalized);
return hasLetters && hasDigits;
};

View File

@@ -167,15 +167,21 @@ def is_bse_code(code: str) -> bool:
"""
Check if the code is a Beijing Stock Exchange (BSE) A-share code.
BSE rules:
- Old format (pre-2024): 8xxxxx (e.g. 838163), 4xxxxx (e.g. 430047)
- New format (2024+, post full migration Oct 2025): 920xxx+
Note: 900xxx are Shanghai B-shares, NOT BSE — must return False.
BSE rules (2026):
- New format (2024+): 92xxxx main trading codes
- Historical ranges: 43xxxx, 83xxxx, 87xxxx, 88xxxx
- Special instruments: 81xxxx convertible bonds, 82xxxx preferred shares
- Subscription codes: 889xxx
Note: 900xxx are Shanghai B-shares and must return False.
"""
c = (code or "").strip().split(".")[0]
if len(c) != 6 or not c.isdigit():
return False
return c.startswith(("8", "4")) or c.startswith("92")
if c.startswith("900"):
return False
return c.startswith(("92", "43", "81", "82", "83", "87", "88"))
def is_st_stock(name: str) -> bool:
"""

View File

@@ -11,10 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### 修复
- 🔎 **Web 自动补全 Enter 提交语义修正** — 股票自动补全在搜索命中候选时不再默认高亮第一项;候选列表展开但用户尚未用方向键或鼠标明确选中时,按 Enter 会继续提交原始输入,避免手动输入被第一条候选静默覆盖。
- 🌍 **补齐 `REPORT_LANGUAGE` 启动解析与历史展示本地化边界**`Config` 在启动时继续遵循“真实环境变量优先、`.env` 兜底”的既有语义,并在两者冲突时输出显式告警,减少 `REPORT_LANGUAGE` 来源不清带来的误判;同时 `/api/v1/history/{id}` 英文详情响应会同步本地化 `sentiment_label`,历史 Markdown 也会正确识别英文 `bias_status` 的风险等级 emoji避免出现 `乐观``🚨Safe` 这类中英混排/误报展示。
### 新功能
- 🔎 **Web 股票自动补全 MVP** — 首页分析输入框新增本地索引驱动的自动补全,支持股票代码、中文名、拼音和别名匹配;选中候选后会提交 canonical code并透传 `stock_name``original_query``selection_source` 到分析请求、任务状态和 SSE 事件索引加载失败时自动退回旧输入模式不阻断原有提交流程。同步补充了静态索引加载器、索引生成脚本和前后端契约测试。分阶段进行开发第一阶段仅支持A股。
- 🔎 **SearXNG 公共实例自动发现与受控轮询**#752)— 新增 `SEARXNG_PUBLIC_INSTANCES_ENABLED`,在未配置 `SEARXNG_BASE_URLS` 时默认从 `searx.space` 拉取公共实例列表并按受控轮询顺序选择实例同次请求内遇到超时、连接错误、HTTP 非 200 或无效 JSON 会自动切换到下一个实例。已配置自建实例的用户保持原有优先级与语义不变;`daily_analysis` GitHub Actions 工作流也已支持显式透传该开关并在启动日志中展示当前状态。
- 📈 **TickFlow market review enhancement** (#632) — 新增可选 `TICKFLOW_API_KEY`配置后A 股大盘复盘的主要指数行情优先尝试 TickFlow若当前 TickFlow 套餐支持标的池查询,市场涨跌统计也会优先尝试 TickFlow。失败或权限不足时立即回退到现有 `AkShare / Tushare / efinance` 链路;板块涨跌榜回退顺序保持不变。接入层同时适配了真实 SDK 契约:主指数查询按单次请求上限分批拉取,并将 TickFlow 返回的比例型 `change_pct` / `amplitude` 统一转换为项目内部的百分比口径。
- 💼 **持仓账本并发写入串行化**#742)— 持仓源事件写入/删除现在会在 SQLite 下先获取串行化写锁,减少并发卖出把超售流水写入账本的窗口;直接持仓写接口在锁竞争时返回 `409 portfolio_busy`CSV 导入保持逐条提交并把 busy 计入 `failed_count`

View File

@@ -0,0 +1,323 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Generate Stock Index from CSV File
Input: logs/stock_basic_*.csv (AkShare format)
Output: apps/dsa-web/public/stocks.index.json
Usage:
python3 scripts/generate_index_from_csv.py
"""
import csv
import json
import re
import sys
import unicodedata
from pathlib import Path
from typing import List, Dict, Any
# Add the project root to sys.path.
sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pypinyin import lazy_pinyin, Style
PYPINYIN_AVAILABLE = True
except ImportError:
PYPINYIN_AVAILABLE = False
print("[Warning] pypinyin not available, pinyin fields will be empty")
print("[Info] Install with: pip install pypinyin")
def load_csv_data(csv_path: Path) -> List[Dict[str, Any]]:
"""
Load stock data from CSV file
Args:
csv_path: CSV file path
Returns:
List of stock data
"""
stocks = []
with open(csv_path, 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
for row in reader:
ts_code = row['ts_code'].strip()
symbol = row['symbol'].strip()
name = row['name'].strip()
# Skip invalid rows.
if not ts_code or not symbol or not name:
continue
stocks.append({
'ts_code': ts_code,
'symbol': symbol,
'name': name,
'area': row.get('area', ''),
'industry': row.get('industry', ''),
'list_date': row.get('list_date', ''),
})
return stocks
def generate_pinyin(name: str) -> tuple:
"""
Generate pinyin for stock name
Args:
name: Stock name
Returns:
Tuple of (pinyin_full, pinyin_abbr)
"""
if not PYPINYIN_AVAILABLE:
return (None, None)
try:
normalized_name = normalize_name_for_pinyin(name)
# Full pinyin spelling.
py_full = lazy_pinyin(normalized_name, style=Style.NORMAL)
pinyin_full = ''.join(py_full)
# Pinyin abbreviation.
py_abbr = lazy_pinyin(normalized_name, style=Style.FIRST_LETTER)
pinyin_abbr = ''.join(py_abbr)
return (pinyin_full, pinyin_abbr)
except Exception as e:
print(f"[Warning] Failed to generate pinyin for {name}: {e}")
return (None, None)
def normalize_name_for_pinyin(name: str) -> str:
"""
Normalize stock name to avoid special prefixes and full-width characters polluting pinyin index
Args:
name: Original stock name
Returns:
Normalized name for pinyin generation
"""
normalized = unicodedata.normalize('NFKC', name).strip()
# Strip common A-share prefixes while preserving the core name.
normalized = re.sub(r'^(?:\*?ST|N)+', '', normalized, flags=re.IGNORECASE)
return normalized.strip() or unicodedata.normalize('NFKC', name).strip()
def determine_market(ts_code: str) -> str:
"""
Determine market based on code
Args:
ts_code: Trading code (e.g., 000001.SZ)
Returns:
Market code
"""
if '.' in ts_code:
suffix = ts_code.split('.')[1]
if suffix in ['SH', 'SZ']:
return 'CN'
elif suffix == 'HK':
return 'HK'
elif suffix == 'BJ':
return 'BSE'
# Default to the A-share market.
return 'CN'
def generate_aliases(name: str) -> List[str]:
"""
Generate stock aliases
Args:
name: Stock name
Returns:
List of aliases
"""
aliases = []
# Common alias mappings.
alias_map = {
'贵州茅台': ['茅台'],
'中国平安': ['平安'],
'平安银行': ['平银'],
'招商银行': ['招行'],
'五粮液': ['五粮'],
'宁德时代': ['宁德'],
'比亚迪': ['比亚'],
'工商银行': ['工行'],
'建设银行': ['建行'],
'农业银行': ['农行'],
'中国银行': ['中行'],
'交通银行': ['交行'],
'兴业银行': ['兴业'],
'浦发银行': ['浦发'],
'民生银行': ['民生'],
'中信证券': ['中信'],
'东方财富': ['东财'],
'海康威视': ['海康'],
'隆基绿能': ['隆基'],
'中国神华': ['神华'],
'长江电力': ['长电'],
'中国石化': ['石化'],
'中国石油': ['石油'],
}
if name in alias_map:
aliases.extend(alias_map[name])
return aliases
def build_stock_index(stocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Build the stock index.
Args:
stocks: Raw stock rows
Returns:
Stock index entries
"""
index = []
for stock in stocks:
ts_code = stock['ts_code']
symbol = stock['symbol']
name = stock['name']
# Generate pinyin fields.
pinyin_full, pinyin_abbr = generate_pinyin(name)
# Determine the market.
market = determine_market(ts_code)
# Generate aliases.
aliases = generate_aliases(name)
index.append({
"canonicalCode": ts_code, # Example: 000001.SZ
"displayCode": symbol, # Example: 000001
"nameZh": name,
"pinyinFull": pinyin_full,
"pinyinAbbr": pinyin_abbr,
"aliases": aliases,
"market": market,
"assetType": "stock",
"active": True,
"popularity": 100,
})
return index
def compress_index(index: List[Dict[str, Any]]) -> List[List]:
"""
压缩索引为数组格式以减少文件大小
Args:
index: 原始索引
Returns:
压缩后的索引
"""
compressed = []
for item in index:
compressed.append([
item["canonicalCode"],
item["displayCode"],
item["nameZh"],
item.get("pinyinFull"),
item.get("pinyinAbbr"),
item.get("aliases", []),
item["market"],
item["assetType"],
item["active"],
item.get("popularity", 0),
])
return compressed
def main():
"""主函数"""
print("=" * 60)
print("股票索引生成工具(从 CSV")
print("=" * 60)
# 查找 CSV 文件
logs_dir = Path(__file__).parent.parent / "logs"
csv_files = list(logs_dir.glob("stock_basic_*.csv"))
if not csv_files:
print("[Error] 未找到 CSV 文件logs/stock_basic_*.csv")
return 1
# 使用最新的 CSV 文件
csv_file = sorted(csv_files)[-1]
print(f"\n[1/5] 读取 CSV 文件:{csv_file.name}")
# 加载数据
stocks = load_csv_data(csv_file)
print(f" 共读取 {len(stocks)} 只股票")
# 生成拼音提示
if not PYPINYIN_AVAILABLE:
print("\n[提示] 安装 pypinyin 可获得拼音搜索功能:")
print(" pip install pypinyin")
print(f"\n[2/5] 生成索引数据...")
index = build_stock_index(stocks)
# 输出路径
output_path = (
Path(__file__).parent.parent / "apps" / "dsa-web" / "public" / "stocks.index.json"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f"\n[3/5] 压缩索引数据...")
compressed = compress_index(index)
print(f"\n[4/5] 写入文件:{output_path}")
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(compressed, f, ensure_ascii=False, separators=(',', ':'))
file_size = output_path.stat().st_size
print(f" 文件大小:{file_size / 1024:.2f} KB")
# 验证文件
print(f"\n[5/5] 验证文件...")
with open(output_path, 'r', encoding='utf-8') as f:
test_data = json.load(f)
print(f" 验证通过:{len(test_data)} 条记录")
# 统计信息
market_stats = {}
for item in index:
market = item['market']
market_stats[market] = market_stats.get(market, 0) + 1
print(f"\n{'=' * 60}")
print("生成完成!市场分布:")
for market, count in sorted(market_stats.items()):
print(f" - {market}: {count}")
print(f"{'=' * 60}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Stock Index Generation Script
Generate stock index file for frontend autocomplete functionality
Output to apps/dsa-web/public/stocks.index.json
Two-phase strategy:
1. MVP: Use existing STOCK_NAME_MAP
2. Future: Combine with AkShare for complete list
Usage:
python3 scripts/generate_stock_index.py
"""
import json
import re
import sys
import unicodedata
from pathlib import Path
from typing import List, Dict, Any
# Add the project root to sys.path.
sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pypinyin import lazy_pinyin
PYPINYIN_AVAILABLE = True
except ImportError:
PYPINYIN_AVAILABLE = False
print("[Warning] pypinyin not available, pinyin fields will be empty")
print("[Info] Install with: pip install pypinyin")
def normalize_name_for_pinyin(name: str) -> str:
"""
Normalize stock name to avoid special prefixes and full-width characters polluting pinyin index
Args:
name: Original stock name
Returns:
Normalized name for pinyin generation
"""
normalized = unicodedata.normalize('NFKC', name).strip()
# Strip common A-share prefixes while preserving the core name.
normalized = re.sub(r'^(?:\*?ST|N)+', '', normalized, flags=re.IGNORECASE)
return normalized.strip() or unicodedata.normalize('NFKC', name).strip()
def generate_stock_index_from_map() -> List[Dict[str, Any]]:
"""
Generate index from STOCK_NAME_MAP (MVP)
Returns:
List of stock index
"""
from src.data.stock_mapping import STOCK_NAME_MAP
index = []
for code, name in STOCK_NAME_MAP.items():
# Generate pinyin fields.
pinyin_full = None
pinyin_abbr = None
if PYPINYIN_AVAILABLE:
try:
normalized_name = normalize_name_for_pinyin(name)
py = lazy_pinyin(normalized_name)
pinyin_full = ''.join(py)
pinyin_abbr = ''.join([p[0] for p in py])
except Exception:
pass
# Determine market and asset type.
market, asset_type = determine_market_and_type(code)
# Generate short aliases.
aliases = generate_aliases(name)
index.append({
"canonicalCode": build_canonical_code(code, market),
"displayCode": code,
"nameZh": name,
"pinyinFull": pinyin_full,
"pinyinAbbr": pinyin_abbr,
"aliases": aliases,
"market": market,
"assetType": asset_type,
"active": True,
"popularity": 100, # Default popularity
})
return index
def determine_market_and_type(code: str) -> tuple:
"""
Determine market and asset type based on stock code
Args:
code: Stock code
Returns:
Tuple of (market, asset_type)
"""
if code.isdigit():
if len(code) == 5:
# Five digits: likely HK stock or legacy B-share.
if code.startswith('0') or code.startswith('2'):
return 'HK', 'stock'
return 'CN', 'stock'
elif len(code) == 6:
# Six digits: A-share universe.
if code.startswith('6'):
return 'CN', 'stock' # Shanghai
elif code.startswith(('0', '2', '3')):
return 'CN', 'stock' # Shenzhen
elif code.startswith('8'):
return 'BSE', 'stock' # Beijing Stock Exchange
return 'CN', 'stock'
elif len(code) == 4:
# Four digits: likely a US symbol or special market code.
return 'US', 'stock'
# 字母代码,美股或其他
return 'US', 'stock'
def market_to_suffix(market: str) -> str:
"""
Convert market code to suffix
Args:
market: Market code
Returns:
Market suffix
"""
suffix_map = {
'CN': 'SH', # 简化处理,默认上海
'HK': 'HK',
'US': 'US',
'INDEX': 'SH',
'ETF': 'SH',
'BSE': 'BJ',
}
return suffix_map.get(market, 'SH')
def build_canonical_code(code: str, market: str) -> str:
"""
Generate canonical stock code based on code and market.
A-shares need to distinguish between SH/SZ/BJ, cannot rely solely on the general CN -> SH mapping.
"""
if market == 'CN' and code.isdigit() and len(code) == 6:
# Shanghai Stock Exchange (SH)
# 60xxxx: Main board, 688xxx: STAR market, 900xxx: B-shares
if code.startswith(('6', '900')):
return f"{code}.SH"
# Shenzhen Stock Exchange (SZ)
# 00xxxx: Main board, 30xxxx: ChiNext, 20xxxx: B-shares
if code.startswith(('0', '2', '3')):
return f"{code}.SZ"
# Beijing Stock Exchange (BJ)
# 920xxx: New codes and migrated stock codes after April 2024
# 43xxxx, 83xxxx, 87xxxx, 88xxxx: Historical/Temporary codes
# 81xxxx, 82xxxx: Convertible bonds/Preferred stocks
if code.startswith(('920', '43', '83', '87', '88', '81', '82')):
return f"{code}.BJ"
if market == 'BSE' and code.isdigit() and len(code) == 6:
return f"{code}.BJ"
return f"{code}.{market_to_suffix(market)}"
def generate_aliases(name: str) -> List[str]:
"""
Generate stock aliases (abbreviations)
Args:
name: Full stock name
Returns:
List of aliases
"""
aliases = []
# 常见简称映射
alias_map = {
'贵州茅台': ['茅台'],
'中国平安': ['平安'],
'平安银行': ['平银'],
'招商银行': ['招行'],
'五粮液': ['五粮'],
'宁德时代': ['宁德'],
'比亚迪': ['比亚'],
'工商银行': ['工行'],
'建设银行': ['建行'],
'农业银行': ['农行'],
'中国银行': ['中行'],
'交通银行': ['交行'],
'兴业银行': ['兴业'],
'浦发银行': ['浦发'],
'民生银行': ['民生'],
'中信证券': ['中信'],
'东方财富': ['东财'],
'海康威视': ['海康'],
'隆基绿能': ['隆基'],
'中国神华': ['神华'],
'长江电力': ['长电'],
'中国石化': ['石化'],
'中国石油': ['石油'],
}
if name in alias_map:
aliases.extend(alias_map[name])
return aliases
def compress_index(index: List[Dict[str, Any]]) -> List[List]:
"""
Compress index to array format to reduce file size
Args:
index: Original index
Returns:
Compressed index
"""
compressed = []
for item in index:
compressed.append([
item["canonicalCode"],
item["displayCode"],
item["nameZh"],
item.get("pinyinFull"),
item.get("pinyinAbbr"),
item.get("aliases", []),
item["market"],
item["assetType"],
item["active"],
item.get("popularity", 0),
])
return compressed
def main():
"""Main function"""
print("开始生成股票索引...")
# 生成索引MVP使用现有映射
index = generate_stock_index_from_map()
print(f"共生成 {len(index)} 条索引")
# 输出路径
output_path = Path(__file__).parent.parent / "apps" / "dsa-web" / "public" / "stocks.index.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
# 压缩格式(减少文件大小)
compressed = compress_index(index)
# 写入文件
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(compressed, f, ensure_ascii=False, separators=(',', ':'))
file_size = output_path.stat().st_size
print(f"索引已生成:{output_path}")
print(f"文件大小:{file_size / 1024:.2f} KB")
# 验证文件可读
with open(output_path, 'r', encoding='utf-8') as f:
test_data = json.load(f)
print(f"验证通过:{len(test_data)} 条记录")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""
===================================
名称→代码解析引擎
Name-to-Code Resolution Engine
===================================
Resolve stock name to code: local mapping + pinyin + AkShare fallback + fuzzy matching.
@@ -21,7 +21,12 @@ logger = logging.getLogger(__name__)
# AkShare result cache: (timestamp, name_to_code_dict)
_akshare_cache: Optional[tuple[float, Dict[str, str]]] = None
_AKSHARE_CACHE_TTL = 3600 # 1 hour
_AKSHARE_CACHE_TTL = 1800 # 30 MIN
def _contains_cjk(text: str) -> bool:
"""Return True when text contains CJK characters."""
return any("\u3400" <= ch <= "\u9fff" for ch in text)
def _is_code_like(s: str) -> bool:
@@ -146,6 +151,12 @@ def resolve_name_to_code(name: str) -> Optional[str]:
except Exception as e:
logger.debug(f"[NameResolver] Pinyin match failed: {e}")
# Skip AkShare/fuzzy fallback for non-CJK free text such as random Latin noise.
# These paths are expensive and only meaningfully help Chinese stock names.
if not _contains_cjk(s):
logger.debug(f"[NameResolver] Skip CJK-only fallbacks for non-CJK input: {s}")
return None
# 4. AkShare fallback
akshare_map = _get_akshare_name_to_code()
if akshare_map and s in akshare_map:

View File

@@ -41,7 +41,7 @@ def is_code_like(value: str) -> bool:
base = text[: -len(suffix)].strip()
if base.isdigit() and len(base) in (5, 6):
return True
if re.match(r"^[A-Z]{1,5}(\.[A-Z])?$", text):
if re.match(r"^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$", text):
return True
# Support exchange-prefixed codes: SH600519, SZ000001, HK00700
if _strip_exchange_prefix(text) is not None:
@@ -63,7 +63,7 @@ def normalize_code(raw: str) -> Optional[str]:
return None
if text.isdigit() and len(text) in (5, 6):
return text
if re.match(r"^[A-Z]{1,5}(\.[A-Z])?$", text):
if re.match(r"^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$", text):
return text
for suffix in (".SH", ".SZ", ".SS"):
if text.endswith(suffix):

View File

@@ -26,25 +26,36 @@ from typing import Optional, Dict, List, Any, TYPE_CHECKING, Tuple, Literal
if TYPE_CHECKING:
from asyncio import Queue as AsyncQueue
from data_provider.base import canonical_stock_code
from data_provider.base import canonical_stock_code, normalize_stock_code
from src.utils.analysis_metadata import SELECTION_SOURCES
logger = logging.getLogger(__name__)
def _dedupe_stock_code_key(stock_code: str) -> str:
"""
Build the internal duplicate-detection key for a stock code.
The task queue should treat equivalent market code shapes as the same
underlying stock, e.g. ``600519`` and ``600519.SH``.
"""
return canonical_stock_code(normalize_stock_code(stock_code))
class TaskStatus(str, Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待执行
PROCESSING = "processing" # 执行中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
"""Task status enumeration"""
PENDING = "pending" # Waiting for execution
PROCESSING = "processing" # In progress
COMPLETED = "completed" # Completed
FAILED = "failed" # Failed
@dataclass
class TaskInfo:
"""
任务信息数据类
包含任务的完整状态信息,用于 API 响应和内部管理
Task information dataclass.
Used for API responses and internal task management.
"""
task_id: str
stock_code: str
@@ -58,9 +69,11 @@ class TaskInfo:
created_at: datetime = field(default_factory=datetime.now)
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
original_query: Optional[str] = None
selection_source: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典,用于 API 响应"""
"""Convert task info into an API-friendly dictionary."""
return {
"task_id": self.task_id,
"stock_code": self.stock_code,
@@ -73,10 +86,12 @@ class TaskInfo:
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"error": self.error,
"original_query": self.original_query,
"selection_source": self.selection_source,
}
def copy(self) -> 'TaskInfo':
"""创建任务信息的副本"""
"""Create a shallow copy of the task information."""
return TaskInfo(
task_id=self.task_id,
stock_code=self.stock_code,
@@ -90,6 +105,8 @@ class TaskInfo:
created_at=self.created_at,
started_at=self.started_at,
completed_at=self.completed_at,
original_query=self.original_query,
selection_source=self.selection_source,
)
@@ -138,7 +155,7 @@ class AnalysisTaskQueue:
# 核心数据结构
self._tasks: Dict[str, TaskInfo] = {} # task_id -> TaskInfo
self._analyzing_stocks: Dict[str, str] = {} # stock_code -> task_id
self._analyzing_stocks: Dict[str, str] = {} # dedupe_key -> task_id
self._futures: Dict[str, Future] = {} # task_id -> Future
# SSE 订阅者列表asyncio.Queue 实例)
@@ -241,8 +258,9 @@ class AnalysisTaskQueue:
Returns:
True 表示正在分析中
"""
dedupe_key = _dedupe_stock_code_key(stock_code)
with self._data_lock:
return stock_code in self._analyzing_stocks
return dedupe_key in self._analyzing_stocks
def get_analyzing_task_id(self, stock_code: str) -> Optional[str]:
"""
@@ -254,30 +272,51 @@ class AnalysisTaskQueue:
Returns:
任务 ID如果没有则返回 None
"""
dedupe_key = _dedupe_stock_code_key(stock_code)
with self._data_lock:
return self._analyzing_stocks.get(stock_code)
return self._analyzing_stocks.get(dedupe_key)
def validate_selection_source(self, selection_source: Optional[str]) -> None:
"""
Validate the selection source parameter.
Args:
selection_source: Selection source label.
Raises:
ValueError: Raised when the selection source is invalid.
"""
if selection_source is not None and selection_source not in SELECTION_SOURCES:
raise ValueError(
f"Invalid selection_source: {selection_source}. "
f"Must be one of {SELECTION_SOURCES}"
)
def submit_task(
self,
stock_code: str,
stock_name: Optional[str] = None,
original_query: Optional[str] = None,
selection_source: Optional[str] = None,
report_type: str = "detailed",
force_refresh: bool = False,
) -> TaskInfo:
"""
提交分析任务
Submit a single analysis task.
Args:
stock_code: 股票代码
stock_name: 股票名称(可选)
report_type: 报告类型
force_refresh: 是否强制刷新
stock_code: Stock code
stock_name: Optional stock name
original_query: Optional raw user input
selection_source: Optional source label
report_type: Report type
force_refresh: Whether to bypass cache
Returns:
TaskInfo: 任务信息
TaskInfo: Accepted task information
Raises:
DuplicateTaskError: 股票正在分析中
DuplicateTaskError: Raised when the stock is already being analyzed
"""
stock_code = canonical_stock_code(stock_code)
if not stock_code:
@@ -286,6 +325,8 @@ class AnalysisTaskQueue:
accepted, duplicates = self.submit_tasks_batch(
[stock_code],
stock_name=stock_name,
original_query=original_query,
selection_source=selection_source,
report_type=report_type,
force_refresh=force_refresh,
)
@@ -297,28 +338,33 @@ class AnalysisTaskQueue:
self,
stock_codes: List[str],
stock_name: Optional[str] = None,
original_query: Optional[str] = None,
selection_source: Optional[str] = None,
report_type: str = "detailed",
force_refresh: bool = False,
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
"""
批量提交分析任务。
Submit analysis tasks in batch.
- 重复股票会被跳过并记录在 duplicates
- 如果线程池提交过程中发生异常,则回滚本次已创建任务,避免部分成功
- Duplicate stocks are skipped and recorded in duplicates.
- If executor submission fails, the current batch is rolled back.
"""
self.validate_selection_source(selection_source)
accepted: List[TaskInfo] = []
duplicates: List[DuplicateTaskError] = []
created_task_ids: List[str] = []
normalized_codes = [
canonical_codes = [
normalized for normalized in (canonical_stock_code(code) for code in stock_codes)
if normalized
]
with self._data_lock:
for stock_code in normalized_codes:
if stock_code in self._analyzing_stocks:
existing_task_id = self._analyzing_stocks[stock_code]
for stock_code in canonical_codes:
dedupe_key = _dedupe_stock_code_key(stock_code)
if dedupe_key in self._analyzing_stocks:
existing_task_id = self._analyzing_stocks[dedupe_key]
duplicates.append(DuplicateTaskError(stock_code, existing_task_id))
continue
@@ -330,9 +376,11 @@ class AnalysisTaskQueue:
status=TaskStatus.PENDING,
message="任务已加入队列",
report_type=report_type,
original_query=original_query,
selection_source=selection_source,
)
self._tasks[task_id] = task_info
self._analyzing_stocks[stock_code] = task_id
self._analyzing_stocks[dedupe_key] = task_id
try:
future = self.executor.submit(
@@ -343,7 +391,7 @@ class AnalysisTaskQueue:
force_refresh,
)
except Exception:
# 回滚当前批次,避免 API 拿不到 task_id 却留下半提交任务。
# Roll back the current batch to avoid partial submission.
self._rollback_submitted_tasks_locked(created_task_ids + [task_id])
raise
@@ -368,8 +416,10 @@ class AnalysisTaskQueue:
future.cancel()
task = self._tasks.pop(task_id, None)
if task and self._analyzing_stocks.get(task.stock_code) == task_id:
del self._analyzing_stocks[task.stock_code]
if task:
dedupe_key = _dedupe_stock_code_key(task.stock_code)
if self._analyzing_stocks.get(dedupe_key) == task_id:
del self._analyzing_stocks[dedupe_key]
def get_task(self, task_id: str) -> Optional[TaskInfo]:
"""
@@ -494,8 +544,9 @@ class AnalysisTaskQueue:
task.stock_name = result.get("stock_name", task.stock_name)
# 从分析中集合移除
if task.stock_code in self._analyzing_stocks:
del self._analyzing_stocks[task.stock_code]
dedupe_key = _dedupe_stock_code_key(task.stock_code)
if dedupe_key in self._analyzing_stocks:
del self._analyzing_stocks[dedupe_key]
self._broadcast_event("task_completed", task.to_dict())
logger.info(f"[TaskQueue] 任务完成: {task_id} ({stock_code})")
@@ -521,8 +572,9 @@ class AnalysisTaskQueue:
task.message = f"分析失败: {error_msg[:50]}"
# 从分析中集合移除
if task.stock_code in self._analyzing_stocks:
del self._analyzing_stocks[task.stock_code]
dedupe_key = _dedupe_stock_code_key(task.stock_code)
if dedupe_key in self._analyzing_stocks:
del self._analyzing_stocks[dedupe_key]
self._broadcast_event("task_failed", task.to_dict())

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""
Shared metadata constants for analysis requests.
"""
from __future__ import annotations
SELECTION_SOURCES: tuple[str, ...] = ("manual", "autocomplete", "import", "image")
SELECTION_SOURCE_PATTERN = "^(" + "|".join(SELECTION_SOURCES) + ")$"

View File

@@ -230,6 +230,267 @@ class AnalysisApiContractTestCase(unittest.TestCase):
"股票代码不能为空或仅包含空白字符",
)
def test_trigger_analysis_rejects_obviously_invalid_mixed_input_before_resolution(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="00AAAAA",
stock_codes=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail["message"], "请输入有效的股票代码或股票名称")
resolve_mock.assert_not_called()
def test_trigger_analysis_rejects_unresolvable_alpha_garbage(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value=None), \
patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock:
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="aaaaaaa",
stock_codes=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail["message"], "请输入有效的股票代码或股票名称")
queue_mock.assert_not_called()
def test_trigger_analysis_accepts_us_suffix_code(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
response = trigger_analysis(
request=SimpleNamespace(
stock_code="AAPL.US",
stock_codes=None,
stock_name=None,
original_query="AAPL.US",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["AAPL.US"],
stock_name=None,
original_query="AAPL.US",
selection_source="manual",
report_type="detailed",
force_refresh=False,
)
def test_trigger_analysis_allows_stock_names_with_star_and_hyphen(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value="688783"), \
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="西安奕材-U",
stock_codes=None,
stock_name=None,
original_query="西安奕材-U",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["688783"],
stock_name=None,
original_query="西安奕材-U",
selection_source="manual",
report_type="detailed",
force_refresh=False,
)
def test_trigger_analysis_accepts_resolvable_free_text_input(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value="600519"), \
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="贵州茅台",
stock_codes=None,
stock_name=None,
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519"],
stock_name=None,
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
force_refresh=False,
)
def test_trigger_analysis_preserves_batch_metadata(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "000001"],
stock_name=None,
original_query="uploaded.csv",
selection_source="import",
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519", "000001"],
stock_name=None,
original_query="uploaded.csv",
selection_source="import",
report_type="detailed",
force_refresh=False,
)
def test_trigger_analysis_rejects_cross_request_duplicate_for_equivalent_code_shapes(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
original_instance = AnalysisTaskQueue._instance
AnalysisTaskQueue._instance = None
try:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
first = trigger_analysis(
request=SimpleNamespace(
stock_code="600519",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
second = trigger_analysis(
request=SimpleNamespace(
stock_code="600519.SH",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(first.status_code, 202)
self.assertEqual(second.status_code, 409)
self.assertEqual(json.loads(second.body)["error"], "duplicate_task")
self.assertEqual(json.loads(second.body)["stock_code"], "600519.SH")
self.assertEqual(
json.loads(second.body)["existing_task_id"],
json.loads(first.body)["task_id"],
)
finally:
queue = AnalysisTaskQueue._instance
if queue is not None and queue is not original_instance:
executor = getattr(queue, "_executor", None)
if executor is not None and hasattr(executor, "shutdown"):
executor.shutdown(wait=False, cancel_futures=True)
AnalysisTaskQueue._instance = original_instance
def test_trigger_analysis_batch_does_not_apply_single_stock_name_to_all_tasks(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "000001"],
stock_name="贵州茅台",
original_query="茅台,平安银行",
selection_source="import",
report_type="detailed",
force_refresh=False,
async_mode=True,
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519", "000001"],
stock_name=None,
original_query="茅台,平安银行",
selection_source="import",
report_type="detailed",
force_refresh=False,
)
def test_spa_fallback_returns_json_404_for_bare_api_path(self) -> None:
if create_app is None:
self.skipTest("fastapi is not installed in this test environment")
@@ -297,6 +558,24 @@ class BatchTaskQueueContractTestCase(unittest.TestCase):
self.assertEqual(duplicates, [])
self.assertEqual(sorted(task.stock_code for task in queue._tasks.values()), ["600519"])
def test_batch_submit_deduplicates_equivalent_stock_code_shapes(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
accepted, duplicates = queue.submit_tasks_batch(["600519"], report_type="detailed")
self.assertEqual(len(accepted), 1)
self.assertEqual(duplicates, [])
self.assertTrue(queue.is_analyzing("600519.SH"))
self.assertEqual(queue.get_analyzing_task_id("600519.SH"), accepted[0].task_id)
accepted_again, duplicates_again = queue.submit_tasks_batch(["600519.SH"], report_type="detailed")
self.assertEqual(accepted_again, [])
self.assertEqual(len(duplicates_again), 1)
self.assertEqual(duplicates_again[0].stock_code, "600519.SH")
self.assertEqual(duplicates_again[0].existing_task_id, accepted[0].task_id)
def test_submit_task_rejects_blank_stock_code(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""
===================================
Analysis Integration Tests
===================================
Covers:
- API endpoint /analyze
- Name resolution to code
- Task queue submission
- Metadata persistence (original_query, selection_source)
"""
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
from api.app import create_app
from src.services.task_queue import AnalysisTaskQueue, TaskStatus
from src.config import Config
import src.auth as auth
@pytest.fixture
def client():
app = create_app()
return TestClient(app)
@pytest.fixture(autouse=True)
def disable_auth():
"""Keep analysis integration tests independent from local auth env state."""
auth._auth_enabled = None
with patch("api.middlewares.auth.is_auth_enabled", return_value=False), \
patch("src.auth.is_auth_enabled", return_value=False):
yield
auth._auth_enabled = None
@pytest.fixture
def mock_task_queue():
with patch("api.v1.endpoints.analysis.get_task_queue") as mock_get:
queue = MagicMock(spec=AnalysisTaskQueue)
mock_get.return_value = queue
yield queue
class TestAnalysisIntegration:
"""End-to-end integration tests for the analysis flow."""
def test_trigger_analysis_flow_manual_name(self, client, mock_task_queue):
"""Test flow: User enters stock name -> resolved to code -> task submitted."""
# Setup mock behavior
mock_task_queue.submit_tasks_batch.return_value = (
[MagicMock(task_id="test_task_123", stock_code="600519")],
[]
)
# Trigger analysis with a stock name
response = client.post(
"/api/v1/analysis/analyze",
json={
"stock_code": "贵州茅台",
"async_mode": True,
"original_query": "贵州茅台",
"selection_source": "manual"
}
)
assert response.status_code == 202
data = response.json()
assert data["task_id"] == "test_task_123"
assert data["status"] == "pending"
# Verify task queue received the correct resolved code and metadata
mock_task_queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519"],
stock_name=None,
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
force_refresh=False
)
def test_trigger_analysis_batch_deduplication(self, client, mock_task_queue):
"""Test de-duplication across different formats (600519 and 600519.SH)."""
mock_task_queue.submit_tasks_batch.return_value = ([], [])
client.post(
"/api/v1/analysis/analyze",
json={
"stock_codes": ["600519", "600519.SH"],
"async_mode": True
}
)
# Should only submit once after de-duplication
mock_task_queue.submit_tasks_batch.assert_called_once()
args, kwargs = mock_task_queue.submit_tasks_batch.call_args
assert len(kwargs["stock_codes"]) == 1
assert kwargs["stock_codes"] == ["600519"]
def test_trigger_analysis_dos_protection(self, client):
"""Test that excessive stock codes are rejected."""
too_many_codes = [f"{i:06d}" for i in range(101)]
response = client.post(
"/api/v1/analysis/analyze",
json={
"stock_codes": too_many_codes,
"async_mode": True
}
)
assert response.status_code == 400
assert "最多支持" in response.json()["message"]
def test_trigger_analysis_metadata_isolation_in_batch(self, client, mock_task_queue):
"""Test that single-stock metadata isn't applied to batch tasks."""
mock_task_queue.submit_tasks_batch.return_value = ([], [])
client.post(
"/api/v1/analysis/analyze",
json={
"stock_codes": ["600519", "000001"],
"stock_name": "贵州茅台",
"original_query": "茅台",
"async_mode": True
}
)
# Batch request: metadata should be None
mock_task_queue.submit_tasks_batch.assert_called_once()
args, kwargs = mock_task_queue.submit_tasks_batch.call_args
assert kwargs["stock_name"] is None
assert kwargs["original_query"] is None
assert kwargs["selection_source"] is None

View File

@@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
"""
===================================
Analysis Metadata Utility Unit Tests
===================================
"""
import pytest
from src.utils.analysis_metadata import SELECTION_SOURCES, SELECTION_SOURCE_PATTERN
class TestSelectionSourceConstants:
"""Test selection source constants"""
def test_selection_sources_tuple(self):
"""Test that SELECTION_SOURCES is a tuple with expected values"""
assert isinstance(SELECTION_SOURCES, tuple)
assert len(SELECTION_SOURCES) == 4
assert "manual" in SELECTION_SOURCES
assert "autocomplete" in SELECTION_SOURCES
assert "import" in SELECTION_SOURCES
assert "image" in SELECTION_SOURCES
def test_selection_sources_order(self):
"""Test that selection sources are in expected order"""
assert SELECTION_SOURCES[0] == "manual"
assert SELECTION_SOURCES[1] == "autocomplete"
assert SELECTION_SOURCES[2] == "import"
assert SELECTION_SOURCES[3] == "image"
def test_selection_sources_unique(self):
"""Test that selection source values are unique"""
assert len(SELECTION_SOURCES) == len(set(SELECTION_SOURCES))
def test_selection_source_pattern_string(self):
"""Test that SELECTION_SOURCE_PATTERN is a string"""
assert isinstance(SELECTION_SOURCE_PATTERN, str)
def test_selection_source_pattern_regex(self):
"""Test that SELECTION_SOURCE_PATTERN is a valid regex"""
import re
# Should be able to compile as regex
pattern = re.compile(SELECTION_SOURCE_PATTERN)
assert pattern is not None
def test_selection_source_pattern_valid_sources(self):
"""Test that regex pattern matches all valid selection sources"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
for source in SELECTION_SOURCES:
assert pattern.fullmatch(source) is not None, f"Pattern should match {source}"
def test_selection_source_pattern_invalid_sources(self):
"""Test that regex pattern rejects invalid selection sources"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
invalid_sources = [
"",
"invalid",
"Manual",
"AUTOCOMPLETE",
"autocomplete ",
" autocomplete",
"manual|autocomplete",
"image;upload",
"upload",
"scan",
"voice",
]
for invalid_source in invalid_sources:
assert pattern.fullmatch(invalid_source) is None, f"Pattern should reject {invalid_source}"
class TestSelectionSourcePatternEdgeCases:
"""Test selection source pattern edge cases"""
def test_pattern_partial_match(self):
"""Test that regex pattern does not do partial matching"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
# Partial matches should fail
assert pattern.fullmatch("manual-extra") is None
assert pattern.fullmatch("autocomplete_value") is None
assert pattern.fullmatch("import_data") is None
def test_pattern_case_sensitive(self):
"""Test that regex pattern is case-sensitive"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
# Uppercase forms should fail
assert pattern.fullmatch("MANUAL") is None
assert pattern.fullmatch("Autocomplete") is None
assert pattern.fullmatch("Import") is None
def test_pattern_whitespace(self):
"""Test that regex pattern rejects inputs with spaces"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
assert pattern.fullmatch(" manual") is None
assert pattern.fullmatch("manual ") is None
assert pattern.fullmatch("aut ocomplete") is None
def test_pattern_special_characters(self):
"""Test that regex pattern rejects special characters"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
special_cases = [
"manual!",
"autocomplete.",
"import@",
"image#",
"manual\n",
"autocomplete\t",
]
for special_case in special_cases:
assert pattern.fullmatch(special_case) is None, f"Pattern should reject {special_case}"
def test_pattern_unicode(self):
"""Test that regex pattern handles Unicode characters"""
import re
pattern = re.compile(SELECTION_SOURCE_PATTERN)
# Chinese characters should fail
assert pattern.fullmatch("手动输入") is None
assert pattern.fullmatch("自动补全") is None
assert pattern.fullmatch("图片识别") is None
# Mixed characters should fail
assert pattern.fullmatch("manual输入") is None
assert pattern.fullmatch("autocomplete识别") is None
class TestSelectionSourceIntegration:
"""Test selection source integration in Pydantic models"""
def test_pydantic_validation_valid_sources(self):
"""Test that Pydantic validates valid selection sources"""
from pydantic import BaseModel, Field
class TestModel(BaseModel):
source: str = Field(pattern=SELECTION_SOURCE_PATTERN)
# All valid selection sources should pass validation
for valid_source in SELECTION_SOURCES:
model = TestModel(source=valid_source)
assert model.source == valid_source
def test_pydantic_validation_invalid_sources(self):
"""Test that Pydantic rejects invalid selection sources"""
from pydantic import BaseModel, Field, ValidationError
class TestModel(BaseModel):
source: str = Field(pattern=SELECTION_SOURCE_PATTERN)
invalid_sources = ["invalid", "upload", "scan", ""]
for invalid_source in invalid_sources:
with pytest.raises(ValidationError):
TestModel(source=invalid_source)
def test_optional_selection_source(self):
"""Test optional selection source field"""
from pydantic import BaseModel, Field
from typing import Optional
class TestModel(BaseModel):
source: Optional[str] = Field(None, pattern=SELECTION_SOURCE_PATTERN)
# None should pass
model = TestModel()
assert model.source is None
# Valid values should pass
model = TestModel(source="manual")
assert model.source == "manual"
# Invalid values should fail
from pydantic import ValidationError
with pytest.raises(ValidationError):
TestModel(source="invalid")
class TestSelectionSourceBusinessLogic:
"""Test selection source business logic"""
def test_all_sources_covered(self):
"""Test that all expected user input scenarios are covered"""
# Manual input
assert "manual" in SELECTION_SOURCES
# Autocomplete selection
assert "autocomplete" in SELECTION_SOURCES
# Batch import
assert "import" in SELECTION_SOURCES
# Image recognition
assert "image" in SELECTION_SOURCES
def test_no_redundant_sources(self):
"""Test that there are no redundant or duplicate selection sources"""
# Each selection source should represent a unique user interaction pattern
unique_patterns = {
"manual": "User directly inputs stock code",
"autocomplete": "User selects from autocomplete list",
"import": "User uses batch import function",
"image": "User uses image recognition function",
}
assert len(SELECTION_SOURCES) == len(unique_patterns)
def test_future_extensibility(self):
"""Test that pattern structure supports future extensions"""
# Current pattern should use group structure for easy extension
pattern_string = SELECTION_SOURCE_PATTERN
# Pattern should contain groups and pipe operator
assert "(" in pattern_string
assert ")" in pattern_string
assert "|" in pattern_string
def test_pattern_match_performance(self):
"""Test regex pattern match performance"""
import re
import time
pattern = re.compile(SELECTION_SOURCE_PATTERN)
# Test performance with many valid matches
start_time = time.time()
for _ in range(10000):
pattern.fullmatch("manual")
pattern.fullmatch("autocomplete")
pattern.fullmatch("import")
pattern.fullmatch("image")
end_time = time.time()
# Should complete in reasonable time (< 1 second)
assert end_time - start_time < 1.0
def test_pattern_reject_performance(self):
"""Test regex pattern reject performance"""
import re
import time
pattern = re.compile(SELECTION_SOURCE_PATTERN)
# Test performance with many invalid matches
start_time = time.time()
for _ in range(10000):
pattern.fullmatch("invalid_source_123")
end_time = time.time()
# Should complete in reasonable time (< 1 second)
assert end_time - start_time < 1.0
class TestSelectionSourceDocumentation:
"""Test selection source documentation and usage"""
def test_source_descriptions(self):
"""Test that each selection source has clear semantics"""
descriptions = {
"manual": "User manually inputs stock code",
"autocomplete": "User selects stock through autocomplete component",
"import": "User batch adds stocks through import function",
"image": "User adds stocks through image recognition function",
}
for source in SELECTION_SOURCES:
assert source in descriptions
assert descriptions[source] # Description is not empty
def test_source_use_cases(self):
"""Test that each selection source corresponds to use cases"""
use_cases = {
"manual": [
"User directly inputs 600519 in input box",
"User directly inputs AAPL in input box",
"User directly inputs 贵州茅台 in input box",
],
"autocomplete": [
"User inputs '茅台', selects '贵州茅台' from dropdown",
"User inputs 'gzmt', selects '贵州茅台' from dropdown",
"User inputs '6005', selects '600519.SH' from dropdown",
],
"import": [
"User batch imports stocks through Excel",
"User batch imports stocks through CSV",
"User imports from history records",
],
"image": [
"User uploads stock screenshot for recognition",
"User uploads market image for recognition",
],
}
for source in SELECTION_SOURCES:
assert source in use_cases
assert len(use_cases[source]) > 0
class TestSelectionSourceValidationIntegration:
"""Test selection source integration in task queue"""
def test_task_queue_validation(self):
"""Test that task queue validates selection sources"""
from src.services.task_queue import AnalysisTaskQueue
queue = AnalysisTaskQueue(max_workers=1)
# Valid selection sources should pass
for source in SELECTION_SOURCES:
try:
queue.validate_selection_source(source)
except ValueError:
pytest.fail(f"Valid selection source {source} was rejected")
def test_task_queue_reject_invalid_source(self):
"""Test that task queue rejects invalid selection sources"""
from src.services.task_queue import AnalysisTaskQueue
queue = AnalysisTaskQueue(max_workers=1)
invalid_sources = ["invalid", "upload", "scan", ""]
for invalid_source in invalid_sources:
with pytest.raises(ValueError, match="Invalid selection_source"):
queue.validate_selection_source(invalid_source)
def test_task_queue_none_source(self):
"""Test that task queue accepts None as selection source"""
from src.services.task_queue import AnalysisTaskQueue
queue = AnalysisTaskQueue(max_workers=1)
# None should pass validation (backward compatibility)
try:
queue.validate_selection_source(None)
except ValueError:
pytest.fail("None should be a valid selection source (backward compatibility)")

View File

@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
"""
===================================
Autocomplete PR0 Unit Tests
===================================
Test backend data contract extensions:
- AnalyzeRequest model extension
- TaskInfo dataclass extension
- Task queue accepts new fields
- Backward compatibility
"""
from api.v1.schemas.analysis import AnalyzeRequest
from concurrent.futures import Future
from src.services.task_queue import TaskInfo, get_task_queue, DuplicateTaskError, AnalysisTaskQueue
class TestAnalyzeRequest:
"""Test AnalyzeRequest model"""
def test_analyze_request_with_new_fields(self):
"""Test that AnalyzeRequest accepts new fields"""
request = AnalyzeRequest(
stock_code="600519",
async_mode=True,
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
)
assert request.stock_code == "600519"
assert request.stock_name == "贵州茅台"
assert request.original_query == "茅台"
assert request.selection_source == "autocomplete"
def test_analyze_request_backward_compatible(self):
"""Test backward compatibility: works fine without new fields"""
request = AnalyzeRequest(
stock_code="600519",
async_mode=True,
)
assert request.stock_code == "600519"
assert request.stock_name is None
assert request.original_query is None
assert request.selection_source is None
def test_analyze_request_validation_selection_source(self):
"""Test selection_source field validation"""
# Valid selection_source values
for source in ["manual", "autocomplete", "import", "image"]:
request = AnalyzeRequest(
stock_code="600519",
selection_source=source,
)
assert request.selection_source == source
def test_analyze_request_with_multiple_stocks(self):
"""Test support for new fields in batch analysis"""
request = AnalyzeRequest(
stock_codes=["600519", "000001"],
async_mode=True,
stock_name="批量股票",
original_query="600519,000001",
selection_source="import",
)
assert request.stock_codes == ["600519", "000001"]
assert request.stock_name == "批量股票"
assert request.original_query == "600519,000001"
assert request.selection_source == "import"
class TestTaskInfo:
"""Test TaskInfo dataclass"""
def test_task_info_with_new_fields(self):
"""Test that TaskInfo contains new fields"""
task = TaskInfo(
task_id="test123",
stock_code="600519",
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
)
d = task.to_dict()
assert "original_query" in d
assert "selection_source" in d
assert d["original_query"] == "茅台"
assert d["selection_source"] == "autocomplete"
def test_task_info_backward_compatible(self):
"""Test TaskInfo backward compatibility: works fine without new fields"""
task = TaskInfo(
task_id="test123",
stock_code="600519",
)
d = task.to_dict()
assert d["original_query"] is None
assert d["selection_source"] is None
def test_task_info_copy_includes_new_fields(self):
"""Test that TaskInfo.copy() includes new fields"""
task = TaskInfo(
task_id="test123",
stock_code="600519",
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
)
copied = task.copy()
assert copied.original_query == "茅台"
assert copied.selection_source == "autocomplete"
class TestTaskQueue:
"""Test task queue"""
def setup_method(self):
self._original_instance = AnalysisTaskQueue._instance
AnalysisTaskQueue._instance = None
def teardown_method(self):
queue = AnalysisTaskQueue._instance
if queue is not None and queue is not self._original_instance:
executor = getattr(queue, "_executor", None)
if executor is not None and hasattr(executor, "shutdown"):
executor.shutdown(wait=False, cancel_futures=True)
AnalysisTaskQueue._instance = self._original_instance
@staticmethod
def _build_queue():
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
return queue
def test_task_queue_accepts_new_fields(self):
"""Test task queue accepts new fields"""
queue = self._build_queue()
tasks, _duplicates = queue.submit_tasks_batch(
stock_codes=["600519"],
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
)
assert len(tasks) == 1
assert tasks[0].stock_name == "贵州茅台"
assert tasks[0].original_query == "茅台"
assert tasks[0].selection_source == "autocomplete"
def test_task_queue_backward_compatible(self):
"""Test task queue backward compatibility: works fine without new fields"""
queue = self._build_queue()
tasks, _duplicates = queue.submit_tasks_batch(
stock_codes=["600519"],
)
assert len(tasks) == 1
assert tasks[0].original_query is None
assert tasks[0].selection_source is None
def test_task_queue_batch_with_new_fields(self):
"""Test support for new fields during batch submission"""
queue = self._build_queue()
tasks, _duplicates = queue.submit_tasks_batch(
stock_codes=["600519", "000001"],
stock_name="批量股票",
original_query="600519,000001",
selection_source="import",
)
assert len(tasks) == 2
for task in tasks:
assert task.stock_name == "批量股票"
assert task.original_query == "600519,000001"
assert task.selection_source == "import"
def test_task_queue_duplicate_detection_with_new_fields(self):
"""Test that new fields do not affect duplicate submission detection logic"""
queue = self._build_queue()
stock_code = "600519"
# First submission
tasks1, dups1 = queue.submit_tasks_batch(
stock_codes=[stock_code],
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
)
assert len(tasks1) == 1
assert len(dups1) == 0
# Second submission (should be rejected)
tasks2, dups2 = queue.submit_tasks_batch(
stock_codes=[stock_code],
stock_name="贵州茅台",
original_query="茅台",
selection_source="manual", # Rejection still applies even if selection_source differs
)
assert len(tasks2) == 0
assert len(dups2) == 1
assert isinstance(dups2[0], DuplicateTaskError)
class TestIntegration:
"""Integration Tests"""
def setup_method(self):
self._original_instance = AnalysisTaskQueue._instance
AnalysisTaskQueue._instance = None
def teardown_method(self):
queue = AnalysisTaskQueue._instance
if queue is not None and queue is not self._original_instance:
executor = getattr(queue, "_executor", None)
if executor is not None and hasattr(executor, "shutdown"):
executor.shutdown(wait=False, cancel_futures=True)
AnalysisTaskQueue._instance = self._original_instance
def test_end_to_end_flow_with_autocomplete(self):
"""Test end-to-end flow: autocomplete -> analysis request -> task creation"""
# Simulate request after autocomplete
request = AnalyzeRequest(
stock_code="600519.SH",
async_mode=True,
stock_name="贵州茅台",
original_query="茅台",
selection_source="autocomplete",
report_type="detailed",
)
# Submit to task queue
queue = get_task_queue()
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
tasks, _duplicates = queue.submit_tasks_batch(
stock_codes=[request.stock_code],
stock_name=request.stock_name,
original_query=request.original_query,
selection_source=request.selection_source,
report_type=request.report_type,
)
assert len(tasks) == 1
task = tasks[0]
assert task.stock_code == "600519.SH"
assert task.stock_name == "贵州茅台"
assert task.original_query == "茅台"
assert task.selection_source == "autocomplete"
assert task.report_type == "detailed"
def test_end_to_end_flow_manual_input(self):
"""Test end-to-end flow: manual input -> analysis request -> task creation"""
# Simulate manual input request
request = AnalyzeRequest(
stock_code="600519",
async_mode=True,
selection_source="manual",
)
# Submit to task queue
queue = get_task_queue()
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
tasks, _duplicates = queue.submit_tasks_batch(
stock_codes=[request.stock_code],
selection_source=request.selection_source,
report_type=request.report_type,
)
assert len(tasks) == 1
task = tasks[0]
assert task.stock_code == "600519"
assert task.selection_source == "manual"

View File

@@ -137,3 +137,9 @@ class TestResolveNameToCode:
mock_akshare.return_value = {}
result = resolve_name_to_code("不存在的股票名称xyz")
assert result is None
@patch("src.services.name_to_code_resolver._get_akshare_name_to_code")
def test_skips_akshare_for_non_cjk_garbage_input(self, mock_akshare):
result = resolve_name_to_code("aaaaaaa")
assert result is None
mock_akshare.assert_not_called()

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""
===================================
Search Algorithm Performance Tests
===================================
Benchmarks the name-to-code resolution engine under load.
"""
import time
import random
import string
import pytest
from unittest.mock import patch
from src.services.name_to_code_resolver import resolve_name_to_code
def generate_random_name(length=4):
return ''.join(random.choices(string.ascii_letters, k=length))
def generate_random_cjk_name(length=3):
return ''.join(chr(random.randint(0x4e00, 0x9fff)) for _ in range(length))
class TestSearchPerformance:
"""Benchmark tests for stock search resolution."""
@pytest.mark.benchmark
def test_resolve_name_to_code_throughput(self):
"""Test throughput of name resolution for various input types."""
# 1. Realistic mixed inputs (codes, names, typos)
inputs = [
"600519", "00700", "AAPL", "TSLA",
"贵州茅台", "腾讯控股", "阿里巴巴",
"贵州茅苔", "平安银形", # typos
"aaaaaaa", "1234567", # garbage
]
start_time = time.time()
iterations = 100
for _ in range(iterations):
for s in inputs:
resolve_name_to_code(s)
duration = time.time() - start_time
avg_ms = (duration / (iterations * len(inputs))) * 1000
print(f"\nAverage resolution time: {avg_ms:.2f}ms")
# Resolution should be fast (mostly < 5ms for local hits, < 20ms for fuzzy)
assert avg_ms < 50, f"Search resolution too slow: {avg_ms:.2f}ms"
@pytest.mark.benchmark
@patch("src.services.name_to_code_resolver._get_akshare_name_to_code")
def test_fuzzy_match_performance_large_set(self, mock_akshare):
"""Test difflib fuzzy matching performance with a 5000+ stock set."""
# Simulate 5000 stocks from AkShare
fake_market = {f"股票_{i}": f"{i:06d}" for i in range(5000)}
mock_akshare.return_value = fake_market
query = "股票_4999" # Worst case or near worst case for fuzzy matching
start_time = time.time()
iterations = 20
for _ in range(iterations):
resolve_name_to_code(query)
duration = time.time() - start_time
avg_ms = (duration / iterations) * 1000
print(f"\nFuzzy match (5000 stocks) avg time: {avg_ms:.2f}ms")
# Fuzzy matching 5000 strings is CPU intensive.
# Aiming for < 100ms per request on a standard CI environment.
assert avg_ms < 200, f"Fuzzy matching too slow: {avg_ms:.2f}ms"