mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: disambiguate profile persisted identities
This commit is contained in:
@@ -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 契约合入后再作为独立块扩展。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user