mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: Web/API 指数入口与共享 canonical 去重基础 (#2312)
* feat: Web/API 指数入口与共享 canonical 去重基础 为 Issue #2303 Phase 2 PR1 落地 Web/API 指数入口适配: - API 使用 parse_analysis_target 构造结构化 AnalysisTarget 并贯通到 pipeline - API 与 TaskQueue 去重按 asset_type 分支(指数用 canonical_id,个股用 legacy code) - BatchTaskAcceptedResponse 追加可选 rejected 字段,未登记 CSI 单股 400、批量仅该目标失败 - TaskInfo 固化 dedupe_key,避免指数与同码个股折叠及 _analyzing_stocks 残留 - Web 移除 assetType=index 全局过滤,Chat 名称识别保护指数 canonical - 补齐 Pipeline 指数 DecisionSignal market_override=cn 真实分支测试 * fix: 收敛指数 canonical 身份与批量响应契约 PR #2312 review 修复:报告 meta 补充 asset_type 隐藏指数自选;/analyze 在解析前限制非空原始 token;is_single 统一驱动 metadata/409/单任务 202;HomePage 三元计数继续后续 chunk。 * fix: avoid double space in index news search query * fix: preserve canonical index identity * fix: validate legacy task asset type * fix: preserve canonical index identity in chat * fix: preserve index identity across chat backends
This commit is contained in:
@@ -21,6 +21,7 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -38,6 +39,7 @@ from api.v1.schemas.analysis import (
|
|||||||
BatchTaskAcceptedResponse,
|
BatchTaskAcceptedResponse,
|
||||||
BatchTaskAcceptedItem,
|
BatchTaskAcceptedItem,
|
||||||
BatchDuplicateTaskItem,
|
BatchDuplicateTaskItem,
|
||||||
|
RejectedTaskItem,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TaskInfo,
|
TaskInfo,
|
||||||
TaskListResponse,
|
TaskListResponse,
|
||||||
@@ -75,6 +77,7 @@ from src.market_phase_summary import (
|
|||||||
rebuild_market_phase_summary_for_stock_code,
|
rebuild_market_phase_summary_for_stock_code,
|
||||||
)
|
)
|
||||||
from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis
|
from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis
|
||||||
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
from src.report_language import get_localized_stock_name, normalize_report_language
|
from src.report_language import get_localized_stock_name, normalize_report_language
|
||||||
from src.schemas.decision_action import build_action_fields
|
from src.schemas.decision_action import build_action_fields
|
||||||
from src.services.name_to_code_resolver import resolve_name_to_code
|
from src.services.name_to_code_resolver import resolve_name_to_code
|
||||||
@@ -83,6 +86,7 @@ from src.services.task_queue import (
|
|||||||
DuplicateTaskError,
|
DuplicateTaskError,
|
||||||
TaskStatus as TaskStatusEnum,
|
TaskStatus as TaskStatusEnum,
|
||||||
)
|
)
|
||||||
|
from src.services.analysis_service import asset_type_from_canonical_code
|
||||||
from src.services.run_diagnostics import build_run_diagnostic_summary
|
from src.services.run_diagnostics import build_run_diagnostic_summary
|
||||||
from src.services.run_flow import build_task_run_flow_snapshot
|
from src.services.run_flow import build_task_run_flow_snapshot
|
||||||
from src.services.empty_news import empty_news_disclosure_from_stored
|
from src.services.empty_news import empty_news_disclosure_from_stored
|
||||||
@@ -113,6 +117,32 @@ def _get_task_trace_id(task: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _task_asset_type(task: Any) -> Optional[str]:
|
||||||
|
"""Return the task's optional asset type only when it is a real literal.
|
||||||
|
|
||||||
|
The Pydantic literal domain (``stock``/``index``) is the only allowed set:
|
||||||
|
real in-domain strings pass through verbatim. Legacy mock/proxy tasks whose
|
||||||
|
``getattr`` yields a ``MagicMock`` child, and missing values, degrade to
|
||||||
|
``None`` so schema defaults keep legacy responses unchanged. Genuine
|
||||||
|
out-of-domain strings are logged and degraded instead of widening the enum
|
||||||
|
or silently masking drift.
|
||||||
|
"""
|
||||||
|
raw = getattr(task, "asset_type", None)
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
if raw in {"stock", "index"}:
|
||||||
|
return raw
|
||||||
|
# 任何真实字符串只要不精确等于字面量域(含空串、纯空白、大小写或带
|
||||||
|
# 空格形态)都必须记录 warning 后降级,避免静默掩盖漂移;仅非字符串
|
||||||
|
# 代理(如 MagicMock 子对象)保持静默 None。
|
||||||
|
logger.warning(
|
||||||
|
"task asset_type 超出字面量域,降级为 None: task_id=%s asset_type=%r",
|
||||||
|
getattr(task, "task_id", None),
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _market_review_lock_path(config: Config) -> Path:
|
def _market_review_lock_path(config: Config) -> Path:
|
||||||
return market_review_lock_path(config)
|
return market_review_lock_path(config)
|
||||||
|
|
||||||
@@ -226,32 +256,68 @@ def _is_obviously_invalid_analysis_input(text: str) -> bool:
|
|||||||
return has_letters and has_digits
|
return has_letters and has_digits
|
||||||
|
|
||||||
|
|
||||||
def _resolve_and_normalize_input(raw_value: str) -> str:
|
def _resolve_analysis_input(raw_value: str):
|
||||||
"""
|
"""
|
||||||
Resolve and normalize a stock input for analysis requests.
|
Resolve one analysis request input into ``(code, analysis_target)``.
|
||||||
|
|
||||||
Code-like values keep the existing canonical path.
|
Code-like tokens go through :func:`parse_analysis_target` (the single
|
||||||
Non-code inputs must resolve to a known stock code. Obvious garbage
|
asset-type authority): registered indices keep their structured
|
||||||
input is rejected before expensive resolver and task-queue work.
|
``AnalysisTarget`` (canonical id + index semantics), unsupported targets
|
||||||
|
(e.g. unregistered ``930956.CSI``) are surfaced with their reason so the
|
||||||
|
caller can reject them explicitly, and stock tokens keep the legacy
|
||||||
|
resolution path. Non-code names (e.g. ``贵州茅台``) keep
|
||||||
|
:func:`resolve_name_to_code` and never enter index classification.
|
||||||
"""
|
"""
|
||||||
text = (raw_value or "").strip()
|
text = (raw_value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
|
# CSI explicit forms (``csi930955`` / ``930955.CSI`` / ``CSI930955``) are
|
||||||
|
# explicit code forms but ``is_code_like`` does not recognise the ``.CSI``
|
||||||
|
# suffix; route them through parse_analysis_target so registered CSI
|
||||||
|
# converges to its canonical index identity and unregistered CSI surfaces
|
||||||
|
# as an explicit unsupported error (never a name-resolution fallback).
|
||||||
|
normalized_csi = unicodedata.normalize("NFKC", text).strip().casefold()
|
||||||
|
if re.fullmatch(r"(?:csi\d{6}|\d{6}\.csi)", normalized_csi):
|
||||||
|
target = parse_analysis_target(text)
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
return (target.canonical_id, target)
|
||||||
|
return (text, target)
|
||||||
|
|
||||||
|
# SH/SZ prefixed six-digit forms (``sh000016`` / ``SH000016``) are explicit
|
||||||
|
# index/stock code shapes, but ``is_code_like`` rejects them when the bare
|
||||||
|
# digits do not match the exchange's *stock* digit rules (``000016`` is an
|
||||||
|
# SH index yet classifies as an SZ stock digit shape). Route them through
|
||||||
|
# parse_analysis_target so registered SH/SZ indices keep their structured
|
||||||
|
# target; unknown prefixed forms degrade to the stock path as usual.
|
||||||
|
normalized_sh_sz = re.fullmatch(r"(sh|sz)(\d{6})", normalized_csi)
|
||||||
|
if normalized_sh_sz is not None:
|
||||||
|
target = parse_analysis_target(text)
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
return (target.canonical_id, target)
|
||||||
|
return (resolve_index_stock_code_for_analysis(text), None)
|
||||||
|
|
||||||
if is_code_like(text):
|
if is_code_like(text):
|
||||||
return resolve_index_stock_code_for_analysis(text)
|
target = parse_analysis_target(text)
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
return (target.canonical_id, target)
|
||||||
|
if target.asset_type == ParseStatus.UNSUPPORTED:
|
||||||
|
return (text, target)
|
||||||
|
# Stock target: keep the existing canonical resolution path and do not
|
||||||
|
# carry a stock target downstream (stock semantics must not change).
|
||||||
|
return (resolve_index_stock_code_for_analysis(text), None)
|
||||||
|
|
||||||
if text.isdigit() and len(text) == 4:
|
if text.isdigit() and len(text) == 4:
|
||||||
resolved_index_code = resolve_index_stock_code_for_analysis(text)
|
resolved_index_code = resolve_index_stock_code_for_analysis(text)
|
||||||
if resolved_index_code != canonical_stock_code(text):
|
if resolved_index_code != canonical_stock_code(text):
|
||||||
return resolved_index_code
|
return (resolved_index_code, None)
|
||||||
|
|
||||||
if _is_obviously_invalid_analysis_input(text):
|
if _is_obviously_invalid_analysis_input(text):
|
||||||
raise _invalid_analysis_input_error()
|
raise _invalid_analysis_input_error()
|
||||||
|
|
||||||
resolved = resolve_name_to_code(text)
|
resolved = resolve_name_to_code(text)
|
||||||
if resolved:
|
if resolved:
|
||||||
return canonical_stock_code(resolved)
|
return (canonical_stock_code(resolved), None)
|
||||||
|
|
||||||
raise _invalid_analysis_input_error()
|
raise _invalid_analysis_input_error()
|
||||||
|
|
||||||
@@ -313,29 +379,56 @@ def trigger_analysis(
|
|||||||
if not stock_codes:
|
if not stock_codes:
|
||||||
raise api_error(400, "validation_error", "必须提供 stock_code 或 stock_codes 参数")
|
raise api_error(400, "validation_error", "必须提供 stock_code 或 stock_codes 参数")
|
||||||
|
|
||||||
|
# Limit the number of non-blank raw tokens BEFORE resolution. Rejected and
|
||||||
|
# duplicate tokens must also count toward the cap, otherwise a request that
|
||||||
|
# mixes one valid token with many rejected/duplicate tokens could bypass the
|
||||||
|
# DoS limit via the post-dedup check below.
|
||||||
|
MAX_BATCH_SIZE = 50
|
||||||
|
non_empty_raw_tokens = [c for c in stock_codes if str(c or "").strip()]
|
||||||
|
if len(non_empty_raw_tokens) > MAX_BATCH_SIZE:
|
||||||
|
raise api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票")
|
||||||
|
|
||||||
# Normalize and de-duplicate inputs while preserving compatibility.
|
# Normalize and de-duplicate inputs while preserving compatibility.
|
||||||
resolved = [_resolve_and_normalize_input(c) for c in stock_codes]
|
# Code-like tokens go through parse_analysis_target (the single asset-type
|
||||||
|
# authority) so registered indices keep their structured target; non-code
|
||||||
|
# names keep the legacy stock-name resolution path.
|
||||||
|
resolved_entries = [_resolve_analysis_input(c) for c in stock_codes]
|
||||||
|
|
||||||
seen = set()
|
seen = set()
|
||||||
unique_codes = []
|
unique_codes = []
|
||||||
for code in resolved:
|
unique_targets = []
|
||||||
|
rejected_entries = []
|
||||||
|
for entry in resolved_entries:
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
code, target = entry
|
||||||
if not code:
|
if not code:
|
||||||
continue
|
continue
|
||||||
# Use normalize_stock_code to ensure '600519' and '600519.SH' are merged
|
if target is not None and target.asset_type == ParseStatus.UNSUPPORTED:
|
||||||
|
rejected_entries.append((code, target))
|
||||||
|
continue
|
||||||
|
# 去重键按 asset_type 分支:INDEX 用 canonical_id(指数与同码股票不折叠、
|
||||||
|
# CSI alias 收敛),STOCK 保持 normalize_stock_code 既有语义
|
||||||
|
# ('600519' 与 '600519.SH' 合并)。
|
||||||
|
if target is not None and target.asset_type == ParseStatus.INDEX:
|
||||||
|
norm = target.canonical_id
|
||||||
|
else:
|
||||||
norm = normalize_stock_code(code)
|
norm = normalize_stock_code(code)
|
||||||
if norm not in seen:
|
if norm not in seen:
|
||||||
seen.add(norm)
|
seen.add(norm)
|
||||||
unique_codes.append(code)
|
unique_codes.append(code)
|
||||||
|
unique_targets.append(target)
|
||||||
|
|
||||||
stock_codes = unique_codes
|
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 api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票")
|
|
||||||
|
|
||||||
if not stock_codes:
|
if not stock_codes:
|
||||||
|
if not rejected_entries:
|
||||||
raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符")
|
raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符")
|
||||||
|
# 全部目标都被拒绝(单请求或批量全拒):无任务被接受时返回 202 会误导
|
||||||
|
# 客户端,一律明确 400;部分被拒则走下方 rejected 语义。
|
||||||
|
code, target = rejected_entries[0]
|
||||||
|
reason = target.unsupported_reason or f"不支持的目标: {code}"
|
||||||
|
raise api_error(400, "validation_error", reason)
|
||||||
|
|
||||||
# Sync mode only supports single-stock analysis.
|
# Sync mode only supports single-stock analysis.
|
||||||
if not request.async_mode:
|
if not request.async_mode:
|
||||||
@@ -345,25 +438,45 @@ def trigger_analysis(
|
|||||||
"validation_error",
|
"validation_error",
|
||||||
"同步模式仅支持单只股票分析,请使用 async_mode=true 进行批量分析",
|
"同步模式仅支持单只股票分析,请使用 async_mode=true 进行批量分析",
|
||||||
)
|
)
|
||||||
return _handle_sync_analysis(stock_codes[0], request)
|
if rejected_entries:
|
||||||
|
# 同步模式不支持 rejected 语义:只要存在被拒绝目标(含全部被拒绝)
|
||||||
|
# 就以第一个未登记目标的明确校验错误返回 4xx。
|
||||||
|
code, target = rejected_entries[0]
|
||||||
|
reason = target.unsupported_reason or f"不支持的目标: {code}"
|
||||||
|
raise api_error(400, "validation_error", reason)
|
||||||
|
return _handle_sync_analysis(stock_codes[0], request, analysis_target=unique_targets[0] if unique_targets else None)
|
||||||
|
|
||||||
# Async mode submits one task per stock.
|
# Async mode submits one task per stock.
|
||||||
return _handle_async_analysis_batch(stock_codes, request)
|
return _handle_async_analysis_batch(stock_codes, request, analysis_targets=unique_targets, rejected_entries=rejected_entries)
|
||||||
|
|
||||||
|
|
||||||
def _handle_async_analysis_batch(
|
def _handle_async_analysis_batch(
|
||||||
stock_codes: list,
|
stock_codes: list,
|
||||||
request: AnalyzeRequest
|
request: AnalyzeRequest,
|
||||||
|
analysis_targets: Optional[list] = None,
|
||||||
|
rejected_entries: Optional[list] = None,
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Handle asynchronous analysis requests, including batch submission.
|
Handle asynchronous analysis requests, including batch submission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_codes: canonical codes to submit
|
||||||
|
request: the analysis request
|
||||||
|
analysis_targets: optional per-code structured targets (index targets
|
||||||
|
flow through to the pipeline)
|
||||||
|
rejected_entries: optional list of ``(code, target)`` pairs that were
|
||||||
|
explicitly rejected (e.g. unregistered CSI); returned in the
|
||||||
|
``rejected`` field for batch requests only.
|
||||||
"""
|
"""
|
||||||
task_queue = get_task_queue()
|
task_queue = get_task_queue()
|
||||||
|
|
||||||
# Preserve metadata for single-stock requests. For batch requests,
|
# Preserve metadata for single-stock requests. For batch requests,
|
||||||
# only carry through metadata that semantically applies to the whole
|
# only carry through metadata that semantically applies to the whole
|
||||||
# batch, such as import/image source tracking.
|
# batch, such as import/image source tracking.
|
||||||
is_single = len(stock_codes) == 1
|
# A single "accepted" code alongside any rejected entries is a batch:
|
||||||
|
# rejected entries mean the server has not fully disposed of a single-stock
|
||||||
|
# request, so single-stock metadata/409/single-202 semantics must not apply.
|
||||||
|
is_single = len(stock_codes) == 1 and not rejected_entries
|
||||||
preserve_batch_metadata = request.selection_source in {"import", "image"}
|
preserve_batch_metadata = request.selection_source in {"import", "image"}
|
||||||
|
|
||||||
stock_name = request.stock_name if is_single else None
|
stock_name = request.stock_name if is_single else None
|
||||||
@@ -388,6 +501,10 @@ def _handle_async_analysis_batch(
|
|||||||
submit_kwargs["report_language"] = report_language
|
submit_kwargs["report_language"] = report_language
|
||||||
if skills is not None:
|
if skills is not None:
|
||||||
submit_kwargs["skills"] = skills
|
submit_kwargs["skills"] = skills
|
||||||
|
# 仅当存在非 None 的结构化 target(如指数)时才传递,保持纯股票请求
|
||||||
|
# 的既有 kwargs 契约不变。
|
||||||
|
if analysis_targets is not None and any(t is not None for t in analysis_targets):
|
||||||
|
submit_kwargs["analysis_targets"] = analysis_targets
|
||||||
|
|
||||||
accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)
|
accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)
|
||||||
|
|
||||||
@@ -399,6 +516,7 @@ def _handle_async_analysis_batch(
|
|||||||
status="pending",
|
status="pending",
|
||||||
message=f"分析任务已加入队列: {task.stock_code}",
|
message=f"分析任务已加入队列: {task.stock_code}",
|
||||||
analysis_phase=task.analysis_phase,
|
analysis_phase=task.analysis_phase,
|
||||||
|
asset_type=_task_asset_type(task),
|
||||||
)
|
)
|
||||||
for task in accepted_tasks
|
for task in accepted_tasks
|
||||||
]
|
]
|
||||||
@@ -410,9 +528,16 @@ def _handle_async_analysis_batch(
|
|||||||
)
|
)
|
||||||
for dup in duplicate_errors
|
for dup in duplicate_errors
|
||||||
]
|
]
|
||||||
|
rejected = [
|
||||||
|
RejectedTaskItem(
|
||||||
|
stock_code=code,
|
||||||
|
message=(target.unsupported_reason or f"不支持的目标: {code}"),
|
||||||
|
)
|
||||||
|
for code, target in (rejected_entries or [])
|
||||||
|
]
|
||||||
|
|
||||||
# 单只股票且被拒绝:保持 409 兼容性
|
# 单只股票且被拒绝:保持 409 兼容性
|
||||||
if len(stock_codes) == 1 and duplicates:
|
if is_single and duplicates:
|
||||||
dup = duplicates[0]
|
dup = duplicates[0]
|
||||||
error_response = DuplicateTaskErrorResponse(
|
error_response = DuplicateTaskErrorResponse(
|
||||||
error="duplicate_task",
|
error="duplicate_task",
|
||||||
@@ -425,8 +550,8 @@ def _handle_async_analysis_batch(
|
|||||||
content=error_response.model_dump()
|
content=error_response.model_dump()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 单只股票成功:保持原有响应格式兼容性
|
# 单只股票成功(且无 rejected):保持原有响应格式兼容性
|
||||||
if len(stock_codes) == 1 and accepted:
|
if is_single and accepted and not rejected:
|
||||||
task_accepted = TaskAccepted(
|
task_accepted = TaskAccepted(
|
||||||
task_id=accepted[0].task_id,
|
task_id=accepted[0].task_id,
|
||||||
trace_id=accepted[0].trace_id,
|
trace_id=accepted[0].trace_id,
|
||||||
@@ -439,11 +564,17 @@ def _handle_async_analysis_batch(
|
|||||||
content=task_accepted.model_dump()
|
content=task_accepted.model_dump()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 批量:返回汇总结果
|
# 批量:返回汇总结果(rejected 仅 async 批量返回)
|
||||||
|
rejected_count = len(rejected)
|
||||||
|
if rejected_count:
|
||||||
|
message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过,{rejected_count} 个被拒绝"
|
||||||
|
else:
|
||||||
|
message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过"
|
||||||
batch_response = BatchTaskAcceptedResponse(
|
batch_response = BatchTaskAcceptedResponse(
|
||||||
accepted=accepted,
|
accepted=accepted,
|
||||||
duplicates=duplicates,
|
duplicates=duplicates,
|
||||||
message=f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过",
|
rejected=rejected if rejected else None,
|
||||||
|
message=message,
|
||||||
)
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=202,
|
status_code=202,
|
||||||
@@ -453,7 +584,8 @@ def _handle_async_analysis_batch(
|
|||||||
|
|
||||||
def _handle_sync_analysis(
|
def _handle_sync_analysis(
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
request: AnalyzeRequest
|
request: AnalyzeRequest,
|
||||||
|
analysis_target: Optional[Any] = None,
|
||||||
) -> AnalysisResultResponse:
|
) -> AnalysisResultResponse:
|
||||||
"""
|
"""
|
||||||
处理同步分析请求
|
处理同步分析请求
|
||||||
@@ -476,6 +608,7 @@ def _handle_sync_analysis(
|
|||||||
skills=getattr(request, "skills", None),
|
skills=getattr(request, "skills", None),
|
||||||
analysis_phase=request.analysis_phase,
|
analysis_phase=request.analysis_phase,
|
||||||
report_language=getattr(request, "report_language", None),
|
report_language=getattr(request, "report_language", None),
|
||||||
|
analysis_target=analysis_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -647,6 +780,7 @@ def get_task_list(
|
|||||||
analysis_phase=t.analysis_phase,
|
analysis_phase=t.analysis_phase,
|
||||||
skills=getattr(t, "skills", None),
|
skills=getattr(t, "skills", None),
|
||||||
region=t.region,
|
region=t.region,
|
||||||
|
asset_type=_task_asset_type(t),
|
||||||
)
|
)
|
||||||
for t in all_tasks
|
for t in all_tasks
|
||||||
]
|
]
|
||||||
@@ -1232,6 +1366,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
|
|||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
change_pct=change_pct,
|
change_pct=change_pct,
|
||||||
market_phase_summary=market_phase_summary,
|
market_phase_summary=market_phase_summary,
|
||||||
|
asset_type=asset_type_from_canonical_code(record.code),
|
||||||
),
|
),
|
||||||
summary=ReportSummary(
|
summary=ReportSummary(
|
||||||
sentiment_score=record.sentiment_score,
|
sentiment_score=record.sentiment_score,
|
||||||
@@ -1394,6 +1529,7 @@ def _build_analysis_report(
|
|||||||
change_pct=change_pct,
|
change_pct=change_pct,
|
||||||
model_used=normalize_model_used(meta_data.get("model_used")),
|
model_used=normalize_model_used(meta_data.get("model_used")),
|
||||||
market_phase_summary=market_phase_summary,
|
market_phase_summary=market_phase_summary,
|
||||||
|
asset_type=asset_type_from_canonical_code(raw_stock_code),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _looks_like_raw_result_payload(candidate: Any) -> bool:
|
def _looks_like_raw_result_payload(candidate: Any) -> bool:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from src.report_language import (
|
|||||||
normalize_report_language,
|
normalize_report_language,
|
||||||
)
|
)
|
||||||
from src.services.history_service import HistoryService, MarkdownReportGenerationError
|
from src.services.history_service import HistoryService, MarkdownReportGenerationError
|
||||||
|
from src.services.analysis_service import asset_type_from_canonical_code
|
||||||
from src.schemas.decision_action import build_action_fields
|
from src.schemas.decision_action import build_action_fields
|
||||||
from src.utils.data_processing import (
|
from src.utils.data_processing import (
|
||||||
normalize_model_used,
|
normalize_model_used,
|
||||||
@@ -129,14 +130,30 @@ def _history_share_image_branding(config: object) -> ShareImageBranding:
|
|||||||
return share_image_branding_from_config(config)
|
return share_image_branding_from_config(config)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_code_for_grouping(code: str) -> str:
|
def _stock_bar_group_key(record_code: str, display_code: str) -> str:
|
||||||
"""Normalize stock code for deduplication grouping.
|
"""Build the stock-bar grouping key from the *persisted* code.
|
||||||
|
|
||||||
Delegates to data_provider.base.normalize_stock_code which handles
|
PR #2312: registered indices are typed by the persisted ``record.code``
|
||||||
SH600519, 600519.SH, HK00700, 00700.HK, BJ920748, etc.
|
(never guessed from the display code) and group by the parser canonical
|
||||||
|
(lowercase ``sh000016`` / ``csi930955``), so every explicit index form
|
||||||
|
(uppercase legacy / dotted alias) converges to one row and never folds
|
||||||
|
with the bare same-code stock. Stocks keep the legacy display-based
|
||||||
|
normalization (``SH600519``/``600519.SH`` -> ``600519``, JP/KR legacy bare
|
||||||
|
``005930`` merging with ``005930.KS`` etc.), preserving existing semantics.
|
||||||
"""
|
"""
|
||||||
from data_provider.base import normalize_stock_code
|
from data_provider.base import normalize_stock_code
|
||||||
return normalize_stock_code(code or "")
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
|
|
||||||
|
code = str(record_code or "").strip()
|
||||||
|
if not code:
|
||||||
|
return normalize_stock_code(display_code or "")
|
||||||
|
try:
|
||||||
|
target = parse_analysis_target(code)
|
||||||
|
except Exception:
|
||||||
|
return normalize_stock_code(display_code or "")
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
return target.canonical_id
|
||||||
|
return normalize_stock_code(display_code or "")
|
||||||
|
|
||||||
|
|
||||||
def _raw_result_value(raw_result: Any, key: str) -> Any:
|
def _raw_result_value(raw_result: Any, key: str) -> Any:
|
||||||
@@ -274,6 +291,7 @@ def get_history_list(
|
|||||||
model_used=item.get("model_used"),
|
model_used=item.get("model_used"),
|
||||||
created_at=item.get("created_at"),
|
created_at=item.get("created_at"),
|
||||||
market_phase_summary=item.get("market_phase_summary"),
|
market_phase_summary=item.get("market_phase_summary"),
|
||||||
|
asset_type=item.get("asset_type"),
|
||||||
)
|
)
|
||||||
for item in result.get("items", [])
|
for item in result.get("items", [])
|
||||||
]
|
]
|
||||||
@@ -431,7 +449,7 @@ def get_stock_bar(
|
|||||||
seen: dict = {}
|
seen: dict = {}
|
||||||
for record in records:
|
for record in records:
|
||||||
display_code = service._display_stock_code(record.code or "")
|
display_code = service._display_stock_code(record.code or "")
|
||||||
norm_code = _normalize_code_for_grouping(display_code)
|
norm_code = _stock_bar_group_key(record.code or "", display_code)
|
||||||
if norm_code not in seen or record.id > seen[norm_code].id:
|
if norm_code not in seen or record.id > seen[norm_code].id:
|
||||||
seen[norm_code] = record
|
seen[norm_code] = record
|
||||||
|
|
||||||
@@ -482,6 +500,7 @@ def get_stock_bar(
|
|||||||
record.code,
|
record.code,
|
||||||
getattr(record, "context_snapshot", None),
|
getattr(record, "context_snapshot", None),
|
||||||
),
|
),
|
||||||
|
asset_type=asset_type_from_canonical_code(record.code),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -589,6 +608,9 @@ def get_history_detail(
|
|||||||
change_pct=change_pct,
|
change_pct=change_pct,
|
||||||
model_used=normalize_model_used(result.get("model_used")),
|
model_used=normalize_model_used(result.get("model_used")),
|
||||||
market_phase_summary=market_phase_summary,
|
market_phase_summary=market_phase_summary,
|
||||||
|
asset_type=asset_type_from_canonical_code(
|
||||||
|
result.get("storage_stock_code") or result.get("stock_code")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = ReportSummary(
|
summary = ReportSummary(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from api.v1.schemas.analysis import (
|
|||||||
AnalysisResultResponse,
|
AnalysisResultResponse,
|
||||||
TaskAccepted,
|
TaskAccepted,
|
||||||
BatchTaskAcceptedResponse,
|
BatchTaskAcceptedResponse,
|
||||||
|
RejectedTaskItem,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
)
|
)
|
||||||
from api.v1.schemas.history import (
|
from api.v1.schemas.history import (
|
||||||
@@ -168,6 +169,7 @@ __all__ = [
|
|||||||
"AnalysisResultResponse",
|
"AnalysisResultResponse",
|
||||||
"TaskAccepted",
|
"TaskAccepted",
|
||||||
"BatchTaskAcceptedResponse",
|
"BatchTaskAcceptedResponse",
|
||||||
|
"RejectedTaskItem",
|
||||||
"TaskStatus",
|
"TaskStatus",
|
||||||
# history
|
# history
|
||||||
"HistoryItem",
|
"HistoryItem",
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ class BatchTaskAcceptedItem(BaseModel):
|
|||||||
)
|
)
|
||||||
message: Optional[str] = Field(None, description="提示信息")
|
message: Optional[str] = Field(None, description="提示信息")
|
||||||
analysis_phase: AnalysisPhase = Field("auto", description="请求的分析阶段")
|
analysis_phase: AnalysisPhase = Field("auto", description="请求的分析阶段")
|
||||||
|
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||||
|
None,
|
||||||
|
description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(json_schema_extra={
|
model_config = ConfigDict(json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
@@ -259,11 +263,29 @@ class BatchDuplicateTaskItem(BaseModel):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class RejectedTaskItem(BaseModel):
|
||||||
|
"""批量异步任务中被明确拒绝的单个目标项(如未登记 CSI 指数)。"""
|
||||||
|
|
||||||
|
stock_code: str = Field(..., description="被拒绝的目标代码")
|
||||||
|
message: str = Field(..., description="拒绝原因")
|
||||||
|
|
||||||
|
model_config = ConfigDict(json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"stock_code": "930956.CSI",
|
||||||
|
"message": "unregistered CSI index: '930956.CSI' is not in the index registry",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class BatchTaskAcceptedResponse(BaseModel):
|
class BatchTaskAcceptedResponse(BaseModel):
|
||||||
"""批量异步任务接受响应。"""
|
"""批量异步任务接受响应。"""
|
||||||
|
|
||||||
accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表")
|
accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表")
|
||||||
duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表")
|
duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表")
|
||||||
|
rejected: Optional[List[RejectedTaskItem]] = Field(
|
||||||
|
None,
|
||||||
|
description="批量中被明确拒绝的目标列表(如未登记 CSI 指数),仅在异步批量请求中返回",
|
||||||
|
)
|
||||||
message: str = Field(..., description="汇总信息")
|
message: str = Field(..., description="汇总信息")
|
||||||
|
|
||||||
model_config = ConfigDict(json_schema_extra={
|
model_config = ConfigDict(json_schema_extra={
|
||||||
@@ -385,6 +407,10 @@ class TaskInfo(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="大盘复盘任务实际执行的 canonical 市场范围",
|
description="大盘复盘任务实际执行的 canonical 市场范围",
|
||||||
)
|
)
|
||||||
|
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||||
|
None,
|
||||||
|
description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(json_schema_extra={
|
model_config = ConfigDict(json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class HistoryItem(BaseModel):
|
|||||||
description="本次分析市场阶段低敏摘要",
|
description="本次分析市场阶段低敏摘要",
|
||||||
)
|
)
|
||||||
created_at: Optional[str] = Field(None, description="创建时间")
|
created_at: Optional[str] = Field(None, description="创建时间")
|
||||||
|
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||||
|
None,
|
||||||
|
description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端与 market review 可缺省。",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = ConfigDict(json_schema_extra={
|
model_config = ConfigDict(json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
@@ -149,6 +153,10 @@ class ReportMeta(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="本次分析市场阶段低敏摘要",
|
description="本次分析市场阶段低敏摘要",
|
||||||
)
|
)
|
||||||
|
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||||
|
None,
|
||||||
|
description="后端权威资产类型(stock/index);指数报告用于隐藏 stock-only 自选操作。market review 与旧客户端可缺省。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReportSummary(BaseModel):
|
class ReportSummary(BaseModel):
|
||||||
@@ -371,6 +379,10 @@ class StockBarItem(BaseModel):
|
|||||||
None,
|
None,
|
||||||
description="最新分析市场阶段低敏摘要",
|
description="最新分析市场阶段低敏摘要",
|
||||||
)
|
)
|
||||||
|
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||||
|
None,
|
||||||
|
description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端可缺省。",
|
||||||
|
)
|
||||||
model_config = ConfigDict(json_schema_extra={
|
model_config = ConfigDict(json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"id": 1234,
|
"id": 1234,
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
|||||||
|
|
||||||
{/* 右侧:情绪指标 / 自选操作 */}
|
{/* 右侧:情绪指标 / 自选操作 */}
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
{watchlist && meta.reportType !== 'market_review' && (
|
{watchlist && meta.reportType !== 'market_review' && meta.assetType !== 'index' && (
|
||||||
<Card variant="bordered" padding="sm" className="home-panel-card">
|
<Card variant="bordered" padding="sm" className="home-panel-card">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<span className="label-uppercase">{t('report.watchlist')}</span>
|
<span className="label-uppercase">{t('report.watchlist')}</span>
|
||||||
|
|||||||
@@ -323,4 +323,57 @@ describe('ReportOverview', () => {
|
|||||||
expect(screen.getByText('领跌')).toBeInTheDocument();
|
expect(screen.getByText('领跌')).toBeInTheDocument();
|
||||||
expect(screen.getByText('-2.50%')).toBeInTheDocument();
|
expect(screen.getByText('-2.50%')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides the stock-only watchlist card for index reports', () => {
|
||||||
|
render(
|
||||||
|
<ReportOverview
|
||||||
|
meta={{ ...baseMeta, stockCode: 'sh000016', assetType: 'index' }}
|
||||||
|
summary={baseSummary}
|
||||||
|
watchlist={{
|
||||||
|
isInWatchlist: () => false,
|
||||||
|
onToggle: () => {},
|
||||||
|
isActioning: false,
|
||||||
|
actionMessage: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('自选')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /自选/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stock-only watchlist card for stock reports', () => {
|
||||||
|
render(
|
||||||
|
<ReportOverview
|
||||||
|
meta={{ ...baseMeta, stockCode: '600519', assetType: 'stock' }}
|
||||||
|
summary={baseSummary}
|
||||||
|
watchlist={{
|
||||||
|
isInWatchlist: () => false,
|
||||||
|
onToggle: () => {},
|
||||||
|
isActioning: false,
|
||||||
|
actionMessage: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('自选')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '加入自选' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stock-only watchlist card when assetType is absent (legacy)', () => {
|
||||||
|
render(
|
||||||
|
<ReportOverview
|
||||||
|
meta={{ ...baseMeta, stockCode: '600519' }}
|
||||||
|
summary={baseSummary}
|
||||||
|
watchlist={{
|
||||||
|
isInWatchlist: () => false,
|
||||||
|
onToggle: () => {},
|
||||||
|
isActioning: false,
|
||||||
|
actionMessage: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('自选')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { StockBarItem, TaskInfo } from '../../types/analysis';
|
|||||||
import { getSentimentColor } from '../../types/analysis';
|
import { getSentimentColor } from '../../types/analysis';
|
||||||
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
|
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
|
||||||
import { formatDateTime } from '../../utils/format';
|
import { formatDateTime } from '../../utils/format';
|
||||||
import { areStockCodesEquivalent } from '../../utils/stockCode';
|
import { areAssetAwareCodesEquivalent, toAssetAwareCodeKey, type AssetAwareAssetType } from '../../utils/stockCode';
|
||||||
import { truncateStockName } from '../../utils/stockName';
|
import { truncateStockName } from '../../utils/stockName';
|
||||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||||
import type { UiTextKey, UiTextParams } from '../../i18n/uiText';
|
import type { UiTextKey, UiTextParams } from '../../i18n/uiText';
|
||||||
@@ -30,6 +30,15 @@ export type WatchlistAnalyzeMode = 'all' | 'pending';
|
|||||||
|
|
||||||
export interface HomeWatchlistRow {
|
export interface HomeWatchlistRow {
|
||||||
code: string;
|
code: string;
|
||||||
|
assetType?: AssetAwareAssetType;
|
||||||
|
/**
|
||||||
|
* Asset-aware identity key already resolved by HomePage from the stock index
|
||||||
|
* registry (canonical for registered indices, e.g. `sh000016` for a raw
|
||||||
|
* watchlist string `000016.SH`). Row-selection compares against this key
|
||||||
|
* FIRST instead of re-parsing the raw alias, so a canonical selected report
|
||||||
|
* always selects its own alias-form row and never a same-code stock row.
|
||||||
|
*/
|
||||||
|
identityKey?: string;
|
||||||
latestItem?: StockBarItem;
|
latestItem?: StockBarItem;
|
||||||
analyzedToday: boolean;
|
analyzedToday: boolean;
|
||||||
isTodayStatusLoading?: boolean;
|
isTodayStatusLoading?: boolean;
|
||||||
@@ -62,6 +71,7 @@ interface HomeStockWorkspaceProps {
|
|||||||
historyItems: StockBarItem[];
|
historyItems: StockBarItem[];
|
||||||
isLoadingHistory: boolean;
|
isLoadingHistory: boolean;
|
||||||
selectedStockCode?: string;
|
selectedStockCode?: string;
|
||||||
|
selectedAssetType?: AssetAwareAssetType | null;
|
||||||
selectedRecordId?: number;
|
selectedRecordId?: number;
|
||||||
onHistoryItemClick: (recordId: number) => void;
|
onHistoryItemClick: (recordId: number) => void;
|
||||||
onDeleteStock?: (stockCode: string) => Promise<void> | void;
|
onDeleteStock?: (stockCode: string) => Promise<void> | void;
|
||||||
@@ -77,6 +87,21 @@ function getTaskStatusLabel(task: TaskInfo | undefined, t: (key: UiTextKey, para
|
|||||||
return task.status;
|
return task.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowHasSelectedIdentity(
|
||||||
|
row: HomeWatchlistRow,
|
||||||
|
selectedStockCode: string | null | undefined,
|
||||||
|
selectedAssetType: AssetAwareAssetType | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
// The registry-derived canonical identity (HomePage) wins: an alias-form raw
|
||||||
|
// watchlist code (e.g. `000016.SH`) whose identityKey is `sh000016` must
|
||||||
|
// match the canonical selected report without re-parsing the raw alias, and
|
||||||
|
// must never fold with the bare `000016` stock row.
|
||||||
|
if (row.identityKey) {
|
||||||
|
return toAssetAwareCodeKey(selectedStockCode, selectedAssetType) === row.identityKey;
|
||||||
|
}
|
||||||
|
return areAssetAwareCodesEquivalent(selectedStockCode, selectedAssetType, row.code, row.assetType);
|
||||||
|
}
|
||||||
|
|
||||||
const ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => {
|
const ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => {
|
||||||
const { t } = useUiLanguage();
|
const { t } = useUiLanguage();
|
||||||
const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null;
|
const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null;
|
||||||
@@ -258,6 +283,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
historyItems,
|
historyItems,
|
||||||
isLoadingHistory,
|
isLoadingHistory,
|
||||||
selectedStockCode,
|
selectedStockCode,
|
||||||
|
selectedAssetType,
|
||||||
selectedRecordId,
|
selectedRecordId,
|
||||||
onHistoryItemClick,
|
onHistoryItemClick,
|
||||||
onDeleteStock,
|
onDeleteStock,
|
||||||
@@ -266,7 +292,12 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useUiLanguage();
|
const { t } = useUiLanguage();
|
||||||
const [draftCode, setDraftCode] = useState('');
|
const [draftCode, setDraftCode] = useState('');
|
||||||
const [workspaceNoticeCode, setWorkspaceNoticeCode] = useState<string | null>(null);
|
// PR #2312: the notice carries the *triggering row's own* identity
|
||||||
|
// (code + assetType). Reusing the candidate row's assetType for the notice
|
||||||
|
// code would let an index row and a same-code stock row cross-match (e.g.
|
||||||
|
// notice code ``000016.SH`` classified with the stock row's asset type folds
|
||||||
|
// to stock ``000016`` and attaches to the wrong row).
|
||||||
|
const [workspaceNotice, setWorkspaceNotice] = useState<{ code: string; assetType: AssetAwareAssetType } | null>(null);
|
||||||
const pendingWatchlistCount = watchlistRows
|
const pendingWatchlistCount = watchlistRows
|
||||||
.filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown)
|
.filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown)
|
||||||
.length;
|
.length;
|
||||||
@@ -286,8 +317,10 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
}, [batchStatus]);
|
}, [batchStatus]);
|
||||||
|
|
||||||
const visibleWorkspaceNotice = useMemo(() => {
|
const visibleWorkspaceNotice = useMemo(() => {
|
||||||
if (!workspaceNoticeCode) return null;
|
if (!workspaceNotice) return null;
|
||||||
const row = watchlistRows.find((item) => areStockCodesEquivalent(item.code, workspaceNoticeCode));
|
const row = watchlistRows.find(
|
||||||
|
(item) => areAssetAwareCodesEquivalent(item.code, item.assetType, workspaceNotice.code, workspaceNotice.assetType),
|
||||||
|
);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.isTodayStatusLoading) {
|
if (row.isTodayStatusLoading) {
|
||||||
return { message: t('watchlist.latestDetailLoading') };
|
return { message: t('watchlist.latestDetailLoading') };
|
||||||
@@ -297,28 +330,28 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
}
|
}
|
||||||
if (row.latestItem) return null;
|
if (row.latestItem) return null;
|
||||||
return { message: t('watchlist.noLatestDetail') };
|
return { message: t('watchlist.noLatestDetail') };
|
||||||
}, [t, watchlistRows, workspaceNoticeCode]);
|
}, [t, watchlistRows, workspaceNotice]);
|
||||||
|
|
||||||
const handleAddSubmit = (event: React.FormEvent) => {
|
const handleAddSubmit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const code = draftCode.trim();
|
const code = draftCode.trim();
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
setWorkspaceNoticeCode(null);
|
setWorkspaceNotice(null);
|
||||||
void onAddToWatchlist(code).then(() => setDraftCode(''));
|
void onAddToWatchlist(code).then(() => setDraftCode(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWatchlistRowOpen = (row: HomeWatchlistRow) => {
|
const handleWatchlistRowOpen = (row: HomeWatchlistRow) => {
|
||||||
if (row.isTodayStatusLoading || row.isTodayStatusUnknown) {
|
if (row.isTodayStatusLoading || row.isTodayStatusUnknown) {
|
||||||
setWorkspaceNoticeCode(row.code);
|
setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const recordId = row.latestItem?.id;
|
const recordId = row.latestItem?.id;
|
||||||
if (typeof recordId === 'number') {
|
if (typeof recordId === 'number') {
|
||||||
setWorkspaceNoticeCode(null);
|
setWorkspaceNotice(null);
|
||||||
onHistoryItemClick(recordId);
|
onHistoryItemClick(recordId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWorkspaceNoticeCode(row.code);
|
setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTabs = (
|
const renderTabs = (
|
||||||
@@ -334,7 +367,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground'
|
selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWorkspaceNoticeCode(null);
|
setWorkspaceNotice(null);
|
||||||
onTabChange(tab.key);
|
onTabChange(tab.key);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -391,7 +424,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
className="h-7 w-7 px-0"
|
className="h-7 w-7 px-0"
|
||||||
disabled={watchlistLoading}
|
disabled={watchlistLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWorkspaceNoticeCode(null);
|
setWorkspaceNotice(null);
|
||||||
void onRefreshWatchlist();
|
void onRefreshWatchlist();
|
||||||
}}
|
}}
|
||||||
aria-label={t('watchlist.refreshAria')}
|
aria-label={t('watchlist.refreshAria')}
|
||||||
@@ -415,7 +448,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="home-action-ai"
|
variant="home-action-ai"
|
||||||
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
||||||
disabled={watchlistRows.length === 0 || isBatchAnalyzing}
|
disabled={watchlistLoading || watchlistRows.length === 0 || isBatchAnalyzing}
|
||||||
isLoading={isBatchAnalyzing}
|
isLoading={isBatchAnalyzing}
|
||||||
loadingText={t('watchlist.submitting')}
|
loadingText={t('watchlist.submitting')}
|
||||||
onClick={() => void onAnalyzeWatchlist('all')}
|
onClick={() => void onAnalyzeWatchlist('all')}
|
||||||
@@ -428,7 +461,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="home-action-report"
|
variant="home-action-report"
|
||||||
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
||||||
disabled={pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing}
|
disabled={watchlistLoading || pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing}
|
||||||
onClick={() => void onAnalyzeWatchlist('pending')}
|
onClick={() => void onAnalyzeWatchlist('pending')}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||||
@@ -520,7 +553,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
key={row.code}
|
key={row.code}
|
||||||
row={row}
|
row={row}
|
||||||
onRemove={async (code) => {
|
onRemove={async (code) => {
|
||||||
setWorkspaceNoticeCode(null);
|
setWorkspaceNotice(null);
|
||||||
await onRemoveFromWatchlist(code);
|
await onRemoveFromWatchlist(code);
|
||||||
}}
|
}}
|
||||||
onOpenDetail={handleWatchlistRowOpen}
|
onOpenDetail={handleWatchlistRowOpen}
|
||||||
@@ -530,8 +563,8 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
|||||||
|| (
|
|| (
|
||||||
Boolean(selectedStockCode)
|
Boolean(selectedStockCode)
|
||||||
&& (
|
&& (
|
||||||
areStockCodesEquivalent(selectedStockCode ?? '', row.code)
|
rowHasSelectedIdentity(row, selectedStockCode, selectedAssetType)
|
||||||
|| areStockCodesEquivalent(selectedStockCode ?? '', row.latestItem?.stockCode ?? '')
|
|| areAssetAwareCodesEquivalent(selectedStockCode ?? '', selectedAssetType, row.latestItem?.stockCode ?? '', row.latestItem?.assetType)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ function renderWorkspace({
|
|||||||
watchlistRows,
|
watchlistRows,
|
||||||
selectedRecordId,
|
selectedRecordId,
|
||||||
selectedStockCode,
|
selectedStockCode,
|
||||||
|
selectedAssetType,
|
||||||
activeTab = 'watchlist',
|
activeTab = 'watchlist',
|
||||||
}: {
|
}: {
|
||||||
watchlistRows: HomeWatchlistRow[];
|
watchlistRows: HomeWatchlistRow[];
|
||||||
selectedRecordId?: number;
|
selectedRecordId?: number;
|
||||||
selectedStockCode?: string;
|
selectedStockCode?: string;
|
||||||
|
selectedAssetType?: 'stock' | 'index' | null;
|
||||||
activeTab?: HomeWorkspaceTab;
|
activeTab?: HomeWorkspaceTab;
|
||||||
}) {
|
}) {
|
||||||
const onHistoryItemClick = vi.fn();
|
const onHistoryItemClick = vi.fn();
|
||||||
@@ -42,6 +44,7 @@ function renderWorkspace({
|
|||||||
historyItems={[]}
|
historyItems={[]}
|
||||||
isLoadingHistory={false}
|
isLoadingHistory={false}
|
||||||
selectedStockCode={selectedStockCode}
|
selectedStockCode={selectedStockCode}
|
||||||
|
selectedAssetType={selectedAssetType ?? null}
|
||||||
selectedRecordId={selectedRecordId}
|
selectedRecordId={selectedRecordId}
|
||||||
onHistoryItemClick={onHistoryItemClick}
|
onHistoryItemClick={onHistoryItemClick}
|
||||||
/>
|
/>
|
||||||
@@ -324,4 +327,159 @@ describe('HomeStockWorkspace', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('button', { name: '打开 HK700 最新分析详情' })).toHaveAttribute('aria-pressed', 'true');
|
expect(screen.getByRole('button', { name: '打开 HK700 最新分析详情' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a same-code stock row selected without selecting the index row', () => {
|
||||||
|
renderWorkspace({
|
||||||
|
watchlistRows: [
|
||||||
|
{
|
||||||
|
code: 'sh000016',
|
||||||
|
assetType: 'index',
|
||||||
|
analyzedToday: true,
|
||||||
|
latestItem: {
|
||||||
|
id: 31,
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
sentimentScore: 66,
|
||||||
|
operationAdvice: '观望',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'index',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '000016',
|
||||||
|
assetType: 'stock',
|
||||||
|
analyzedToday: true,
|
||||||
|
latestItem: {
|
||||||
|
id: 32,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
sentimentScore: 71,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedStockCode: '000016',
|
||||||
|
selectedAssetType: 'stock',
|
||||||
|
selectedRecordId: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
|
||||||
|
const indexRowButton = screen.getByTestId('watchlist-row-sh000016').querySelector('button[aria-pressed]');
|
||||||
|
expect(stockRowButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(indexRowButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects the index row when the index report is active', () => {
|
||||||
|
renderWorkspace({
|
||||||
|
watchlistRows: [
|
||||||
|
{
|
||||||
|
code: 'sh000016',
|
||||||
|
assetType: 'index',
|
||||||
|
analyzedToday: true,
|
||||||
|
latestItem: {
|
||||||
|
id: 41,
|
||||||
|
stockCode: 'SH000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
sentimentScore: 66,
|
||||||
|
operationAdvice: '观望',
|
||||||
|
analysisCount: 2,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'index',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '000016',
|
||||||
|
assetType: 'stock',
|
||||||
|
analyzedToday: true,
|
||||||
|
latestItem: {
|
||||||
|
id: 42,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
sentimentScore: 71,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedStockCode: 'sh000016',
|
||||||
|
selectedAssetType: 'index',
|
||||||
|
selectedRecordId: 41,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexRowButton = screen.getByTestId('watchlist-row-sh000016').querySelector('button[aria-pressed]');
|
||||||
|
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
|
||||||
|
expect(indexRowButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(stockRowButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects an alias-form index row via its registry identity even without a latest detail (PR #2312)', () => {
|
||||||
|
renderWorkspace({
|
||||||
|
watchlistRows: [
|
||||||
|
{
|
||||||
|
code: '000016.SH',
|
||||||
|
assetType: 'index',
|
||||||
|
identityKey: 'sh000016',
|
||||||
|
analyzedToday: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '000016',
|
||||||
|
assetType: 'stock',
|
||||||
|
analyzedToday: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedStockCode: 'sh000016',
|
||||||
|
selectedAssetType: 'index',
|
||||||
|
});
|
||||||
|
|
||||||
|
// No `selectedRecordId` and no latest detail on either row, so selection
|
||||||
|
// must come from the identity comparison: the canonical report pairs with
|
||||||
|
// the alias row through `identityKey` (HomePage's registry resolution) and
|
||||||
|
// never with the bare `000016` stock row.
|
||||||
|
const aliasRowButton = screen.getByTestId('watchlist-row-000016.SH').querySelector('button[aria-pressed]');
|
||||||
|
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
|
||||||
|
expect(aliasRowButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(stockRowButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the no-detail notice on the triggering index row instead of reusing the same-code stock row detail (PR #2312)', async () => {
|
||||||
|
const { onHistoryItemClick } = renderWorkspace({
|
||||||
|
watchlistRows: [
|
||||||
|
{
|
||||||
|
code: '000016',
|
||||||
|
assetType: 'stock',
|
||||||
|
analyzedToday: true,
|
||||||
|
latestItem: {
|
||||||
|
id: 99,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
sentimentScore: 70,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '000016.SH',
|
||||||
|
assetType: 'index',
|
||||||
|
analyzedToday: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('watchlist-row-000016.SH').querySelector('button[aria-pressed]') as HTMLButtonElement);
|
||||||
|
|
||||||
|
// The index row has no detail yet. The notice carries the triggering row's
|
||||||
|
// own code+assetType (000016.SH / index); without that, the shared stock
|
||||||
|
// normalization would fold 000016.SH to 000016 and the notice would be
|
||||||
|
// swallowed by the stock row (which HAS a detail).
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('暂无分析详情,可先分析。');
|
||||||
|
expect(onHistoryItemClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ describe('useTaskStream', () => {
|
|||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
task_id: 'task-1',
|
task_id: 'task-1',
|
||||||
trace_id: 'trace-task-1',
|
trace_id: 'trace-task-1',
|
||||||
stock_code: '600519',
|
stock_code: 'sh000016',
|
||||||
stock_name: '贵州茅台',
|
stock_name: '上证50',
|
||||||
|
asset_type: 'index',
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
progress: 72,
|
progress: 72,
|
||||||
message: 'LLM 正在生成分析结果',
|
message: 'LLM 正在生成分析结果',
|
||||||
@@ -129,8 +130,8 @@ describe('useTaskStream', () => {
|
|||||||
expect(onTaskProgress).toHaveBeenCalledWith({
|
expect(onTaskProgress).toHaveBeenCalledWith({
|
||||||
taskId: 'task-1',
|
taskId: 'task-1',
|
||||||
traceId: 'trace-task-1',
|
traceId: 'trace-task-1',
|
||||||
stockCode: '600519',
|
stockCode: 'sh000016',
|
||||||
stockName: '贵州茅台',
|
stockName: '上证50',
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
progress: 72,
|
progress: 72,
|
||||||
message: 'LLM 正在生成分析结果',
|
message: 'LLM 正在生成分析结果',
|
||||||
@@ -144,6 +145,7 @@ describe('useTaskStream', () => {
|
|||||||
analysisPhase: 'intraday',
|
analysisPhase: 'intraday',
|
||||||
skills: ['growth_quality'],
|
skills: ['growth_quality'],
|
||||||
region: 'jp,kr',
|
region: 'jp,kr',
|
||||||
|
assetType: 'index',
|
||||||
});
|
});
|
||||||
expect(onTaskFlowEvent).toHaveBeenCalledWith(
|
expect(onTaskFlowEvent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ taskId: 'task-1' }),
|
expect.objectContaining({ taskId: 'task-1' }),
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const toTaskInfo = (data: Record<string, unknown>): TaskInfo => {
|
|||||||
selectionSource: data.selection_source as string | undefined,
|
selectionSource: data.selection_source as string | undefined,
|
||||||
analysisPhase: data.analysis_phase as TaskInfo['analysisPhase'],
|
analysisPhase: data.analysis_phase as TaskInfo['analysisPhase'],
|
||||||
skills: Array.isArray(data.skills) ? data.skills.map(String) : undefined,
|
skills: Array.isArray(data.skills) ? data.skills.map(String) : undefined,
|
||||||
|
assetType: data.asset_type as TaskInfo['assetType'] | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof data.trace_id === 'string' && data.trace_id.trim()) {
|
if (typeof data.trace_id === 'string' && data.trace_id.trim()) {
|
||||||
|
|||||||
@@ -448,7 +448,9 @@ const zh = {
|
|||||||
'watchlist.batchFailed': '自选股批量分析提交失败',
|
'watchlist.batchFailed': '自选股批量分析提交失败',
|
||||||
'watchlist.batchIncompleteResponse': '批量接口本组请求 {requested} 只,仅确认 {confirmed} 只,返回结果不完整',
|
'watchlist.batchIncompleteResponse': '批量接口本组请求 {requested} 只,仅确认 {confirmed} 只,返回结果不完整',
|
||||||
'watchlist.batchPartiallySubmitted': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
|
'watchlist.batchPartiallySubmitted': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
|
||||||
|
'watchlist.batchPartiallySubmittedWithRejected': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
|
||||||
'watchlist.batchSubmitted': '已提交 {accepted} 个任务,{duplicates} 个正在运行',
|
'watchlist.batchSubmitted': '已提交 {accepted} 个任务,{duplicates} 个正在运行',
|
||||||
|
'watchlist.batchSubmittedWithRejected': '已提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝。首个拒绝原因:{reason}',
|
||||||
'watchlist.emptyDescription': '在报告详情或这里添加股票后,可一键运行整组自选股。',
|
'watchlist.emptyDescription': '在报告详情或这里添加股票后,可一键运行整组自选股。',
|
||||||
'watchlist.emptyTitle': '暂无自选股',
|
'watchlist.emptyTitle': '暂无自选股',
|
||||||
'watchlist.listHint': '按自选顺序展示,今日状态实时标记',
|
'watchlist.listHint': '按自选顺序展示,今日状态实时标记',
|
||||||
@@ -1405,7 +1407,9 @@ const en: Record<UiTextKey, string> = {
|
|||||||
'watchlist.batchFailed': 'Watchlist batch submit failed',
|
'watchlist.batchFailed': 'Watchlist batch submit failed',
|
||||||
'watchlist.batchIncompleteResponse': 'The batch API received {requested} stock(s) in this chunk but confirmed only {confirmed}; the response is incomplete',
|
'watchlist.batchIncompleteResponse': 'The batch API received {requested} stock(s) in this chunk but confirmed only {confirmed}; the response is incomplete',
|
||||||
'watchlist.batchPartiallySubmitted': 'Confirmed {accepted} submitted task(s) and {duplicates} already running; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}',
|
'watchlist.batchPartiallySubmitted': 'Confirmed {accepted} submitted task(s) and {duplicates} already running; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}',
|
||||||
|
'watchlist.batchPartiallySubmittedWithRejected': 'Confirmed {accepted} submitted task(s), {duplicates} already running, {rejected} rejected; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}',
|
||||||
'watchlist.batchSubmitted': 'Submitted {accepted} task(s), {duplicates} already running',
|
'watchlist.batchSubmitted': 'Submitted {accepted} task(s), {duplicates} already running',
|
||||||
|
'watchlist.batchSubmittedWithRejected': 'Submitted {accepted} task(s), {duplicates} already running, {rejected} rejected. First rejection reason: {reason}',
|
||||||
'watchlist.emptyDescription': 'Add stocks here or from a report, then run the whole watchlist from one place.',
|
'watchlist.emptyDescription': 'Add stocks here or from a report, then run the whole watchlist from one place.',
|
||||||
'watchlist.emptyTitle': 'No watchlist stocks',
|
'watchlist.emptyTitle': 'No watchlist stocks',
|
||||||
'watchlist.listHint': 'Shown in watchlist order with today status',
|
'watchlist.listHint': 'Shown in watchlist order with today status',
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ import {
|
|||||||
import { isNearBottom } from '../utils/chatScroll';
|
import { isNearBottom } from '../utils/chatScroll';
|
||||||
import { getReportText } from '../utils/reportLanguage';
|
import { getReportText } from '../utils/reportLanguage';
|
||||||
import { extractStockCodesFromMessage } from '../utils/chatStockCode';
|
import { extractStockCodesFromMessage } from '../utils/chatStockCode';
|
||||||
import { findMatchingStockCode, includesStockCode, normalizeStockCode } from '../utils/stockCode';
|
import {
|
||||||
|
findMatchingStockCode,
|
||||||
|
includesStockCode,
|
||||||
|
normalizeStockCode,
|
||||||
|
resolveRegisteredIndexCanonical,
|
||||||
|
} from '../utils/stockCode';
|
||||||
import { useStockIndex } from '../hooks/useStockIndex';
|
import { useStockIndex } from '../hooks/useStockIndex';
|
||||||
import type { StockIndexItem } from '../types/stockIndex';
|
import type { StockIndexItem } from '../types/stockIndex';
|
||||||
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
||||||
@@ -78,13 +83,33 @@ const resolveUniqueStockNameContext = (
|
|||||||
if (!terms.some((term) => normalizedMessage.includes(term.toLocaleLowerCase()))) {
|
if (!terms.some((term) => normalizedMessage.includes(term.toLocaleLowerCase()))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const stockCode = normalizeStockCode(item.canonicalCode);
|
// Index canonical codes (sh000001 / csi930955) must be preserved verbatim —
|
||||||
|
// normalizeStockCode would strip the exchange prefix and collide with a
|
||||||
|
// same-digit stock (sh000001 → 000001 vs 平安银行 000001).
|
||||||
|
const stockCode = item.assetType === 'index'
|
||||||
|
? item.canonicalCode
|
||||||
|
: normalizeStockCode(item.canonicalCode);
|
||||||
matches.set(stockCode, { stock_code: stockCode, stock_name: item.nameZh || null });
|
matches.set(stockCode, { stock_code: stockCode, stock_name: item.nameZh || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches.size === 1 ? [...matches.values()][0] : null;
|
return matches.size === 1 ? [...matches.values()][0] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an active stock code resolves to a registered index.
|
||||||
|
*
|
||||||
|
* Only an exact registry canonical/display/explicit-alias hit counts; bare
|
||||||
|
* same-digit stocks must never be typed as indexes through normalization or
|
||||||
|
* prefix guessing. Stock-only watchlist actions are hidden for these matches.
|
||||||
|
*/
|
||||||
|
const isRegisteredIndexCanonicalCode = (
|
||||||
|
code: string | null,
|
||||||
|
index: StockIndexItem[],
|
||||||
|
): boolean => {
|
||||||
|
if (!code) return false;
|
||||||
|
return resolveRegisteredIndexCanonical(index, code) !== null;
|
||||||
|
};
|
||||||
|
|
||||||
const getMessageSkillNames = (msg: Message): string[] => {
|
const getMessageSkillNames = (msg: Message): string[] => {
|
||||||
if (msg.skillNames?.length) return msg.skillNames;
|
if (msg.skillNames?.length) return msg.skillNames;
|
||||||
if (msg.skillName) return [msg.skillName];
|
if (msg.skillName) return [msg.skillName];
|
||||||
@@ -113,15 +138,28 @@ const getPipelineBudgetSkippedLabel = (step: ProgressStep): string => {
|
|||||||
return `${step.stage || 'pipeline'} skipped: insufficient budget`;
|
return `${step.stage || 'pipeline'} skipped: insufficient budget`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Comparison identity key: registry canonical first (so an index context keeps
|
||||||
|
// its lowercase canonical and never normalizes into the bare same-code stock),
|
||||||
|
// then the stock normalization fallback. Never guesses index types from prefixes.
|
||||||
|
const resolveComparisonStockKey = (
|
||||||
|
code: string | null | undefined,
|
||||||
|
index: StockIndexItem[],
|
||||||
|
): string | null => {
|
||||||
|
if (!code) return null;
|
||||||
|
const trimmed = code.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return resolveRegisteredIndexCanonical(index, trimmed) ?? normalizeStockCode(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
const isCompareStockMessage = (
|
const isCompareStockMessage = (
|
||||||
message: string,
|
message: string,
|
||||||
stockCodes: string[],
|
stockCodes: string[],
|
||||||
currentStockCode?: string | null,
|
currentStockKey?: string | null,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (STRONG_COMPARE_STOCK_MESSAGE_RE.test(message)) {
|
if (STRONG_COMPARE_STOCK_MESSAGE_RE.test(message)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const current = currentStockCode ? normalizeStockCode(currentStockCode) : null;
|
const current = currentStockKey ?? null;
|
||||||
const newStockCodes = current
|
const newStockCodes = current
|
||||||
? stockCodes.filter((code) => code !== current)
|
? stockCodes.filter((code) => code !== current)
|
||||||
: stockCodes;
|
: stockCodes;
|
||||||
@@ -137,7 +175,7 @@ const isCompareStockMessage = (
|
|||||||
if (stockCodes.length >= 2) {
|
if (stockCodes.length >= 2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!currentStockCode) {
|
if (!currentStockKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const hasNewStock = stockCodes.some((code) => code !== current);
|
const hasNewStock = stockCodes.some((code) => code !== current);
|
||||||
@@ -147,26 +185,27 @@ const isCompareStockMessage = (
|
|||||||
const resolveActiveStockContextFromMessage = (
|
const resolveActiveStockContextFromMessage = (
|
||||||
message: string,
|
message: string,
|
||||||
currentContext: ActiveStockContext | null,
|
currentContext: ActiveStockContext | null,
|
||||||
|
index: StockIndexItem[],
|
||||||
): ActiveStockResolution | null => {
|
): ActiveStockResolution | null => {
|
||||||
const stockCodes = extractStockCodesFromMessage(message);
|
const stockCodes = extractStockCodesFromMessage(message, index);
|
||||||
const stockCode = stockCodes[0] ?? null;
|
const stockCode = stockCodes[0] ?? null;
|
||||||
if (!stockCode) {
|
if (!stockCode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCompare = isCompareStockMessage(message, stockCodes, currentContext?.stock_code);
|
// Registry-first identity keys so an index context (sh000016) is never
|
||||||
|
// folded with the bare same-code stock when comparing or switching.
|
||||||
|
const currentStockKey = resolveComparisonStockKey(currentContext?.stock_code, index);
|
||||||
|
const isCompare = isCompareStockMessage(message, stockCodes, currentStockKey);
|
||||||
const isSwitch = SWITCH_STOCK_MESSAGE_RE.test(message);
|
const isSwitch = SWITCH_STOCK_MESSAGE_RE.test(message);
|
||||||
const currentStockCode = currentContext?.stock_code
|
const newStockCodes = currentStockKey
|
||||||
? normalizeStockCode(currentContext.stock_code)
|
? stockCodes.filter((code) => code !== currentStockKey)
|
||||||
: null;
|
|
||||||
const newStockCodes = currentStockCode
|
|
||||||
? stockCodes.filter((code) => code !== currentStockCode)
|
|
||||||
: stockCodes;
|
: stockCodes;
|
||||||
// Explicit switches can mention the old stock; use the single new code when present.
|
// Explicit switches can mention the old stock; use the single new code when present.
|
||||||
const targetStockCode = isSwitch && newStockCodes.length === 1
|
const targetStockCode = isSwitch && newStockCodes.length === 1
|
||||||
? newStockCodes[0]
|
? newStockCodes[0]
|
||||||
: stockCode;
|
: stockCode;
|
||||||
const isDifferentStock = currentStockCode !== targetStockCode;
|
const isDifferentStock = currentStockKey !== resolveComparisonStockKey(targetStockCode, index);
|
||||||
|
|
||||||
// Compare messages and implicit follow-ups must not rewrite the active stock context.
|
// Compare messages and implicit follow-ups must not rewrite the active stock context.
|
||||||
if (isCompare || (currentContext && !isSwitch)) {
|
if (isCompare || (currentContext && !isSwitch)) {
|
||||||
@@ -185,13 +224,16 @@ const resolveActiveStockContextFromMessage = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreActiveStockContextFromMessages = (messages: Message[]): ActiveStockContext | null => {
|
const restoreActiveStockContextFromMessages = (
|
||||||
|
messages: Message[],
|
||||||
|
index: StockIndexItem[],
|
||||||
|
): ActiveStockContext | null => {
|
||||||
let restoredContext: ActiveStockContext | null = null;
|
let restoredContext: ActiveStockContext | null = null;
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.role !== 'user') {
|
if (message.role !== 'user') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext);
|
const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext, index);
|
||||||
if (resolution) {
|
if (resolution) {
|
||||||
restoredContext = resolution.context;
|
restoredContext = resolution.context;
|
||||||
}
|
}
|
||||||
@@ -233,9 +275,9 @@ const ChatPage: React.FC = () => {
|
|||||||
const [agentStatus, setAgentStatus] = useState<AgentStatusResponse | null>(null);
|
const [agentStatus, setAgentStatus] = useState<AgentStatusResponse | null>(null);
|
||||||
const [agentStatusError, setAgentStatusError] = useState<string | null>(null);
|
const [agentStatusError, setAgentStatusError] = useState<string | null>(null);
|
||||||
const [agentStatusChecking, setAgentStatusChecking] = useState(true);
|
const [agentStatusChecking, setAgentStatusChecking] = useState(true);
|
||||||
const { index: stockIndex } = useStockIndex(
|
// All Chat backends need the registry before resolving stock identity.
|
||||||
agentStatus?.backend === 'codex_app_server',
|
const { index: stockIndex, loading: stockIndexLoading } = useStockIndex();
|
||||||
);
|
|
||||||
const watchlistMessageTimerRef = useRef<number | null>(null);
|
const watchlistMessageTimerRef = useRef<number | null>(null);
|
||||||
const copyResetTimerRef = useRef<Partial<Record<string, number>>>({});
|
const copyResetTimerRef = useRef<Partial<Record<string, number>>>({});
|
||||||
const messagesViewportRef = useRef<HTMLDivElement>(null);
|
const messagesViewportRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -365,13 +407,16 @@ const ChatPage: React.FC = () => {
|
|||||||
if (activeStockContext || messages.length === 0) {
|
if (activeStockContext || messages.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const restoredContext = restoreActiveStockContextFromMessages(messages);
|
if (stockIndexLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const restoredContext = restoreActiveStockContextFromMessages(messages, stockIndex);
|
||||||
if (!restoredContext) {
|
if (!restoredContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveStockContext(restoredContext);
|
setActiveStockContext(restoredContext);
|
||||||
setActiveStockCode(restoredContext.stock_code);
|
setActiveStockCode(restoredContext.stock_code);
|
||||||
}, [activeStockContext, messages, sessionId]);
|
}, [activeStockContext, messages, sessionId, stockIndex, stockIndexLoading]);
|
||||||
|
|
||||||
const syncScrollState = useCallback(() => {
|
const syncScrollState = useCallback(() => {
|
||||||
const viewport = messagesViewportRef.current;
|
const viewport = messagesViewportRef.current;
|
||||||
@@ -632,7 +677,33 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
// Handle follow-up from report page: ?stock=600519&name=贵州茅台&recordId=xxx
|
// Handle follow-up from report page: ?stock=600519&name=贵州茅台&recordId=xxx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stock = sanitizeFollowUpStockCode(searchParams.get('stock'));
|
const rawStockCode = searchParams.get('stock');
|
||||||
|
if (!rawStockCode) {
|
||||||
|
// Nothing to follow up — only clear when there actually ARE stale query
|
||||||
|
// params. Skipping the empty case avoids redundant `replace: true`
|
||||||
|
// navigations on every effect re-run (the gate below legitimately re-runs
|
||||||
|
// as status/registry state settles), which would otherwise clobber
|
||||||
|
// subsequent RouterProvider navigation state asserted by other tests.
|
||||||
|
if (searchParams.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stockIndexLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (agentStatusChecking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry canonical first for explicit index follow-ups (sh000016 /
|
||||||
|
// 000016.SH / 930955.CSI / csi930955) so the report-follow-up canonical is
|
||||||
|
// preserved end-to-end; the sanitize contract is untouched and remains the
|
||||||
|
// stock fail-open when the registry is unavailable or the code unregistered.
|
||||||
|
const registryCanonical = resolveRegisteredIndexCanonical(stockIndex, rawStockCode);
|
||||||
|
const stock = registryCanonical ?? sanitizeFollowUpStockCode(rawStockCode);
|
||||||
const name = sanitizeFollowUpStockName(searchParams.get('name'));
|
const name = sanitizeFollowUpStockName(searchParams.get('name'));
|
||||||
const recordId = parseFollowUpRecordId(searchParams.get('recordId'));
|
const recordId = parseFollowUpRecordId(searchParams.get('recordId'));
|
||||||
|
|
||||||
@@ -670,7 +741,7 @@ const ChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams, stockIndex, stockIndexLoading, agentStatusChecking]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -679,7 +750,7 @@ const ChatPage: React.FC = () => {
|
|||||||
overrideStockContext?: ActiveStockContext,
|
overrideStockContext?: ActiveStockContext,
|
||||||
) => {
|
) => {
|
||||||
const msgText = (overrideMessage ?? input).trim();
|
const msgText = (overrideMessage ?? input).trim();
|
||||||
if (!msgText || loading || !agentAvailable || !agentStatus) return;
|
if (!msgText || loading || stockIndexLoading || !agentAvailable || !agentStatus) return;
|
||||||
if (overrideMessage !== undefined) {
|
if (overrideMessage !== undefined) {
|
||||||
setInput(msgText);
|
setInput(msgText);
|
||||||
}
|
}
|
||||||
@@ -696,7 +767,7 @@ const ChatPage: React.FC = () => {
|
|||||||
let useActiveContextForThisSend = Boolean(codexStockContext);
|
let useActiveContextForThisSend = Boolean(codexStockContext);
|
||||||
const stockResolution = codexStockContext
|
const stockResolution = codexStockContext
|
||||||
? null
|
? null
|
||||||
: resolveActiveStockContextFromMessage(msgText, activeStockContext);
|
: resolveActiveStockContextFromMessage(msgText, activeStockContext, stockIndex);
|
||||||
if (stockResolution) {
|
if (stockResolution) {
|
||||||
nextActiveStockContext = stockResolution.context;
|
nextActiveStockContext = stockResolution.context;
|
||||||
useActiveContextForThisSend = stockResolution.useForCurrentSend;
|
useActiveContextForThisSend = stockResolution.useForCurrentSend;
|
||||||
@@ -740,7 +811,7 @@ const ChatPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[activeStockContext, agentAvailable, agentStatus, getSkillNames, input, loading, normalizeSelectedSkillIds, requestScrollToBottom, selectedSkillIds, sessionId, sessionSelectedSkillIds, startStream, stockIndex],
|
[activeStockContext, agentAvailable, agentStatus, getSkillNames, input, loading, normalizeSelectedSkillIds, requestScrollToBottom, selectedSkillIds, sessionId, sessionSelectedSkillIds, startStream, stockIndex, stockIndexLoading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -1281,7 +1352,7 @@ const ChatPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => handleQuickQuestion(q)}
|
onClick={() => handleQuickQuestion(q)}
|
||||||
disabled={!agentAvailable}
|
disabled={!agentAvailable || stockIndexLoading}
|
||||||
className="quick-question-btn disabled:cursor-not-allowed disabled:opacity-60"
|
className="quick-question-btn disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{q.label}
|
{q.label}
|
||||||
@@ -1619,7 +1690,7 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStockCode && (
|
{activeStockCode && !isRegisteredIndexCanonicalCode(activeStockCode, stockIndex) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-text font-mono">{activeStockCode}</span>
|
<span className="text-xs text-muted-text font-mono">{activeStockCode}</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -1666,8 +1737,8 @@ const ChatPage: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={!input.trim() || loading || !agentAvailable}
|
disabled={!input.trim() || loading || stockIndexLoading || !agentAvailable}
|
||||||
isLoading={loading}
|
isLoading={loading || stockIndexLoading}
|
||||||
className="btn-primary flex-shrink-0"
|
className="btn-primary flex-shrink-0"
|
||||||
>
|
>
|
||||||
发送
|
发送
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ import type {
|
|||||||
} from '../types/analysis';
|
} from '../types/analysis';
|
||||||
import type { RunFlowSnapshotSource } from '../types/runFlow';
|
import type { RunFlowSnapshotSource } from '../types/runFlow';
|
||||||
import { getTodayInShanghai } from '../utils/format';
|
import { getTodayInShanghai } from '../utils/format';
|
||||||
import { normalizeStockCode } from '../utils/stockCode';
|
import { useStockIndex } from '../hooks/useStockIndex';
|
||||||
|
import { resolveRegisteredIndexCanonical, toAssetAwareCodeKey, type AssetAwareAssetType } from '../utils/stockCode';
|
||||||
|
|
||||||
type MarketReviewNotice = {
|
type MarketReviewNotice = {
|
||||||
variant: 'success' | 'warning' | 'danger';
|
variant: 'success' | 'warning' | 'danger';
|
||||||
@@ -142,9 +143,10 @@ function shiftDateKey(dateKey: string, days: number): string {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStockCodeKey(code?: string | null): string {
|
function getStockCodeKey(code?: string | null, assetType?: AssetAwareAssetType | null): string {
|
||||||
const trimmed = (code ?? '').trim();
|
// Direct delegation: `toAssetAwareCodeKey` already treats unknown/stock codes
|
||||||
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : '';
|
// with the existing stock normalization, so no extra branching is needed.
|
||||||
|
return toAssetAwareCodeKey(code, assetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunkStockCodes(codes: string[]): string[][] {
|
function chunkStockCodes(codes: string[]): string[][] {
|
||||||
@@ -180,14 +182,15 @@ function writeTaskPanelCollapsedPreference(collapsed: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function countBatchAccepted(result: AnalyzeAsyncResponse): { accepted: number; duplicates: number } {
|
function countBatchAccepted(result: AnalyzeAsyncResponse): { accepted: number; duplicates: number; rejected: number } {
|
||||||
if ('accepted' in result) {
|
if ('accepted' in result) {
|
||||||
return {
|
return {
|
||||||
accepted: result.accepted.length,
|
accepted: result.accepted.length,
|
||||||
duplicates: result.duplicates.length,
|
duplicates: result.duplicates.length,
|
||||||
|
rejected: result.rejected?.length ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { accepted: 1, duplicates: 0 };
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
|
function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
|
||||||
@@ -204,6 +207,7 @@ function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
|
|||||||
lastAnalysisTime: item.createdAt,
|
lastAnalysisTime: item.createdAt,
|
||||||
modelUsed: item.modelUsed,
|
modelUsed: item.modelUsed,
|
||||||
marketPhaseSummary: item.marketPhaseSummary ?? null,
|
marketPhaseSummary: item.marketPhaseSummary ?? null,
|
||||||
|
assetType: item.assetType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +252,40 @@ const HomePage: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { language: uiLanguage, t } = useUiLanguage();
|
const { language: uiLanguage, t } = useUiLanguage();
|
||||||
|
const {
|
||||||
|
index: stockIndexItems,
|
||||||
|
fallback: isStockIndexFallback,
|
||||||
|
loaded: isStockIndexLoaded,
|
||||||
|
} = useStockIndex();
|
||||||
|
// Registry is "ready" once loaded — or once an explicit fallback happened
|
||||||
|
// (load failure), which keeps the existing fail-open stock semantics.
|
||||||
|
const isStockIndexReady = isStockIndexLoaded || isStockIndexFallback;
|
||||||
|
|
||||||
|
// PR #2312: raw watchlist strings carry no asset type — only an exact
|
||||||
|
// canonical/display/alias hit on a loaded ``assetType=index`` registry row
|
||||||
|
// buckets the code as an index; anything else keeps the legacy stock
|
||||||
|
// normalization (fail-open). Backend-tagged items/tasks/reports never touch
|
||||||
|
// the registry — they use their explicit asset type directly.
|
||||||
|
const getWatchlistCodeIdentity = useCallback(
|
||||||
|
(code: string | null | undefined): { key: string; assetType: AssetAwareAssetType } => {
|
||||||
|
const trimmed = (code ?? '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { key: '', assetType: 'stock' };
|
||||||
|
}
|
||||||
|
const indexCanonical = resolveRegisteredIndexCanonical(stockIndexItems, trimmed);
|
||||||
|
if (indexCanonical) {
|
||||||
|
return { key: indexCanonical, assetType: 'index' };
|
||||||
|
}
|
||||||
|
return { key: getStockCodeKey(trimmed, 'stock'), assetType: 'stock' };
|
||||||
|
},
|
||||||
|
[stockIndexItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getWatchlistCodeKey = useCallback(
|
||||||
|
(code: string | null | undefined): string => getWatchlistCodeIdentity(code).key,
|
||||||
|
[getWatchlistCodeIdentity],
|
||||||
|
);
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false);
|
const [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false);
|
||||||
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
|
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
|
||||||
@@ -603,7 +641,7 @@ const HomePage: React.FC = () => {
|
|||||||
if (task.reportType === 'market_review') {
|
if (task.reportType === 'market_review') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = getStockCodeKey(task.stockCode);
|
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -618,7 +656,7 @@ const HomePage: React.FC = () => {
|
|||||||
if (task.reportType === 'market_review') {
|
if (task.reportType === 'market_review') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = getStockCodeKey(task.stockCode);
|
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||||
if (key) {
|
if (key) {
|
||||||
setCompletedTaskRefreshPendingCounts((current) => {
|
setCompletedTaskRefreshPendingCounts((current) => {
|
||||||
const pendingCount = current.get(key) ?? 0;
|
const pendingCount = current.get(key) ?? 0;
|
||||||
@@ -674,14 +712,14 @@ const HomePage: React.FC = () => {
|
|||||||
const watchlistCodesByNormalized = useMemo(() => {
|
const watchlistCodesByNormalized = useMemo(() => {
|
||||||
const codesByNormalized = new Map<string, string>();
|
const codesByNormalized = new Map<string, string>();
|
||||||
for (const code of watchlistState.watchlistCodes) {
|
for (const code of watchlistState.watchlistCodes) {
|
||||||
const key = getStockCodeKey(code);
|
const key = getWatchlistCodeKey(code);
|
||||||
if (!key || key === 'MARKET' || codesByNormalized.has(key)) {
|
if (!key || key === 'MARKET' || codesByNormalized.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
codesByNormalized.set(key, code);
|
codesByNormalized.set(key, code);
|
||||||
}
|
}
|
||||||
return Array.from(codesByNormalized.entries());
|
return Array.from(codesByNormalized.entries());
|
||||||
}, [watchlistState.watchlistCodes]);
|
}, [getWatchlistCodeKey, watchlistState.watchlistCodes]);
|
||||||
|
|
||||||
const stockBarItemByCode = useMemo(() => {
|
const stockBarItemByCode = useMemo(() => {
|
||||||
const itemsByCode = new Map<string, StockBarItem>();
|
const itemsByCode = new Map<string, StockBarItem>();
|
||||||
@@ -689,7 +727,7 @@ const HomePage: React.FC = () => {
|
|||||||
if (item.stockCode === 'MARKET') {
|
if (item.stockCode === 'MARKET') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const key = getStockCodeKey(item.stockCode);
|
const key = getStockCodeKey(item.stockCode, item.assetType);
|
||||||
if (key) {
|
if (key) {
|
||||||
itemsByCode.set(key, item);
|
itemsByCode.set(key, item);
|
||||||
}
|
}
|
||||||
@@ -697,7 +735,12 @@ const HomePage: React.FC = () => {
|
|||||||
return itemsByCode;
|
return itemsByCode;
|
||||||
}, [stockBarItems]);
|
}, [stockBarItems]);
|
||||||
|
|
||||||
const canLookupWatchlistHistory = !isLoadingStockBar && isStockBarInitialLoadSettled;
|
// Watchlist identity resolution must never run while the stock index registry
|
||||||
|
// is still loading: a raw index string (`sh000016`) would transiently fall
|
||||||
|
// into the stock bucket and cross-link with the same-code stock. The lookup
|
||||||
|
// stays off until the registry is loaded (or an explicit fallback happened),
|
||||||
|
// at which point the existing fail-open semantics apply.
|
||||||
|
const canLookupWatchlistHistory = isStockIndexReady && !isLoadingStockBar && isStockBarInitialLoadSettled;
|
||||||
|
|
||||||
const watchlistMissingHistoryEntries = useMemo(
|
const watchlistMissingHistoryEntries = useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -748,7 +791,7 @@ const HomePage: React.FC = () => {
|
|||||||
const next = new Map<string, StockBarItem>();
|
const next = new Map<string, StockBarItem>();
|
||||||
const failedKeys = new Set<string>();
|
const failedKeys = new Set<string>();
|
||||||
for (const entry of results) {
|
for (const entry of results) {
|
||||||
const key = getStockCodeKey(entry.code);
|
const key = getWatchlistCodeKey(entry.code);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -782,7 +825,7 @@ const HomePage: React.FC = () => {
|
|||||||
isCanceled = true;
|
isCanceled = true;
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
}, [canLookupWatchlistHistory, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]);
|
}, [canLookupWatchlistHistory, getWatchlistCodeKey, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]);
|
||||||
|
|
||||||
const clearMarketReviewState = useCallback(() => {
|
const clearMarketReviewState = useCallback(() => {
|
||||||
stopMarketReviewPolling();
|
stopMarketReviewPolling();
|
||||||
@@ -1101,7 +1144,7 @@ const HomePage: React.FC = () => {
|
|||||||
if (task.reportType === 'market_review') {
|
if (task.reportType === 'market_review') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const key = getStockCodeKey(task.stockCode);
|
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||||
if (key) {
|
if (key) {
|
||||||
tasksByCode.set(key, task);
|
tasksByCode.set(key, task);
|
||||||
}
|
}
|
||||||
@@ -1111,7 +1154,8 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
const watchlistRows = useMemo<HomeWatchlistRow[]>(() => (
|
const watchlistRows = useMemo<HomeWatchlistRow[]>(() => (
|
||||||
watchlistState.watchlistCodes.map((code) => {
|
watchlistState.watchlistCodes.map((code) => {
|
||||||
const key = getStockCodeKey(code);
|
const identity = getWatchlistCodeIdentity(code);
|
||||||
|
const key = identity.key;
|
||||||
const latestItemCandidate = key
|
const latestItemCandidate = key
|
||||||
? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key)
|
? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1145,6 +1189,11 @@ const HomePage: React.FC = () => {
|
|||||||
: latestItemCandidate;
|
: latestItemCandidate;
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
|
assetType: identity.assetType,
|
||||||
|
// Registry-derived canonical identity: lets the workspace match a
|
||||||
|
// canonical selected report against an alias-form raw watchlist row
|
||||||
|
// (e.g. `000016.SH` -> identityKey `sh000016`) without re-parsing.
|
||||||
|
identityKey: identity.key,
|
||||||
latestItem,
|
latestItem,
|
||||||
analyzedToday: !isTodayStatusLoading && !isTodayStatusUnknown && getShanghaiDateKey(latestItem?.lastAnalysisTime) === todayDateKey,
|
analyzedToday: !isTodayStatusLoading && !isTodayStatusUnknown && getShanghaiDateKey(latestItem?.lastAnalysisTime) === todayDateKey,
|
||||||
isTodayStatusLoading,
|
isTodayStatusLoading,
|
||||||
@@ -1158,6 +1207,7 @@ const HomePage: React.FC = () => {
|
|||||||
completedTaskRefreshPendingCounts,
|
completedTaskRefreshPendingCounts,
|
||||||
isLoadingStockBar,
|
isLoadingStockBar,
|
||||||
stockBarRefreshFailed,
|
stockBarRefreshFailed,
|
||||||
|
getWatchlistCodeIdentity,
|
||||||
stockBarItemByCode,
|
stockBarItemByCode,
|
||||||
todayDateKey,
|
todayDateKey,
|
||||||
watchlistHistoryItemsByCode,
|
watchlistHistoryItemsByCode,
|
||||||
@@ -1213,6 +1263,12 @@ const HomePage: React.FC = () => {
|
|||||||
}, [todayDateKey, todayHistoryItems]);
|
}, [todayDateKey, todayHistoryItems]);
|
||||||
|
|
||||||
const handleAnalyzeWatchlist = useCallback(async (mode: WatchlistAnalyzeMode) => {
|
const handleAnalyzeWatchlist = useCallback(async (mode: WatchlistAnalyzeMode) => {
|
||||||
|
// The workspace button is disabled while the registry loads, but keep this
|
||||||
|
// guard at the action boundary so no alternate caller can dedupe an index
|
||||||
|
// alias with a same-code stock using the temporary fail-open key.
|
||||||
|
if (!isStockIndexReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mode === 'pending' && watchlistTodayStatusBlocked) {
|
if (mode === 'pending' && watchlistTodayStatusBlocked) {
|
||||||
setBatchAnalyzeStatus({
|
setBatchAnalyzeStatus({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
@@ -1224,7 +1280,7 @@ const HomePage: React.FC = () => {
|
|||||||
const sourceCodes = mode === 'pending' ? pendingWatchlistCodes : watchlistState.watchlistCodes;
|
const sourceCodes = mode === 'pending' ? pendingWatchlistCodes : watchlistState.watchlistCodes;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const targetCodes = sourceCodes.filter((code) => {
|
const targetCodes = sourceCodes.filter((code) => {
|
||||||
const key = getStockCodeKey(code);
|
const key = getWatchlistCodeKey(code);
|
||||||
if (!key || seen.has(key)) {
|
if (!key || seen.has(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1300,8 @@ const HomePage: React.FC = () => {
|
|||||||
setBatchAnalyzeStatus(null);
|
setBatchAnalyzeStatus(null);
|
||||||
let acceptedCount = 0;
|
let acceptedCount = 0;
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
let rejectedCount = 0;
|
||||||
|
let rejectedReasons: string[] = [];
|
||||||
let confirmedCodeCount = 0;
|
let confirmedCodeCount = 0;
|
||||||
let submissionError: ParsedApiError | null = null;
|
let submissionError: ParsedApiError | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -1258,7 +1316,17 @@ const HomePage: React.FC = () => {
|
|||||||
const counts = countBatchAccepted(result);
|
const counts = countBatchAccepted(result);
|
||||||
acceptedCount += counts.accepted;
|
acceptedCount += counts.accepted;
|
||||||
duplicateCount += counts.duplicates;
|
duplicateCount += counts.duplicates;
|
||||||
const confirmedInChunk = counts.accepted + counts.duplicates;
|
rejectedCount += counts.rejected;
|
||||||
|
if (counts.rejected > 0 && 'rejected' in result && result.rejected) {
|
||||||
|
rejectedReasons = rejectedReasons.concat(
|
||||||
|
result.rejected.map((entry) => `${entry.stockCode}: ${entry.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// accepted + duplicates + rejected is the server's full disposition of
|
||||||
|
// this chunk. Rejected entries are an explicit server response (not a
|
||||||
|
// short/partial response), so they count toward "confirmed" and must
|
||||||
|
// not be mistaken for an incomplete response that halts later chunks.
|
||||||
|
const confirmedInChunk = counts.accepted + counts.duplicates + counts.rejected;
|
||||||
confirmedCodeCount += Math.min(confirmedInChunk, chunk.length);
|
confirmedCodeCount += Math.min(confirmedInChunk, chunk.length);
|
||||||
if (confirmedInChunk !== chunk.length) {
|
if (confirmedInChunk !== chunk.length) {
|
||||||
submissionError = getParsedApiError(new Error(t('watchlist.batchIncompleteResponse', {
|
submissionError = getParsedApiError(new Error(t('watchlist.batchIncompleteResponse', {
|
||||||
@@ -1284,12 +1352,16 @@ const HomePage: React.FC = () => {
|
|||||||
setSidebarWorkspaceTab('watchlist');
|
setSidebarWorkspaceTab('watchlist');
|
||||||
|
|
||||||
if (submissionError) {
|
if (submissionError) {
|
||||||
if (acceptedCount > 0 || duplicateCount > 0) {
|
if (acceptedCount > 0 || duplicateCount > 0 || rejectedCount > 0) {
|
||||||
|
const partialKey = rejectedCount > 0
|
||||||
|
? 'watchlist.batchPartiallySubmittedWithRejected'
|
||||||
|
: 'watchlist.batchPartiallySubmitted';
|
||||||
setBatchAnalyzeStatus({
|
setBatchAnalyzeStatus({
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
message: t('watchlist.batchPartiallySubmitted', {
|
message: t(partialKey, {
|
||||||
accepted: acceptedCount,
|
accepted: acceptedCount,
|
||||||
duplicates: duplicateCount,
|
duplicates: duplicateCount,
|
||||||
|
rejected: rejectedCount,
|
||||||
unconfirmed: targetCodes.length - confirmedCodeCount,
|
unconfirmed: targetCodes.length - confirmedCodeCount,
|
||||||
error: submissionError.message || t('watchlist.batchFailed'),
|
error: submissionError.message || t('watchlist.batchFailed'),
|
||||||
}),
|
}),
|
||||||
@@ -1303,6 +1375,19 @@ const HomePage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rejectedCount > 0) {
|
||||||
|
setBatchAnalyzeStatus({
|
||||||
|
variant: 'warning',
|
||||||
|
message: t('watchlist.batchSubmittedWithRejected', {
|
||||||
|
accepted: acceptedCount,
|
||||||
|
duplicates: duplicateCount,
|
||||||
|
rejected: rejectedCount,
|
||||||
|
reason: rejectedReasons[0] ?? t('watchlist.batchFailed'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBatchAnalyzeStatus({
|
setBatchAnalyzeStatus({
|
||||||
variant: acceptedCount > 0 ? 'success' : 'warning',
|
variant: acceptedCount > 0 ? 'success' : 'warning',
|
||||||
message: t('watchlist.batchSubmitted', {
|
message: t('watchlist.batchSubmitted', {
|
||||||
@@ -1320,6 +1405,8 @@ const HomePage: React.FC = () => {
|
|||||||
setIsBatchAnalyzingWatchlist(false);
|
setIsBatchAnalyzingWatchlist(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
getWatchlistCodeKey,
|
||||||
|
isStockIndexReady,
|
||||||
notify,
|
notify,
|
||||||
pendingWatchlistCodes,
|
pendingWatchlistCodes,
|
||||||
refreshActiveTasks,
|
refreshActiveTasks,
|
||||||
@@ -1369,7 +1456,7 @@ const HomePage: React.FC = () => {
|
|||||||
activeTab={sidebarWorkspaceTab}
|
activeTab={sidebarWorkspaceTab}
|
||||||
onTabChange={setSidebarWorkspaceTab}
|
onTabChange={setSidebarWorkspaceTab}
|
||||||
watchlistRows={watchlistRows}
|
watchlistRows={watchlistRows}
|
||||||
watchlistLoading={watchlistState.isLoading}
|
watchlistLoading={watchlistState.isLoading || !isStockIndexReady}
|
||||||
watchlistActioning={watchlistState.isActioning}
|
watchlistActioning={watchlistState.isActioning}
|
||||||
watchlistMessage={watchlistState.actionMessage}
|
watchlistMessage={watchlistState.actionMessage}
|
||||||
onAddToWatchlist={watchlistState.addToWatchlist}
|
onAddToWatchlist={watchlistState.addToWatchlist}
|
||||||
@@ -1385,6 +1472,7 @@ const HomePage: React.FC = () => {
|
|||||||
historyItems={mergedStockBarItems}
|
historyItems={mergedStockBarItems}
|
||||||
isLoadingHistory={isLoadingStockBar}
|
isLoadingHistory={isLoadingStockBar}
|
||||||
selectedStockCode={selectedReport?.meta.stockCode}
|
selectedStockCode={selectedReport?.meta.stockCode}
|
||||||
|
selectedAssetType={selectedReport?.meta.assetType ?? null}
|
||||||
selectedRecordId={selectedReport?.meta.id}
|
selectedRecordId={selectedReport?.meta.id}
|
||||||
onHistoryItemClick={handleHistoryItemClick}
|
onHistoryItemClick={handleHistoryItemClick}
|
||||||
onDeleteStock={handleDeleteStock}
|
onDeleteStock={handleDeleteStock}
|
||||||
@@ -1405,11 +1493,13 @@ const HomePage: React.FC = () => {
|
|||||||
isDeletingStock,
|
isDeletingStock,
|
||||||
isLoadingStockBar,
|
isLoadingStockBar,
|
||||||
isLoadingTodayAnalysisItems,
|
isLoadingTodayAnalysisItems,
|
||||||
|
isStockIndexReady,
|
||||||
isTaskPanelCollapsed,
|
isTaskPanelCollapsed,
|
||||||
todayAnalysisLoadFailed,
|
todayAnalysisLoadFailed,
|
||||||
mergedStockBarItems,
|
mergedStockBarItems,
|
||||||
openTaskRunFlow,
|
openTaskRunFlow,
|
||||||
selectedReport?.meta.id,
|
selectedReport?.meta.id,
|
||||||
|
selectedReport?.meta.assetType,
|
||||||
selectedReport?.meta.stockCode,
|
selectedReport?.meta.stockCode,
|
||||||
sidebarWorkspaceTab,
|
sidebarWorkspaceTab,
|
||||||
todayAnalysisItems,
|
todayAnalysisItems,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createParsedApiError } from '../../api/error';
|
|||||||
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
|
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
|
||||||
import { historyApi } from '../../api/history';
|
import { historyApi } from '../../api/history';
|
||||||
import type { Message, ProgressStep } from '../../stores/agentChatStore';
|
import type { Message, ProgressStep } from '../../stores/agentChatStore';
|
||||||
|
import type { StockIndexItem } from '../../types/stockIndex';
|
||||||
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
|
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
|
||||||
import ChatPage from '../ChatPage';
|
import ChatPage from '../ChatPage';
|
||||||
import { extractStockCodeFromMessage, extractStockCodesFromMessage } from '../../utils/chatStockCode';
|
import { extractStockCodeFromMessage, extractStockCodesFromMessage } from '../../utils/chatStockCode';
|
||||||
@@ -33,7 +34,21 @@ const {
|
|||||||
mockDownloadSession,
|
mockDownloadSession,
|
||||||
mockFormatSessionAsMarkdown,
|
mockFormatSessionAsMarkdown,
|
||||||
mockStockIndex,
|
mockStockIndex,
|
||||||
} = vi.hoisted(() => ({
|
mockStockIndexState,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const mockStockIndex = [
|
||||||
|
{ canonicalCode: '600519.SH', displayCode: '600519', nameZh: '贵州茅台', aliases: ['茅台'], market: 'CN', assetType: 'stock', active: true },
|
||||||
|
{ canonicalCode: '300750.SZ', displayCode: '300750', nameZh: '宁德时代', aliases: [], market: 'CN', assetType: 'stock', active: true },
|
||||||
|
{ canonicalCode: '000001.SZ', displayCode: '000001', nameZh: '平安银行', aliases: [], market: 'CN', assetType: 'stock', active: true },
|
||||||
|
{ canonicalCode: 'BABA', displayCode: 'BABA', nameZh: '阿里巴巴', aliases: [], market: 'US', assetType: 'stock', active: true },
|
||||||
|
{ canonicalCode: '09988.HK', displayCode: '09988', nameZh: '阿里巴巴', aliases: [], market: 'HK', assetType: 'stock', active: true },
|
||||||
|
{ canonicalCode: 'sh000001', displayCode: 'sh000001', nameZh: '上证指数', aliases: ['000001.SH'], market: 'CN', assetType: 'index', active: true },
|
||||||
|
{ canonicalCode: 'sh000016', displayCode: 'sh000016', nameZh: '上证50', aliases: ['000016.SH'], market: 'CN', assetType: 'index', active: true },
|
||||||
|
{ canonicalCode: 'sz399001', displayCode: 'sz399001', nameZh: '深证成指', aliases: ['399001.SZ'], market: 'CN', assetType: 'index', active: true },
|
||||||
|
{ canonicalCode: 'sh000300', displayCode: 'sh000300', nameZh: '沪深300', aliases: ['sz399300', '399300.SZ', '000300.SH', '000300.CSI'], market: 'CN', assetType: 'index', active: true },
|
||||||
|
{ canonicalCode: 'csi930955', displayCode: '930955.CSI', nameZh: '红利低波100', aliases: [], market: 'CN', assetType: 'index', active: true },
|
||||||
|
];
|
||||||
|
return {
|
||||||
mockGetSkills: vi.fn(),
|
mockGetSkills: vi.fn(),
|
||||||
mockGetStatus: vi.fn(),
|
mockGetStatus: vi.fn(),
|
||||||
mockDeleteChatSession: vi.fn(),
|
mockDeleteChatSession: vi.fn(),
|
||||||
@@ -45,13 +60,17 @@ const {
|
|||||||
mockRemoveFromWatchlist: vi.fn(),
|
mockRemoveFromWatchlist: vi.fn(),
|
||||||
mockDownloadSession: vi.fn(),
|
mockDownloadSession: vi.fn(),
|
||||||
mockFormatSessionAsMarkdown: vi.fn(),
|
mockFormatSessionAsMarkdown: vi.fn(),
|
||||||
mockStockIndex: [
|
mockStockIndex,
|
||||||
{ canonicalCode: '600519.SH', displayCode: '600519', nameZh: '贵州茅台', aliases: ['茅台'], market: 'CN', assetType: 'stock', active: true },
|
// Mutable registry-load state for the async window shared by every backend.
|
||||||
{ canonicalCode: '300750.SZ', displayCode: '300750', nameZh: '宁德时代', aliases: [], market: 'CN', assetType: 'stock', active: true },
|
mockStockIndexState: {
|
||||||
{ canonicalCode: 'BABA', displayCode: 'BABA', nameZh: '阿里巴巴', aliases: [], market: 'US', assetType: 'stock', active: true },
|
index: mockStockIndex,
|
||||||
{ canonicalCode: '09988.HK', displayCode: '09988', nameZh: '阿里巴巴', aliases: [], market: 'HK', assetType: 'stock', active: true },
|
loading: false,
|
||||||
],
|
error: null as Error | null,
|
||||||
}));
|
fallback: false,
|
||||||
|
loaded: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mockLoadSessions = vi.fn();
|
const mockLoadSessions = vi.fn();
|
||||||
const mockLoadInitialSession = vi.fn();
|
const mockLoadInitialSession = vi.fn();
|
||||||
@@ -121,11 +140,11 @@ vi.mock('../../api/history', () => ({
|
|||||||
|
|
||||||
vi.mock('../../hooks/useStockIndex', () => ({
|
vi.mock('../../hooks/useStockIndex', () => ({
|
||||||
useStockIndex: () => ({
|
useStockIndex: () => ({
|
||||||
index: mockStockIndex,
|
index: mockStockIndexState.index,
|
||||||
loading: false,
|
loading: mockStockIndexState.loading,
|
||||||
error: null,
|
error: mockStockIndexState.error,
|
||||||
fallback: false,
|
fallback: mockStockIndexState.fallback,
|
||||||
loaded: true,
|
loaded: mockStockIndexState.loaded,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -257,6 +276,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
mockDownloadSession.mockImplementation(() => {});
|
mockDownloadSession.mockImplementation(() => {});
|
||||||
mockFormatSessionAsMarkdown.mockReturnValue('# exported session');
|
mockFormatSessionAsMarkdown.mockReturnValue('# exported session');
|
||||||
|
mockStockIndexState.index = mockStockIndex;
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.error = null;
|
||||||
|
mockStockIndexState.fallback = false;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ChatPage', () => {
|
describe('ChatPage', () => {
|
||||||
@@ -445,8 +469,12 @@ describe('ChatPage', () => {
|
|||||||
expect(input).toBeDisabled();
|
expect(input).toBeDisabled();
|
||||||
fireEvent.click(screen.getByRole('button', { name: '前往 Agent 设置' }));
|
fireEvent.click(screen.getByRole('button', { name: '前往 Agent 设置' }));
|
||||||
expect(await screen.findByText('Agent settings destination')).toBeInTheDocument();
|
expect(await screen.findByText('Agent settings destination')).toBeInTheDocument();
|
||||||
|
// React Router v7 applies navigations asynchronously; waitFor keeps the
|
||||||
|
// assertion in an act-wrapped retry loop instead of reading a stale router state.
|
||||||
|
await waitFor(() => {
|
||||||
expect(router.state.location.search).toBe('?category=agent');
|
expect(router.state.location.search).toBe('?category=agent');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps sending disabled when backend status cannot be established', async () => {
|
it('keeps sending disabled when backend status cannot be established', async () => {
|
||||||
mockGetStatus.mockRejectedValueOnce(new Error('temporary status failure'));
|
mockGetStatus.mockRejectedValueOnce(new Error('temporary status failure'));
|
||||||
@@ -507,6 +535,358 @@ describe('ChatPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves a registered index name to its canonical code without stripping the prefix', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
|
||||||
|
mockStartStream.mockImplementation(async (payload) => {
|
||||||
|
sentPayload = payload as typeof sentPayload;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析上证指数' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// sh000001 (上证指数) must be preserved verbatim — normalizeStockCode would
|
||||||
|
// strip it to 000001 and collide with 平安银行 (000001.SZ).
|
||||||
|
expect(sentPayload?.context?.stock_code).toBe('sh000001');
|
||||||
|
expect(sentPayload?.context?.stock_name).toBe('上证指数');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a registered CSI index display alias to its canonical code', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
|
||||||
|
mockStartStream.mockImplementation(async (payload) => {
|
||||||
|
sentPayload = payload as typeof sentPayload;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析红利低波100' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(sentPayload?.context?.stock_code).toBe('csi930955');
|
||||||
|
expect(sentPayload?.context?.stock_name).toBe('红利低波100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the watchlist action for a registered index canonical in Codex mode', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
mockStartStream.mockImplementation(async (_payload, meta) => {
|
||||||
|
meta?.onAccepted?.({
|
||||||
|
type: 'accepted',
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
request_id: 'request-index',
|
||||||
|
session_id: 'session-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析上证50' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// sh000016 is a registered index canonical → stock-only watchlist hidden.
|
||||||
|
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the watchlist action for a bare stock code that shares digits with an index', async () => {
|
||||||
|
mockGetWatchlist.mockResolvedValue([]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 000001' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
|
||||||
|
// 000001 (平安银行) is a stock; only the sh000001 index canonical hides the
|
||||||
|
// action, so the bare same-digit stock keeps its watchlist button.
|
||||||
|
expect(await screen.findByText('加入自选')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const CODEX_STATUS = {
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
|
const acceptedEvent = (requestId: string) => ({
|
||||||
|
type: 'accepted' as const,
|
||||||
|
backend: 'codex_app_server' as const,
|
||||||
|
request_id: requestId,
|
||||||
|
session_id: 'session-1' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['sh000016', 'sh000016'],
|
||||||
|
['000016.SH', 'sh000016'],
|
||||||
|
['930955.CSI', 'csi930955'],
|
||||||
|
['csi930955', 'csi930955'],
|
||||||
|
['sz399001', 'sz399001'],
|
||||||
|
] as const)('sends an explicit index code %s as its registry canonical', async (inputCode, expectedCanonical) => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
|
||||||
|
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
|
||||||
|
mockStartStream.mockImplementation(async (payload) => {
|
||||||
|
sentPayload = payload as typeof sentPayload;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: `分析 ${inputCode}` } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// The registry canonical must survive extraction end-to-end — never be
|
||||||
|
// stripped to a bare same-code stock, and the inner bare digits of a dotted
|
||||||
|
// alias must not leak as a second code.
|
||||||
|
expect(sentPayload?.context?.stock_code).toBe(expectedCanonical);
|
||||||
|
expect(sentPayload?.context?.stock_name).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['sh000016', 'sh000016'],
|
||||||
|
['000016.SH', 'sh000016'],
|
||||||
|
['000016.sh', 'sh000016'],
|
||||||
|
['CSI930955', 'csi930955'],
|
||||||
|
['930955.CSI', 'csi930955'],
|
||||||
|
['csi930955', 'csi930955'],
|
||||||
|
['sz399001', 'sz399001'],
|
||||||
|
['sz399300', 'sh000300'],
|
||||||
|
['399300.SZ', 'sh000300'],
|
||||||
|
['000300.SH', 'sh000300'],
|
||||||
|
] as const)('sends %s as %s under the default LiteLLM backend', async (inputCode, expectedCanonical) => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: `分析 ${inputCode}` } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
context: { stock_code: expectedCanonical, stock_name: null },
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks every chat send entry point while the index registry is loading', async () => {
|
||||||
|
mockStockIndexState.index = [];
|
||||||
|
mockStockIndexState.loading = true;
|
||||||
|
mockStockIndexState.loaded = false;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 sh000016' } });
|
||||||
|
const sendButton = screen.getByRole('button', { name: '处理中...' });
|
||||||
|
const quickQuestion = screen.getByRole('button', { name: '分析比亚迪趋势' });
|
||||||
|
|
||||||
|
expect(sendButton).toBeDisabled();
|
||||||
|
expect(quickQuestion).toBeDisabled();
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
fireEvent.click(sendButton);
|
||||||
|
fireEvent.click(quickQuestion);
|
||||||
|
expect(mockStartStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', mockStockIndex, null, false, 'sh000016'],
|
||||||
|
['failure', [], new Error('registry unavailable'), true, '000016'],
|
||||||
|
['empty', [], null, false, '000016'],
|
||||||
|
] as const)('releases direct sends after registry %s settle', async (_scenario, index, error, fallback, expectedCode) => {
|
||||||
|
mockStockIndexState.index = [];
|
||||||
|
mockStockIndexState.loading = true;
|
||||||
|
mockStockIndexState.loaded = false;
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 sh000016' } });
|
||||||
|
|
||||||
|
mockStockIndexState.index = [...index];
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.error = error;
|
||||||
|
mockStockIndexState.fallback = fallback;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendButton = screen.getByRole('button', { name: '发送' });
|
||||||
|
await waitFor(() => expect(sendButton).toBeEnabled());
|
||||||
|
fireEvent.click(sendButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
context: { stock_code: expectedCode, stock_name: null },
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from an explicit index canonical to the bare same-code stock', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
|
||||||
|
mockStartStream.mockImplementation(async (_payload, meta) => {
|
||||||
|
meta?.onAccepted?.(acceptedEvent('request-index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 sh000016' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '换成 000016 看看' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
// The explicit switch must send the BARE stock context — the index name or
|
||||||
|
// self-selected state must not be reused across identities.
|
||||||
|
expect(mockStartStream.mock.calls[1][0].context).toEqual({
|
||||||
|
stock_code: '000016',
|
||||||
|
stock_name: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the active index context untouched for compare messages mixing same-code identities', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
|
||||||
|
mockStartStream.mockImplementation(async (_payload, meta) => {
|
||||||
|
meta?.onAccepted?.(acceptedEvent('request-index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 sh000016' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '比较 sh000016 和 000016 的差异' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
expect(mockStartStream.mock.calls[1][0].context).toEqual({
|
||||||
|
stock_code: 'sh000016',
|
||||||
|
stock_name: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the stock-only watchlist action after an explicit index code is sent', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
|
||||||
|
mockStartStream.mockImplementation(async (_payload, meta) => {
|
||||||
|
meta?.onAccepted?.(acceptedEvent('request-index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 930955.CSI' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the stock guard for sh600519 / SZ000001 even when the registry is loaded', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
|
||||||
|
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
|
||||||
|
mockStartStream.mockImplementation(async (payload) => {
|
||||||
|
sentPayload = payload as typeof sentPayload;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await screen.findByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '分析 sh600519' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
// sh600519 is not a registered index alias → the stock guard keeps the
|
||||||
|
// bare 600519 identity even with the registry loaded.
|
||||||
|
expect(sentPayload?.context?.stock_code).toBe('600519');
|
||||||
|
expect(sentPayload?.context?.stock_name).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the new Codex status copy in English when the UI language is English', async () => {
|
it('renders the new Codex status copy in English when the UI language is English', async () => {
|
||||||
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
|
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
|
||||||
mockGetStatus.mockResolvedValueOnce({
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
@@ -2044,7 +2424,9 @@ describe('ChatPage', () => {
|
|||||||
expect(await screen.findByDisplayValue('请深入分析 贵州茅台(600519)')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('请深入分析 贵州茅台(600519)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('正在加载历史分析上下文;现在可直接发送追问。')).toBeInTheDocument();
|
expect(screen.getByText('正在加载历史分析上下文;现在可直接发送追问。')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
await router.navigate('/chat?stock=AAPL&name=Apple&recordId=2');
|
await router.navigate('/chat?stock=AAPL&name=Apple&recordId=2');
|
||||||
|
});
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue('请深入分析 Apple(AAPL)')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('请深入分析 Apple(AAPL)')).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -2119,6 +2501,217 @@ describe('ChatPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['sh000016', 'sh000016'],
|
||||||
|
['csi930955', 'csi930955'],
|
||||||
|
['000016.SH', 'sh000016'],
|
||||||
|
] as const)('restores the %s follow-up URL to the registry canonical and hides the stock-only watchlist action', async (stockParam, expectedCanonical) => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/chat?stock=${stockParam}`]}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedPrompt = `请深入分析 ${expectedCanonical}`;
|
||||||
|
expect(await screen.findByDisplayValue(expectedPrompt)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expectedPrompt,
|
||||||
|
context: {
|
||||||
|
stock_code: expectedCanonical,
|
||||||
|
stock_name: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The index canonical hides the stock-only watchlist action immediately.
|
||||||
|
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers a default-backend index follow-up until the registry settles', async () => {
|
||||||
|
mockStockIndexState.index = [];
|
||||||
|
mockStockIndexState.loading = true;
|
||||||
|
mockStockIndexState.loaded = false;
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByDisplayValue(/请深入分析/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
mockStockIndexState.index = mockStockIndex;
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('请深入分析 sh000016')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegistrySettleRow = [
|
||||||
|
string,
|
||||||
|
{ index: typeof mockStockIndexState.index; error: Error | null; fallback: boolean },
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each<RegistrySettleRow>([
|
||||||
|
[
|
||||||
|
'success settle with index data',
|
||||||
|
{ index: mockStockIndex, error: null, fallback: false },
|
||||||
|
'请深入分析 sh000016',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'explicit load failure fail-open',
|
||||||
|
{ index: [], error: new Error('registry unavailable'), fallback: true },
|
||||||
|
'请深入分析 SH000016',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'successful-empty registry fail-open',
|
||||||
|
{ index: [], error: null, fallback: false },
|
||||||
|
'请深入分析 SH000016',
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'releases the default-backend index follow-up after the shared registry settles: %s',
|
||||||
|
async (_scenario, finalRegistry, expectedPrompt) => {
|
||||||
|
mockStockIndexState.index = [];
|
||||||
|
mockStockIndexState.loading = true;
|
||||||
|
mockStockIndexState.error = null;
|
||||||
|
mockStockIndexState.fallback = false;
|
||||||
|
mockStockIndexState.loaded = false;
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByDisplayValue(/请深入分析 (SH000016|sh000016)/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settle regardless of outcome: success consumes the registry canonical;
|
||||||
|
// failure and an empty registry fail open to the stock path.
|
||||||
|
mockStockIndexState.index = finalRegistry.index;
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.error = finalRegistry.error;
|
||||||
|
mockStockIndexState.fallback = finalRegistry.fallback;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue(expectedPrompt)).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('restores the active Codex index canonical from a loaded session message and hides the stock-only watchlist action', async () => {
|
||||||
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
|
backend: 'codex_app_server',
|
||||||
|
available: true,
|
||||||
|
experimental: true,
|
||||||
|
errorCode: null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
|
// Registry already settled with index data before the session loads — the
|
||||||
|
// approved message-restore path must resolve the explicit SH index
|
||||||
|
// canonical so the follow-up context and watchlist gating stay consistent.
|
||||||
|
mockStockIndexState.index = mockStockIndex;
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.error = null;
|
||||||
|
mockStockIndexState.fallback = false;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
|
mockStoreState.messages = [
|
||||||
|
{ id: 'm-1', role: 'user', content: '分析 sh000016' },
|
||||||
|
{ id: 'm-2', role: 'assistant', content: '上证50 分析结果', skillName: '指数分析' },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('chat-workspace')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Restored canonical keeps the lowercase index identity → the stock-only
|
||||||
|
// watchlist button is hidden, exactly like a direct index follow-up.
|
||||||
|
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/分析 600519/), {
|
||||||
|
target: { value: '继续看上证50的支撑位' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: '继续看上证50的支撑位',
|
||||||
|
context: {
|
||||||
|
stock_code: 'sh000016',
|
||||||
|
stock_name: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// The send meta uses the session's default skill, not the historical
|
||||||
|
// assistant message's skill label.
|
||||||
|
expect.objectContaining({
|
||||||
|
skillName: '趋势分析',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers default-backend history restoration until the registry settles', async () => {
|
||||||
|
mockStockIndexState.index = [];
|
||||||
|
mockStockIndexState.loading = true;
|
||||||
|
mockStockIndexState.loaded = false;
|
||||||
|
mockStoreState.messages = [
|
||||||
|
{ id: 'm-1', role: 'user', content: '分析 sh000016' },
|
||||||
|
{ id: 'm-2', role: 'assistant', content: '上证50 分析结果', skillName: '指数分析' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByRole('button', { name: '处理中...' })).toBeDisabled();
|
||||||
|
|
||||||
|
mockStockIndexState.index = mockStockIndex;
|
||||||
|
mockStockIndexState.loading = false;
|
||||||
|
mockStockIndexState.loaded = true;
|
||||||
|
rerender(
|
||||||
|
<MemoryRouter initialEntries={['/chat']}>
|
||||||
|
<ChatPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/分析 600519/);
|
||||||
|
fireEvent.change(input, { target: { value: '继续看支撑位' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockStartStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
context: { stock_code: 'sh000016', stock_name: null },
|
||||||
|
}),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a jump-to-latest action when new content arrives while the user is away from bottom', async () => {
|
it('shows a jump-to-latest action when new content arrives while the user is away from bottom', async () => {
|
||||||
mockStoreState.messages = [
|
mockStoreState.messages = [
|
||||||
{ id: 'user-1', role: 'user', content: '请分析 600519' },
|
{ id: 'user-1', role: 'user', content: '请分析 600519' },
|
||||||
@@ -2264,6 +2857,75 @@ describe('extractStockCodeFromMessage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractStockCodesFromMessage with index registry', () => {
|
||||||
|
// The hoisted mock widens market/assetType to plain strings; the extraction
|
||||||
|
// parameter is StockIndexItem[], so a structural cast keeps the registry rows.
|
||||||
|
const registeredIndex = mockStockIndex as unknown as StockIndexItem[];
|
||||||
|
|
||||||
|
it('resolves explicit SH-prefixed index tokens to the registry canonical only', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 sh000016', registeredIndex)).toEqual(['sh000016']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 SH000016', registeredIndex)).toEqual(['sh000016']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves dotted SH index aliases without leaking the inner bare digits', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 000016.SH', registeredIndex)).toEqual(['sh000016']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 000016.sh', registeredIndex)).toEqual(['sh000016']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves CSI display and prefix forms to the single csi canonical', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 930955.CSI', registeredIndex)).toEqual(['csi930955']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 CSI930955', registeredIndex)).toEqual(['csi930955']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 csi930955', registeredIndex)).toEqual(['csi930955']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a registered SZ index code via its registry canonical', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 sz399001', registeredIndex)).toEqual(['sz399001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the bare same-code stock distinct from a registered index', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 000016', registeredIndex)).toEqual(['000016']);
|
||||||
|
expect(extractStockCodesFromMessage('SH000016 和 000016', registeredIndex)).toEqual(['sh000016', '000016']);
|
||||||
|
expect(extractStockCodesFromMessage('000016 和 sh000016', registeredIndex)).toEqual(['000016', 'sh000016']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stock semantics for unregistered explicit forms when the registry is loaded', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 sh600519', registeredIndex)).toEqual(['600519']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 SZ000001', registeredIndex)).toEqual(['000001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails open to the stock path when no registry is passed', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 sh000016')).toEqual(['000016']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy no-registry baseline for dotted index forms without leaking the suffix token', () => {
|
||||||
|
// Without a registry, `930955.CSI` must behave EXACTLY as before this
|
||||||
|
// change: the dotted form is NOT a registered index hit, so only the bare
|
||||||
|
// digits surface (the `.CSI` suffix never leaks as a separate token).
|
||||||
|
expect(extractStockCodesFromMessage('分析 930955.CSI')).toEqual(['930955']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy baseline for csi-prefixed forms without a registry (no output)', () => {
|
||||||
|
expect(extractStockCodesFromMessage('CSI930955')).toEqual([]);
|
||||||
|
expect(extractStockCodesFromMessage('csi930955')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps SH/SZ stock alias normalization unchanged when the registry miss falls through', () => {
|
||||||
|
expect(extractStockCodesFromMessage('分析 000016.SH')).toEqual(['000016']);
|
||||||
|
expect(extractStockCodesFromMessage('分析 SH600519')).toEqual(['600519']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT surface the whole dotted form for an UNREGISTERED index even when the registry is loaded', () => {
|
||||||
|
// The shared mock registry DOES contain the `930955.CSI` alias (csi930955),
|
||||||
|
// so build a registry WITHOUT it to exercise the genuinely unregistered
|
||||||
|
// case: it must fall through to the legacy bare-digit behavior, not emit
|
||||||
|
// the whole token nor suppress the legacy patterns.
|
||||||
|
const registryWithoutCsi = mockStockIndex.filter(
|
||||||
|
(item) => item.canonicalCode !== 'csi930955',
|
||||||
|
) as StockIndexItem[];
|
||||||
|
expect(extractStockCodesFromMessage('分析 930955.CSI', registryWithoutCsi)).toEqual(['930955']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('watchlist button with code variants', () => {
|
describe('watchlist button with code variants', () => {
|
||||||
it('shows "从自选删除" when canonical code is in watchlist and user inputs variant', async () => {
|
it('shows "从自选删除" when canonical code is in watchlist and user inputs variant', async () => {
|
||||||
mockGetWatchlist.mockResolvedValue(['600519', 'HK01810']);
|
mockGetWatchlist.mockResolvedValue(['600519', 'HK01810']);
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ vi.mock('../../hooks/useTaskStream', () => ({
|
|||||||
useTaskStream: vi.fn(),
|
useTaskStream: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let stockIndexItems: Array<{
|
||||||
|
canonicalCode: string;
|
||||||
|
displayCode: string;
|
||||||
|
aliases?: string[];
|
||||||
|
assetType: string;
|
||||||
|
}> = [];
|
||||||
|
let stockIndexLoading = false;
|
||||||
|
let stockIndexFallback = false;
|
||||||
|
let stockIndexLoaded = true;
|
||||||
|
|
||||||
|
vi.mock('../../hooks/useStockIndex', () => ({
|
||||||
|
useStockIndex: () => ({
|
||||||
|
index: stockIndexItems,
|
||||||
|
loading: stockIndexLoading,
|
||||||
|
error: null,
|
||||||
|
fallback: stockIndexFallback,
|
||||||
|
loaded: stockIndexLoaded,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const historyItem = {
|
const historyItem = {
|
||||||
id: 1,
|
id: 1,
|
||||||
queryId: 'q-1',
|
queryId: 'q-1',
|
||||||
@@ -210,6 +230,10 @@ describe('HomePage', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
navigateMock.mockReset();
|
navigateMock.mockReset();
|
||||||
|
stockIndexItems = [];
|
||||||
|
stockIndexLoading = false;
|
||||||
|
stockIndexFallback = false;
|
||||||
|
stockIndexLoaded = true;
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh');
|
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh');
|
||||||
@@ -533,6 +557,196 @@ describe('HomePage', () => {
|
|||||||
expect(rowButton).toHaveAttribute('aria-pressed', 'true');
|
expect(rowButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps index and same-code stock watchlist rows independent when stock-bar and active tasks coexist (PR #2312)', async () => {
|
||||||
|
stockIndexItems = [
|
||||||
|
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
|
||||||
|
];
|
||||||
|
// The index watchlist input is the raw ALIAS form (`000016.SH`) while the
|
||||||
|
// stock-bar items and active tasks stay on the parser canonical
|
||||||
|
// (`sh000016`) — the registry-resolved identity must bridge them without
|
||||||
|
// crossing into the bare `000016` stock row.
|
||||||
|
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH', '000016']);
|
||||||
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||||
|
total: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 61,
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
reportType: 'detailed',
|
||||||
|
sentimentScore: 70,
|
||||||
|
operationAdvice: '观察',
|
||||||
|
analysisCount: 3,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 62,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
reportType: 'detailed',
|
||||||
|
sentimentScore: 75,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
vi.mocked(analysisApi.getTasks).mockResolvedValue({
|
||||||
|
total: 2,
|
||||||
|
pending: 1,
|
||||||
|
processing: 1,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
taskId: 'task-index',
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 40,
|
||||||
|
reportType: 'detailed',
|
||||||
|
createdAt: '2026-03-19T08:00:00Z',
|
||||||
|
assetType: 'index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: 'task-stock',
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
reportType: 'detailed',
|
||||||
|
createdAt: '2026-03-19T08:00:00Z',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
|
||||||
|
|
||||||
|
// The alias-form watchlist row (`000016.SH`) must pair with the INDEX
|
||||||
|
// stock-bar row and the INDEX task through the registry-resolved identity;
|
||||||
|
// the bare `000016` row must pair with the STOCK row/task. Before the fix
|
||||||
|
// the shared stock-normalization key folded both rows to the stock identity.
|
||||||
|
const indexRow = await screen.findByTestId('watchlist-row-000016.SH');
|
||||||
|
const stockRow = screen.getByTestId('watchlist-row-000016');
|
||||||
|
|
||||||
|
expect(indexRow).toHaveTextContent('上证50');
|
||||||
|
expect(indexRow).not.toHaveTextContent('深康佳A');
|
||||||
|
await waitFor(() => expect(indexRow).toHaveTextContent('任务分析中'));
|
||||||
|
expect(indexRow).not.toHaveTextContent('任务等待中');
|
||||||
|
|
||||||
|
expect(stockRow).toHaveTextContent('深康佳A');
|
||||||
|
expect(stockRow).not.toHaveTextContent('上证50');
|
||||||
|
expect(stockRow).toHaveTextContent('任务等待中');
|
||||||
|
expect(stockRow).not.toHaveTextContent('任务分析中');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the watchlist workspace loading and defers history lookup until the index registry is ready (PR #2312)', async () => {
|
||||||
|
stockIndexItems = [
|
||||||
|
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
|
||||||
|
];
|
||||||
|
stockIndexLoading = true;
|
||||||
|
stockIndexLoaded = false;
|
||||||
|
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH']);
|
||||||
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({ total: 0, items: [] });
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
|
||||||
|
|
||||||
|
// Registry still loading: the watchlist stays in the loading state, rows
|
||||||
|
// are not rendered, and no per-code history lookup runs — no transient
|
||||||
|
// fallback stock bucketing inside the load window.
|
||||||
|
expect(screen.getByTestId('home-stock-workspace')).toBeInTheDocument();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('watchlist-row-000016.SH')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const analyzeAllButton = screen.getByRole('button', { name: '分析全部' });
|
||||||
|
expect(analyzeAllButton).toBeDisabled();
|
||||||
|
fireEvent.click(analyzeAllButton);
|
||||||
|
expect(analysisApi.analyzeAsync).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
stockIndexLoading = false;
|
||||||
|
stockIndexLoaded = true;
|
||||||
|
view.rerender(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ready: the alias row renders through the registry-resolved canonical
|
||||||
|
// identity, and the deferred missing-history lookup now runs for the raw
|
||||||
|
// watchlist code (the backend resolves the alias to the index record and
|
||||||
|
// the result is keyed by the canonical identity).
|
||||||
|
await waitFor(() => expect(screen.getByTestId('watchlist-row-000016.SH')).toBeInTheDocument());
|
||||||
|
expect(historyApi.getList).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ stockCode: '000016.SH', limit: 1 }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails open to stock identity after the index registry load fails (PR #2312)', async () => {
|
||||||
|
stockIndexItems = [];
|
||||||
|
stockIndexLoading = false;
|
||||||
|
stockIndexLoaded = false;
|
||||||
|
stockIndexFallback = true;
|
||||||
|
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH']);
|
||||||
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||||
|
total: 1,
|
||||||
|
items: [{
|
||||||
|
id: 63,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
reportType: 'detailed',
|
||||||
|
sentimentScore: 75,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
|
||||||
|
|
||||||
|
const row = await screen.findByTestId('watchlist-row-000016.SH');
|
||||||
|
expect(row).toHaveTextContent('深康佳A');
|
||||||
|
expect(screen.getByRole('button', { name: '分析全部' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not open details when removing a watchlist row', async () => {
|
it('does not open details when removing a watchlist row', async () => {
|
||||||
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']);
|
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']);
|
||||||
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||||
@@ -2022,6 +2236,70 @@ describe('HomePage', () => {
|
|||||||
expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit);
|
expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates index aliases while keeping the same-code stock in analyze-all (PR #2312)', async () => {
|
||||||
|
stockIndexItems = [
|
||||||
|
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
|
||||||
|
];
|
||||||
|
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH', 'sh000016', '000016']);
|
||||||
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||||
|
total: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 71,
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
reportType: 'detailed',
|
||||||
|
sentimentScore: 70,
|
||||||
|
operationAdvice: '观察',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 72,
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
reportType: 'detailed',
|
||||||
|
sentimentScore: 75,
|
||||||
|
operationAdvice: '买入',
|
||||||
|
analysisCount: 1,
|
||||||
|
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
|
||||||
|
assetType: 'stock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
vi.mocked(analysisApi.analyzeAsync).mockImplementation(async ({ stockCodes = [] }) => ({
|
||||||
|
accepted: stockCodes.map((stockCode, index) => ({
|
||||||
|
taskId: `task-${index}`,
|
||||||
|
stockCode,
|
||||||
|
status: 'pending' as const,
|
||||||
|
})),
|
||||||
|
duplicates: [],
|
||||||
|
message: 'accepted',
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '分析全部' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1));
|
||||||
|
expect(vi.mocked(analysisApi.analyzeAsync).mock.calls[0]?.[0].stockCodes).toEqual([
|
||||||
|
'000016.SH',
|
||||||
|
'000016',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports partial watchlist submission and refreshes accepted tasks after a later chunk fails', async () => {
|
it('reports partial watchlist submission and refreshes accepted tasks after a later chunk fails', async () => {
|
||||||
configureWatchlistBatch(51);
|
configureWatchlistBatch(51);
|
||||||
vi.mocked(analysisApi.analyzeAsync)
|
vi.mocked(analysisApi.analyzeAsync)
|
||||||
@@ -2124,6 +2402,53 @@ describe('HomePage', () => {
|
|||||||
expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1);
|
expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues to the next chunk when a full chunk is accepted+duplicate+rejected', async () => {
|
||||||
|
configureWatchlistBatch(51);
|
||||||
|
vi.mocked(analysisApi.analyzeAsync)
|
||||||
|
.mockImplementationOnce(async ({ stockCodes = [] }) => ({
|
||||||
|
accepted: stockCodes.slice(0, 40).map((stockCode, index) => ({
|
||||||
|
taskId: `task-${stockCode}-${index}`,
|
||||||
|
stockCode,
|
||||||
|
status: 'pending' as const,
|
||||||
|
})),
|
||||||
|
duplicates: stockCodes.slice(40, 45).map((stockCode, index) => ({
|
||||||
|
stockCode,
|
||||||
|
existingTaskId: `existing-${index}`,
|
||||||
|
message: 'already running',
|
||||||
|
})),
|
||||||
|
rejected: stockCodes.slice(45).map((stockCode) => ({
|
||||||
|
stockCode,
|
||||||
|
message: 'unregistered CSI index',
|
||||||
|
})),
|
||||||
|
message: 'accepted',
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(async ({ stockCodes = [] }) => ({
|
||||||
|
accepted: stockCodes.map((stockCode, index) => ({
|
||||||
|
taskId: `task-${stockCode}-${index}`,
|
||||||
|
stockCode,
|
||||||
|
status: 'pending' as const,
|
||||||
|
})),
|
||||||
|
duplicates: [],
|
||||||
|
message: 'accepted',
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<HomePage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
|
||||||
|
const taskRefreshCallsBeforeSubmit = vi.mocked(analysisApi.getTasks).mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '分析全部' }));
|
||||||
|
|
||||||
|
// 40 accepted + 5 duplicates + 5 rejected = 50 = full chunk, so the next
|
||||||
|
// chunk is submitted (2 analyzeAsync calls), not reported as incomplete.
|
||||||
|
expect(await screen.findByText(/已提交 41 个任务,5 个正在运行,5 个被拒绝/)).toBeInTheDocument();
|
||||||
|
expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit);
|
||||||
|
});
|
||||||
|
|
||||||
it('removes the MARKET stock bar item after deleting market review history', async () => {
|
it('removes the MARKET stock bar item after deleting market review history', async () => {
|
||||||
let isMarketReviewDeleted = false;
|
let isMarketReviewDeleted = false;
|
||||||
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||||
|
|||||||
@@ -554,6 +554,62 @@ describe('stockPoolStore', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a registered SH index canonical from autocomplete without local validation errors', async () => {
|
||||||
|
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
|
||||||
|
taskId: 'task-index-1',
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
status: 'pending',
|
||||||
|
message: 'accepted',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await useStockPoolStore.getState().submitAnalysis({
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
originalQuery: 'sh000016',
|
||||||
|
selectionSource: 'autocomplete',
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useStockPoolStore.getState();
|
||||||
|
expect(state.inputError).toBeUndefined();
|
||||||
|
expect(state.isAnalyzing).toBe(false);
|
||||||
|
expect(analysisApi.analyzeAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
stockCode: 'SH000016',
|
||||||
|
reportType: 'detailed',
|
||||||
|
stockName: '上证50',
|
||||||
|
originalQuery: 'sh000016',
|
||||||
|
selectionSource: 'autocomplete',
|
||||||
|
notify: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a registered CSI index canonical from autocomplete', async () => {
|
||||||
|
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
|
||||||
|
taskId: 'task-csi-1',
|
||||||
|
stockCode: 'csi930955',
|
||||||
|
status: 'pending',
|
||||||
|
message: 'accepted',
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await useStockPoolStore.getState().submitAnalysis({
|
||||||
|
stockCode: 'csi930955',
|
||||||
|
stockName: '红利低波100',
|
||||||
|
originalQuery: 'csi930955',
|
||||||
|
selectionSource: 'autocomplete',
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useStockPoolStore.getState();
|
||||||
|
expect(state.inputError).toBeUndefined();
|
||||||
|
expect(state.isAnalyzing).toBe(false);
|
||||||
|
expect(analysisApi.analyzeAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
stockCode: 'CSI930955',
|
||||||
|
reportType: 'detailed',
|
||||||
|
stockName: '红利低波100',
|
||||||
|
originalQuery: 'csi930955',
|
||||||
|
selectionSource: 'autocomplete',
|
||||||
|
notify: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('merges newly discovered history items during silent refresh', async () => {
|
it('merges newly discovered history items during silent refresh', async () => {
|
||||||
useStockPoolStore.setState({
|
useStockPoolStore.setState({
|
||||||
historyItems: [historyItem],
|
historyItems: [historyItem],
|
||||||
@@ -736,6 +792,92 @@ describe('stockPoolStore', () => {
|
|||||||
expect(state.selectedReport?.meta.id).toBe(11);
|
expect(state.selectedReport?.meta.id).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auto-selects the same-code index report, not the bare stock report, on index task completion', async () => {
|
||||||
|
const indexItem = {
|
||||||
|
...historyItem,
|
||||||
|
id: 20,
|
||||||
|
queryId: 'q-20',
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
assetType: 'index' as const,
|
||||||
|
};
|
||||||
|
const stockItem = {
|
||||||
|
...historyItem,
|
||||||
|
id: 21,
|
||||||
|
queryId: 'q-21',
|
||||||
|
stockCode: '000016',
|
||||||
|
stockName: '深康佳A',
|
||||||
|
assetType: 'stock' as const,
|
||||||
|
};
|
||||||
|
const indexReport = {
|
||||||
|
...historyReport,
|
||||||
|
meta: {
|
||||||
|
...historyReport.meta,
|
||||||
|
id: 20,
|
||||||
|
queryId: 'q-20',
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
assetType: 'index' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useStockPoolStore.setState({
|
||||||
|
historyItems: [],
|
||||||
|
selectedReport: null,
|
||||||
|
});
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [indexItem, stockItem],
|
||||||
|
});
|
||||||
|
vi.mocked(historyApi.getDetail).mockResolvedValue(indexReport);
|
||||||
|
|
||||||
|
// The completed index task carries its parser asset type; the bare
|
||||||
|
// same-code stock row must not capture the selection.
|
||||||
|
await useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
assetType: 'index',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const state = useStockPoolStore.getState();
|
||||||
|
expect(historyApi.getDetail).toHaveBeenCalledWith(20);
|
||||||
|
expect(historyApi.getDetail).not.toHaveBeenCalledWith(21);
|
||||||
|
expect(state.selectedReport?.meta.stockCode).toBe('sh000016');
|
||||||
|
expect(state.selectedReport?.meta.assetType).toBe('index');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries report.meta.assetType into the history-trend item derived from the selected report (PR #2312)', async () => {
|
||||||
|
const indexReport = {
|
||||||
|
...historyReport,
|
||||||
|
meta: {
|
||||||
|
...historyReport.meta,
|
||||||
|
id: 30,
|
||||||
|
queryId: 'q-30',
|
||||||
|
stockCode: 'sh000016',
|
||||||
|
stockName: '上证50',
|
||||||
|
assetType: 'index' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useStockPoolStore.setState({ selectedReport: indexReport });
|
||||||
|
vi.mocked(historyApi.getList).mockResolvedValue({
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
items: [historyItem],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useStockPoolStore.getState().openHistoryTrend();
|
||||||
|
|
||||||
|
const state = useStockPoolStore.getState();
|
||||||
|
expect(state.stockHistoryItems).toHaveLength(2);
|
||||||
|
const derived = state.stockHistoryItems.find((item) => item.stockCode === 'sh000016');
|
||||||
|
expect(derived).toBeDefined();
|
||||||
|
expect(derived?.assetType).toBe('index');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not replace the selected report when another stock task completes', async () => {
|
it('does not replace the selected report when another stock task completes', async () => {
|
||||||
const otherReport = {
|
const otherReport = {
|
||||||
...historyReport,
|
...historyReport,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getParsedApiError } from '../api/error';
|
|||||||
import { historyApi } from '../api/history';
|
import { historyApi } from '../api/history';
|
||||||
import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis';
|
import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis';
|
||||||
import { getRecentStartDate, getTodayInShanghai } from '../utils/format';
|
import { getRecentStartDate, getTodayInShanghai } from '../utils/format';
|
||||||
import { normalizeStockCode } from '../utils/stockCode';
|
import { toAssetAwareCodeKey, type AssetAwareAssetType } from '../utils/stockCode';
|
||||||
import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation';
|
import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -20,6 +20,7 @@ type FetchHistoryOptions = {
|
|||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
selectLatestForStockCode?: string;
|
selectLatestForStockCode?: string;
|
||||||
|
selectLatestForStockAssetType?: AssetAwareAssetType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubmitAnalysisOptions = {
|
type SubmitAnalysisOptions = {
|
||||||
@@ -235,6 +236,7 @@ function reportToHistoryItem(report: AnalysisReport): HistoryItem | null {
|
|||||||
currentPrice: report.meta.currentPrice,
|
currentPrice: report.meta.currentPrice,
|
||||||
changePct: report.meta.changePct,
|
changePct: report.meta.changePct,
|
||||||
modelUsed: report.meta.modelUsed,
|
modelUsed: report.meta.modelUsed,
|
||||||
|
assetType: report.meta.assetType,
|
||||||
createdAt: report.meta.createdAt,
|
createdAt: report.meta.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -252,17 +254,22 @@ function normalizeSelectedReport(report: AnalysisReport): AnalysisReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStockCodeKey(stockCode: string | undefined): string {
|
function normalizeStockCodeKey(
|
||||||
const trimmed = (stockCode ?? '').trim();
|
stockCode: string | undefined,
|
||||||
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : '';
|
assetType?: AssetAwareAssetType | null,
|
||||||
|
): string {
|
||||||
|
return toAssetAwareCodeKey(stockCode, assetType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueCompletedTaskSelection(
|
function queueCompletedTaskSelection(
|
||||||
stockCode: string | undefined,
|
stockCode: string | undefined,
|
||||||
selectedReport: AnalysisReport | null,
|
selectedReport: AnalysisReport | null,
|
||||||
|
assetType?: AssetAwareAssetType | null,
|
||||||
): void {
|
): void {
|
||||||
const key = normalizeStockCodeKey(stockCode);
|
const key = normalizeStockCodeKey(stockCode, assetType);
|
||||||
if (key) {
|
if (key) {
|
||||||
|
// Matching only consumes the already-constructed identity key; the stored
|
||||||
|
// intent carries no assetType (it was only ever used to build the key).
|
||||||
pendingCompletedTaskSelectionKeys.set(key, {
|
pendingCompletedTaskSelectionKeys.set(key, {
|
||||||
manualSelectionSeq: manualSelectionRequestSeq,
|
manualSelectionSeq: manualSelectionRequestSeq,
|
||||||
selectedReportId: selectedReport?.meta.id,
|
selectedReportId: selectedReport?.meta.id,
|
||||||
@@ -285,7 +292,10 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedReport) {
|
if (selectedReport) {
|
||||||
const selectedStockCode = normalizeStockCodeKey(selectedReport.meta.stockCode);
|
const selectedStockCode = normalizeStockCodeKey(
|
||||||
|
selectedReport.meta.stockCode,
|
||||||
|
selectedReport.meta.assetType,
|
||||||
|
);
|
||||||
const pendingSelectionIntent = selectedStockCode
|
const pendingSelectionIntent = selectedStockCode
|
||||||
? pendingCompletedTaskSelectionKeys.get(selectedStockCode)
|
? pendingCompletedTaskSelectionKeys.get(selectedStockCode)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -311,7 +321,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
|||||||
const latestItem = items.find(
|
const latestItem = items.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.reportType !== 'market_review' &&
|
item.reportType !== 'market_review' &&
|
||||||
normalizeStockCodeKey(item.stockCode) === selectedStockCode,
|
normalizeStockCodeKey(item.stockCode, item.assetType) === selectedStockCode,
|
||||||
);
|
);
|
||||||
if (latestItem) {
|
if (latestItem) {
|
||||||
pendingCompletedTaskSelectionKeys.delete(selectedStockCode);
|
pendingCompletedTaskSelectionKeys.delete(selectedStockCode);
|
||||||
@@ -323,7 +333,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
|||||||
if (item.reportType === 'market_review') {
|
if (item.reportType === 'market_review') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const stockCode = normalizeStockCodeKey(item.stockCode);
|
const stockCode = normalizeStockCodeKey(item.stockCode, item.assetType);
|
||||||
const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode);
|
const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode);
|
||||||
return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq;
|
return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq;
|
||||||
});
|
});
|
||||||
@@ -453,11 +463,16 @@ async function fetchHistory(
|
|||||||
reset = true,
|
reset = true,
|
||||||
silent = false,
|
silent = false,
|
||||||
selectLatestForStockCode,
|
selectLatestForStockCode,
|
||||||
|
selectLatestForStockAssetType,
|
||||||
} = options;
|
} = options;
|
||||||
const currentState = get();
|
const currentState = get();
|
||||||
const page = reset ? 1 : currentState.currentPage + 1;
|
const page = reset ? 1 : currentState.currentPage + 1;
|
||||||
if (reset) {
|
if (reset) {
|
||||||
queueCompletedTaskSelection(selectLatestForStockCode, currentState.selectedReport);
|
queueCompletedTaskSelection(
|
||||||
|
selectLatestForStockCode,
|
||||||
|
currentState.selectedReport,
|
||||||
|
selectLatestForStockAssetType,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const requestId = ++historyRequestSeq;
|
const requestId = ++historyRequestSeq;
|
||||||
|
|
||||||
@@ -668,6 +683,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
|
|||||||
reset: true,
|
reset: true,
|
||||||
silent: true,
|
silent: true,
|
||||||
selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode,
|
selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode,
|
||||||
|
selectLatestForStockAssetType: task.reportType === 'market_review' ? null : (task.assetType ?? null),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface ReportMeta {
|
|||||||
changePct?: number;
|
changePct?: number;
|
||||||
modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择
|
modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择
|
||||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||||
|
assetType?: 'stock' | 'index'; // 后端权威资产类型;index 用于隐藏 stock-only 自选操作
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sentiment label */
|
/** Sentiment label */
|
||||||
@@ -439,6 +440,7 @@ export interface BatchTaskAcceptedItem {
|
|||||||
status: 'pending' | 'processing';
|
status: 'pending' | 'processing';
|
||||||
message?: string;
|
message?: string;
|
||||||
analysisPhase?: AnalysisPhase;
|
analysisPhase?: AnalysisPhase;
|
||||||
|
assetType?: 'stock' | 'index';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchDuplicateTaskItem {
|
export interface BatchDuplicateTaskItem {
|
||||||
@@ -447,9 +449,15 @@ export interface BatchDuplicateTaskItem {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BatchRejectedTaskItem {
|
||||||
|
stockCode: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BatchTaskAcceptedResponse {
|
export interface BatchTaskAcceptedResponse {
|
||||||
accepted: BatchTaskAcceptedItem[];
|
accepted: BatchTaskAcceptedItem[];
|
||||||
duplicates: BatchDuplicateTaskItem[];
|
duplicates: BatchDuplicateTaskItem[];
|
||||||
|
rejected?: BatchRejectedTaskItem[];
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +502,7 @@ export interface TaskInfo {
|
|||||||
analysisPhase?: AnalysisPhase;
|
analysisPhase?: AnalysisPhase;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
region?: string;
|
region?: string;
|
||||||
|
assetType?: 'stock' | 'index';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task list response */
|
/** Task list response */
|
||||||
@@ -534,6 +543,7 @@ export interface HistoryItem {
|
|||||||
turnoverRate?: number;
|
turnoverRate?: number;
|
||||||
modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由
|
modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由
|
||||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||||
|
assetType?: 'stock' | 'index';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +605,7 @@ export interface StockBarItem {
|
|||||||
lastAnalysisTime?: string;
|
lastAnalysisTime?: string;
|
||||||
modelUsed?: string;
|
modelUsed?: string;
|
||||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||||
|
assetType?: 'stock' | 'index';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockBarResponse {
|
export interface StockBarResponse {
|
||||||
|
|||||||
@@ -485,4 +485,58 @@ describe('searchStocks', () => {
|
|||||||
expect(results.length).toBeGreaterThan(0);
|
expect(results.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Index row visibility', () => {
|
||||||
|
const indexRows: StockIndexItem[] = [
|
||||||
|
...mockIndex,
|
||||||
|
{
|
||||||
|
canonicalCode: 'sh000016',
|
||||||
|
displayCode: 'sh000016',
|
||||||
|
nameZh: '上证50',
|
||||||
|
pinyinFull: 'shangzheng50',
|
||||||
|
pinyinAbbr: 'sz50',
|
||||||
|
aliases: ['000016.SH'],
|
||||||
|
market: 'CN',
|
||||||
|
assetType: 'index',
|
||||||
|
active: true,
|
||||||
|
popularity: 99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
canonicalCode: 'csi930955',
|
||||||
|
displayCode: '930955.CSI',
|
||||||
|
nameZh: '红利低波100',
|
||||||
|
pinyinFull: 'honglidibo100',
|
||||||
|
pinyinAbbr: 'hldb100',
|
||||||
|
aliases: ['930955.CSI'],
|
||||||
|
market: 'CN',
|
||||||
|
assetType: 'index',
|
||||||
|
active: true,
|
||||||
|
popularity: 98,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('search by registered index Chinese name returns the index row', () => {
|
||||||
|
const results = searchStocks('上证50', indexRows);
|
||||||
|
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
|
||||||
|
const hit = results.find(r => r.canonicalCode === 'sh000016');
|
||||||
|
expect(hit?.nameZh).toBe('上证50');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search by registered index canonical code returns the index row', () => {
|
||||||
|
const results = searchStocks('sh000016', indexRows);
|
||||||
|
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search by registered CSI canonical and display alias converges to one row', () => {
|
||||||
|
const byCanonical = searchStocks('csi930955', indexRows);
|
||||||
|
const byDisplay = searchStocks('930955.CSI', indexRows);
|
||||||
|
expect(byCanonical.some(r => r.canonicalCode === 'csi930955')).toBe(true);
|
||||||
|
expect(byDisplay.some(r => r.canonicalCode === 'csi930955')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search by registered index alias (000016.SH) returns the index row', () => {
|
||||||
|
const results = searchStocks('000016.SH', indexRows);
|
||||||
|
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { areStockCodesEquivalent, findMatchingStockCode, includesStockCode, normalizeStockCode } from '../stockCode';
|
import {
|
||||||
|
areAssetAwareCodesEquivalent,
|
||||||
|
areStockCodesEquivalent,
|
||||||
|
findMatchingStockCode,
|
||||||
|
includesStockCode,
|
||||||
|
normalizeStockCode,
|
||||||
|
resolveRegisteredIndexCanonical,
|
||||||
|
toAssetAwareCodeKey,
|
||||||
|
} from '../stockCode';
|
||||||
|
|
||||||
describe('normalizeStockCode', () => {
|
describe('normalizeStockCode', () => {
|
||||||
it('keeps clean A-share codes as-is', () => {
|
it('keeps clean A-share codes as-is', () => {
|
||||||
@@ -110,3 +118,90 @@ describe('normalizeStockCode', () => {
|
|||||||
expect(findMatchingStockCode(codes, 'AAPL')).toBe('aapl');
|
expect(findMatchingStockCode(codes, 'AAPL')).toBe('aapl');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('asset-aware identity keys (PR #2312)', () => {
|
||||||
|
const indexRows = [
|
||||||
|
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
|
||||||
|
{ canonicalCode: 'csi930955', displayCode: '930955.CSI', aliases: ['930955.CSI'], assetType: 'index' },
|
||||||
|
{ canonicalCode: 'sh000300', displayCode: 'sh000300', aliases: ['sz399300', '000300.SH', '000300.CSI'], assetType: 'index' },
|
||||||
|
{ canonicalCode: '600519.SH', displayCode: '600519', aliases: [], assetType: 'stock' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('buckets registered indices by lowercase canonical without stock folding', () => {
|
||||||
|
expect(toAssetAwareCodeKey('sh000016', 'index')).toBe('sh000016');
|
||||||
|
expect(toAssetAwareCodeKey('000016', 'stock')).toBe('000016');
|
||||||
|
expect(toAssetAwareCodeKey('sh000016', 'index')).not.toBe(toAssetAwareCodeKey('000016', 'stock'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('case-folds legacy uppercase index codes only and never regex-derives a canonical from aliases', () => {
|
||||||
|
// Backend guarantees API/task/report codes tagged assetType=index are
|
||||||
|
// already the parser canonical. The frontend only case-folds; it must not
|
||||||
|
// fabricate a canonical from prefixes/suffixes (000300.CSI -> csi000300 or
|
||||||
|
// sz399300 -> sz399300 would violate the registry single source of truth).
|
||||||
|
expect(toAssetAwareCodeKey('SH000016', 'index')).toBe('sh000016');
|
||||||
|
expect(toAssetAwareCodeKey('CSI930955', 'index')).toBe('csi930955');
|
||||||
|
expect(toAssetAwareCodeKey('000016.SH', 'index')).toBe('000016.sh');
|
||||||
|
expect(toAssetAwareCodeKey('000016.SH', 'index')).not.toBe('sh000016');
|
||||||
|
expect(toAssetAwareCodeKey('930955.CSI', 'index')).toBe('930955.csi');
|
||||||
|
expect(toAssetAwareCodeKey('930955.CSI', 'index')).not.toBe('csi930955');
|
||||||
|
expect(toAssetAwareCodeKey('sz399300', 'index')).toBe('sz399300');
|
||||||
|
expect(toAssetAwareCodeKey('sz399300', 'index')).not.toBe('sh000300');
|
||||||
|
expect(toAssetAwareCodeKey('000300.CSI', 'index')).toBe('000300.csi');
|
||||||
|
expect(toAssetAwareCodeKey('000300.CSI', 'index')).not.toBe('csi000300');
|
||||||
|
expect(toAssetAwareCodeKey('000300.CSI', 'index')).not.toBe('sh000300');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legacy stock normalization for stock/unknown codes', () => {
|
||||||
|
expect(toAssetAwareCodeKey('SH600519', 'stock')).toBe('600519');
|
||||||
|
expect(toAssetAwareCodeKey('600519.SH', undefined)).toBe('600519');
|
||||||
|
expect(toAssetAwareCodeKey('00700.HK', undefined)).toBe('HK00700');
|
||||||
|
expect(toAssetAwareCodeKey('sh600519', undefined)).toBe('600519');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves raw codes only through exact registry hits', () => {
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'sh000016')).toBe('sh000016');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'SH000016')).toBe('sh000016');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '000016.SH')).toBe('sh000016');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'sz399300')).toBe('sh000300');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '000300.CSI')).toBe('sh000300');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '000300.SH')).toBe('sh000300');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'csi000300')).toBeNull();
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '000016')).toBeNull();
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '600519')).toBeNull();
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'sh600519')).toBeNull();
|
||||||
|
expect(resolveRegisteredIndexCanonical([], 'sh000016')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never folds an index with the same-code stock in equivalence', () => {
|
||||||
|
expect(areAssetAwareCodesEquivalent('sh000016', 'index', 'sh000016', 'index')).toBe(true);
|
||||||
|
expect(areAssetAwareCodesEquivalent('SH000016', 'index', 'sh000016', 'index')).toBe(true);
|
||||||
|
// Alias-form index codes are not canonicalized by the frontend — the backend
|
||||||
|
// guarantees canonical output — so an alias form never self-equates to the
|
||||||
|
// canonical and never folds with the bare stock either.
|
||||||
|
expect(areAssetAwareCodesEquivalent('000016.SH', 'index', 'sh000016', 'index')).toBe(false);
|
||||||
|
expect(areAssetAwareCodesEquivalent('000016.SH', 'index', '000016', 'stock')).toBe(false);
|
||||||
|
expect(areAssetAwareCodesEquivalent('sh000016', 'index', '000016', 'stock')).toBe(false);
|
||||||
|
expect(areAssetAwareCodesEquivalent('SH600519', 'stock', '600519', 'stock')).toBe(true);
|
||||||
|
expect(areAssetAwareCodesEquivalent('', 'index', 'sh000016', 'index')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds case independently of the host locale (Turkish dotless-i safe)', () => {
|
||||||
|
// Simulate the Turkish locale where `toLocaleLowerCase()` maps `I` to
|
||||||
|
// dotless `ı` — `CSI930955` would become `csı930955` and never match the
|
||||||
|
// registry canonical. The asset-aware helpers must use locale-independent
|
||||||
|
// `toLowerCase()` (plain ASCII namespace), so they keep working even when
|
||||||
|
// `toLocaleLowerCase` is poisoned.
|
||||||
|
const original = String.prototype.toLocaleLowerCase;
|
||||||
|
String.prototype.toLocaleLowerCase = function (this: string) {
|
||||||
|
return this.replace(/I/g, 'ı').replace(/İ/g, 'i');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
expect(toAssetAwareCodeKey('CSI930955', 'index')).toBe('csi930955');
|
||||||
|
expect(toAssetAwareCodeKey('SH000016', 'index')).toBe('sh000016');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, 'CSI930955')).toBe('csi930955');
|
||||||
|
expect(resolveRegisteredIndexCanonical(indexRows, '000300.CSI')).toBe('sh000300');
|
||||||
|
} finally {
|
||||||
|
String.prototype.toLocaleLowerCase = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,6 +132,36 @@ describe('stockIndexLoader', () => {
|
|||||||
expect(result.error).toBeInstanceOf(Error);
|
expect(result.error).toBeInstanceOf(Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('aborts a stalled index request and returns fallback mode', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
try {
|
||||||
|
mockFetch.mockImplementationOnce((_input, init) => {
|
||||||
|
capturedSignal = init?.signal ?? undefined;
|
||||||
|
if (!capturedSignal) {
|
||||||
|
return Promise.reject(new Error('missing AbortSignal'));
|
||||||
|
}
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
capturedSignal?.addEventListener('abort', () => reject(new Error('request aborted')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = loadStockIndex();
|
||||||
|
|
||||||
|
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(capturedSignal?.aborted).toBe(true);
|
||||||
|
expect(result.loaded).toBe(false);
|
||||||
|
expect(result.fallback).toBe(true);
|
||||||
|
expect(result.data).toEqual([]);
|
||||||
|
expect(result.error).toBeInstanceOf(Error);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('returns fallback mode on HTTP error', async () => {
|
test('returns fallback mode on HTTP error', async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -188,7 +218,7 @@ describe('stockIndexLoader', () => {
|
|||||||
expect(fetchCallArgs).toContain('?_t=');
|
expect(fetchCallArgs).toContain('?_t=');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters out assetType=index rows from the returned data', async () => {
|
test('keeps assetType=index rows in the returned data', async () => {
|
||||||
const withIndex = [
|
const withIndex = [
|
||||||
...mockIndexData,
|
...mockIndexData,
|
||||||
{
|
{
|
||||||
@@ -214,13 +244,16 @@ describe('stockIndexLoader', () => {
|
|||||||
|
|
||||||
expect(result.loaded).toBe(true);
|
expect(result.loaded).toBe(true);
|
||||||
expect(result.fallback).toBe(false);
|
expect(result.fallback).toBe(false);
|
||||||
// Index rows are hidden from the current consumers.
|
// Registered index rows flow to autocomplete/search/group consumers.
|
||||||
expect(result.data.some(item => item.assetType === 'index')).toBe(false);
|
expect(result.data.some(item => item.assetType === 'index')).toBe(true);
|
||||||
|
const indexRow = result.data.find(item => item.assetType === 'index');
|
||||||
|
expect(indexRow?.canonicalCode).toBe('sh000300');
|
||||||
|
expect(indexRow?.nameZh).toBe('沪深300');
|
||||||
// Stock rows are preserved.
|
// Stock rows are preserved.
|
||||||
expect(result.data).toHaveLength(mockIndexData.length);
|
expect(result.data).toHaveLength(mockIndexData.length + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('filters index rows from compressed tuple payload', async () => {
|
test('keeps index rows from compressed tuple payload', async () => {
|
||||||
const compressedWithIndex = [
|
const compressedWithIndex = [
|
||||||
['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100],
|
['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100],
|
||||||
['sh000300', 'sh000300', '沪深300', 'hushen300', 'hs300', ['000300.SH'], 'CN', 'index', true, 100],
|
['sh000300', 'sh000300', '沪深300', 'hushen300', 'hs300', ['000300.SH'], 'CN', 'index', true, 100],
|
||||||
@@ -234,8 +267,10 @@ describe('stockIndexLoader', () => {
|
|||||||
const result = await loadStockIndex();
|
const result = await loadStockIndex();
|
||||||
|
|
||||||
expect(result.loaded).toBe(true);
|
expect(result.loaded).toBe(true);
|
||||||
expect(result.data).toHaveLength(1);
|
expect(result.data).toHaveLength(2);
|
||||||
expect(result.data[0].canonicalCode).toBe('600519.SH');
|
expect(result.data[0].canonicalCode).toBe('600519.SH');
|
||||||
|
expect(result.data[1].canonicalCode).toBe('sh000300');
|
||||||
|
expect(result.data[1].nameZh).toBe('沪深300');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,4 +27,38 @@ describe('stock code validation', () => {
|
|||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['sh000016', 'SH000016'],
|
||||||
|
['sh000300', 'SH000300'],
|
||||||
|
['sz399001', 'SZ399001'],
|
||||||
|
])('accepts registered SH/SZ index canonical %s', (input, normalized) => {
|
||||||
|
expect(looksLikeStockCode(input)).toBe(true);
|
||||||
|
expect(validateStockCode(input)).toEqual({
|
||||||
|
valid: true,
|
||||||
|
normalized,
|
||||||
|
});
|
||||||
|
expect(isObviouslyInvalidStockQuery(input)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['csi930955', 'CSI930955'],
|
||||||
|
['930955.CSI', '930955.CSI'],
|
||||||
|
['CSI930955', 'CSI930955'],
|
||||||
|
])('accepts registered CSI index forms %s', (input, normalized) => {
|
||||||
|
expect(looksLikeStockCode(input)).toBe(true);
|
||||||
|
expect(validateStockCode(input)).toEqual({
|
||||||
|
valid: true,
|
||||||
|
normalized,
|
||||||
|
});
|
||||||
|
expect(isObviouslyInvalidStockQuery(input)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unregistered CSI forms that are not registered index canonicals', () => {
|
||||||
|
// 930956.CSI is not in the registry; the Web validation layer is
|
||||||
|
// format-based (registered rows pass), while the API returns a 4xx for
|
||||||
|
// unregistered CSI at the backend boundary.
|
||||||
|
expect(looksLikeStockCode('930956.CSI')).toBe(true);
|
||||||
|
expect(validateStockCode('930956.CSI').valid).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateStockCode } from './validation';
|
import { validateStockCode } from './validation';
|
||||||
import { normalizeStockCode } from './stockCode';
|
import { normalizeStockCode, resolveRegisteredIndexCanonical } from './stockCode';
|
||||||
|
import type { StockIndexItem } from '../types/stockIndex';
|
||||||
|
|
||||||
const EXCHANGE_PREFIXES = new Set(['SH', 'SZ', 'BJ', 'HK', 'US', 'SS']);
|
const EXCHANGE_PREFIXES = new Set(['SH', 'SZ', 'BJ', 'HK', 'US', 'SS']);
|
||||||
const LOWERCASE_TICKER_CONTEXT_RE = /换成|改看|分析|看看|研究|诊断|比较|对比|\bvs\b|和[^,。,.!?!?]{0,40}比|差异(?!化)|区别|不同|相比|对照|比一比|哪个|哪只|哪一个|谁更|更值得|更适合|怎么选|选哪|二选一/i;
|
const LOWERCASE_TICKER_CONTEXT_RE = /换成|改看|分析|看看|研究|诊断|比较|对比|\bvs\b|和[^,。,.!?!?]{0,40}比|差异(?!化)|区别|不同|相比|对照|比一比|哪个|哪只|哪一个|谁更|更值得|更适合|怎么选|选哪|二选一/i;
|
||||||
@@ -46,13 +47,23 @@ function isDeniedTickerCandidate(value: string, message: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractStockCodeFromMessage(message: string): string | null {
|
export function extractStockCodeFromMessage(
|
||||||
return extractStockCodesFromMessage(message)[0] ?? null;
|
message: string,
|
||||||
|
index?: ReadonlyArray<StockIndexItem>,
|
||||||
|
): string | null {
|
||||||
|
return extractStockCodesFromMessage(message, index)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractStockCodesFromMessage(message: string): string[] {
|
export function extractStockCodesFromMessage(
|
||||||
// More specific patterns first to avoid greedy \d{6} capturing inside .SH/.SZ codes
|
message: string,
|
||||||
|
index?: ReadonlyArray<StockIndexItem>,
|
||||||
|
): string[] {
|
||||||
|
// Explicit dotted CSI/SH/SZ and csi-prefixed forms MUST precede the fallbacks so
|
||||||
|
// a registered alias is captured as one token; the registry hit suppresses its
|
||||||
|
// inner bare digits (a registry miss leaves the baseline patterns untouched).
|
||||||
const patterns = [
|
const patterns = [
|
||||||
|
/\b(\d{6}\.(?:CSI|SH|SZ))\b/gi,
|
||||||
|
/\b(CSI\d{6})\b/gi,
|
||||||
/\b(30\d{4}\.SZ)\b/gi,
|
/\b(30\d{4}\.SZ)\b/gi,
|
||||||
/\b(68\d{4}\.SH)\b/gi,
|
/\b(68\d{4}\.SH)\b/gi,
|
||||||
/\b(00\d{4}\.SZ)\b/gi,
|
/\b(00\d{4}\.SZ)\b/gi,
|
||||||
@@ -70,7 +81,7 @@ export function extractStockCodesFromMessage(message: string): string[] {
|
|||||||
patterns.push(/\b([a-z]{2,5}(?:\.[a-z]{1,2})?)\b/g);
|
patterns.push(/\b([a-z]{2,5}(?:\.[a-z]{1,2})?)\b/g);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches: Array<{ value: string; index: number; priority: number }> = [];
|
const matches: Array<{ value: string; index: number; priority: number; end: number }> = [];
|
||||||
patterns.forEach((pattern, priority) => {
|
patterns.forEach((pattern, priority) => {
|
||||||
pattern.lastIndex = 0;
|
pattern.lastIndex = 0;
|
||||||
for (const match of message.matchAll(pattern)) {
|
for (const match of message.matchAll(pattern)) {
|
||||||
@@ -84,6 +95,7 @@ export function extractStockCodesFromMessage(message: string): string[] {
|
|||||||
value,
|
value,
|
||||||
index: start,
|
index: start,
|
||||||
priority,
|
priority,
|
||||||
|
end,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -92,17 +104,43 @@ export function extractStockCodesFromMessage(message: string): string[] {
|
|||||||
|
|
||||||
const stockCodes: string[] = [];
|
const stockCodes: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
// Spans of already-accepted tokens suppress strictly-inner matches so a
|
||||||
|
// dotted index alias never leaks its inner bare digits as a separate stock.
|
||||||
|
const acceptedSpans: Array<{ start: number; end: number }> = [];
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
|
if (acceptedSpans.some((span) => match.index >= span.start && match.end <= span.end)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (EXCHANGE_PREFIXES.has(match.value.toUpperCase())) {
|
if (EXCHANGE_PREFIXES.has(match.value.toUpperCase())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isDeniedTickerCandidate(match.value, message)) {
|
if (isDeniedTickerCandidate(match.value, message)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Registered index exact hit (canonical / display / explicit alias) — use
|
||||||
|
// the registry canonical verbatim; never stock-normalize it.
|
||||||
|
const registeredCanonical = index && index.length > 0
|
||||||
|
? resolveRegisteredIndexCanonical(index, match.value)
|
||||||
|
: null;
|
||||||
|
if (registeredCanonical) {
|
||||||
|
acceptedSpans.push({ start: match.index, end: match.end });
|
||||||
|
if (!seen.has(registeredCanonical)) {
|
||||||
|
seen.add(registeredCanonical);
|
||||||
|
stockCodes.push(registeredCanonical);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Priority 0-1 forms are REGISTRY-ONLY: on a miss, drop the whole token
|
||||||
|
// (no accepted span) so the legacy patterns beneath keep the no-registry
|
||||||
|
// baseline (930955.CSI → ['930955'], csi930955 → []).
|
||||||
|
if (match.priority <= 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const { valid, normalized } = validateStockCode(match.value);
|
const { valid, normalized } = validateStockCode(match.value);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
acceptedSpans.push({ start: match.index, end: match.end });
|
||||||
const stockCode = normalizeStockCode(normalized);
|
const stockCode = normalizeStockCode(normalized);
|
||||||
if (!seen.has(stockCode)) {
|
if (!seen.has(stockCode)) {
|
||||||
seen.add(stockCode);
|
seen.add(stockCode);
|
||||||
|
|||||||
@@ -112,3 +112,106 @@ export function findMatchingStockCode(codes: string[], stockCode: string): strin
|
|||||||
export function includesStockCode(codes: string[], stockCode: string): boolean {
|
export function includesStockCode(codes: string[], stockCode: string): boolean {
|
||||||
return findMatchingStockCode(codes, stockCode) !== undefined;
|
return findMatchingStockCode(codes, stockCode) !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Asset-aware identity key (PR #2312) ============
|
||||||
|
//
|
||||||
|
// Dashboard grouping must not fold a registered index (`sh000016`) with the
|
||||||
|
// same-code bare stock (`000016`). The backend now exposes an optional
|
||||||
|
// `asset_type` on tasks / history items / stock-bar rows and reports; the Web
|
||||||
|
// trusts that field first and only falls back to the loaded stock index
|
||||||
|
// registry for raw watchlist strings that carry no type. `normalizeStockCode`
|
||||||
|
// is deliberately NOT changed and is NOT applied to index identities — it
|
||||||
|
// would strip the `sh`/`sz`/`csi` prefix and re-fold the index with its stock.
|
||||||
|
|
||||||
|
export type AssetAwareAssetType = 'stock' | 'index';
|
||||||
|
|
||||||
|
export interface RegisteredIndexIdentity {
|
||||||
|
canonicalCode: string;
|
||||||
|
displayCode: string;
|
||||||
|
aliases?: string[];
|
||||||
|
assetType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold an already-identified index code into its lowercase identity namespace.
|
||||||
|
*
|
||||||
|
* The backend guarantees that any API/task/report code tagged ``assetType=index``
|
||||||
|
* is already the parser canonical (``sh000016`` / ``csi930955``) — including
|
||||||
|
* legacy uppercase persisted forms (``SH000016`` / ``CSI930955``, folded by
|
||||||
|
* case). The frontend therefore only case-folds; it must NEVER derive a
|
||||||
|
* canonical from prefixes/suffixes, because registry aliases such as
|
||||||
|
* ``000300.CSI`` and ``sz399300`` belong to ``sh000300`` and would be
|
||||||
|
* fabricated into ``csi000300`` / ``sz399300`` by a regex guess. Only
|
||||||
|
* ``resolveRegisteredIndexCanonical`` (exact registry hit) maps aliases, and
|
||||||
|
* it is used solely for raw watchlist strings that carry no asset type.
|
||||||
|
*/
|
||||||
|
function foldIndexKey(code: string): string {
|
||||||
|
// Locale-independent: `toLocaleLowerCase()` under the Turkish locale maps
|
||||||
|
// `I` to dotless `ı`, which would break `CSI930955` -> `csi930955`. The
|
||||||
|
// canonical namespace is plain ASCII, so `toLowerCase()` is always correct.
|
||||||
|
return code.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset-aware identity key shared by stock-bar, watchlist fallback, active
|
||||||
|
* task, completed-refresh and batch-dedupe grouping.
|
||||||
|
*
|
||||||
|
* - `assetType === 'index'` -> case-folded canonical bucket (never runs stock
|
||||||
|
* normalization, so `sh000016` stays distinct from stock `000016`; never
|
||||||
|
* regex-derives a canonical from an alias form).
|
||||||
|
* - otherwise (stock or unknown) -> existing stock normalization semantics.
|
||||||
|
*/
|
||||||
|
export function toAssetAwareCodeKey(
|
||||||
|
code: string | null | undefined,
|
||||||
|
assetType?: AssetAwareAssetType | null,
|
||||||
|
): string {
|
||||||
|
const trimmed = (code ?? '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (assetType === 'index') {
|
||||||
|
return foldIndexKey(trimmed);
|
||||||
|
}
|
||||||
|
return normalizeStockCode(trimmed).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact registry hit used only for raw codes WITHOUT an asset type (e.g. raw
|
||||||
|
* watchlist strings). Only an `assetType=index` row whose canonical/display/
|
||||||
|
* explicit alias matches the code *exactly* (case-insensitive, no
|
||||||
|
* normalization, no prefix guessing) buckets it as an index. Returns the
|
||||||
|
* matched row's canonical (in lowercase canonical form) or null.
|
||||||
|
*/
|
||||||
|
export function resolveRegisteredIndexCanonical(
|
||||||
|
index: ReadonlyArray<RegisteredIndexIdentity>,
|
||||||
|
code: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const trimmed = (code ?? '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const folded = trimmed.toLowerCase();
|
||||||
|
for (const item of index) {
|
||||||
|
if (!item || item.assetType !== 'index') continue;
|
||||||
|
const candidates = [item.canonicalCode, item.displayCode, ...(item.aliases ?? [])];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate && candidate.trim().toLowerCase() === folded) {
|
||||||
|
const canonical = (item.canonicalCode ?? '').trim();
|
||||||
|
return canonical ? canonical.toLowerCase() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset-aware equivalence used by row selection / notice matching: an index
|
||||||
|
* row never matches a same-code stock row, because each side carries its own
|
||||||
|
* asset type.
|
||||||
|
*/
|
||||||
|
export function areAssetAwareCodesEquivalent(
|
||||||
|
left: string | null | undefined,
|
||||||
|
leftAssetType: AssetAwareAssetType | null | undefined,
|
||||||
|
right: string | null | undefined,
|
||||||
|
rightAssetType: AssetAwareAssetType | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const leftKey = toAssetAwareCodeKey(left, leftAssetType);
|
||||||
|
const rightKey = toAssetAwareCodeKey(right, rightAssetType);
|
||||||
|
return Boolean(leftKey && rightKey && leftKey === rightKey);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex';
|
import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex';
|
||||||
import { INDEX_FIELD } from './stockIndexFields';
|
import { INDEX_FIELD } from './stockIndexFields';
|
||||||
|
|
||||||
|
const STOCK_INDEX_LOAD_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export interface IndexLoadResult {
|
export interface IndexLoadResult {
|
||||||
/** Index data */
|
/** Index data */
|
||||||
data: StockIndexItem[];
|
data: StockIndexItem[];
|
||||||
@@ -24,9 +26,17 @@ export interface IndexLoadResult {
|
|||||||
* @returns Index load result
|
* @returns Index load result
|
||||||
*/
|
*/
|
||||||
export async function loadStockIndex(): Promise<IndexLoadResult> {
|
export async function loadStockIndex(): Promise<IndexLoadResult> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = globalThis.setTimeout(
|
||||||
|
() => abortController.abort(),
|
||||||
|
STOCK_INDEX_LOAD_TIMEOUT_MS,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// Add time parameter to bypass cache (in case the backend doesn't handle ETag/Cache-Control)
|
// 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)}`);
|
const response = await fetch(
|
||||||
|
`/stocks.index.json?_t=${Math.floor(Date.now() / 3600000)}`,
|
||||||
|
{ signal: abortController.signal },
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load index: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to load index: ${response.status} ${response.statusText}`);
|
||||||
@@ -39,11 +49,10 @@ export async function loadStockIndex(): Promise<IndexLoadResult> {
|
|||||||
? unpackTuples(data as StockIndexTuple[])
|
? unpackTuples(data as StockIndexTuple[])
|
||||||
: data as StockIndexItem[];
|
: data as StockIndexItem[];
|
||||||
|
|
||||||
// The shared payload may now carry ``assetType=index`` rows, but the
|
// Registered index rows flow to autocomplete/search/group consumers. The
|
||||||
// current autocomplete/popular/group consumers must not see them. Filter
|
// per-consumer gates (popular keeps stock-only) are enforced by each
|
||||||
// index rows out before constructing the successful result so stock/ETF
|
// consumer, not by a global filter here.
|
||||||
// behaviour is unchanged.
|
const visibleItems = items;
|
||||||
const visibleItems = items.filter(item => item.assetType !== 'index');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: visibleItems,
|
data: visibleItems,
|
||||||
@@ -58,6 +67,8 @@ export async function loadStockIndex(): Promise<IndexLoadResult> {
|
|||||||
error: error as Error,
|
error: error as Error,
|
||||||
fallback: true, // Load failed, fallback to old mode
|
fallback: true, // Load failed, fallback to old mode
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SUPPORTED_QUERY_CHARACTERS = /^[A-Z0-9.\u3400-\u9FFF\s]+$/;
|
|||||||
|
|
||||||
const STOCK_CODE_PATTERNS = [
|
const STOCK_CODE_PATTERNS = [
|
||||||
/^\d{6}$/, // A-share 6-digit code
|
/^\d{6}$/, // A-share 6-digit code
|
||||||
/^(SH|SZ|BJ)\d{6}$/, // A-share code with exchange prefix
|
/^(SH|SZ|BJ)\d{6}$/, // A-share code with exchange prefix (also covers index canonical sh000016 / sh000300)
|
||||||
/^\d{6}\.(SH|SZ|SS|BJ)$/, // A-share code with exchange suffix
|
/^\d{6}\.(SH|SZ|SS|BJ)$/, // A-share code with exchange suffix
|
||||||
/^\d{5}$/, // HK code without prefix
|
/^\d{5}$/, // HK code without prefix
|
||||||
/^HK\d{1,5}$/, // HK-prefixed code, for example HK00700
|
/^HK\d{1,5}$/, // HK-prefixed code, for example HK00700
|
||||||
@@ -16,6 +16,8 @@ const STOCK_CODE_PATTERNS = [
|
|||||||
/^\d{4,5}\.T$/, // Japan Yahoo suffix format, for example 7203.T
|
/^\d{4,5}\.T$/, // Japan Yahoo suffix format, for example 7203.T
|
||||||
/^\d{6}\.(KS|KQ)$/, // Korea Yahoo suffix format, for example 005930.KS or 035720.KQ
|
/^\d{6}\.(KS|KQ)$/, // Korea Yahoo suffix format, for example 005930.KS or 035720.KQ
|
||||||
/^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$/, // Common US ticker format
|
/^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$/, // Common US ticker format
|
||||||
|
/^CSI\d{6}$/, // Registered CSI index canonical (e.g. csi930955)
|
||||||
|
/^\d{6}\.CSI$/, // Registered CSI index display form (e.g. 930955.CSI)
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|||||||
- [修复] 阻止任意更新的非 bundled 指数候选(含 legacy `static` 子集)在 remote 缺失/损坏时以 active-index 子集覆盖 bundled baseline:所有非 bundled 候选必须为 bundled active-index canonical 集合的合法超集,否则回退 bundled 并记录 WARNING。
|
- [修复] 阻止任意更新的非 bundled 指数候选(含 legacy `static` 子集)在 remote 缺失/损坏时以 active-index 子集覆盖 bundled baseline:所有非 bundled 候选必须为 bundled active-index canonical 集合的合法超集,否则回退 bundled 并记录 WARNING。
|
||||||
- [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。
|
- [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。
|
||||||
- [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 GitHub Releases 检查;主进程手动检查路径同步增加 in-flight 防重。
|
- [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 GitHub Releases 检查;主进程手动检查路径同步增加 in-flight 防重。
|
||||||
|
- [新功能] Web 自动补全与搜索放行已登记指数(注册中文名/`sh`/`sz` 前缀/`csi`/`.CSI` 显式形式均可检索与提交),热门候选仍仅股票;`/analyze` API 对显式指数输入构造结构化 `AnalysisTarget`(INDEX 用 `canonical_id` 去重,与同码个股互不折叠,CSI alias 收敛),未登记 CSI 单请求返回明确 4xx、异步批量仅该目标进入响应 `rejected` 列表;指数 target 经任务队列贯穿到 Pipeline `process_single_stock`,`DecisionSignal` 以 `market_override="cn"` 落库并有真实分支测试。
|
||||||
|
- [修复] `/analyze` 在解析前按 strip 后非空原始 token 数限制 50 上限,rejected/duplicate token 也计入,防止用拒绝或重复 token 绕过批量上限;以唯一 `is_single = len(stock_codes) == 1 and not rejected_entries` 统一驱动 metadata、409 与单任务 202,duplicate+rejected 混合批次不再误返 legacy 409(单 duplicate 仍 409、部分 rejected 仍 202、全 rejected 仍 400)。
|
||||||
|
- [新功能] 报告 meta 补充可选 `asset_type`(`stock`/`index`),由后端 canonical code 经 `parse_analysis_target` 生成权威类型;指数报告在 Web 报告页与 Chat 自选入口隐藏 stock-only 自选操作,裸同码股票(如 `000016`)保持股票行为,market review 与旧客户端可缺省。
|
||||||
|
- [修复] Web 批量分析把 accepted/duplicates/rejected 三类计入确认数,含 rejected 的完整 chunk 继续提交下一 chunk 而非误报 incomplete,最终以 warning 展示拒绝数量与首个拒绝原因(中英文文案)。
|
||||||
|
- [修复] 历史筛选/删除/计数与 stock-bar 对已登记指数按 parser canonical 身份隔离:`sh000016`/`SH000016`/`000016.SH` 等显式形态互相可达(lowercase canonical + uppercase legacy canonical + 显式 alias),但永远不命中同码裸股票,裸码查询也不会命中指数记录;同一指数多条显式形态旧记录在 `/history/stocks` 合并为一行并计数全部形态,无记录删除仍返回 `deleted=0`。SH/SZ/CSI 共用同一 parser 分支,股票 alias、港股与海外市场行为不变。历史列表、历史详情与 stock-bar 对已登记指数(含旧 uppercase/显式 alias 持久化记录,如 `SZ399300`、`000300.CSI`)的 API `stock_code` 一律输出 parser canonical(`sh000300`/`csi930955`)。
|
||||||
|
- [改进] 任务列表/SSE 事件、历史列表项与 stock-bar 项追加可选 `asset_type`(`stock`/`index`):任务侧从已提交的 `analysis_target` 透传(不重新猜测),历史与 stock-bar 侧由持久化 `record.code` 经 `parse_analysis_target` 生成;旧客户端与 market review 可缺省,字段可选追加不破坏既有契约。
|
||||||
|
- [修复] Web 首页与自选工作区改用资产感知身份键:任务/报告/历史的 `assetType` 优先,且被后端保证为 parser canonical 的代码只做**大小写折叠**(`SH000016`→`sh000016`),禁止再用前缀/后缀正则猜 canonical(否则 `000300.CSI` 会被误猜成 `csi000300`、`sz399300` 被误当成独立 canonical,违反注册表唯一判型真源);仅 watchlist 原始字符串缺少类型时,才用已加载 `stocks.index.json` 的 `assetType=index` 行 canonical/display/显式 alias 精确命中(不先 normalize、不用前缀正则猜测;加载期间禁用批量分析,加载失败或请求超过 10 秒时按既有股票语义 fail-open);行选中、active task 与完成自动选中按资产类型分桶,`sh000016` 指数行与 `000016` 股票行状态独立,完成后自动选中正确 canonical 指数报告。
|
||||||
|
- [修复] Chat 消息恢复、发送与报告追问对显式 SH/SZ/CSI 已登记指数统一按注册表 canonical 判型并覆盖默认 LiteLLM 与 Codex:所有 Chat 后端首帧加载 registry,加载期间延迟 URL/历史恢复并禁用发送,settle 后 `sh000016`/`sz399001`/`000016.SH`/`930955.CSI`/`csi930955` 只产出一个 lowercase canonical;后端 `resolve_stock_scope`、工具守卫与 cache key 复用 parser `INDEX` 身份,保持指数与裸同码股票隔离;未登记、空 registry 或加载失败时维持既有股票 fail-open,绝不按前缀猜指数。
|
||||||
|
|
||||||
## [3.31.0] - 2026-08-23
|
## [3.31.0] - 2026-08-23
|
||||||
|
|
||||||
|
|||||||
@@ -738,7 +738,24 @@ python main.py --stocks sh000016 --dry-run
|
|||||||
|
|
||||||
指数实时行情使用独立固定链:腾讯 → 新浪 → 东财单股接口 → TickFlow;SH/SZ 指数按 `sh000016`/`sz399001` 显式符号请求,CSI 指数仅由东财单股接口提供(`2.{code}` secid)。显式指数身份全程保留,不会退化为同码股票行情。
|
指数实时行情使用独立固定链:腾讯 → 新浪 → 东财单股接口 → TickFlow;SH/SZ 指数按 `sh000016`/`sz399001` 显式符号请求,CSI 指数仅由东财单股接口提供(`2.{code}` secid)。显式指数身份全程保留,不会退化为同码股票行情。
|
||||||
|
|
||||||
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Web/API 自动补全与分析入口、Bot 与 GitHub Actions 每日工作流暂不开放指数入口;本能力仅通过一次性 `--stocks` 提供。
|
### 指数 Web/API 入口(Phase 2 PR1)
|
||||||
|
|
||||||
|
Web 自动补全与搜索已放行已登记指数:搜索注册中文名(如 `上证50`)或显式代码(`sh000016`、`930955.CSI`)会返回对应指数条目并可提交分析;热门候选仍仅展示股票(`assetType=stock`),不包含指数。
|
||||||
|
|
||||||
|
API `/analyze` 对显式指数输入构造结构化 `AnalysisTarget`:`sh000016` 以 `asset_type=INDEX` 且 `canonical_id=sh000016` 入队,`930955.CSI`/`csi930955` 收敛为 `csi930955`。指数与同码个股(如 `sh000016` 与 `000016`)独立去重调度、互不折叠;未登记的 CSI 输入(如 `930956.CSI`)在异步单股或同步模式返回明确的 4xx,在异步批量中仅该目标进入响应 `rejected` 列表、同批其他目标正常入队。中文名称输入(如 `贵州茅台`)仍走既有股票名解析,不进入指数判型。
|
||||||
|
|
||||||
|
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Bot 与 GitHub Actions 每日工作流暂不开放指数入口;Web/API 与一次性 `--stocks` 已支持指数,Bot/定时/每日工作流入口留待 Phase 2 后续 PR。
|
||||||
|
|
||||||
|
### 指数与个股 Dashboard canonical 隔离(PR #2312)
|
||||||
|
|
||||||
|
已登记指数以 lowercase canonical(`sh000016`/`sz399001`/`csi930955`)写入历史存储;历史筛选、按代码删除、计数与个股栏(stock-bar)聚合统一使用 parser 判型,指数记录与同码裸股票(如 `000016`)严格隔离,互不折叠:
|
||||||
|
|
||||||
|
- **历史候选**:指数查询(`sh000016`、`SH000016`、`000016.SH`、`sz399001`、`csi930955`、`930955.CSI` 等显式形式)会命中 lowercase canonical、uppercase legacy canonical 与显式 alias 的既有记录,但**不会**命中裸同码股票记录;裸码查询(`000016`/`930955`)也不会命中指数记录。股票 alias、港股与海外市场的既有等价匹配保持不变。
|
||||||
|
- **删除与计数**:`DELETE /api/v1/history/by-code/{code}` 与历史总数对指数 canonical 收敛全部显式形态;无记录时仍返回 `deleted=0`,不引入破坏性 404。
|
||||||
|
- **个股栏**:同一指数的 `sh000016`/`SH000016`/`000016.SH` 旧记录在 `/history/stocks` 合并为一行并计数全部显式形态,且与裸 `000016` 股票行并列存在、互不合并。stock-bar 判型来自持久化 `record.code`,不从 display code 反推。
|
||||||
|
- **API `stock_code` 输出 canonical**:历史列表、历史详情与 stock-bar 对已登记指数(含旧 uppercase/显式 alias 持久化记录,例如 `SZ399300`、`000300.CSI`)一律输出 parser canonical(`sh000300`/`csi930955`),前端不再需要从别名猜 canonical。
|
||||||
|
- **任务 / SSE / API 元数据**:任务列表与 SSE 事件对已提交的 `analysis_target` 暴露可选 `asset_type`(`stock`/`index`),不重新猜测;历史列表项与 stock-bar 项也追加可选 `asset_type`。不认识该字段的旧客户端直接忽略,字段可选追加不破坏既有契约。
|
||||||
|
- **Web Dashboard 身份键**:任务/报告/历史的 `assetType` 优先,且被后端保证为 parser canonical 的代码只做**大小写折叠**(`SH000016`→`sh000016`),**禁止**再用前缀/后缀正则猜 canonical(否则 `000300.CSI` 会被误猜成 `csi000300`、`sz399300` 被误当成独立 canonical,违反注册表唯一判型真源);仅 watchlist 原始字符串缺少类型时,才使用已加载 `stocks.index.json` 中 `assetType=index` 行的 canonical/display/显式 alias **精确命中**(不做先 normalize 再匹配、不用前缀正则猜测;加载期间禁用批量分析,加载失败或请求超过 10 秒时按既有股票语义 fail-open)。行选中、active task 与完成自动选中均按资产类型分桶,指数行与同码股票行状态独立,完成后自动选中正确的 canonical 指数报告。
|
||||||
|
|
||||||
### Futu 真实持仓作为分析列表
|
### Futu 真实持仓作为分析列表
|
||||||
|
|
||||||
|
|||||||
@@ -677,7 +677,24 @@ Indices share the A-share trading-day semantics: when the trading-day check is e
|
|||||||
|
|
||||||
Index realtime quotes use a dedicated fixed chain: Tencent → Sina → Eastmoney single-stock endpoint → TickFlow. SH/SZ indices are requested with explicit symbols (`sh000016`/`sz399001`); CSI indices are served only by the Eastmoney single-stock endpoint (`2.{code}` secid). The explicit index identity is preserved end-to-end and never degrades into the colliding stock quote.
|
Index realtime quotes use a dedicated fixed chain: Tencent → Sina → Eastmoney single-stock endpoint → TickFlow. SH/SZ indices are requested with explicit symbols (`sh000016`/`sz399001`); CSI indices are served only by the Eastmoney single-stock endpoint (`2.{code}` secid). The explicit index identity is preserved end-to-end and never degrades into the colliding stock quote.
|
||||||
|
|
||||||
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Web/API autocomplete and analysis entrypoints, Bot, and the GitHub Actions daily workflow do not yet expose index entrypoints; this capability is available only through the one-shot `--stocks` entry.
|
### Web/API index entry (Phase 2 PR1)
|
||||||
|
|
||||||
|
Web autocomplete and search now expose registered indices: searching a registry Chinese name (e.g. `上证50`) or an explicit code (`sh000016`, `930955.CSI`) returns the matching index row and lets you submit its analysis; popular candidates still show stocks only (`assetType=stock`), never indices.
|
||||||
|
|
||||||
|
The API `/analyze` endpoint builds a structured `AnalysisTarget` for explicit index inputs: `sh000016` is enqueued as `asset_type=INDEX` with `canonical_id=sh000016`, and `930955.CSI`/`csi930955` converge to `csi930955`. Indices and same-digit stocks (e.g. `sh000016` vs `000016`) are deduplicated independently and never collapse. An unregistered CSI input (e.g. `930956.CSI`) returns an explicit 4xx for a single async or sync request, and in an async batch only that target enters the response `rejected` list while the rest of the batch is enqueued normally. Chinese-name inputs (e.g. `贵州茅台`) keep the existing stock-name resolution path and never enter index classification.
|
||||||
|
|
||||||
|
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Bot, and the GitHub Actions daily workflow do not yet expose index entrypoints; Web/API and the one-shot `--stocks` entry support indices, with Bot/scheduled/daily-workflow entries landing in later Phase 2 PRs.
|
||||||
|
|
||||||
|
### Index vs stock Dashboard canonical isolation (PR #2312)
|
||||||
|
|
||||||
|
Registered indices are persisted under their lowercase canonical identity (`sh000016` / `sz399001` / `csi930955`). History filtering, delete-by-code, counts, and the stock bar now all use the parser for asset typing, so index records and the same-code bare stock (e.g. `000016`) are strictly isolated and never collapse:
|
||||||
|
|
||||||
|
- **History candidates**: index queries (`sh000016`, `SH000016`, `000016.SH`, `sz399001`, `csi930955`, `930955.CSI` and other explicit forms) reach records persisted under the lowercase canonical, the legacy uppercase canonical, or an explicit alias — but **never** the bare same-code stock record; a bare query (`000016` / `930955`) likewise never reaches index records. Stock aliases, HK, and offshore markets keep their existing equivalence semantics.
|
||||||
|
- **Delete and count**: `DELETE /api/v1/history/by-code/{code}` and history totals converge every explicit index form; a code with no records still returns `deleted=0` (no breaking 404).
|
||||||
|
- **Stock bar**: legacy index records (`sh000016` / `SH000016` / `000016.SH`) merge into one `/history/stocks` row whose count covers all explicit forms, and that row sits beside the bare `000016` stock row without merging. Stock-bar typing derives from the persisted `record.code`, never from the display code.
|
||||||
|
- **API `stock_code` output is canonical**: history list, history detail, and stock-bar always surface the parser canonical for registered indices — including legacy uppercase / explicit-alias persisted records such as `SZ399300` or `000300.CSI` (both output `sh000300`) — so the frontend never has to derive a canonical from aliases.
|
||||||
|
- **Task / SSE / API metadata**: task lists and SSE events expose an optional `asset_type` (`stock`/`index`) derived from the submitted `analysis_target` (never re-guessed); history list items and stock-bar items gain the same optional field. Clients that do not know the field simply ignore it — the field is optional and additive.
|
||||||
|
- **Web Dashboard identity keys**: `assetType` on tasks/reports/history wins first, and backend-guaranteed canonical codes are only **case-folded** (`SH000016` -> `sh000016`) — prefix/suffix regex canonical guessing is forbidden (it would fabricate `csi000300` from `000300.CSI` or treat `sz399300` as an independent canonical, violating the registry as the single asset-type authority). Only raw watchlist strings without a type use an exact canonical/display/alias hit on the loaded `assetType=index` rows of `stocks.index.json` (never normalize-then-match, never prefix-regex guessing; batch analysis stays disabled while the registry loads, and a load failure or request exceeding 10 seconds falls back to existing stock semantics). Row selection, active-task matching, and completed-task auto-selection bucket by asset type, so an index row and a same-code stock row keep independent states and completion auto-selects the correct canonical index report.
|
||||||
|
|
||||||
### Use real Futu holdings as the analysis list
|
### Use real Futu holdings as the analysis list
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,62 @@ _INDICATOR_CONTEXT_PATTERN = re.compile(
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Match complete explicit SH/SZ/CSI tokens; the registry parser remains the
|
||||||
|
# only authority that can promote a match to index identity.
|
||||||
|
_INDEX_TOKEN_PATTERNS = (
|
||||||
|
(r"(?<![a-zA-Z0-9_])(?:sh|sz)\d{6}(?![a-zA-Z0-9_])", re.IGNORECASE),
|
||||||
|
(r"(?<![a-zA-Z0-9_])csi\d{6}(?![a-zA-Z0-9_])", re.IGNORECASE),
|
||||||
|
(
|
||||||
|
r"(?<![a-zA-Z0-9_])\d{6}\.(?:sh|sz|csi)(?![a-zA-Z0-9_])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_index_canonical_tokens(
|
||||||
|
text: str,
|
||||||
|
registry: Any,
|
||||||
|
) -> "tuple[list[tuple[int, int]], list[str]]":
|
||||||
|
"""Return full spans and canonicals for exact registered index tokens."""
|
||||||
|
spans: List[tuple[int, int]] = []
|
||||||
|
canonicals: List[str] = []
|
||||||
|
for pattern, flags in _INDEX_TOKEN_PATTERNS:
|
||||||
|
for match in re.finditer(pattern, text, flags):
|
||||||
|
raw = match.group(0)
|
||||||
|
try:
|
||||||
|
from src.services.stock_list_parser import (
|
||||||
|
ParseStatus,
|
||||||
|
parse_analysis_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
target = parse_analysis_target(raw, registry)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if target.asset_type != ParseStatus.INDEX:
|
||||||
|
continue
|
||||||
|
if not target.canonical_id:
|
||||||
|
continue
|
||||||
|
start, end = match.span()
|
||||||
|
if any(s <= start and end <= e for s, e in spans):
|
||||||
|
continue
|
||||||
|
spans.append((start, end))
|
||||||
|
canonicals.append(target.canonical_id)
|
||||||
|
return spans, canonicals
|
||||||
|
|
||||||
|
|
||||||
|
def _is_inside_index_span(start: int, end: int, spans: List[tuple[int, int]]) -> bool:
|
||||||
|
return any(span_start <= start and end <= span_end for span_start, span_end in spans)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_ascii_token_boundaries(text: str, start: int, end: int) -> bool:
|
||||||
|
def _is_word_char(char: str) -> bool:
|
||||||
|
return bool(char) and char.isascii() and (char.isalnum() or char == "_")
|
||||||
|
|
||||||
|
return (
|
||||||
|
not _is_word_char(text[start - 1:start])
|
||||||
|
and not _is_word_char(text[end:end + 1])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class StockScope:
|
class StockScope:
|
||||||
@@ -65,8 +121,8 @@ class StockScopeResolution:
|
|||||||
stock_scope: Optional[StockScope]
|
stock_scope: Optional[StockScope]
|
||||||
|
|
||||||
|
|
||||||
def _normalize_stock_code(value: Any) -> str:
|
def _normalize_stock_code(value: Any, registry: Optional[Any] = None) -> str:
|
||||||
"""Normalize a code with the runner's canonical stock-code rules."""
|
"""Normalize a code, preserving exact registered index canonicals."""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return ""
|
return ""
|
||||||
text = value.strip()
|
text = value.strip()
|
||||||
@@ -75,7 +131,7 @@ def _normalize_stock_code(value: Any) -> str:
|
|||||||
try:
|
try:
|
||||||
from src.agent.tools.execution import _normalize_tool_stock_code
|
from src.agent.tools.execution import _normalize_tool_stock_code
|
||||||
|
|
||||||
normalized = _normalize_tool_stock_code(text)
|
normalized = _normalize_tool_stock_code(text, registry)
|
||||||
except Exception:
|
except Exception:
|
||||||
normalized = text.strip().upper()
|
normalized = text.strip().upper()
|
||||||
return normalized if isinstance(normalized, str) else str(normalized)
|
return normalized if isinstance(normalized, str) else str(normalized)
|
||||||
@@ -95,20 +151,31 @@ def _is_denied_candidate(candidate: str, text: str = "") -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _append_candidate(candidates: List[str], candidate: str, text: str = "") -> None:
|
def _append_candidate(
|
||||||
normalized = _normalize_stock_code(candidate)
|
candidates: List[str],
|
||||||
|
candidate: str,
|
||||||
|
text: str = "",
|
||||||
|
registry: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
normalized = _normalize_stock_code(candidate, registry)
|
||||||
if not normalized or _is_denied_candidate(normalized, text):
|
if not normalized or _is_denied_candidate(normalized, text):
|
||||||
return
|
return
|
||||||
if normalized not in candidates:
|
if normalized not in candidates:
|
||||||
candidates.append(normalized)
|
candidates.append(normalized)
|
||||||
|
|
||||||
|
|
||||||
def extract_stock_codes(text: str) -> List[str]:
|
def extract_stock_codes(text: str, registry: Optional[Any] = None) -> List[str]:
|
||||||
"""Extract all explicit stock-code candidates from free text."""
|
"""Extract candidates; no registry preserves the legacy stock-only path."""
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
candidates: List[str] = []
|
candidates: List[str] = []
|
||||||
|
index_spans: List[tuple[int, int]] = []
|
||||||
|
if registry is not None:
|
||||||
|
index_spans, canonicals = _extract_index_canonical_tokens(text, registry)
|
||||||
|
for canonical in canonicals:
|
||||||
|
if canonical not in candidates:
|
||||||
|
candidates.append(canonical)
|
||||||
|
|
||||||
for pattern, flags in (
|
for pattern, flags in (
|
||||||
(r"(?<![a-zA-Z])(?:SH|SZ|BJ)\d{6}(?!\d)", re.IGNORECASE),
|
(r"(?<![a-zA-Z])(?:SH|SZ|BJ)\d{6}(?!\d)", re.IGNORECASE),
|
||||||
@@ -119,8 +186,13 @@ def extract_stock_codes(text: str) -> List[str]:
|
|||||||
(r"(?<![a-zA-Z.])([A-Z]{2,5}(?:\.[A-Z]{1,2})?)(?![a-zA-Z0-9])", 0),
|
(r"(?<![a-zA-Z.])([A-Z]{2,5}(?:\.[A-Z]{1,2})?)(?![a-zA-Z0-9])", 0),
|
||||||
):
|
):
|
||||||
for match in re.finditer(pattern, text, flags):
|
for match in re.finditer(pattern, text, flags):
|
||||||
|
start, end = match.span()
|
||||||
|
if registry is not None and not _has_ascii_token_boundaries(text, start, end):
|
||||||
|
continue
|
||||||
|
if _is_inside_index_span(start, end, index_spans):
|
||||||
|
continue
|
||||||
raw = match.group(1) if match.lastindex else match.group(0)
|
raw = match.group(1) if match.lastindex else match.group(0)
|
||||||
_append_candidate(candidates, raw, text)
|
_append_candidate(candidates, raw, text, registry)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
_SWITCH_PATTERN.search(text)
|
_SWITCH_PATTERN.search(text)
|
||||||
@@ -129,12 +201,22 @@ def extract_stock_codes(text: str) -> List[str]:
|
|||||||
or _CHOICE_COMPARE_PATTERN.search(text)
|
or _CHOICE_COMPARE_PATTERN.search(text)
|
||||||
):
|
):
|
||||||
for match in _LOWERCASE_TICKER_PATTERN.finditer(text):
|
for match in _LOWERCASE_TICKER_PATTERN.finditer(text):
|
||||||
_append_candidate(candidates, match.group(1), text)
|
start, end = match.span(1)
|
||||||
|
if registry is not None and not _has_ascii_token_boundaries(text, start, end):
|
||||||
|
continue
|
||||||
|
if _is_inside_index_span(start, end, index_spans):
|
||||||
|
continue
|
||||||
|
_append_candidate(candidates, match.group(1), text, registry)
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
def _is_compare_message(message: str, candidates: List[str], current_code: str) -> bool:
|
def _is_compare_message(
|
||||||
|
message: str,
|
||||||
|
candidates: List[str],
|
||||||
|
current_code: str,
|
||||||
|
registry: Optional[Any] = None,
|
||||||
|
) -> bool:
|
||||||
if _STRONG_COMPARE_PATTERN.search(message):
|
if _STRONG_COMPARE_PATTERN.search(message):
|
||||||
return True
|
return True
|
||||||
new_candidates = {code for code in candidates if code != current_code}
|
new_candidates = {code for code in candidates if code != current_code}
|
||||||
@@ -151,7 +233,7 @@ def _is_compare_message(message: str, candidates: List[str], current_code: str)
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
for match in _LINKED_COMPARE_PATTERN.finditer(message):
|
for match in _LINKED_COMPARE_PATTERN.finditer(message):
|
||||||
body_candidates = set(extract_stock_codes(f"比较 {match.group('body')}"))
|
body_candidates = set(extract_stock_codes(f"比较 {match.group('body')}", registry))
|
||||||
if body_candidates & new_candidates:
|
if body_candidates & new_candidates:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -182,11 +264,22 @@ def resolve_stock_scope(
|
|||||||
*,
|
*,
|
||||||
skills: Optional[Iterable[str]] = None,
|
skills: Optional[Iterable[str]] = None,
|
||||||
strict_initial_scope: bool = False,
|
strict_initial_scope: bool = False,
|
||||||
|
registry: Optional[Any] = None,
|
||||||
) -> StockScopeResolution:
|
) -> StockScopeResolution:
|
||||||
"""Resolve the effective context and stock tool scope for one chat turn."""
|
"""Resolve one turn with a shared registry, failing open to stock semantics."""
|
||||||
|
if registry is None:
|
||||||
|
try:
|
||||||
|
from src.services.stock_list_parser import default_index_registry
|
||||||
|
|
||||||
|
registry = default_index_registry()
|
||||||
|
except Exception:
|
||||||
|
registry = None
|
||||||
|
if registry is not None and not getattr(registry, "_entries", ()):
|
||||||
|
registry = None
|
||||||
|
|
||||||
original_context = dict(context or {})
|
original_context = dict(context or {})
|
||||||
message_text = message or ""
|
message_text = message or ""
|
||||||
current_code = _normalize_stock_code(original_context.get("stock_code"))
|
current_code = _normalize_stock_code(original_context.get("stock_code"), registry)
|
||||||
invalid_context_code = bool(current_code and _is_denied_candidate(current_code, message_text))
|
invalid_context_code = bool(current_code and _is_denied_candidate(current_code, message_text))
|
||||||
original_context.pop("allowed_stock_codes", None)
|
original_context.pop("allowed_stock_codes", None)
|
||||||
if invalid_context_code:
|
if invalid_context_code:
|
||||||
@@ -196,7 +289,7 @@ def resolve_stock_scope(
|
|||||||
|
|
||||||
if not current_code:
|
if not current_code:
|
||||||
if invalid_context_code or strict_initial_scope:
|
if invalid_context_code or strict_initial_scope:
|
||||||
candidates = extract_stock_codes(message_text)
|
candidates = extract_stock_codes(message_text, registry)
|
||||||
if strict_initial_scope and not invalid_context_code and not candidates:
|
if strict_initial_scope and not invalid_context_code and not candidates:
|
||||||
return StockScopeResolution(
|
return StockScopeResolution(
|
||||||
effective_context=_with_skills(original_context, skills),
|
effective_context=_with_skills(original_context, skills),
|
||||||
@@ -222,14 +315,14 @@ def resolve_stock_scope(
|
|||||||
stock_scope=None,
|
stock_scope=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates = extract_stock_codes(message_text)
|
candidates = extract_stock_codes(message_text, registry)
|
||||||
new_candidates = [code for code in candidates if code != current_code]
|
new_candidates = [code for code in candidates if code != current_code]
|
||||||
mode = "maintain"
|
mode = "maintain"
|
||||||
effective_context = dict(original_context)
|
effective_context = dict(original_context)
|
||||||
expected = current_code
|
expected = current_code
|
||||||
allowed = {current_code}
|
allowed = {current_code}
|
||||||
|
|
||||||
if _is_compare_message(message_text, candidates, current_code):
|
if _is_compare_message(message_text, candidates, current_code, registry):
|
||||||
mode = "compare"
|
mode = "compare"
|
||||||
allowed.update(candidates)
|
allowed.update(candidates)
|
||||||
elif _SWITCH_PATTERN.search(message_text) and len(new_candidates) == 1:
|
elif _SWITCH_PATTERN.search(message_text) and len(new_candidates) == 1:
|
||||||
|
|||||||
@@ -169,45 +169,75 @@ def serialize_tool_result(result: Any) -> str:
|
|||||||
return str(result)
|
return str(result)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_tool_stock_code(value: Any) -> Any:
|
def _normalize_tool_stock_code(value: Any, registry: Optional[Any] = None) -> Any:
|
||||||
"""Canonicalize stock code arguments so equivalent HK variants share one cache key."""
|
"""Canonicalize a stock argument for tool scope and cache keys.
|
||||||
|
|
||||||
|
An injected registry preserves exact parser INDEX canonicals. Direct calls
|
||||||
|
without one retain the legacy stock/HK normalization path.
|
||||||
|
"""
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
text = value.strip().upper()
|
text = value.strip()
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
if text.endswith(".HK"):
|
if registry is not None:
|
||||||
base = text[:-3]
|
try:
|
||||||
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
|
|
||||||
|
target = parse_analysis_target(text, registry)
|
||||||
|
if target.asset_type == ParseStatus.INDEX and target.canonical_id:
|
||||||
|
return target.canonical_id
|
||||||
|
except Exception:
|
||||||
|
# Registry malformed/unavailable — fall through to the stock path.
|
||||||
|
pass
|
||||||
|
|
||||||
|
upper = text.upper()
|
||||||
|
if upper.endswith(".HK"):
|
||||||
|
base = upper[:-3]
|
||||||
if base.isdigit() and 1 <= len(base) <= 5:
|
if base.isdigit() and 1 <= len(base) <= 5:
|
||||||
return f"HK{base.zfill(5)}"
|
return f"HK{base.zfill(5)}"
|
||||||
|
|
||||||
if text.startswith("HK"):
|
if upper.startswith("HK"):
|
||||||
base = text[2:]
|
base = upper[2:]
|
||||||
if base.isdigit() and 1 <= len(base) <= 5:
|
if base.isdigit() and 1 <= len(base) <= 5:
|
||||||
return f"HK{base.zfill(5)}"
|
return f"HK{base.zfill(5)}"
|
||||||
|
|
||||||
if text.isdigit() and len(text) == 5:
|
if upper.isdigit() and len(upper) == 5:
|
||||||
return f"HK{text}"
|
return f"HK{upper}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from data_provider.base import canonical_stock_code, normalize_stock_code
|
from data_provider.base import canonical_stock_code, normalize_stock_code
|
||||||
|
|
||||||
return canonical_stock_code(normalize_stock_code(text))
|
return canonical_stock_code(normalize_stock_code(upper))
|
||||||
except Exception:
|
except Exception:
|
||||||
return text
|
return upper
|
||||||
|
|
||||||
|
|
||||||
|
def _default_index_registry_or_none() -> Optional[Any]:
|
||||||
|
"""Return the default IndexRegistry, failing open to stock semantics."""
|
||||||
|
try:
|
||||||
|
from src.services.stock_list_parser import default_index_registry
|
||||||
|
|
||||||
|
return default_index_registry()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]:
|
def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Build a stable cache key for tool calls with normalized stock-code arguments."""
|
"""Build a stable cache key without folding index and bare-stock identities."""
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
registry = None
|
||||||
|
if "stock_code" in arguments:
|
||||||
|
registry = _default_index_registry_or_none()
|
||||||
|
|
||||||
normalized_args: Dict[str, Any] = {}
|
normalized_args: Dict[str, Any] = {}
|
||||||
for key, value in arguments.items():
|
for key, value in arguments.items():
|
||||||
if key == "stock_code":
|
if key == "stock_code":
|
||||||
normalized_args[key] = _normalize_tool_stock_code(value)
|
normalized_args[key] = _normalize_tool_stock_code(value, registry)
|
||||||
else:
|
else:
|
||||||
normalized_args[key] = value
|
normalized_args[key] = value
|
||||||
|
|
||||||
@@ -234,13 +264,13 @@ def _is_stock_scoped_tool(tool_registry: ToolRegistry, tool_name: str) -> bool:
|
|||||||
return any(param.name == "stock_code" for param in tool_def.parameters)
|
return any(param.name == "stock_code" for param in tool_def.parameters)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_guard_stock_code(value: Any) -> str:
|
def _normalize_guard_stock_code(value: Any, registry: Optional[Any] = None) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(value, float) and value.is_integer():
|
if isinstance(value, float) and value.is_integer():
|
||||||
value = int(value)
|
value = int(value)
|
||||||
raw = value if isinstance(value, str) else str(value)
|
raw = value if isinstance(value, str) else str(value)
|
||||||
normalized = _normalize_tool_stock_code(raw)
|
normalized = _normalize_tool_stock_code(raw, registry)
|
||||||
return normalized if isinstance(normalized, str) else str(normalized)
|
return normalized if isinstance(normalized, str) else str(normalized)
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +283,9 @@ def _guard_tool_stock_scope(
|
|||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: Dict[str, Any],
|
arguments: Dict[str, Any],
|
||||||
stock_scope: Any,
|
stock_scope: Any,
|
||||||
|
index_registry: Optional[Any] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Enforce one tool stock argument against the active canonical scope."""
|
||||||
if stock_scope is None or not isinstance(arguments, dict):
|
if stock_scope is None or not isinstance(arguments, dict):
|
||||||
return None
|
return None
|
||||||
if not _is_stock_scoped_tool(tool_registry, tool_name):
|
if not _is_stock_scoped_tool(tool_registry, tool_name):
|
||||||
@@ -261,12 +293,17 @@ def _guard_tool_stock_scope(
|
|||||||
if "stock_code" not in arguments:
|
if "stock_code" not in arguments:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
requested = _normalize_guard_stock_code(arguments.get("stock_code"))
|
if index_registry is None:
|
||||||
expected = _normalize_guard_stock_code(getattr(stock_scope, "expected_stock_code", ""))
|
index_registry = _default_index_registry_or_none()
|
||||||
|
|
||||||
|
requested = _normalize_guard_stock_code(arguments.get("stock_code"), index_registry)
|
||||||
|
expected = _normalize_guard_stock_code(
|
||||||
|
getattr(stock_scope, "expected_stock_code", ""), index_registry
|
||||||
|
)
|
||||||
allowed = {
|
allowed = {
|
||||||
normalized
|
normalized
|
||||||
for code in _iter_allowed_stock_codes(stock_scope)
|
for code in _iter_allowed_stock_codes(stock_scope)
|
||||||
for normalized in [_normalize_guard_stock_code(code)]
|
for normalized in [_normalize_guard_stock_code(code, index_registry)]
|
||||||
if normalized
|
if normalized
|
||||||
}
|
}
|
||||||
if requested and (requested == expected or requested in allowed):
|
if requested and (requested == expected or requested in allowed):
|
||||||
|
|||||||
@@ -4051,7 +4051,13 @@ class SearchService:
|
|||||||
# 如果提供了关键词,直接使用关键词作为查询
|
# 如果提供了关键词,直接使用关键词作为查询
|
||||||
query = " ".join(focus_keywords)
|
query = " ".join(focus_keywords)
|
||||||
elif prefer_chinese:
|
elif prefer_chinese:
|
||||||
query = f"{stock_name} {stock_code} 股票 最新消息"
|
# 指数 target 的 stock_code 可能为空(Agent 工具 _resolve_search_subject
|
||||||
|
# 有意只传显示名),空 code 直接省略,避免拼出 "上证50 股票" 双空格。
|
||||||
|
query = (
|
||||||
|
f"{stock_name} {stock_code} 股票 最新消息"
|
||||||
|
if stock_code
|
||||||
|
else f"{stock_name} 股票 最新消息"
|
||||||
|
)
|
||||||
elif is_foreign:
|
elif is_foreign:
|
||||||
# 港股/美股使用英文搜索关键词;优先使用英文公司名(issue #2026)
|
# 港股/美股使用英文搜索关键词;优先使用英文公司名(issue #2026)
|
||||||
if english_aliases and short_name and short_name != effective_name:
|
if english_aliases and short_name and short_name != effective_name:
|
||||||
@@ -4064,13 +4070,13 @@ class SearchService:
|
|||||||
# 默认主查询:股票名称 + 核心关键词
|
# 默认主查询:股票名称 + 核心关键词
|
||||||
query = f"{stock_name} {stock_code} 股票 最新消息"
|
query = f"{stock_name} {stock_code} 股票 最新消息"
|
||||||
|
|
||||||
|
subject_label = f"{stock_name}({stock_code})" if stock_code else stock_name
|
||||||
logger.info(
|
logger.info(
|
||||||
(
|
(
|
||||||
"搜索股票新闻: %s(%s), query='%s', 时间范围: 近%s天 "
|
"搜索股票新闻: %s, query='%s', 时间范围: 近%s天 "
|
||||||
"(profile=%s, NEWS_MAX_AGE_DAYS=%s, prefer_chinese=%s), 目标条数=%s, provider请求条数=%s"
|
"(profile=%s, NEWS_MAX_AGE_DAYS=%s, prefer_chinese=%s), 目标条数=%s, provider请求条数=%s"
|
||||||
),
|
),
|
||||||
stock_name,
|
subject_label,
|
||||||
stock_code,
|
|
||||||
query,
|
query,
|
||||||
search_days,
|
search_days,
|
||||||
self.news_strategy_profile,
|
self.news_strategy_profile,
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ from src.services.empty_news import empty_news_disclosure
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_type_from_canonical_code(code: Any) -> Optional[str]:
|
||||||
|
"""Derive the authoritative ``asset_type`` for a canonical stock/index code.
|
||||||
|
|
||||||
|
Uses :func:`parse_analysis_target` — the single asset-type authority — on
|
||||||
|
the *canonical* code, never the display code, so ``sh000016`` (index) and
|
||||||
|
bare ``000016`` (stock) are distinguished by the parser rather than by
|
||||||
|
display normalization. Returns ``None`` for market review / empty /
|
||||||
|
unsupported codes, so legacy clients and market reviews simply omit the
|
||||||
|
optional field.
|
||||||
|
"""
|
||||||
|
text = str(code or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.upper() == "MARKET":
|
||||||
|
return None
|
||||||
|
|
||||||
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
|
|
||||||
|
target = parse_analysis_target(text)
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
return "index"
|
||||||
|
if target.asset_type == ParseStatus.STOCK:
|
||||||
|
return "stock"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class AnalysisService:
|
class AnalysisService:
|
||||||
"""
|
"""
|
||||||
分析服务
|
分析服务
|
||||||
@@ -62,6 +88,7 @@ class AnalysisService:
|
|||||||
query_source: str = "api",
|
query_source: str = "api",
|
||||||
portfolio_context: Optional[Dict[str, Any]] = None,
|
portfolio_context: Optional[Dict[str, Any]] = None,
|
||||||
report_language: Optional[str] = None,
|
report_language: Optional[str] = None,
|
||||||
|
analysis_target: Optional[Any] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
执行股票分析
|
执行股票分析
|
||||||
@@ -73,6 +100,8 @@ class AnalysisService:
|
|||||||
query_id: 查询 ID(可选)
|
query_id: 查询 ID(可选)
|
||||||
send_notification: 是否发送通知(API 触发默认发送)
|
send_notification: 是否发送通知(API 触发默认发送)
|
||||||
analysis_phase: 请求的分析阶段覆盖(auto/premarket/intraday/postmarket)
|
analysis_phase: 请求的分析阶段覆盖(auto/premarket/intraday/postmarket)
|
||||||
|
analysis_target: 可选的结构化分析目标(指数目标贯穿到 pipeline,
|
||||||
|
否则指数会退化为股票语义)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分析结果字典,包含:
|
分析结果字典,包含:
|
||||||
@@ -128,6 +157,7 @@ class AnalysisService:
|
|||||||
skip_analysis=False,
|
skip_analysis=False,
|
||||||
single_stock_notify=send_notification,
|
single_stock_notify=send_notification,
|
||||||
report_type=rt,
|
report_type=rt,
|
||||||
|
analysis_target=analysis_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -219,6 +249,7 @@ class AnalysisService:
|
|||||||
"change_pct": result.change_pct,
|
"change_pct": result.change_pct,
|
||||||
"model_used": getattr(result, "model_used", None),
|
"model_used": getattr(result, "model_used", None),
|
||||||
"market_phase_summary": market_phase_summary,
|
"market_phase_summary": market_phase_summary,
|
||||||
|
"asset_type": asset_type_from_canonical_code(result.code),
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"analysis_summary": result.analysis_summary,
|
"analysis_summary": result.analysis_summary,
|
||||||
|
|||||||
@@ -141,36 +141,50 @@ class HistoryService:
|
|||||||
is_bse_code,
|
is_bse_code,
|
||||||
normalize_stock_code,
|
normalize_stock_code,
|
||||||
)
|
)
|
||||||
from src.services.stock_code_utils import (
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
_converge_registered_csi_identity,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PR #2267 review remediation: converge registered CSI aliases
|
# PR #2312: parser-aware index branch. ``parse_analysis_target``
|
||||||
# (``csi930955`` / ``930955.CSI`` / ``CSI930955``) so a record persisted under any
|
# (with the index registry) is the single asset-type authority. When
|
||||||
# equivalent form is reachable from every equivalent query input.
|
# the query token is a registered INDEX, the persisted-read candidate
|
||||||
# This is a *persisted-read* filter path, so the candidate set must
|
# set must be exactly:
|
||||||
# include:
|
# 1. the lowercase parser canonical (``sh000016`` / ``csi930955`` —
|
||||||
# 1. the parser canonical (``csi930955`` — current storage form),
|
# the current storage form),
|
||||||
# 2. the old resolver's uppercase canonical (``CSI930955`` — how
|
# 2. the legacy uppercase canonical (``SH000016`` / ``CSI930955`` —
|
||||||
# pre-fix records were saved), and
|
# how pre-fix records were saved), and
|
||||||
# 3. the IndexEntry's explicit aliases (``930955.CSI``).
|
# 3. every explicit alias/display form (``000016.SH`` /
|
||||||
converged_csi = _converge_registered_csi_identity(raw_code)
|
# ``930955.CSI`` — ``IndexEntry.aliases``).
|
||||||
if converged_csi is not None:
|
# The bare same-code stock (``000016`` / ``930955``) is deliberately
|
||||||
from src.data.stock_index_loader import _load_active_index_rows
|
# excluded so an index record is never reachable through a stock
|
||||||
active_rows = _load_active_index_rows()
|
# query and vice versa. This unifies the previous CSI-only convergence
|
||||||
alias_keys: List[str] = []
|
# (PR #2267) with the SH/SZ index identity so all three namespaces
|
||||||
display_keys: List[str] = []
|
# share one branch.
|
||||||
for row in active_rows:
|
target = parse_analysis_target(raw_code)
|
||||||
if row and str(row[0] or "").strip() == converged_csi:
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
display_keys = [str(row[1] or "").strip()]
|
index_keys = [target.canonical_id]
|
||||||
alias_keys = [
|
legacy_upper = (
|
||||||
str(a) for a in (row[5] if isinstance(row[5], list) else [])
|
canonical_stock_code(target.canonical_id)
|
||||||
if str(a).strip()
|
or target.canonical_id.upper()
|
||||||
]
|
)
|
||||||
break
|
if legacy_upper and legacy_upper not in index_keys:
|
||||||
canonical_upper = canonical_stock_code(converged_csi) or converged_csi.upper()
|
index_keys.append(legacy_upper)
|
||||||
add_keys = [converged_csi, canonical_upper] + display_keys + alias_keys
|
entry = target.matched_index
|
||||||
for key in add_keys:
|
if entry is not None:
|
||||||
|
for alias in entry.aliases:
|
||||||
|
alias = str(alias or "").strip()
|
||||||
|
if not alias:
|
||||||
|
continue
|
||||||
|
if alias not in index_keys:
|
||||||
|
index_keys.append(alias)
|
||||||
|
# sqlite ``IN`` comparison is case-sensitive: a legacy
|
||||||
|
# record may have been persisted under the uppercase
|
||||||
|
# alias form (``SZ399300`` for registry alias
|
||||||
|
# ``sz399300``), so the persisted-read candidate set must
|
||||||
|
# carry both the raw alias and its uppercase form. The
|
||||||
|
# bare same-code stock (``000300``) is still never added.
|
||||||
|
alias_upper = alias.upper()
|
||||||
|
if alias_upper and alias_upper not in index_keys:
|
||||||
|
index_keys.append(alias_upper)
|
||||||
|
for key in index_keys:
|
||||||
if key and key not in candidates:
|
if key and key not in candidates:
|
||||||
candidates.append(key)
|
candidates.append(key)
|
||||||
return candidates
|
return candidates
|
||||||
@@ -373,6 +387,21 @@ class HistoryService:
|
|||||||
code = str(raw_code or "").strip()
|
code = str(raw_code or "").strip()
|
||||||
if not code:
|
if not code:
|
||||||
return code
|
return code
|
||||||
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = parse_analysis_target(code)
|
||||||
|
except Exception:
|
||||||
|
return resolve_index_stock_code(code) or code
|
||||||
|
if target.asset_type == ParseStatus.INDEX:
|
||||||
|
# PR #2312: registered index records always surface the parser
|
||||||
|
# canonical (lowercase ``sh000300`` / ``csi930955``), even when the
|
||||||
|
# persisted row was saved under a legacy uppercase canonical or an
|
||||||
|
# explicit alias (``SH000016`` / ``000016.SH`` / ``sz399300`` /
|
||||||
|
# ``000300.CSI``). Web identity keys therefore only need a simple
|
||||||
|
# case fold for API/task/report codes — they must never guess a
|
||||||
|
# canonical from prefixes/suffixes.
|
||||||
|
return target.canonical_id
|
||||||
return resolve_index_stock_code(code) or code
|
return resolve_index_stock_code(code) or code
|
||||||
|
|
||||||
def _display_market_phase_summary(self, stock_code: str, context_snapshot: Any) -> Any:
|
def _display_market_phase_summary(self, stock_code: str, context_snapshot: Any) -> Any:
|
||||||
@@ -433,9 +462,23 @@ class HistoryService:
|
|||||||
"model_used": normalize_model_used(model_used),
|
"model_used": normalize_model_used(model_used),
|
||||||
"created_at": self._serialize_created_at(record.created_at),
|
"created_at": self._serialize_created_at(record.created_at),
|
||||||
"market_phase_summary": market_phase_summary,
|
"market_phase_summary": market_phase_summary,
|
||||||
|
"asset_type": self._asset_type_for_record(record),
|
||||||
**market_fields,
|
**market_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _asset_type_for_record(record) -> Optional[str]:
|
||||||
|
"""Return the parser-derived optional ``asset_type`` for a history row.
|
||||||
|
|
||||||
|
Uses the *persisted* ``record.code`` (never the display code) via
|
||||||
|
:func:`asset_type_from_canonical_code`, so ``sh000016`` is ``index``,
|
||||||
|
bare ``000016`` is ``stock`` and market review rows (``MARKET``) omit
|
||||||
|
the field. Optional by contract: legacy clients simply ignore it.
|
||||||
|
"""
|
||||||
|
from src.services.analysis_service import asset_type_from_canonical_code
|
||||||
|
|
||||||
|
return asset_type_from_canonical_code(getattr(record, "code", None))
|
||||||
|
|
||||||
def _resolve_record(
|
def _resolve_record(
|
||||||
self,
|
self,
|
||||||
record_id: str,
|
record_id: str,
|
||||||
|
|||||||
@@ -49,6 +49,47 @@ def _dedupe_stock_code_key(stock_code: str) -> str:
|
|||||||
return resolve_index_stock_code_for_analysis(normalize_stock_code(stock_code))
|
return resolve_index_stock_code_for_analysis(normalize_stock_code(stock_code))
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_task_key(
|
||||||
|
stock_code: str,
|
||||||
|
analysis_target: Optional[Any] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build the duplicate-detection key for a submitted task.
|
||||||
|
|
||||||
|
Index targets dedupe by their canonical id (``sh000016`` / ``csi930955``),
|
||||||
|
so an index never collapses with a same-digit stock (``000016``) and
|
||||||
|
registered CSI aliases converge to one key. Stock targets keep the legacy
|
||||||
|
code-based key, preserving existing dedup semantics for
|
||||||
|
``600519``/``600519.SH`` and friends.
|
||||||
|
"""
|
||||||
|
from src.services.stock_list_parser import ParseStatus
|
||||||
|
|
||||||
|
if analysis_target is not None and analysis_target.asset_type == ParseStatus.INDEX:
|
||||||
|
return analysis_target.canonical_id
|
||||||
|
return _dedupe_stock_code_key(stock_code)
|
||||||
|
|
||||||
|
|
||||||
|
def asset_type_from_analysis_target(analysis_target: Optional[Any]) -> Optional[str]:
|
||||||
|
"""Derive the optional ``asset_type`` from a submitted analysis target.
|
||||||
|
|
||||||
|
Uses the parser target only (never re-guesses from the code). Registered
|
||||||
|
index targets (``ParseStatus.INDEX``) yield ``"index"``, explicit stock
|
||||||
|
targets yield ``"stock"``, and targets that were not carried downstream
|
||||||
|
(plain stock submissions, market review) yield ``None`` so the field is
|
||||||
|
simply omitted from task payloads — keeping legacy clients compatible.
|
||||||
|
"""
|
||||||
|
from src.services.stock_list_parser import ParseStatus
|
||||||
|
|
||||||
|
if analysis_target is None:
|
||||||
|
return None
|
||||||
|
asset_type = getattr(analysis_target, "asset_type", None)
|
||||||
|
if asset_type == ParseStatus.INDEX:
|
||||||
|
return "index"
|
||||||
|
if asset_type == ParseStatus.STOCK:
|
||||||
|
return "stock"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TaskStatus(str, Enum):
|
class TaskStatus(str, Enum):
|
||||||
"""Task status enumeration"""
|
"""Task status enumeration"""
|
||||||
PENDING = "pending" # Waiting for execution
|
PENDING = "pending" # Waiting for execution
|
||||||
@@ -88,6 +129,13 @@ class TaskInfo:
|
|||||||
trace_id: Optional[str] = None
|
trace_id: Optional[str] = None
|
||||||
region: Optional[str] = None
|
region: Optional[str] = None
|
||||||
flow_events: List[Dict[str, Any]] = field(default_factory=list)
|
flow_events: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
# 固化去重键:submit 时按 asset_type 分支算好,完成/失败移除时直接使用,
|
||||||
|
# 避免 key 生成/移除不一致导致 _analyzing_stocks 残留。
|
||||||
|
dedupe_key: Optional[str] = None
|
||||||
|
analysis_target: Optional[Any] = None
|
||||||
|
# parser 来源的可选资产类型(``index``/``stock``/``None``);SSE 与任务列表
|
||||||
|
# 从这里透传,不得在消费端重新猜测。
|
||||||
|
asset_type: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert task info into an API-friendly dictionary."""
|
"""Convert task info into an API-friendly dictionary."""
|
||||||
@@ -111,6 +159,8 @@ class TaskInfo:
|
|||||||
}
|
}
|
||||||
if self.region is not None:
|
if self.region is not None:
|
||||||
payload["region"] = self.region
|
payload["region"] = self.region
|
||||||
|
if self.asset_type is not None:
|
||||||
|
payload["asset_type"] = self.asset_type
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def copy(self) -> 'TaskInfo':
|
def copy(self) -> 'TaskInfo':
|
||||||
@@ -138,6 +188,9 @@ class TaskInfo:
|
|||||||
trace_id=self.trace_id or self.task_id,
|
trace_id=self.trace_id or self.task_id,
|
||||||
region=self.region,
|
region=self.region,
|
||||||
flow_events=copy.deepcopy(self.flow_events),
|
flow_events=copy.deepcopy(self.flow_events),
|
||||||
|
dedupe_key=self.dedupe_key,
|
||||||
|
analysis_target=self.analysis_target,
|
||||||
|
asset_type=self.asset_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +390,7 @@ class AnalysisTaskQueue:
|
|||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
skills: Optional[List[str]] = None,
|
skills: Optional[List[str]] = None,
|
||||||
report_language: Optional[str] = None,
|
report_language: Optional[str] = None,
|
||||||
|
analysis_target: Optional[Any] = None,
|
||||||
) -> TaskInfo:
|
) -> TaskInfo:
|
||||||
"""
|
"""
|
||||||
Submit a single analysis task.
|
Submit a single analysis task.
|
||||||
@@ -349,6 +403,8 @@ class AnalysisTaskQueue:
|
|||||||
report_type: Report type
|
report_type: Report type
|
||||||
analysis_phase: Requested analysis phase override
|
analysis_phase: Requested analysis phase override
|
||||||
force_refresh: Whether to bypass cache
|
force_refresh: Whether to bypass cache
|
||||||
|
analysis_target: Optional structured analysis target (index targets
|
||||||
|
dedupe by canonical id and flow through to the pipeline)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TaskInfo: Accepted task information
|
TaskInfo: Accepted task information
|
||||||
@@ -372,6 +428,7 @@ class AnalysisTaskQueue:
|
|||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
report_language=report_language,
|
report_language=report_language,
|
||||||
|
analysis_targets=([analysis_target] if analysis_target is not None else None),
|
||||||
)
|
)
|
||||||
if duplicates:
|
if duplicates:
|
||||||
raise duplicates[0]
|
raise duplicates[0]
|
||||||
@@ -391,12 +448,16 @@ class AnalysisTaskQueue:
|
|||||||
notify: bool = True,
|
notify: bool = True,
|
||||||
skills: Optional[List[str]] = None,
|
skills: Optional[List[str]] = None,
|
||||||
report_language: Optional[str] = None,
|
report_language: Optional[str] = None,
|
||||||
|
analysis_targets: Optional[List[Any]] = None,
|
||||||
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
|
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
|
||||||
"""
|
"""
|
||||||
Submit analysis tasks in batch.
|
Submit analysis tasks in batch.
|
||||||
|
|
||||||
- Duplicate stocks are skipped and recorded in duplicates.
|
- Duplicate stocks are skipped and recorded in duplicates.
|
||||||
- If executor submission fails, the current batch is rolled back.
|
- If executor submission fails, the current batch is rolled back.
|
||||||
|
- ``analysis_targets`` is an optional per-code structured target list.
|
||||||
|
Index targets dedupe by ``canonical_id`` (never collapsing with a
|
||||||
|
same-digit stock); stock targets keep the legacy code-based key.
|
||||||
"""
|
"""
|
||||||
self.validate_selection_source(selection_source)
|
self.validate_selection_source(selection_source)
|
||||||
|
|
||||||
@@ -404,14 +465,34 @@ class AnalysisTaskQueue:
|
|||||||
duplicates: List[DuplicateTaskError] = []
|
duplicates: List[DuplicateTaskError] = []
|
||||||
created_task_ids: List[str] = []
|
created_task_ids: List[str] = []
|
||||||
|
|
||||||
canonical_codes = [
|
# Align per-code targets with the canonicalized code list. Index targets
|
||||||
normalized for normalized in (resolve_index_stock_code_for_analysis(code) for code in stock_codes)
|
# keep their parser canonical id (lowercase ``sh000016`` / ``csi930955``)
|
||||||
if normalized
|
# so the pipeline and fetchers see the exact identity the API resolved;
|
||||||
]
|
# stock codes keep the legacy canonicalization path.
|
||||||
|
targets_by_index: Dict[int, Any] = {}
|
||||||
|
for idx, code in enumerate(stock_codes):
|
||||||
|
target = (
|
||||||
|
analysis_targets[idx]
|
||||||
|
if analysis_targets is not None and idx < len(analysis_targets)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if target is not None and getattr(target, "asset_type", None) == "index":
|
||||||
|
targets_by_index[idx] = target
|
||||||
|
|
||||||
|
canonical_codes = []
|
||||||
|
for idx, code in enumerate(stock_codes):
|
||||||
|
target = targets_by_index.get(idx)
|
||||||
|
if target is not None:
|
||||||
|
canonical_codes.append(target.canonical_id)
|
||||||
|
else:
|
||||||
|
normalized = resolve_index_stock_code_for_analysis(code)
|
||||||
|
if normalized:
|
||||||
|
canonical_codes.append(normalized)
|
||||||
|
|
||||||
with self._data_lock:
|
with self._data_lock:
|
||||||
for stock_code in canonical_codes:
|
for idx, stock_code in enumerate(canonical_codes):
|
||||||
dedupe_key = _dedupe_stock_code_key(stock_code)
|
analysis_target = targets_by_index.get(idx)
|
||||||
|
dedupe_key = _dedupe_task_key(stock_code, analysis_target)
|
||||||
if dedupe_key in self._analyzing_stocks:
|
if dedupe_key in self._analyzing_stocks:
|
||||||
existing_task_id = self._analyzing_stocks[dedupe_key]
|
existing_task_id = self._analyzing_stocks[dedupe_key]
|
||||||
duplicates.append(DuplicateTaskError(stock_code, existing_task_id))
|
duplicates.append(DuplicateTaskError(stock_code, existing_task_id))
|
||||||
@@ -434,6 +515,9 @@ class AnalysisTaskQueue:
|
|||||||
portfolio_context=dict(portfolio_context) if isinstance(portfolio_context, dict) else None,
|
portfolio_context=dict(portfolio_context) if isinstance(portfolio_context, dict) else None,
|
||||||
skills=task_skills,
|
skills=task_skills,
|
||||||
report_language=report_language,
|
report_language=report_language,
|
||||||
|
dedupe_key=dedupe_key,
|
||||||
|
analysis_target=analysis_target,
|
||||||
|
asset_type=asset_type_from_analysis_target(analysis_target),
|
||||||
)
|
)
|
||||||
self._tasks[task_id] = task_info
|
self._tasks[task_id] = task_info
|
||||||
self._analyzing_stocks[dedupe_key] = task_id
|
self._analyzing_stocks[dedupe_key] = task_id
|
||||||
@@ -448,6 +532,7 @@ class AnalysisTaskQueue:
|
|||||||
notify,
|
notify,
|
||||||
task_skills,
|
task_skills,
|
||||||
report_language,
|
report_language,
|
||||||
|
analysis_target,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Roll back the current batch to avoid partial submission.
|
# Roll back the current batch to avoid partial submission.
|
||||||
@@ -521,7 +606,12 @@ class AnalysisTaskQueue:
|
|||||||
|
|
||||||
task = self._tasks.pop(task_id, None)
|
task = self._tasks.pop(task_id, None)
|
||||||
if task:
|
if task:
|
||||||
dedupe_key = _dedupe_stock_code_key(task.stock_code)
|
# 使用 submit 时固化的 dedupe_key(若存在),避免按 code 重算
|
||||||
|
# 与提交路径不一致导致 _analyzing_stocks 残留。
|
||||||
|
dedupe_key = task.dedupe_key or _dedupe_task_key(
|
||||||
|
task.stock_code,
|
||||||
|
getattr(task, "analysis_target", None),
|
||||||
|
)
|
||||||
if self._analyzing_stocks.get(dedupe_key) == task_id:
|
if self._analyzing_stocks.get(dedupe_key) == task_id:
|
||||||
del self._analyzing_stocks[dedupe_key]
|
del self._analyzing_stocks[dedupe_key]
|
||||||
|
|
||||||
@@ -674,6 +764,7 @@ class AnalysisTaskQueue:
|
|||||||
notify: bool = True,
|
notify: bool = True,
|
||||||
skills: Optional[List[str]] = None,
|
skills: Optional[List[str]] = None,
|
||||||
report_language: Optional[str] = None,
|
report_language: Optional[str] = None,
|
||||||
|
analysis_target: Optional[Any] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
执行分析任务(在线程池中运行)
|
执行分析任务(在线程池中运行)
|
||||||
@@ -683,6 +774,7 @@ class AnalysisTaskQueue:
|
|||||||
stock_code: 股票代码
|
stock_code: 股票代码
|
||||||
report_type: 报告类型
|
report_type: 报告类型
|
||||||
force_refresh: 是否强制刷新
|
force_refresh: 是否强制刷新
|
||||||
|
analysis_target: 可选的结构化分析目标(指数目标透传到 pipeline)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
分析结果字典
|
分析结果字典
|
||||||
@@ -736,6 +828,7 @@ class AnalysisTaskQueue:
|
|||||||
query_source=query_source,
|
query_source=query_source,
|
||||||
portfolio_context=portfolio_context,
|
portfolio_context=portfolio_context,
|
||||||
report_language=report_language,
|
report_language=report_language,
|
||||||
|
analysis_target=analysis_target,
|
||||||
)
|
)
|
||||||
reset_run_diagnostic_context(diag_token)
|
reset_run_diagnostic_context(diag_token)
|
||||||
diag_token = None
|
diag_token = None
|
||||||
@@ -752,8 +845,11 @@ class AnalysisTaskQueue:
|
|||||||
task.message = "分析完成"
|
task.message = "分析完成"
|
||||||
task.stock_name = result.get("stock_name", task.stock_name)
|
task.stock_name = result.get("stock_name", task.stock_name)
|
||||||
|
|
||||||
# 从分析中集合移除
|
# 从分析中集合移除(使用 submit 时固化的 key,避免不一致残留)
|
||||||
dedupe_key = _dedupe_stock_code_key(task.stock_code)
|
dedupe_key = task.dedupe_key or _dedupe_task_key(
|
||||||
|
task.stock_code,
|
||||||
|
getattr(task, "analysis_target", None),
|
||||||
|
)
|
||||||
if dedupe_key in self._analyzing_stocks:
|
if dedupe_key in self._analyzing_stocks:
|
||||||
del self._analyzing_stocks[dedupe_key]
|
del self._analyzing_stocks[dedupe_key]
|
||||||
|
|
||||||
@@ -782,8 +878,11 @@ class AnalysisTaskQueue:
|
|||||||
task.error = error_msg[:200] # 限制错误信息长度
|
task.error = error_msg[:200] # 限制错误信息长度
|
||||||
task.message = f"分析失败: {error_msg[:50]}"
|
task.message = f"分析失败: {error_msg[:50]}"
|
||||||
|
|
||||||
# 从分析中集合移除
|
# 从分析中集合移除(使用 submit 时固化的 key,避免不一致残留)
|
||||||
dedupe_key = _dedupe_stock_code_key(task.stock_code)
|
dedupe_key = task.dedupe_key or _dedupe_task_key(
|
||||||
|
task.stock_code,
|
||||||
|
getattr(task, "analysis_target", None),
|
||||||
|
)
|
||||||
if dedupe_key in self._analyzing_stocks:
|
if dedupe_key in self._analyzing_stocks:
|
||||||
del self._analyzing_stocks[dedupe_key]
|
del self._analyzing_stocks[dedupe_key]
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,44 @@ def _make_mock_adapter():
|
|||||||
return adapter
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _make_index_registry():
|
||||||
|
"""Build the registered identities exercised by the scope tests."""
|
||||||
|
from src.services.stock_list_parser import IndexEntry, IndexRegistry
|
||||||
|
|
||||||
|
return IndexRegistry(
|
||||||
|
[
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="000016",
|
||||||
|
exchange="SH",
|
||||||
|
canonical_id="sh000016",
|
||||||
|
display_name="上证50",
|
||||||
|
aliases=("000016.SH",),
|
||||||
|
),
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="000300",
|
||||||
|
exchange="SH",
|
||||||
|
canonical_id="sh000300",
|
||||||
|
display_name="沪深300",
|
||||||
|
aliases=("sz399300", "000300.SH", "000300.CSI"),
|
||||||
|
),
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="399001",
|
||||||
|
exchange="SZ",
|
||||||
|
canonical_id="sz399001",
|
||||||
|
display_name="深证成指",
|
||||||
|
aliases=("399001.SZ",),
|
||||||
|
),
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="930955",
|
||||||
|
exchange="CSI",
|
||||||
|
canonical_id="csi930955",
|
||||||
|
display_name="红利低波100",
|
||||||
|
aliases=("930955.CSI",),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_analysis_context_pack_summary(
|
def _build_analysis_context_pack_summary(
|
||||||
*,
|
*,
|
||||||
realtime_quote=None,
|
realtime_quote=None,
|
||||||
@@ -422,6 +460,183 @@ class TestAgentExecutor(unittest.TestCase):
|
|||||||
self.assertEqual(result.effective_context["stock_code"], "600519")
|
self.assertEqual(result.effective_context["stock_code"], "600519")
|
||||||
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
|
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_explicit_index_forms_fold_to_canonical(self):
|
||||||
|
# PR #2312 loop 1: default chat (litellm) + explicit registered index
|
||||||
|
# tokens must keep the registry canonical — never stock-normalized into
|
||||||
|
# a bare same-code or a prefix-derived fabrication. Case-insensitive,
|
||||||
|
# dotted SH/SZ/CSI, and CSI-prefix forms all fold to the registry
|
||||||
|
# canonical; cross-exchange aliases (sz399300/399300.SZ/000300.SH) fold
|
||||||
|
# to sh000300 without guessing a canonical from the input prefix.
|
||||||
|
registry = _make_index_registry()
|
||||||
|
cases = [
|
||||||
|
("换成 SH000016 看看", "sh000016"),
|
||||||
|
("换成 000016.SH 看看", "sh000016"),
|
||||||
|
("换成 000016.sh 看看", "sh000016"),
|
||||||
|
("换成 sz399001 看看", "sz399001"),
|
||||||
|
("换成 399001.SZ 看看", "sz399001"),
|
||||||
|
("换成 CSI930955 看看", "csi930955"),
|
||||||
|
("换成 930955.CSI 看看", "csi930955"),
|
||||||
|
("换成 csi930955 看看", "csi930955"),
|
||||||
|
("换成 sz399300 看看", "sh000300"),
|
||||||
|
("换成 399300.SZ 看看", "sh000300"),
|
||||||
|
("换成 000300.SH 看看", "sh000300"),
|
||||||
|
]
|
||||||
|
for message, expected in cases:
|
||||||
|
with self.subTest(message=message, expected=expected):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
message,
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, expected)
|
||||||
|
self.assertEqual(result.stock_scope.allowed_stock_codes, {expected})
|
||||||
|
self.assertEqual(result.effective_context["stock_code"], expected)
|
||||||
|
# The switched context must not reuse the previous stock name.
|
||||||
|
self.assertEqual(result.effective_context["stock_name"], "")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_keeps_bare_code_as_stock_identity(self):
|
||||||
|
registry = _make_index_registry()
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
"换成 000016 看看",
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bare 000016 shares digits with sh000016 but stays a stock identity.
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
|
||||||
|
self.assertEqual(result.effective_context["stock_code"], "000016")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_same_code_switch_isolates_identities(self):
|
||||||
|
registry = _make_index_registry()
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
"换成 000016 看看",
|
||||||
|
{"stock_code": "sh000016", "stock_name": "上证50"},
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Explicit switch index -> bare same-code stock must send the bare
|
||||||
|
# identity and never reuse the index name.
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
|
||||||
|
self.assertEqual(result.stock_scope.allowed_stock_codes, {"000016"})
|
||||||
|
self.assertEqual(result.effective_context["stock_code"], "000016")
|
||||||
|
self.assertEqual(result.effective_context["stock_name"], "")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_compare_keeps_both_identities(self):
|
||||||
|
registry = _make_index_registry()
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
"比较 sh000016 和 000016 的差异",
|
||||||
|
{"stock_code": "sh000016", "stock_name": "上证50"},
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compare keeps the active index context and admits both identities
|
||||||
|
# without folding them.
|
||||||
|
self.assertEqual(result.stock_scope.mode, "compare")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "sh000016")
|
||||||
|
self.assertEqual(
|
||||||
|
result.stock_scope.allowed_stock_codes,
|
||||||
|
{"sh000016", "000016"},
|
||||||
|
)
|
||||||
|
self.assertEqual(result.effective_context["stock_code"], "sh000016")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_embedded_ascii_never_leaks_identity(self):
|
||||||
|
registry = _make_index_registry()
|
||||||
|
cases = [
|
||||||
|
"换成 10SH000016 看看",
|
||||||
|
"换成 SH000016yy 看看",
|
||||||
|
"换成 _SH000016 看看",
|
||||||
|
"换成 SH000016_x 看看",
|
||||||
|
"换成 10sz399001 看看",
|
||||||
|
"换成 sz399001xx 看看",
|
||||||
|
]
|
||||||
|
for message in cases:
|
||||||
|
with self.subTest(message=message):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
message,
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
# No index canonical and no bare code may leak from embedded
|
||||||
|
# ASCII strings; the current stock context stays untouched.
|
||||||
|
self.assertEqual(result.stock_scope.mode, "maintain")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "600519")
|
||||||
|
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
|
||||||
|
self.assertEqual(result.effective_context["stock_code"], "600519")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_registry_embedded_ascii_strict_initial_scope(self):
|
||||||
|
registry = _make_index_registry()
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
"比较 10SH000016 和 SH000016yy 的差异",
|
||||||
|
None,
|
||||||
|
strict_initial_scope=True,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result.stock_scope)
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_empty_registry_falls_back_to_stock_semantics(self):
|
||||||
|
from src.services.stock_list_parser import IndexRegistry
|
||||||
|
|
||||||
|
for message in ("换成 sh000016 看看", "换成 10SH000016 看看"):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
message,
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
registry=IndexRegistry([]),
|
||||||
|
)
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_default_index_registry_production_branch(self):
|
||||||
|
cases = [
|
||||||
|
("sh000016", "sh000016"),
|
||||||
|
("000016.SH", "sh000016"),
|
||||||
|
("930955.CSI", "csi930955"),
|
||||||
|
("sz399300", "sh000300"),
|
||||||
|
]
|
||||||
|
for form, expected in cases:
|
||||||
|
with self.subTest(form=form):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
f"换成 {form} 看看",
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
)
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, expected)
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_default_registry_failure_falls_back_to_stock(self):
|
||||||
|
with patch(
|
||||||
|
"src.services.stock_list_parser.default_index_registry",
|
||||||
|
side_effect=RuntimeError("registry unavailable"),
|
||||||
|
):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
"换成 sh000016 看看",
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.stock_scope.mode, "switch")
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
|
||||||
|
|
||||||
|
def test_resolve_stock_scope_production_stock_guard_unchanged(self):
|
||||||
|
# sh600519 / SZ000001 / bare 000016 must keep stock semantics even with
|
||||||
|
# the default registry loaded (they are not registered index aliases).
|
||||||
|
cases = [
|
||||||
|
("换成 sh600519 看看", "600519"),
|
||||||
|
("换成 SZ000001 看看", "000001"),
|
||||||
|
("换成 000016 看看", "000016"),
|
||||||
|
("换成 00700.HK 看看", "HK00700"),
|
||||||
|
("换成 AAPL 看看", "AAPL"),
|
||||||
|
]
|
||||||
|
for message, expected in cases:
|
||||||
|
with self.subTest(message=message, expected=expected):
|
||||||
|
result = resolve_stock_scope(
|
||||||
|
message,
|
||||||
|
{"stock_code": "600519", "stock_name": "贵州茅台"},
|
||||||
|
)
|
||||||
|
self.assertEqual(result.stock_scope.expected_stock_code, expected)
|
||||||
|
|
||||||
def test_run_agent_loop_does_not_persist_agent_usage_without_provider_usage(self):
|
def test_run_agent_loop_does_not_persist_agent_usage_without_provider_usage(self):
|
||||||
registry = _make_registry_with_echo()
|
registry = _make_registry_with_echo()
|
||||||
adapter = _make_mock_adapter()
|
adapter = _make_mock_adapter()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from src.agent.stock_scope import StockScope
|
from src.agent.stock_scope import StockScope
|
||||||
from src.agent.tool_surface import ToolSurface
|
from src.agent.tool_surface import ToolSurface
|
||||||
@@ -415,6 +416,154 @@ def test_declared_stock_scope_requires_explicit_stock_context_before_handler() -
|
|||||||
assert calls == []
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def _register_quote_tool(registry: ToolRegistry) -> None:
|
||||||
|
registry.register(
|
||||||
|
ToolDefinition(
|
||||||
|
name="quote",
|
||||||
|
description="Quote",
|
||||||
|
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
|
||||||
|
handler=lambda stock_code: {"code": stock_code},
|
||||||
|
policy=ToolPolicy.declared(
|
||||||
|
read_only=True,
|
||||||
|
permissions=["market_data:read"],
|
||||||
|
scope_dimensions=["stock"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_canonical_passes_and_bare_same_code_rejected_with_default_registry() -> None:
|
||||||
|
# Real ToolSurface WITHOUT any index-registry injection: the guard resolves
|
||||||
|
# the default bundled registry once, so an explicit index scope
|
||||||
|
# (`sh000016`) admits its canonical/dotted alias forms but rejects the bare
|
||||||
|
# same-code stock (`000016`) with the existing `stock_scope_violation`.
|
||||||
|
tool_registry = ToolRegistry()
|
||||||
|
_register_quote_tool(tool_registry)
|
||||||
|
surface = ToolSurface(tool_registry)
|
||||||
|
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
|
||||||
|
|
||||||
|
for alias in ("sh000016", "000016.SH", "SH000016"):
|
||||||
|
ok = surface.execute_tool(
|
||||||
|
"quote",
|
||||||
|
{"stock_code": alias},
|
||||||
|
ToolAccessContext(stock_scope=scope),
|
||||||
|
)
|
||||||
|
assert ok["ok"] is True, alias
|
||||||
|
assert ok["result"] == {"code": alias}
|
||||||
|
|
||||||
|
rejected = surface.execute_tool(
|
||||||
|
"quote",
|
||||||
|
{"stock_code": "000016"},
|
||||||
|
ToolAccessContext(stock_scope=scope),
|
||||||
|
)
|
||||||
|
assert rejected["ok"] is False
|
||||||
|
assert rejected["error"]["code"] == "stock_scope_violation"
|
||||||
|
assert rejected["error"]["details"]["requested_stock_code"] == "000016"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_cache_key_isolates_index_canonical_from_bare_same_code() -> None:
|
||||||
|
from src.agent.tools.execution import _build_tool_cache_key
|
||||||
|
|
||||||
|
index_key = _build_tool_cache_key("quote", {"stock_code": "sh000016"})
|
||||||
|
alias_key = _build_tool_cache_key("quote", {"stock_code": "000016.SH"})
|
||||||
|
bare_key = _build_tool_cache_key("quote", {"stock_code": "000016"})
|
||||||
|
|
||||||
|
assert index_key is not None
|
||||||
|
assert index_key == alias_key
|
||||||
|
assert index_key != bare_key
|
||||||
|
# No stock_code in the arguments -> legacy key, no registry resolution.
|
||||||
|
assert _build_tool_cache_key("quote", {"limit": 5}) == "quote:{\"limit\": 5}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_tool_stock_code_registry_seam() -> None:
|
||||||
|
from src.agent.tools.execution import _normalize_tool_stock_code
|
||||||
|
from src.services.stock_list_parser import IndexEntry, IndexRegistry
|
||||||
|
|
||||||
|
registry = IndexRegistry(
|
||||||
|
[
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="000016",
|
||||||
|
exchange="SH",
|
||||||
|
canonical_id="sh000016",
|
||||||
|
display_name="上证50",
|
||||||
|
aliases=("000016.SH",),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert _normalize_tool_stock_code("000016.SH", registry) == "sh000016"
|
||||||
|
assert _normalize_tool_stock_code("sh000016", registry) == "sh000016"
|
||||||
|
assert _normalize_tool_stock_code("SH000016", registry) == "sh000016"
|
||||||
|
# Explicit index token with an EMPTY registry falls open to stock semantics.
|
||||||
|
assert _normalize_tool_stock_code("sh000016", IndexRegistry([])) == "000016"
|
||||||
|
# Direct call without a registry keeps the legacy stock identity byte-for-byte.
|
||||||
|
assert _normalize_tool_stock_code("000016.SH") == "000016"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_tool_stock_scope_registry_injection_seam() -> None:
|
||||||
|
from src.agent.tools.execution import _guard_tool_stock_scope
|
||||||
|
from src.services.stock_list_parser import IndexEntry, IndexRegistry
|
||||||
|
|
||||||
|
index_registry = IndexRegistry(
|
||||||
|
[
|
||||||
|
IndexEntry(
|
||||||
|
bare_code="000016",
|
||||||
|
exchange="SH",
|
||||||
|
canonical_id="sh000016",
|
||||||
|
display_name="上证50",
|
||||||
|
aliases=("000016.SH",),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
tool_registry = ToolRegistry()
|
||||||
|
_register_quote_tool(tool_registry)
|
||||||
|
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
|
||||||
|
|
||||||
|
assert (
|
||||||
|
_guard_tool_stock_scope(
|
||||||
|
tool_registry,
|
||||||
|
"quote",
|
||||||
|
{"stock_code": "sh000016"},
|
||||||
|
scope,
|
||||||
|
index_registry=index_registry,
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
violation = _guard_tool_stock_scope(
|
||||||
|
tool_registry,
|
||||||
|
"quote",
|
||||||
|
{"stock_code": "000016"},
|
||||||
|
scope,
|
||||||
|
index_registry=index_registry,
|
||||||
|
)
|
||||||
|
assert violation is not None
|
||||||
|
assert violation["error"] == "stock_scope_violation"
|
||||||
|
assert violation["requested_stock_code"] == "000016"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_guard_and_cache_registry_failure_use_legacy_stock_semantics() -> None:
|
||||||
|
from src.agent.tools.execution import _build_tool_cache_key
|
||||||
|
|
||||||
|
tool_registry = ToolRegistry()
|
||||||
|
_register_quote_tool(tool_registry)
|
||||||
|
surface = ToolSurface(tool_registry)
|
||||||
|
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.stock_list_parser.default_index_registry",
|
||||||
|
side_effect=RuntimeError("registry unavailable"),
|
||||||
|
):
|
||||||
|
result = surface.execute_tool(
|
||||||
|
"quote",
|
||||||
|
{"stock_code": "000016.SH"},
|
||||||
|
ToolAccessContext(stock_scope=scope),
|
||||||
|
)
|
||||||
|
alias_key = _build_tool_cache_key("quote", {"stock_code": "000016.SH"})
|
||||||
|
bare_key = _build_tool_cache_key("quote", {"stock_code": "000016"})
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert alias_key == bare_key
|
||||||
|
|
||||||
|
|
||||||
def test_handler_error_is_structured_without_traceback() -> None:
|
def test_handler_error_is_structured_without_traceback() -> None:
|
||||||
def _fail():
|
def _fail():
|
||||||
raise RuntimeError("secret stack")
|
raise RuntimeError("secret stack")
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from src.enums import ReportType
|
|||||||
from src.config import Config
|
from src.config import Config
|
||||||
from src.services.analysis_service import AnalysisService
|
from src.services.analysis_service import AnalysisService
|
||||||
from src.services.image_stock_extractor import _call_litellm_vision
|
from src.services.image_stock_extractor import _call_litellm_vision
|
||||||
from src.services.task_queue import AnalysisTaskQueue, TaskInfo as QueueTaskInfo, TaskStatus
|
from src.services.task_queue import AnalysisTaskQueue, DuplicateTaskError, TaskInfo as QueueTaskInfo, TaskStatus
|
||||||
|
|
||||||
|
|
||||||
def tearDownModule() -> None:
|
def tearDownModule() -> None:
|
||||||
@@ -3119,6 +3119,460 @@ class AnalysisApiContractTestCase(unittest.TestCase):
|
|||||||
notify=True,
|
notify=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_trigger_analysis_async_index_submits_structured_target(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
task = SimpleNamespace(
|
||||||
|
task_id="task-index-1",
|
||||||
|
trace_id="trace-index-1",
|
||||||
|
stock_code="sh000016",
|
||||||
|
analysis_phase="auto",
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([task], [])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="sh000016",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name=None,
|
||||||
|
original_query="sh000016",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs["stock_codes"], ["sh000016"])
|
||||||
|
targets = call_kwargs["analysis_targets"]
|
||||||
|
self.assertEqual(len(targets), 1)
|
||||||
|
self.assertIsNotNone(targets[0])
|
||||||
|
self.assertEqual(targets[0].asset_type, "index")
|
||||||
|
self.assertEqual(targets[0].canonical_id, "sh000016")
|
||||||
|
|
||||||
|
def test_trigger_analysis_async_csi_alias_converges_to_canonical(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
task = SimpleNamespace(
|
||||||
|
task_id="task-csi-1",
|
||||||
|
trace_id="trace-csi-1",
|
||||||
|
stock_code="csi930955",
|
||||||
|
analysis_phase="auto",
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([task], [])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="930955.CSI",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name=None,
|
||||||
|
original_query="930955.CSI",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs["stock_codes"], ["csi930955"])
|
||||||
|
self.assertEqual(call_kwargs["analysis_targets"][0].canonical_id, "csi930955")
|
||||||
|
|
||||||
|
def test_trigger_analysis_batch_index_and_same_digit_stock_do_not_collapse(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=["sh000016", "000016"],
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs["stock_codes"], ["sh000016", "000016"])
|
||||||
|
targets = call_kwargs["analysis_targets"]
|
||||||
|
self.assertEqual(targets[0].asset_type, "index")
|
||||||
|
self.assertEqual(targets[0].canonical_id, "sh000016")
|
||||||
|
self.assertIsNone(targets[1])
|
||||||
|
|
||||||
|
def test_trigger_analysis_raw_token_limit_counts_rejected_and_duplicate_tokens(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 = ([], [])
|
||||||
|
|
||||||
|
# 51 non-blank raw tokens: 1 valid + 50 unregistered CSI (rejected).
|
||||||
|
# The pre-resolution limit must reject, otherwise the post-dedup check
|
||||||
|
# (which only sees the single accepted code) would let the request through.
|
||||||
|
codes = ["600519"] + [f"{i:06d}.CSI" for i in range(100000, 100050)]
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=codes,
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("最多支持 50", ctx.exception.detail["message"])
|
||||||
|
queue.submit_tasks_batch.assert_not_called()
|
||||||
|
|
||||||
|
def test_trigger_analysis_exactly_fifty_raw_tokens_is_accepted(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 = ([], [])
|
||||||
|
|
||||||
|
codes = [f"{i:06d}" for i in range(100000, 100050)]
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
|
||||||
|
patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value=None):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=codes,
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
self.assertEqual(len(queue.submit_tasks_batch.call_args.kwargs["stock_codes"]), 50)
|
||||||
|
|
||||||
|
def test_trigger_analysis_duplicate_plus_rejected_is_batch_not_legacy_409(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
# One accepted code that is already analyzing (duplicate) plus one
|
||||||
|
# rejected code must be a batch: is_single=false, so no legacy 409 and
|
||||||
|
# no single-stock 202. Both the duplicate and the rejected survive in
|
||||||
|
# the batch payload.
|
||||||
|
dup = DuplicateTaskError("600519", "existing-1")
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([], [dup])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=["600519", "930956.CSI"],
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
self.assertEqual(len(payload["accepted"]), 0)
|
||||||
|
self.assertEqual(len(payload["duplicates"]), 1)
|
||||||
|
self.assertEqual(payload["duplicates"][0]["stock_code"], "600519")
|
||||||
|
self.assertEqual(len(payload["rejected"]), 1)
|
||||||
|
self.assertEqual(payload["rejected"][0]["stock_code"], "930956.CSI")
|
||||||
|
|
||||||
|
def test_trigger_analysis_valid_plus_rejected_metadata_is_batch(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
accepted_task = SimpleNamespace(
|
||||||
|
task_id="task-600519",
|
||||||
|
trace_id="trace-600519",
|
||||||
|
stock_code="600519",
|
||||||
|
analysis_phase="auto",
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([accepted_task], [])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=["600519", "930956.CSI"],
|
||||||
|
stock_name="贵州茅台",
|
||||||
|
original_query="贵州茅台,930956.CSI",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
# Batch payload (not single-stock TaskAccepted), with rejected present.
|
||||||
|
self.assertIn("accepted", payload)
|
||||||
|
self.assertEqual(len(payload["rejected"]), 1)
|
||||||
|
# Single-stock metadata must NOT leak into the batch: stock_name is None.
|
||||||
|
self.assertIsNone(queue.submit_tasks_batch.call_args.kwargs["stock_name"])
|
||||||
|
self.assertIsNone(queue.submit_tasks_batch.call_args.kwargs["original_query"])
|
||||||
|
|
||||||
|
def test_trigger_analysis_single_duplicate_still_returns_409(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
dup = DuplicateTaskError("600519", "existing-1")
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([], [dup])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="600519",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
self.assertEqual(payload["error"], "duplicate_task")
|
||||||
|
self.assertEqual(payload["stock_code"], "600519")
|
||||||
|
|
||||||
|
def test_trigger_analysis_unregistered_csi_single_returns_400(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
queue = MagicMock()
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="930956.CSI",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name=None,
|
||||||
|
original_query="930956.CSI",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
|
||||||
|
queue.submit_tasks_batch.assert_not_called()
|
||||||
|
|
||||||
|
def test_trigger_analysis_unregistered_csi_sync_single_returns_400(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="csi930956",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name=None,
|
||||||
|
original_query="csi930956",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=False,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
|
||||||
|
|
||||||
|
def test_trigger_analysis_batch_rejects_unregistered_csi_in_rejected_field(self) -> None:
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
accepted_task = SimpleNamespace(
|
||||||
|
task_id="task-600519",
|
||||||
|
trace_id="trace-600519",
|
||||||
|
stock_code="600519",
|
||||||
|
analysis_phase="auto",
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([accepted_task], [])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=["600519", "930956.CSI"],
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
body = response.body if hasattr(response, "body") else None
|
||||||
|
if body is not None:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
payload = _json.loads(body)
|
||||||
|
else:
|
||||||
|
payload = response.model_dump()
|
||||||
|
self.assertEqual(payload["accepted"][0]["stock_code"], "600519")
|
||||||
|
self.assertEqual(len(payload["rejected"]), 1)
|
||||||
|
self.assertEqual(payload["rejected"][0]["stock_code"], "930956.CSI")
|
||||||
|
self.assertIn("unregistered CSI index", payload["rejected"][0]["message"])
|
||||||
|
# The valid stock still reaches the queue.
|
||||||
|
self.assertEqual(queue.submit_tasks_batch.call_args.kwargs["stock_codes"], ["600519"])
|
||||||
|
|
||||||
|
def test_trigger_analysis_name_input_stays_on_name_resolution(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") as resolve_mock, \
|
||||||
|
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code="贵州茅台",
|
||||||
|
stock_codes=None,
|
||||||
|
stock_name="贵州茅台",
|
||||||
|
original_query="贵州茅台",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
resolve_mock.assert_called_once_with("贵州茅台")
|
||||||
|
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
|
||||||
|
self.assertEqual(call_kwargs["stock_codes"], ["600519"])
|
||||||
|
self.assertNotIn("analysis_targets", call_kwargs)
|
||||||
|
|
||||||
|
def test_trigger_analysis_sync_all_rejected_returns_400(self) -> None:
|
||||||
|
"""Sync mode with only unregistered targets must 400, not IndexError."""
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=["930956.CSI", "csi930956"],
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=False,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
|
||||||
|
|
||||||
|
def test_trigger_analysis_async_all_rejected_returns_400(self) -> None:
|
||||||
|
"""Async batch with only unregistered targets must 400, not 202+empty."""
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
queue = MagicMock()
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=["930956.CSI", "csi930957"],
|
||||||
|
stock_name=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.status_code, 400)
|
||||||
|
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
|
||||||
|
queue.submit_tasks_batch.assert_not_called()
|
||||||
|
|
||||||
def test_trigger_analysis_accepts_camel_case_report_language_alias(self) -> None:
|
def test_trigger_analysis_accepts_camel_case_report_language_alias(self) -> None:
|
||||||
if trigger_analysis is None or analysis_endpoint_module is None:
|
if trigger_analysis is None or analysis_endpoint_module is None:
|
||||||
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||||
@@ -3674,6 +4128,316 @@ class AnalysisApiContractTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(response.tasks[0].analysis_phase, "postmarket")
|
self.assertEqual(response.tasks[0].analysis_phase, "postmarket")
|
||||||
self.assertEqual(response.tasks[0].skills, ["growth_quality"])
|
self.assertEqual(response.tasks[0].skills, ["growth_quality"])
|
||||||
|
|
||||||
|
def test_task_list_exposes_parser_asset_type(self) -> None:
|
||||||
|
"""Valid asset_type literals pass through verbatim on the task list."""
|
||||||
|
if get_task_list is None:
|
||||||
|
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||||
|
|
||||||
|
for expected_asset_type, stock_code, stock_name in (
|
||||||
|
("index", "sh000016", "上证50"),
|
||||||
|
("stock", "600519", "贵州茅台"),
|
||||||
|
):
|
||||||
|
with self.subTest(asset_type=expected_asset_type):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
task_id="task-list-asset-type",
|
||||||
|
trace_id="trace-list-asset-type",
|
||||||
|
stock_code=stock_code,
|
||||||
|
stock_name=stock_name,
|
||||||
|
status=TaskStatus.PROCESSING,
|
||||||
|
progress=42,
|
||||||
|
message="running",
|
||||||
|
report_type="detailed",
|
||||||
|
created_at=datetime(2026, 4, 10, 12, 0, 0),
|
||||||
|
started_at=datetime(2026, 4, 10, 12, 0, 1),
|
||||||
|
completed_at=None,
|
||||||
|
error=None,
|
||||||
|
original_query=stock_code,
|
||||||
|
selection_source="manual",
|
||||||
|
analysis_phase="intraday",
|
||||||
|
skills=None,
|
||||||
|
region=None,
|
||||||
|
asset_type=expected_asset_type,
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.list_all_tasks.return_value = [task]
|
||||||
|
queue.get_task_stats.return_value = {
|
||||||
|
"total": 1,
|
||||||
|
"pending": 0,
|
||||||
|
"processing": 1,
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = get_task_list(status=None, limit=20)
|
||||||
|
|
||||||
|
self.assertEqual(response.tasks[0].stock_code, stock_code)
|
||||||
|
self.assertEqual(response.tasks[0].asset_type, expected_asset_type)
|
||||||
|
|
||||||
|
def test_task_list_omits_asset_type_for_legacy_tasks(self) -> None:
|
||||||
|
if get_task_list is None:
|
||||||
|
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||||
|
|
||||||
|
task = SimpleNamespace(
|
||||||
|
task_id="task-list-legacy",
|
||||||
|
trace_id="trace-list-legacy",
|
||||||
|
stock_code="600519",
|
||||||
|
stock_name="贵州茅台",
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
progress=0,
|
||||||
|
message="waiting",
|
||||||
|
report_type="detailed",
|
||||||
|
created_at=datetime(2026, 4, 10, 12, 0, 0),
|
||||||
|
started_at=None,
|
||||||
|
completed_at=None,
|
||||||
|
error=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source=None,
|
||||||
|
analysis_phase="auto",
|
||||||
|
skills=None,
|
||||||
|
region=None,
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.list_all_tasks.return_value = [task]
|
||||||
|
queue.get_task_stats.return_value = {
|
||||||
|
"total": 1,
|
||||||
|
"pending": 1,
|
||||||
|
"processing": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = get_task_list(status=None, limit=20)
|
||||||
|
|
||||||
|
self.assertIsNone(response.tasks[0].asset_type)
|
||||||
|
|
||||||
|
def test_task_info_to_dict_exposes_parser_asset_type(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
queue = AnalysisTaskQueue(max_workers=1)
|
||||||
|
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
|
||||||
|
|
||||||
|
index_target = parse_analysis_target("sh000016")
|
||||||
|
|
||||||
|
index_tasks, _ = queue.submit_tasks_batch(
|
||||||
|
["sh000016"],
|
||||||
|
analysis_targets=[index_target],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
plain_tasks, _ = queue.submit_tasks_batch(
|
||||||
|
["000016"],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index targets are carried on TaskInfo, so the SSE/task payload exposes
|
||||||
|
# the parser asset type. Stock targets are intentionally NOT carried
|
||||||
|
# downstream (PR #2303 contract: stock semantics unchanged), so the
|
||||||
|
# optional field is simply omitted there.
|
||||||
|
index_payload = index_tasks[0].to_dict()
|
||||||
|
plain_payload = plain_tasks[0].to_dict()
|
||||||
|
|
||||||
|
self.assertEqual(index_payload["asset_type"], "index")
|
||||||
|
self.assertNotIn("asset_type", plain_payload)
|
||||||
|
|
||||||
|
def test_batch_accepted_item_exposes_parser_asset_type(self) -> None:
|
||||||
|
"""Valid asset_type literals pass through verbatim on batch accepted items."""
|
||||||
|
if trigger_analysis is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
for expected_asset_type, stock_code, stock_name, second_code in (
|
||||||
|
("index", "sh000016", "上证50", "000016"),
|
||||||
|
("stock", "600519", "贵州茅台", "000001"),
|
||||||
|
):
|
||||||
|
request_codes = [stock_code, second_code]
|
||||||
|
with self.subTest(asset_type=expected_asset_type):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
task_id="task-batch-asset-type",
|
||||||
|
trace_id="trace-batch-asset-type",
|
||||||
|
stock_code=stock_code,
|
||||||
|
stock_name=stock_name,
|
||||||
|
analysis_phase="auto",
|
||||||
|
asset_type=expected_asset_type,
|
||||||
|
)
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([task], [])
|
||||||
|
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = trigger_analysis(
|
||||||
|
request=SimpleNamespace(
|
||||||
|
stock_code=None,
|
||||||
|
stock_codes=request_codes,
|
||||||
|
stock_name=None,
|
||||||
|
original_query=",".join(request_codes),
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
accepted = payload["accepted"]
|
||||||
|
self.assertEqual(len(accepted), 1)
|
||||||
|
self.assertEqual(accepted[0]["stock_code"], stock_code)
|
||||||
|
self.assertEqual(accepted[0]["asset_type"], expected_asset_type)
|
||||||
|
|
||||||
|
def _legacy_task_mock(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
asset_type: str | None = None,
|
||||||
|
with_task_list_fields: bool = False,
|
||||||
|
) -> MagicMock:
|
||||||
|
"""Build a legacy MagicMock proxy task; ``asset_type`` stays implicit
|
||||||
|
unless explicitly requested so ``getattr`` yields a MagicMock child."""
|
||||||
|
kwargs: dict = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"trace_id": f"trace-{task_id}",
|
||||||
|
"stock_code": "600519",
|
||||||
|
"stock_name": "贵州茅台",
|
||||||
|
"analysis_phase": "auto",
|
||||||
|
}
|
||||||
|
if with_task_list_fields:
|
||||||
|
kwargs.update(
|
||||||
|
status=TaskStatus.PENDING,
|
||||||
|
progress=0,
|
||||||
|
message="waiting",
|
||||||
|
report_type="detailed",
|
||||||
|
created_at=datetime(2026, 4, 10, 12, 0, 0),
|
||||||
|
started_at=None,
|
||||||
|
completed_at=None,
|
||||||
|
error=None,
|
||||||
|
original_query=None,
|
||||||
|
selection_source=None,
|
||||||
|
skills=None,
|
||||||
|
region=None,
|
||||||
|
)
|
||||||
|
if asset_type is not None:
|
||||||
|
kwargs["asset_type"] = asset_type
|
||||||
|
return MagicMock(**kwargs)
|
||||||
|
|
||||||
|
def _task_list_asset_type_value(self, task) -> object:
|
||||||
|
"""Run get_task_list against a mocked queue and return the asset_type."""
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.list_all_tasks.return_value = [task]
|
||||||
|
queue.get_task_stats.return_value = {
|
||||||
|
"total": 1,
|
||||||
|
"pending": 1,
|
||||||
|
"processing": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
|
||||||
|
response = get_task_list(status=None, limit=20)
|
||||||
|
# 走真实 JSON 序列化再读取,证明 GET /tasks 响应层面降级为 null,
|
||||||
|
# 而非仅依赖 Pydantic 对象属性。
|
||||||
|
payload = json.loads(response.model_dump_json())
|
||||||
|
return payload["tasks"][0]["asset_type"]
|
||||||
|
|
||||||
|
def _batch_accepted_asset_type_value(self, task) -> object:
|
||||||
|
"""Run trigger_analysis in batch mode against a mocked queue and return
|
||||||
|
the first accepted item's asset_type."""
|
||||||
|
queue = MagicMock()
|
||||||
|
queue.submit_tasks_batch.return_value = ([task], [])
|
||||||
|
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="600519,000001",
|
||||||
|
selection_source="manual",
|
||||||
|
report_type="detailed",
|
||||||
|
force_refresh=False,
|
||||||
|
async_mode=True,
|
||||||
|
notify=True,
|
||||||
|
analysis_phase="auto",
|
||||||
|
),
|
||||||
|
config=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
payload = json.loads(response.body)
|
||||||
|
accepted = payload["accepted"]
|
||||||
|
self.assertEqual(len(accepted), 1)
|
||||||
|
return accepted[0]["asset_type"]
|
||||||
|
|
||||||
|
def test_asset_type_degrades_magicmock_proxy_without_warning(self) -> None:
|
||||||
|
"""Legacy MagicMock proxy (implicit asset_type) degrades to None on both
|
||||||
|
response entries and never emits a warning."""
|
||||||
|
if get_task_list is None or trigger_analysis is None:
|
||||||
|
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||||
|
|
||||||
|
with self.subTest(entry="task_list"):
|
||||||
|
with self.assertNoLogs("api.v1.endpoints.analysis", level="WARNING"):
|
||||||
|
value = self._task_list_asset_type_value(
|
||||||
|
self._legacy_task_mock(
|
||||||
|
task_id="task-list-magic-asset-type",
|
||||||
|
with_task_list_fields=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
|
||||||
|
with self.subTest(entry="batch_accepted"):
|
||||||
|
with self.assertNoLogs("api.v1.endpoints.analysis", level="WARNING"):
|
||||||
|
value = self._batch_accepted_asset_type_value(
|
||||||
|
self._legacy_task_mock(task_id="task-batch-magic-asset-type")
|
||||||
|
)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
|
||||||
|
def test_asset_type_degrades_out_of_domain_string_with_warning(self) -> None:
|
||||||
|
"""A real string outside the literal domain (including blank and
|
||||||
|
whitespace-only forms) degrades to None on both response entries and
|
||||||
|
logs a warning carrying the task id and value on every entry."""
|
||||||
|
if get_task_list is None or trigger_analysis is None:
|
||||||
|
self.skipTest("analysis endpoint helpers unavailable in this environment")
|
||||||
|
|
||||||
|
invalid_values = ("etf", " ")
|
||||||
|
for invalid_value in invalid_values:
|
||||||
|
with self.subTest(invalid_value=invalid_value):
|
||||||
|
with self.subTest(entry="task_list"):
|
||||||
|
with self.assertLogs("api.v1.endpoints.analysis", level="WARNING") as logs:
|
||||||
|
value = self._task_list_asset_type_value(
|
||||||
|
self._legacy_task_mock(
|
||||||
|
task_id="task-list-out-of-domain-asset-type",
|
||||||
|
asset_type=invalid_value,
|
||||||
|
with_task_list_fields=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"task_id=task-list-out-of-domain-asset-type" in line
|
||||||
|
and f"asset_type={invalid_value!r}" in line
|
||||||
|
for line in logs.output
|
||||||
|
),
|
||||||
|
"expected warning carrying task id and "
|
||||||
|
f"{invalid_value!r} value, got: {logs.output}",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.subTest(entry="batch_accepted"):
|
||||||
|
with self.assertLogs("api.v1.endpoints.analysis", level="WARNING") as logs:
|
||||||
|
value = self._batch_accepted_asset_type_value(
|
||||||
|
self._legacy_task_mock(
|
||||||
|
task_id="task-batch-out-of-domain-asset-type",
|
||||||
|
asset_type=invalid_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(value)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"task_id=task-batch-out-of-domain-asset-type" in line
|
||||||
|
and f"asset_type={invalid_value!r}" in line
|
||||||
|
for line in logs.output
|
||||||
|
),
|
||||||
|
"expected warning carrying task id and "
|
||||||
|
f"{invalid_value!r} value, got: {logs.output}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BatchTaskQueueContractTestCase(unittest.TestCase):
|
class BatchTaskQueueContractTestCase(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -3853,6 +4617,196 @@ class BatchTaskQueueContractTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(updated.message, "LLM 正在生成分析结果")
|
self.assertEqual(updated.message, "LLM 正在生成分析结果")
|
||||||
self.assertEqual(events, [("task_progress", updated.to_dict())])
|
self.assertEqual(events, [("task_progress", updated.to_dict())])
|
||||||
|
|
||||||
|
def _executor_stub_queue(self) -> AnalysisTaskQueue:
|
||||||
|
queue = AnalysisTaskQueue(max_workers=1)
|
||||||
|
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def test_index_and_same_digit_stock_do_not_collapse_in_task_queue(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
index_target = parse_analysis_target("sh000016")
|
||||||
|
self.assertEqual(index_target.asset_type, "index")
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
|
||||||
|
accepted, duplicates = queue.submit_tasks_batch(
|
||||||
|
["sh000016", "000016"],
|
||||||
|
analysis_targets=[index_target, None],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(duplicates, [])
|
||||||
|
self.assertEqual(len(accepted), 2)
|
||||||
|
codes = sorted(task.stock_code for task in accepted)
|
||||||
|
self.assertEqual(codes, ["000016", "sh000016"])
|
||||||
|
# The index task keeps a canonical-id dedupe key; the stock keeps its
|
||||||
|
# code-based key, so neither collapses with the other.
|
||||||
|
index_task = next(task for task in accepted if task.stock_code == "sh000016")
|
||||||
|
stock_task = next(task for task in accepted if task.stock_code == "000016")
|
||||||
|
self.assertEqual(index_task.dedupe_key, "sh000016")
|
||||||
|
self.assertNotEqual(stock_task.dedupe_key, "sh000016")
|
||||||
|
self.assertIs(index_task.analysis_target, index_target)
|
||||||
|
|
||||||
|
def test_alias_index_input_submits_canonical_task_info(self) -> None:
|
||||||
|
"""``000300.CSI`` (a registered alias of ``sh000300``) must submit a
|
||||||
|
task whose stock_code / to_dict().stock_code / dedupe_key are all the
|
||||||
|
parser canonical ``sh000300`` with asset_type ``index`` — locking the
|
||||||
|
REST/SSE premise that consumers only ever receive canonical index
|
||||||
|
codes (the frontend only case-folds them)."""
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
target = parse_analysis_target("000300.CSI")
|
||||||
|
self.assertEqual(target.asset_type, "index")
|
||||||
|
self.assertEqual(target.canonical_id, "sh000300")
|
||||||
|
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
accepted, duplicates = queue.submit_tasks_batch(
|
||||||
|
["000300.CSI"],
|
||||||
|
analysis_targets=[target],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(duplicates, [])
|
||||||
|
self.assertEqual(len(accepted), 1)
|
||||||
|
task = accepted[0]
|
||||||
|
self.assertEqual(task.stock_code, "sh000300")
|
||||||
|
self.assertEqual(task.to_dict()["stock_code"], "sh000300")
|
||||||
|
self.assertEqual(task.to_dict()["asset_type"], "index")
|
||||||
|
self.assertEqual(task.dedupe_key, "sh000300")
|
||||||
|
self.assertIs(task.analysis_target, target)
|
||||||
|
|
||||||
|
def test_csi_aliases_converge_to_single_task_queue_key(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
a = parse_analysis_target("930955.CSI")
|
||||||
|
b = parse_analysis_target("csi930955")
|
||||||
|
self.assertEqual(a.asset_type, "index")
|
||||||
|
self.assertEqual(b.asset_type, "index")
|
||||||
|
self.assertEqual(a.canonical_id, "csi930955")
|
||||||
|
self.assertEqual(b.canonical_id, "csi930955")
|
||||||
|
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
accepted, duplicates = queue.submit_tasks_batch(
|
||||||
|
["930955.CSI", "csi930955"],
|
||||||
|
analysis_targets=[a, b],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(accepted), 1)
|
||||||
|
self.assertEqual(len(duplicates), 1)
|
||||||
|
self.assertEqual(accepted[0].stock_code, "csi930955")
|
||||||
|
self.assertEqual(accepted[0].dedupe_key, "csi930955")
|
||||||
|
|
||||||
|
def test_mixed_quad_batch_produces_four_independent_tasks(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
index_sh = parse_analysis_target("sh000016")
|
||||||
|
stock_bare = parse_analysis_target("000016")
|
||||||
|
stock_600 = parse_analysis_target("600519")
|
||||||
|
index_csi = parse_analysis_target("930955.CSI")
|
||||||
|
for target in (index_sh, index_csi):
|
||||||
|
self.assertEqual(target.asset_type, "index")
|
||||||
|
for target in (stock_bare, stock_600):
|
||||||
|
self.assertEqual(target.asset_type, "stock")
|
||||||
|
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
accepted, duplicates = queue.submit_tasks_batch(
|
||||||
|
["sh000016", "000016", "600519", "930955.CSI"],
|
||||||
|
analysis_targets=[index_sh, stock_bare, stock_600, index_csi],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(duplicates, [])
|
||||||
|
self.assertEqual(len(accepted), 4)
|
||||||
|
# Index targets keep their parser canonical id (lowercase), so CSI alias
|
||||||
|
# input is submitted under its canonical identity.
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(task.stock_code for task in accepted),
|
||||||
|
["000016", "600519", "csi930955", "sh000016"],
|
||||||
|
)
|
||||||
|
# INDEX keys are canonical ids; STOCK keys stay code-based and distinct.
|
||||||
|
keys = {task.stock_code: task.dedupe_key for task in accepted}
|
||||||
|
self.assertEqual(keys["sh000016"], "sh000016")
|
||||||
|
self.assertEqual(keys["csi930955"], "csi930955")
|
||||||
|
self.assertNotEqual(keys["000016"], "sh000016")
|
||||||
|
self.assertNotEqual(keys["600519"], "csi930955")
|
||||||
|
|
||||||
|
def test_stock_dedup_semantics_unchanged_with_analysis_targets_none(self) -> None:
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
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)
|
||||||
|
self.assertIsNone(accepted[0].analysis_target)
|
||||||
|
self.assertIsNotNone(accepted[0].dedupe_key)
|
||||||
|
|
||||||
|
def test_worker_removes_index_task_by_stored_dedupe_key(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
index_target = parse_analysis_target("sh000016")
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
accepted, _ = queue.submit_tasks_batch(
|
||||||
|
["sh000016"],
|
||||||
|
analysis_targets=[index_target],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
task = accepted[0]
|
||||||
|
|
||||||
|
service_instance = MagicMock()
|
||||||
|
service_instance.analyze_stock.return_value = {"stock_code": "sh000016", "stock_name": "上证50"}
|
||||||
|
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
|
||||||
|
result = queue._execute_task(
|
||||||
|
task.task_id,
|
||||||
|
task.stock_code,
|
||||||
|
task.report_type,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
index_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
# The canonical-id key must be removed on completion, leaving no residue.
|
||||||
|
self.assertNotIn("sh000016", queue._analyzing_stocks)
|
||||||
|
self.assertIs(
|
||||||
|
service_instance.analyze_stock.call_args.kwargs["analysis_target"],
|
||||||
|
index_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_worker_failure_removes_index_task_by_stored_dedupe_key(self) -> None:
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
index_target = parse_analysis_target("sh000016")
|
||||||
|
queue = self._executor_stub_queue()
|
||||||
|
accepted, _ = queue.submit_tasks_batch(
|
||||||
|
["sh000016"],
|
||||||
|
analysis_targets=[index_target],
|
||||||
|
report_type="detailed",
|
||||||
|
)
|
||||||
|
task = accepted[0]
|
||||||
|
self.assertIn("sh000016", queue._analyzing_stocks)
|
||||||
|
|
||||||
|
service_instance = MagicMock()
|
||||||
|
service_instance.analyze_stock.side_effect = RuntimeError("boom")
|
||||||
|
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
|
||||||
|
queue._execute_task(
|
||||||
|
task.task_id,
|
||||||
|
task.stock_code,
|
||||||
|
task.report_type,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
index_target,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The failure branch must clear the stored canonical-id key so the index
|
||||||
|
# does not linger in the analyzing set (a residue would force 409 forever).
|
||||||
|
self.assertNotIn("sh000016", queue._analyzing_stocks)
|
||||||
|
|
||||||
|
|
||||||
class ImageStockExtractorContractTestCase(unittest.TestCase):
|
class ImageStockExtractorContractTestCase(unittest.TestCase):
|
||||||
def test_litellm_completion_patch_target_remains_available(self) -> None:
|
def test_litellm_completion_patch_target_remains_available(self) -> None:
|
||||||
|
|||||||
@@ -141,6 +141,95 @@ class TestHistoryCsiCandidateConvergence(unittest.TestCase):
|
|||||||
db.get_analysis_history_paginated.assert_not_called()
|
db.get_analysis_history_paginated.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryIndexCanonicalCandidates(unittest.TestCase):
|
||||||
|
"""PR #2312 review remediation: registered SH/SZ indices (not just CSI)
|
||||||
|
must use parser-aware persisted-read candidates — lowercase canonical +
|
||||||
|
uppercase legacy canonical + explicit aliases — and must never include the
|
||||||
|
bare same-code stock, so an index record is never reachable through a
|
||||||
|
stock query and vice versa."""
|
||||||
|
|
||||||
|
def test_registered_sh_index_forms_include_canonical_uppercase_and_aliases(self):
|
||||||
|
"""``sh000016`` must map to canonical + uppercase legacy + ``000016.SH``
|
||||||
|
alias, and must NOT include the bare ``000016`` stock."""
|
||||||
|
for code in ("sh000016", "SH000016", "000016.SH", " sh000016 "):
|
||||||
|
candidates = HistoryService._history_code_filter_candidates(code)
|
||||||
|
self.assertEqual(
|
||||||
|
set(candidates),
|
||||||
|
{"sh000016", "SH000016", "000016.SH"},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(candidates), len(set(candidates)))
|
||||||
|
self.assertNotIn("000016", candidates)
|
||||||
|
|
||||||
|
def test_registered_sz_index_forms_include_canonical_uppercase_and_aliases(self):
|
||||||
|
for code in ("sz399001", "SZ399001", "399001.SZ"):
|
||||||
|
candidates = HistoryService._history_code_filter_candidates(code)
|
||||||
|
self.assertEqual(
|
||||||
|
set(candidates),
|
||||||
|
{"sz399001", "SZ399001", "399001.SZ"},
|
||||||
|
)
|
||||||
|
self.assertNotIn("399001", candidates)
|
||||||
|
|
||||||
|
def test_multi_alias_sh300_index_candidates_include_uppercase_alias_forms(self):
|
||||||
|
"""sqlite `IN` 大小写敏感:`sz399300` 的旧 uppercase 持久化形态
|
||||||
|
`SZ399300` 必须进入候选集,否则旧记录查不到。"""
|
||||||
|
expected = {
|
||||||
|
"sh000300",
|
||||||
|
"SH000300",
|
||||||
|
"sz399300",
|
||||||
|
"SZ399300",
|
||||||
|
"000300.SH",
|
||||||
|
"000300.CSI",
|
||||||
|
}
|
||||||
|
for code in ("sh000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"):
|
||||||
|
candidates = HistoryService._history_code_filter_candidates(code)
|
||||||
|
self.assertEqual(set(candidates), expected)
|
||||||
|
self.assertEqual(len(candidates), len(set(candidates)))
|
||||||
|
self.assertNotIn("000300", candidates)
|
||||||
|
|
||||||
|
def test_registered_csi_forms_keep_existing_converged_candidates(self):
|
||||||
|
"""The unified parser-aware branch must preserve the PR #2267 CSI
|
||||||
|
candidate contract exactly."""
|
||||||
|
for code in ("csi930955", "930955.CSI", "CSI930955", " csi930955 "):
|
||||||
|
candidates = HistoryService._history_code_filter_candidates(code)
|
||||||
|
self.assertEqual(
|
||||||
|
set(candidates),
|
||||||
|
{"csi930955", "CSI930955", "930955.CSI"},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(candidates), len(set(candidates)))
|
||||||
|
self.assertNotIn("930955", candidates)
|
||||||
|
|
||||||
|
def test_multi_alias_sh300_index_converges_all_explicit_forms(self):
|
||||||
|
"""``sh000300`` owns ``sz399300`` / ``000300.SH`` / ``000300.CSI``
|
||||||
|
aliases; every explicit form must converge to the same candidate set,
|
||||||
|
including the uppercase alias form ``SZ399300`` (case-sensitive SQL)."""
|
||||||
|
for code in ("sh000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"):
|
||||||
|
candidates = HistoryService._history_code_filter_candidates(code)
|
||||||
|
self.assertEqual(
|
||||||
|
set(candidates),
|
||||||
|
{"sh000300", "SH000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"},
|
||||||
|
)
|
||||||
|
self.assertNotIn("000300", candidates)
|
||||||
|
|
||||||
|
def test_bare_stock_does_not_include_index_identity(self):
|
||||||
|
"""Filtering the bare stock ``000016`` must not reach the ``sh000016``
|
||||||
|
index or its ``000016.SH`` alias."""
|
||||||
|
candidates = HistoryService._history_code_filter_candidates("000016")
|
||||||
|
self.assertIn("000016", candidates)
|
||||||
|
self.assertNotIn("sh000016", candidates)
|
||||||
|
self.assertNotIn("000016.SH", candidates)
|
||||||
|
|
||||||
|
def test_unregistered_prefixed_index_form_stays_stock(self):
|
||||||
|
"""An unregistered ``sh``-prefixed token parses as a stock and must
|
||||||
|
keep the legacy stock candidate path untouched — no lowercase index
|
||||||
|
canonical (``sh900999``) and no registry alias are invented."""
|
||||||
|
candidates = HistoryService._history_code_filter_candidates("sh900999")
|
||||||
|
self.assertEqual(
|
||||||
|
set(candidates),
|
||||||
|
{"SH900999", "900999", "900999.SH", "900999.SS", "SS900999"},
|
||||||
|
)
|
||||||
|
self.assertNotIn("sh900999", candidates)
|
||||||
|
|
||||||
|
|
||||||
def _analysis_context_pack_overview() -> dict:
|
def _analysis_context_pack_overview() -> dict:
|
||||||
return {
|
return {
|
||||||
"pack_version": "1.0",
|
"pack_version": "1.0",
|
||||||
@@ -1248,6 +1337,67 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(report.meta.current_price, 200.0)
|
self.assertEqual(report.meta.current_price, 200.0)
|
||||||
self.assertEqual(report.meta.change_pct, 1.23)
|
self.assertEqual(report.meta.change_pct, 1.23)
|
||||||
|
|
||||||
|
def test_history_detail_reports_index_asset_type_from_canonical_code(self) -> None:
|
||||||
|
"""Index reports must expose meta.asset_type='index' so the Web can hide
|
||||||
|
the stock-only watchlist action, and bare same-digit stock codes must
|
||||||
|
remain 'stock' (never index via display normalization)."""
|
||||||
|
if get_history_detail is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
def save_record(code: str, query_id: str) -> int:
|
||||||
|
result = self._build_result()
|
||||||
|
result.code = code
|
||||||
|
saved = self.db.save_analysis_history(
|
||||||
|
result=result,
|
||||||
|
query_id=query_id,
|
||||||
|
report_type="simple",
|
||||||
|
news_content="新闻摘要",
|
||||||
|
context_snapshot=None,
|
||||||
|
save_snapshot=False,
|
||||||
|
)
|
||||||
|
self.assertGreater(saved, 0)
|
||||||
|
with self.db.get_session() as session:
|
||||||
|
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
return row.id
|
||||||
|
|
||||||
|
index_id = save_record("sh000016", "query_asset_type_index")
|
||||||
|
stock_id = save_record("000016", "query_asset_type_stock")
|
||||||
|
stock_id2 = save_record("600519", "query_asset_type_stock2")
|
||||||
|
|
||||||
|
index_report = get_history_detail(str(index_id), db_manager=self.db)
|
||||||
|
self.assertEqual(index_report.meta.asset_type, "index")
|
||||||
|
|
||||||
|
stock_report = get_history_detail(str(stock_id), db_manager=self.db)
|
||||||
|
self.assertEqual(stock_report.meta.asset_type, "stock")
|
||||||
|
|
||||||
|
stock_report2 = get_history_detail(str(stock_id2), db_manager=self.db)
|
||||||
|
self.assertEqual(stock_report2.meta.asset_type, "stock")
|
||||||
|
|
||||||
|
def test_history_detail_omits_asset_type_for_market_review(self) -> None:
|
||||||
|
"""Market review records must omit the optional asset_type field."""
|
||||||
|
if get_history_detail is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
result = self._build_result()
|
||||||
|
result.code = "MARKET"
|
||||||
|
saved = self.db.save_analysis_history(
|
||||||
|
result=result,
|
||||||
|
query_id="query_asset_type_market_review",
|
||||||
|
report_type="market_review",
|
||||||
|
news_content="大盘复盘",
|
||||||
|
context_snapshot=None,
|
||||||
|
save_snapshot=False,
|
||||||
|
)
|
||||||
|
self.assertGreater(saved, 0)
|
||||||
|
with self.db.get_session() as session:
|
||||||
|
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_asset_type_market_review").first()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
record_id = row.id
|
||||||
|
|
||||||
|
report = get_history_detail(str(record_id), db_manager=self.db)
|
||||||
|
self.assertIsNone(report.meta.asset_type)
|
||||||
|
|
||||||
@patch("src.auth.is_auth_enabled", return_value=False)
|
@patch("src.auth.is_auth_enabled", return_value=False)
|
||||||
def test_history_detail_ignores_non_dict_realtime_quote_raw(self, mock_auth) -> None:
|
def test_history_detail_ignores_non_dict_realtime_quote_raw(self, mock_auth) -> None:
|
||||||
"""GET /api/v1/history/{id} should tolerate truthy non-dict realtime_quote_raw."""
|
"""GET /api/v1/history/{id} should tolerate truthy non-dict realtime_quote_raw."""
|
||||||
@@ -2528,6 +2678,311 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
|||||||
report = get_history_detail(str(record_id), db_manager=self.db)
|
report = get_history_detail(str(record_id), db_manager=self.db)
|
||||||
self.assertIsNone(report.details.empty_news_disclosure)
|
self.assertIsNone(report.details.empty_news_disclosure)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PR #2312: 指数 canonical 历史隔离(并入本类,避免子类重复继承放大测试)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _save_result_with_code(self, code: str, query_id: str) -> int:
|
||||||
|
result = self._build_result()
|
||||||
|
result.code = code
|
||||||
|
saved = self.db.save_analysis_history(
|
||||||
|
result=result,
|
||||||
|
query_id=query_id,
|
||||||
|
report_type="simple",
|
||||||
|
news_content="新闻摘要",
|
||||||
|
context_snapshot=None,
|
||||||
|
save_snapshot=False,
|
||||||
|
)
|
||||||
|
self.assertGreater(saved, 0)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
def test_history_filter_isolates_index_from_same_code_stock(self):
|
||||||
|
self._save_result_with_code("sh000016", "query_index")
|
||||||
|
self._save_result_with_code("000016", "query_stock")
|
||||||
|
|
||||||
|
index_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="sh000016", page=1, limit=10
|
||||||
|
)
|
||||||
|
stock_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="000016", page=1, limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(index_listing["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in index_listing["items"]},
|
||||||
|
{"query_index"},
|
||||||
|
)
|
||||||
|
self.assertEqual(stock_listing["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in stock_listing["items"]},
|
||||||
|
{"query_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_history_filter_reaches_legacy_uppercase_and_alias_index_records(self):
|
||||||
|
self._save_result_with_code("sh000016", "query_canonical")
|
||||||
|
self._save_result_with_code("SH000016", "query_upper")
|
||||||
|
self._save_result_with_code("000016.SH", "query_alias")
|
||||||
|
|
||||||
|
for code in ("sh000016", "SH000016", "000016.SH"):
|
||||||
|
listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code=code, page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(listing["total"], 3)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in listing["items"]},
|
||||||
|
{"query_canonical", "query_upper", "query_alias"},
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="000016", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(stock_listing["total"], 0)
|
||||||
|
|
||||||
|
def test_sz_index_history_filter_and_delete_isolate_from_same_code_stock(self):
|
||||||
|
"""I/O matrix INDEX_HISTORY 的 SZ 侧真实 SQL 路径:``sz399001``
|
||||||
|
lowercase canonical 筛选、删除与计数均命中自身记录,不并入裸
|
||||||
|
``399001`` 股票。"""
|
||||||
|
self._save_result_with_code("sz399001", "query_sz_index")
|
||||||
|
self._save_result_with_code("399001", "query_sz_stock")
|
||||||
|
|
||||||
|
sz_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="sz399001", page=1, limit=10
|
||||||
|
)
|
||||||
|
stock_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="399001", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(sz_listing["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in sz_listing["items"]},
|
||||||
|
{"query_sz_index"},
|
||||||
|
)
|
||||||
|
self.assertEqual(stock_listing["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in stock_listing["items"]},
|
||||||
|
{"query_sz_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if delete_history_by_code is not None:
|
||||||
|
response = delete_history_by_code("sz399001", db_manager=self.db)
|
||||||
|
self.assertEqual(response.deleted, 1)
|
||||||
|
remaining = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="399001", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(remaining["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in remaining["items"]},
|
||||||
|
{"query_sz_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_history_detail_displays_parser_canonical_for_legacy_index_record(self):
|
||||||
|
"""报告详情 meta 对旧 uppercase/alias 指数记录输出 parser canonical。"""
|
||||||
|
if get_history_detail is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
self._save_result_with_code("SZ399300", "query_sz399300_upper")
|
||||||
|
with self.db.get_session() as session:
|
||||||
|
row = session.query(AnalysisHistory).filter(
|
||||||
|
AnalysisHistory.query_id == "query_sz399300_upper"
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
self.fail("未找到保存的历史记录")
|
||||||
|
|
||||||
|
report = get_history_detail(str(row.id), db_manager=self.db)
|
||||||
|
self.assertEqual(report.meta.stock_code, "sh000300")
|
||||||
|
self.assertEqual(report.meta.asset_type, "index")
|
||||||
|
|
||||||
|
def test_history_list_displays_parser_canonical_for_legacy_index_records(self):
|
||||||
|
"""已登记指数旧记录(uppercase legacy / 显式 alias)的 API
|
||||||
|
``stock_code`` 输出 parser canonical(``sz399300``/``000300.CSI`` ->
|
||||||
|
``sh000300``),前端只做大小写折叠即可,无需前缀/后缀正则猜 canonical。"""
|
||||||
|
self._save_result_with_code("SZ399300", "query_sz399300_upper")
|
||||||
|
self._save_result_with_code("000300.CSI", "query_000300_csi")
|
||||||
|
|
||||||
|
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
|
||||||
|
by_query = {item["query_id"]: item for item in listing["items"]}
|
||||||
|
self.assertEqual(by_query["query_sz399300_upper"]["stock_code"], "sh000300")
|
||||||
|
self.assertEqual(by_query["query_000300_csi"]["stock_code"], "sh000300")
|
||||||
|
|
||||||
|
def test_sh300_uppercase_alias_filter_delete_and_stock_bar_count_real_sql(self):
|
||||||
|
"""sqlite ``IN`` 大小写敏感回归:``SZ399300``/``sz399300``/
|
||||||
|
``000300.CSI``/``sh000300`` 任一查询都命中全部显式形态旧记录(含
|
||||||
|
uppercase alias 持久化记录),并与裸 ``000300`` 股票隔离。"""
|
||||||
|
self._save_result_with_code("sh000300", "query_canonical")
|
||||||
|
self._save_result_with_code("SZ399300", "query_uppercase_alias")
|
||||||
|
self._save_result_with_code("000300.CSI", "query_dotted_alias")
|
||||||
|
self._save_result_with_code("000300", "query_bare_stock")
|
||||||
|
|
||||||
|
for code in ("sh000300", "sz399300", "SZ399300", "000300.CSI"):
|
||||||
|
listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code=code, page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(listing["total"], 3)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in listing["items"]},
|
||||||
|
{"query_canonical", "query_uppercase_alias", "query_dotted_alias"},
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_listing = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="000300", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(stock_listing["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in stock_listing["items"]},
|
||||||
|
{"query_bare_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if get_stock_bar is not None:
|
||||||
|
stock_bar = get_stock_bar(
|
||||||
|
start_date=None,
|
||||||
|
end_date=None,
|
||||||
|
limit=10,
|
||||||
|
db_manager=self.db,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(stock_bar.items), 2)
|
||||||
|
index_item = next(
|
||||||
|
item for item in stock_bar.items if item.asset_type == "index"
|
||||||
|
)
|
||||||
|
stock_item = next(
|
||||||
|
item for item in stock_bar.items if item.asset_type == "stock"
|
||||||
|
)
|
||||||
|
self.assertEqual(index_item.stock_code, "sh000300")
|
||||||
|
self.assertEqual(index_item.analysis_count, 3)
|
||||||
|
self.assertEqual(stock_item.stock_code, "000300")
|
||||||
|
self.assertEqual(stock_item.analysis_count, 1)
|
||||||
|
|
||||||
|
if delete_history_by_code is not None:
|
||||||
|
response = delete_history_by_code("SZ399300", db_manager=self.db)
|
||||||
|
self.assertEqual(response.deleted, 3)
|
||||||
|
remaining = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="000300", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(remaining["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in remaining["items"]},
|
||||||
|
{"query_bare_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stock_bar_isolates_index_and_same_code_stock(self):
|
||||||
|
if get_stock_bar is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
self._save_result_with_code("sh000016", "query_index")
|
||||||
|
self._save_result_with_code("000016", "query_stock")
|
||||||
|
|
||||||
|
stock_bar = get_stock_bar(
|
||||||
|
start_date=None,
|
||||||
|
end_date=None,
|
||||||
|
limit=10,
|
||||||
|
db_manager=self.db,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(stock_bar.items), 2)
|
||||||
|
by_code = {item.stock_code: item for item in stock_bar.items}
|
||||||
|
self.assertIn("sh000016", by_code)
|
||||||
|
self.assertIn("000016", by_code)
|
||||||
|
self.assertEqual(by_code["sh000016"].analysis_count, 1)
|
||||||
|
self.assertEqual(by_code["000016"].analysis_count, 1)
|
||||||
|
self.assertEqual(by_code["sh000016"].asset_type, "index")
|
||||||
|
self.assertEqual(by_code["000016"].asset_type, "stock")
|
||||||
|
|
||||||
|
def test_stock_bar_merges_index_explicit_forms_and_counts_them(self):
|
||||||
|
if get_stock_bar is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
self._save_result_with_code("sh000016", "query_canonical")
|
||||||
|
self._save_result_with_code("SH000016", "query_upper")
|
||||||
|
self._save_result_with_code("000016.SH", "query_alias")
|
||||||
|
self._save_result_with_code("000016", "query_stock")
|
||||||
|
|
||||||
|
stock_bar = get_stock_bar(
|
||||||
|
start_date=None,
|
||||||
|
end_date=None,
|
||||||
|
limit=10,
|
||||||
|
db_manager=self.db,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(stock_bar.items), 2)
|
||||||
|
index_item = next(
|
||||||
|
item for item in stock_bar.items if item.asset_type == "index"
|
||||||
|
)
|
||||||
|
stock_item = next(
|
||||||
|
item for item in stock_bar.items if item.asset_type == "stock"
|
||||||
|
)
|
||||||
|
self.assertEqual(index_item.analysis_count, 3)
|
||||||
|
self.assertEqual(index_item.stock_code, "sh000016")
|
||||||
|
self.assertEqual(stock_item.stock_code, "000016")
|
||||||
|
self.assertEqual(stock_item.analysis_count, 1)
|
||||||
|
|
||||||
|
def test_stock_bar_exposes_sz_index_canonical_row_with_independent_count(self):
|
||||||
|
"""``sz399001`` 在 stock-bar 以 canonical 单行展示,``analysis_count``
|
||||||
|
只计自身记录,不与裸 ``399001`` 股票合并。"""
|
||||||
|
if get_stock_bar is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
self._save_result_with_code("sz399001", "query_sz_index")
|
||||||
|
self._save_result_with_code("399001", "query_sz_stock")
|
||||||
|
|
||||||
|
stock_bar = get_stock_bar(
|
||||||
|
start_date=None,
|
||||||
|
end_date=None,
|
||||||
|
limit=10,
|
||||||
|
db_manager=self.db,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(stock_bar.items), 2)
|
||||||
|
by_code = {item.stock_code: item for item in stock_bar.items}
|
||||||
|
self.assertIn("sz399001", by_code)
|
||||||
|
self.assertIn("399001", by_code)
|
||||||
|
self.assertEqual(by_code["sz399001"].analysis_count, 1)
|
||||||
|
self.assertEqual(by_code["sz399001"].asset_type, "index")
|
||||||
|
self.assertEqual(by_code["399001"].analysis_count, 1)
|
||||||
|
self.assertEqual(by_code["399001"].asset_type, "stock")
|
||||||
|
|
||||||
|
def test_delete_index_by_code_deletes_all_explicit_forms_only(self):
|
||||||
|
if delete_history_by_code is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
self._save_result_with_code("sh000016", "query_canonical")
|
||||||
|
self._save_result_with_code("SH000016", "query_upper")
|
||||||
|
self._save_result_with_code("000016.SH", "query_alias")
|
||||||
|
self._save_result_with_code("000016", "query_stock")
|
||||||
|
|
||||||
|
response = delete_history_by_code("sh000016", db_manager=self.db)
|
||||||
|
self.assertEqual(response.deleted, 3)
|
||||||
|
|
||||||
|
remaining = HistoryService(self.db).get_history_list(
|
||||||
|
stock_code="000016", page=1, limit=10
|
||||||
|
)
|
||||||
|
self.assertEqual(remaining["total"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
{item["query_id"] for item in remaining["items"]},
|
||||||
|
{"query_stock"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_index_with_no_records_returns_zero(self):
|
||||||
|
if delete_history_by_code is None:
|
||||||
|
self.skipTest("fastapi is not installed in this test environment")
|
||||||
|
|
||||||
|
response = delete_history_by_code("sh000016", db_manager=self.db)
|
||||||
|
self.assertEqual(response.deleted, 0)
|
||||||
|
|
||||||
|
def test_history_list_exposes_parser_asset_type(self):
|
||||||
|
self._save_result_with_code("sh000016", "query_index")
|
||||||
|
self._save_result_with_code("000016", "query_stock")
|
||||||
|
self._save_result_with_code("600519", "query_stock2")
|
||||||
|
|
||||||
|
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
|
||||||
|
by_query = {item["query_id"]: item for item in listing["items"]}
|
||||||
|
self.assertEqual(by_query["query_index"]["asset_type"], "index")
|
||||||
|
self.assertEqual(by_query["query_stock"]["asset_type"], "stock")
|
||||||
|
self.assertEqual(by_query["query_stock2"]["asset_type"], "stock")
|
||||||
|
|
||||||
|
def test_history_list_omits_asset_type_for_market_review(self):
|
||||||
|
self._save_result_with_code("MARKET", "query_market_review")
|
||||||
|
|
||||||
|
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
|
||||||
|
by_query = {item["query_id"]: item for item in listing["items"]}
|
||||||
|
self.assertTrue(by_query["query_market_review"]["asset_type"] is None)
|
||||||
|
|
||||||
|
|
||||||
class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase):
|
class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase):
|
||||||
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""
|
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class TestAnalysisIntegration:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["task_id"] == "test_task_123"
|
assert data["task_id"] == "test_task_123"
|
||||||
assert data["status"] == "pending"
|
assert data["status"] == "pending"
|
||||||
|
# 单股 202(TaskAccepted schema)不包含可选的 asset_type 字段;legacy
|
||||||
|
# MagicMock 任务即使 asset_type 是 MagicMock 子对象也必须保持该契约。
|
||||||
|
assert "asset_type" not in data
|
||||||
|
|
||||||
# Verify task queue received the correct resolved code and metadata.
|
# Verify task queue received the correct resolved code and metadata.
|
||||||
# Use call_args so this integration test stays focused on analysis flow
|
# Use call_args so this integration test stays focused on analysis flow
|
||||||
|
|||||||
@@ -845,6 +845,69 @@ class PipelineMarketPhaseContextTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(kwargs["query_source"], "api")
|
self.assertEqual(kwargs["query_source"], "api")
|
||||||
self.assertEqual(kwargs["report_type"], ReportType.SIMPLE.value)
|
self.assertEqual(kwargs["report_type"], ReportType.SIMPLE.value)
|
||||||
self.assertEqual(kwargs["profile_source"], "auto_default")
|
self.assertEqual(kwargs["profile_source"], "auto_default")
|
||||||
|
# No index target -> no market override.
|
||||||
|
self.assertIsNone(kwargs["market_override"])
|
||||||
|
|
||||||
|
def test_legacy_pipeline_passes_market_override_cn_for_index_target(self):
|
||||||
|
"""V8 — index targets must reach extract_and_persist_from_analysis_result
|
||||||
|
with market_override="cn" via the real wiring (not a mocked helper)."""
|
||||||
|
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||||
|
|
||||||
|
target = parse_analysis_target("sh000016")
|
||||||
|
self.assertEqual(target.asset_type, ParseStatus.INDEX)
|
||||||
|
|
||||||
|
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
|
||||||
|
pipeline.trace_id = "trace-index"
|
||||||
|
pipeline.query_source = "api"
|
||||||
|
pipeline.db.save_analysis_history.return_value = 42
|
||||||
|
phase_context = SimpleNamespace(to_dict=MagicMock(return_value=_phase_payload()))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("src.core.pipeline.build_market_phase_context", return_value=phase_context),
|
||||||
|
patch("src.core.pipeline.extract_and_persist_from_analysis_result") as mock_extract,
|
||||||
|
):
|
||||||
|
result = pipeline.analyze_stock(
|
||||||
|
"sh000016",
|
||||||
|
ReportType.SIMPLE,
|
||||||
|
"q-index-signal",
|
||||||
|
current_time=datetime(2026, 3, 27, 10, 0),
|
||||||
|
analysis_target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
mock_extract.assert_called_once()
|
||||||
|
kwargs = mock_extract.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["source_report_id"], 42)
|
||||||
|
self.assertEqual(kwargs["market_override"], "cn")
|
||||||
|
|
||||||
|
def test_legacy_pipeline_passes_market_override_none_for_stock_target(self):
|
||||||
|
"""Stock targets (and analysis_target=None) must keep market_override=None."""
|
||||||
|
from src.services.stock_list_parser import parse_analysis_target
|
||||||
|
|
||||||
|
target = parse_analysis_target("600519")
|
||||||
|
self.assertEqual(target.asset_type, "stock")
|
||||||
|
|
||||||
|
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
|
||||||
|
pipeline.trace_id = "trace-stock"
|
||||||
|
pipeline.query_source = "api"
|
||||||
|
pipeline.db.save_analysis_history.return_value = 42
|
||||||
|
phase_context = SimpleNamespace(to_dict=MagicMock(return_value=_phase_payload()))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("src.core.pipeline.build_market_phase_context", return_value=phase_context),
|
||||||
|
patch("src.core.pipeline.extract_and_persist_from_analysis_result") as mock_extract,
|
||||||
|
):
|
||||||
|
result = pipeline.analyze_stock(
|
||||||
|
"600519",
|
||||||
|
ReportType.SIMPLE,
|
||||||
|
"q-stock-signal",
|
||||||
|
current_time=datetime(2026, 3, 27, 10, 0),
|
||||||
|
analysis_target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
mock_extract.assert_called_once()
|
||||||
|
self.assertIsNone(mock_extract.call_args.kwargs["market_override"])
|
||||||
|
|
||||||
def test_legacy_pipeline_does_not_extract_when_history_save_fails(self):
|
def test_legacy_pipeline_does_not_extract_when_history_save_fails(self):
|
||||||
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
|
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user