mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: complete stock profile history evidence
This commit is contained in:
@@ -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/身份;刷新失败、超时或禁用不阻断分析。
|
||||
|
||||
@@ -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 同形的裸数字别名,避免跨市场规则误归属。
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user