mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* 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:
13
README.md
13
README.md
@@ -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)。
|
||||
|
||||
@@ -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. 从数据库查询已完成的记录
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
1
apps/dsa-web/public/stocks.index.json
Normal file
1
apps/dsa-web/public/stocks.index.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
apps/dsa-web/src/components/StockAutocomplete/index.ts
Normal file
8
apps/dsa-web/src/components/StockAutocomplete/index.ts
Normal 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';
|
||||
89
apps/dsa-web/src/hooks/__tests__/useAutocomplete.test.tsx
Normal file
89
apps/dsa-web/src/hooks/__tests__/useAutocomplete.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
199
apps/dsa-web/src/hooks/useAutocomplete.ts
Normal file
199
apps/dsa-web/src/hooks/useAutocomplete.ts
Normal 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;
|
||||
74
apps/dsa-web/src/hooks/useStockIndex.ts
Normal file
74
apps/dsa-web/src/hooks/useStockIndex.ts
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '分析' }));
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
77
apps/dsa-web/src/types/stockIndex.ts
Normal file
77
apps/dsa-web/src/types/stockIndex.ts
Normal 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[];
|
||||
305
apps/dsa-web/src/utils/__tests__/normalizeQuery.test.ts
Normal file
305
apps/dsa-web/src/utils/__tests__/normalizeQuery.test.ts
Normal 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('wkA')).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');
|
||||
});
|
||||
});
|
||||
});
|
||||
418
apps/dsa-web/src/utils/__tests__/searchStocks.test.ts
Normal file
418
apps/dsa-web/src/utils/__tests__/searchStocks.test.ts
Normal 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: "wankeA",
|
||||
pinyinAbbr: "wkA",
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
485
apps/dsa-web/src/utils/__tests__/stockIndexLoader.test.ts
Normal file
485
apps/dsa-web/src/utils/__tests__/stockIndexLoader.test.ts
Normal 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(['测试(集团)']);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
apps/dsa-web/src/utils/normalizeQuery.ts
Normal file
87
apps/dsa-web/src/utils/normalizeQuery.ts
Normal 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);
|
||||
}
|
||||
186
apps/dsa-web/src/utils/searchStocks.ts
Normal file
186
apps/dsa-web/src/utils/searchStocks.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
54
apps/dsa-web/src/utils/stockIndexFields.ts
Normal file
54
apps/dsa-web/src/utils/stockIndexFields.ts
Normal 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;
|
||||
161
apps/dsa-web/src/utils/stockIndexLoader.ts
Normal file
161
apps/dsa-web/src/utils/stockIndexLoader.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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`。
|
||||
|
||||
323
scripts/generate_index_from_csv.py
Normal file
323
scripts/generate_index_from_csv.py
Normal 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())
|
||||
288
scripts/generate_stock_index.py
Normal file
288
scripts/generate_stock_index.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
10
src/utils/analysis_metadata.py
Normal file
10
src/utils/analysis_metadata.py
Normal 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) + ")$"
|
||||
@@ -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()})()
|
||||
|
||||
132
tests/test_analysis_integration.py
Normal file
132
tests/test_analysis_integration.py
Normal 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
|
||||
346
tests/test_analysis_metadata.py
Normal file
346
tests/test_analysis_metadata.py
Normal 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)")
|
||||
268
tests/test_autocomplete_pr0.py
Normal file
268
tests/test_autocomplete_pr0.py
Normal 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"
|
||||
@@ -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()
|
||||
|
||||
71
tests/test_search_performance.py
Normal file
71
tests/test_search_performance.py
Normal 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"
|
||||
Reference in New Issue
Block a user