fix: disambiguate profile persisted identities

This commit is contained in:
ZhuLinsen
2026-08-29 13:15:06 +08:00
parent 6a94c13542
commit d2f054394b
3 changed files with 81 additions and 10 deletions

View File

@@ -29,8 +29,8 @@
- quote/history复用 `StockService`,不新增数据获取器。
- research复用 `HistoryService`#2291`ResearchArtifact` builder本 PR 因此堆叠在 #2291 上。
- intelligence复用 `IntelligenceService` 的 symbol scope 查询,并兼容 canonical、交易所前后缀、港股前后缀及大小写历史别名同时读取具体市场与 `global` 的 symbol 资讯。任一别名/市场查询失败时块保持 partial limitation即使其余查询成功但为空也不误报为已确认无资讯。
- portfolio只读 `PortfolioRepository.list_cached_position_identities()`不为了打开个股页触发实时估值或写 snapshot所以状态固定为 `partial` 并包含 `cached_positions_only`
- monitors复用 `AlertService.list_rules()`,分页汇总并去重 canonical code 及等价历史别名下的 `single_symbol` 规则。
- portfolio只读 `PortfolioRepository.list_cached_position_identities()`,并使用每条缓存持仓自己的 market 解析旧裸代码;只有 market 与档案身份一致时才算持有。不为了打开个股页触发实时估值或写 snapshot所以状态固定为 `partial` 并包含 `cached_positions_only`
- monitors复用 `AlertService.list_rules()`,分页汇总并去重 canonical code 及等价历史别名下的 `single_symbol` 规则。由于现有告警目标没有独立 market 字段,日韩台档案不会查询可能与 A/HK 同形的裸数字别名,避免跨市场规则误归属。
本阶段不新增 Web 路由/页面、不接线 Home/Watchlist/Screening/Portfolio/Report 入口,也不包含日历事件。后续 Web PR 必须消费本端点并分别渲染块状态;不得重新恢复多请求页面聚合。日历事件在 #2307 契约合入后再作为独立块扩展。

View File

@@ -46,8 +46,8 @@ class StockProfileService:
"history": self._history_block(canonical_code, history_days=history_days),
"research": self._research_block(canonical_code),
"intelligence": self._intelligence_block(canonical_code, market=market),
"portfolio": self._portfolio_block(canonical_code),
"monitors": self._monitor_block(canonical_code),
"portfolio": self._portfolio_block(canonical_code, market=market),
"monitors": self._monitor_block(canonical_code, market=market),
}
return {
"requested_code": str(requested_code).strip(),
@@ -160,8 +160,18 @@ class StockProfileService:
successful_queries = 0
failed_queries = 0
markets = [market] if market == "global" else [market, "global"]
for alias in self._code_aliases(code):
aliases = self._code_aliases(code, market_hint=market)
safe_global_aliases = set(
self._code_aliases(
code,
market_hint=market,
include_ambiguous_numeric=False,
)
)
for alias in aliases:
for query_market in markets:
if query_market == "global" and alias not in safe_global_aliases:
continue
try:
result = self._intelligence_service().list_items(
scope_type="symbol",
@@ -203,10 +213,18 @@ class StockProfileService:
"limitations": ["intelligence_alias_query_partial"] if failed_queries else [],
}
def _portfolio_block(self, code: str) -> Dict[str, Any]:
def _portfolio_block(self, code: str, *, market: str) -> Dict[str, Any]:
try:
identities = self._portfolio_repository().list_cached_position_identities()
matches = [market for market, symbol in identities if self.canonicalize_code(symbol) == code]
matches = []
for position_market, symbol in identities:
normalized_market = str(position_market or "").strip().lower()
identity = resolve_daily_stock_identity(symbol, market_hint=normalized_market)
if identity is None or identity.market != normalized_market or identity.market != market:
continue
position_code = self.canonicalize_code(identity.refill_code or identity.normalized_code)
if position_code == code:
matches.append(normalized_market)
except Exception:
return self._unavailable(
"portfolio_relation_unavailable",
@@ -218,11 +236,15 @@ class StockProfileService:
"limitations": ["cached_positions_only"],
}
def _monitor_block(self, code: str) -> Dict[str, Any]:
def _monitor_block(self, code: str, *, market: str) -> Dict[str, Any]:
rules_by_id: Dict[Any, Dict[str, Any]] = {}
successful_queries = 0
failed_queries = 0
for alias in self._code_aliases(code):
for alias in self._code_aliases(
code,
market_hint=market,
include_ambiguous_numeric=False,
):
page = 1
scanned = 0
try:
@@ -264,13 +286,25 @@ class StockProfileService:
}
@staticmethod
def _code_aliases(code: str) -> List[str]:
def _code_aliases(
code: str,
*,
market_hint: Optional[str] = None,
include_ambiguous_numeric: bool = True,
) -> List[str]:
market = str(market_hint or StockProfileService.market_for_code(code)).strip().lower()
candidates = [
*build_daily_code_candidates(code),
*HistoryService._history_code_filter_candidates(code),
]
aliases: List[str] = []
for candidate in candidates or [code]:
if (
not include_ambiguous_numeric
and market in {"jp", "kr", "tw"}
and str(candidate).strip().isdigit()
):
continue
for alias in (str(candidate).strip(), str(candidate).strip().lower()):
if alias and alias not in aliases:
aliases.append(alias)

View File

@@ -197,6 +197,43 @@ def test_jp_and_kr_codes_preserve_shared_market_identity() -> None:
}
def test_portfolio_identity_uses_cached_market_hint_and_requires_same_market() -> None:
jp_service, jp_dependencies = _service()
jp_dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("jp", "8035"),
]
kr_service, kr_dependencies = _service()
kr_dependencies["portfolio_repository"].list_cached_position_identities.return_value = [
("cn", "005930"),
]
jp_payload = jp_service.get_profile("8035.T")
kr_payload = kr_service.get_profile("005930.KS")
assert jp_payload["portfolio"]["data"] == {"held": True, "matched_markets": ["jp"]}
assert kr_payload["portfolio"]["data"] == {"held": False, "matched_markets": []}
def test_offshore_monitor_lookup_excludes_ambiguous_bare_numeric_alias() -> None:
service, dependencies = _service()
def rules_by_alias(**kwargs: object) -> dict:
if kwargs.get("target") == "005930":
return {"items": [{"id": 99, "enabled": True}], "total": 1}
return {"items": [], "total": 0}
dependencies["alert_service"].list_rules.side_effect = rules_by_alias
payload = service.get_profile("005930.KS")
queried_targets = {
call.kwargs["target"] for call in dependencies["alert_service"].list_rules.call_args_list
}
assert "005930" not in queried_targets
assert "005930.KS" in queried_targets
assert payload["monitors"]["data"]["total_rule_count"] == 0
def test_profile_collects_intelligence_and_monitors_saved_under_legacy_aliases() -> None:
service, dependencies = _service()