fix: complete stock profile history evidence

This commit is contained in:
ZhuLinsen
2026-08-29 14:32:02 +08:00
parent 08a714ac65
commit d554277cc0
6 changed files with 76 additions and 5 deletions

View File

@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 美股日线路由现按各数据源当前优先级排序,单项 `*_PRIORITY` 配置(如 `YFINANCE_PRIORITY=0`)对美股即时生效;指数固定首选与 Longbridge preferred 语义保持不变
- [新功能] 新增个股研究聚合 API以统一 canonical code 返回行情、历史、研究产物、资讯、缓存持仓关系和监控规则,并对每个块独立标记 fresh/partial/unavailable。
- [修复] 个股研究聚合在市场限定后无历史候选时保持空结果,并从独立基本面快照补齐 ResearchArtifact 的财报与分红证据。
- [新功能] 支持通过 `main.py --stocks` 一次性分析已登记板块指数,自动使用指数适用的数据与分析能力,并保持报告、历史和决策信号兼容。
- [修复] `main.py --stocks` 在解析股票列表前先 best-effort 刷新股票索引注册表,保证首次运行能吃到刷新后的指数 alias/身份;刷新失败、超时或禁用不阻断分析。

View File

@@ -28,7 +28,7 @@
## 数据来源与边界
- quote/history复用 `StockService`,不新增数据获取器。
- research复用 `HistoryService`#2291`ResearchArtifact` builder本 PR 因此堆叠在 #2291 上。报告查询把档案入口已经解析出的 market hint 传到 `HistoryService` 的候选生成层A 股裸 canonical code 只展开对应 `SH` / `SZ` / `BJ` 代码,日韩台查询保留交易所后缀及其他无歧义别名,但不展开可能命中其他市场历史记录的裸数字代码,避免二次索引解析把跨市场报告构造成当前档案的 artifact。
- research复用 `HistoryService`#2291`ResearchArtifact` builder本 PR 因此堆叠在 #2291 上。报告查询把档案入口已经解析出的 market hint 传到 `HistoryService` 的候选生成层A 股裸 canonical code 只展开对应 `SH` / `SZ` / `BJ` 代码,日韩台查询保留交易所后缀及其他无歧义别名,但不展开可能命中其他市场历史记录的裸数字代码市场限定后候选为空时直接返回空分页不会退化成无过滤查询。artifact 同时复用历史详情的上下文、raw result 和独立基本面快照 fallback保留财报、分红与市场结构专项证据及其 source count。
- intelligence复用 `IntelligenceService` 的 symbol scope 查询,并兼容 canonical、交易所前后缀、港股前后缀及大小写历史别名同时读取具体市场与 `global` 的 symbol 资讯。任一别名/市场查询失败时块保持 partial limitation即使其余查询成功但为空也不误报为已确认无资讯。
- portfolio只读 `PortfolioRepository.list_cached_position_identities()`,并使用每条缓存持仓自己的 market 解析旧裸代码;只有 market 与档案身份一致时才算持有。不为了打开个股页触发实时估值或写 snapshot所以状态固定为 `partial` 并包含 `cached_positions_only`
- monitors复用 `AlertService.list_rules()`,分页汇总并去重 canonical code 及等价历史别名下的 `single_symbol` 规则。由于现有告警目标没有独立 market 字段,日韩台档案不会查询可能与 A/HK 同形的裸数字别名,避免跨市场规则误归属。

View File

@@ -263,6 +263,8 @@ class HistoryService:
)
if not include_ambiguous_numeric_aliases:
stock_code = [candidate for candidate in stock_code if not candidate.isdigit()]
if not stock_code:
return {"total": 0, "items": []}
# Parse date parameters
start_dt = None
@@ -598,6 +600,18 @@ class HistoryService:
logger.error(f"根据 ID 查询历史详情失败: {e}", exc_info=True)
return None
def get_latest_fundamental_snapshot(
self,
*,
query_id: str,
stock_code: str,
) -> Optional[Dict[str, Any]]:
"""Read the same persisted fundamental fallback used by history detail APIs."""
return self.db.get_latest_fundamental_snapshot(
query_id=query_id,
code=stock_code,
)
@staticmethod
def _normalize_display_sniper_value(value: Any) -> Optional[str]:
"""Normalize sniper point values for history display."""

View File

@@ -343,8 +343,7 @@ class StockProfileService:
aliases.append(alias)
return aliases
@staticmethod
def _artifact_input(detail: Dict[str, Any]) -> Dict[str, Any]:
def _artifact_input(self, detail: Dict[str, Any]) -> Dict[str, Any]:
context_snapshot = detail.get("context_snapshot")
raw_result = detail.get("raw_result")
raw_fundamental = (
@@ -356,6 +355,19 @@ class StockProfileService:
context_snapshot,
raw_fundamental,
)
try:
persisted_fundamental = self._history_service().get_latest_fundamental_snapshot(
query_id=str(detail.get("query_id") or "").strip(),
stock_code=str(
detail.get("storage_stock_code") or detail.get("stock_code") or ""
).strip(),
)
except Exception:
persisted_fundamental = None
persisted_fields = extract_fundamental_detail_fields(
None,
persisted_fundamental,
)
context_overview = extract_analysis_context_pack_overview(context_snapshot)
market_structure = extract_market_structure_detail_field(
context_snapshot,
@@ -388,9 +400,11 @@ class StockProfileService:
"empty_news_disclosure": detail.get("empty_news_disclosure"),
"analysis_context_pack_overview": context_overview,
"financial_report": detail.get("financial_report")
or extracted_fundamental.get("financial_report"),
or extracted_fundamental.get("financial_report")
or persisted_fields.get("financial_report"),
"dividend_metrics": detail.get("dividend_metrics")
or extracted_fundamental.get("dividend_metrics"),
or extracted_fundamental.get("dividend_metrics")
or persisted_fields.get("dividend_metrics"),
"market_structure": detail.get("market_structure") or market_structure,
},
}

View File

@@ -111,6 +111,19 @@ class TestHistoryCsiCandidateConvergence(unittest.TestCase):
self.assertIn("000660.SZ", queried_codes)
self.assertNotIn("000660.KS", queried_codes)
def test_empty_market_qualified_candidate_set_fails_closed(self):
db = MagicMock()
result = HistoryService(db).get_history_list(
stock_code="AAPL",
page=1,
limit=5,
market_hint="cn",
)
self.assertEqual(result, {"total": 0, "items": []})
db.get_analysis_history_paginated.assert_not_called()
def _analysis_context_pack_overview() -> dict:
return {

View File

@@ -109,6 +109,7 @@ def _service(**overrides: object) -> tuple[StockProfileService, dict[str, MagicM
dependencies["stock_service"].get_history_data.return_value = _history()
dependencies["history_service"].get_history_list.return_value = _report_list()
dependencies["history_service"].get_history_detail_by_id.return_value = _report_detail()
dependencies["history_service"].get_latest_fundamental_snapshot.return_value = None
dependencies["intelligence_service"].list_items.return_value = _intelligence()
dependencies["portfolio_repository"].list_cached_position_identities.return_value = [("us", "aapl")]
dependencies["alert_service"].list_rules.return_value = {
@@ -192,6 +193,34 @@ def test_profile_research_preserves_specialized_report_evidence() -> None:
assert artifact["data_quality"]["source_count"] == len(artifact["evidence"])
def test_profile_research_reads_independent_fundamental_snapshot() -> None:
service, dependencies = _service()
detail = _report_detail()
detail["storage_stock_code"] = "AAPL.US"
dependencies["history_service"].get_history_detail_by_id.return_value = detail
dependencies["history_service"].get_latest_fundamental_snapshot.return_value = {
"earnings": {
"data": {
"financial_report": {"report_date": "2026-06-30"},
"dividend": {"ttm_cash_dividend_per_share": 1.2},
}
}
}
payload = service.get_profile("AAPL")
artifact = payload["research"]["data"]["structured_report"]
evidence_ids = {item["id"] for item in artifact["evidence"]}
assert {
"fundamental:financial_report",
"fundamental:dividend_metrics",
} <= evidence_ids
dependencies["history_service"].get_latest_fundamental_snapshot.assert_called_once_with(
query_id="query-12",
stock_code="AAPL.US",
)
def test_hk_alias_is_canonicalized_before_every_downstream_query() -> None:
service, dependencies = _service()
dependencies["stock_service"].get_realtime_quote.return_value = _quote("HK00700")