From 6da1360a0bc8a323ba75d7fd5649de24f2b8ec9d Mon Sep 17 00:00:00 2001 From: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:30:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BA=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=B8=8A=E4=B8=8B=E6=96=87=20(#1981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: rebuild market structure context * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): Scope in-flight ranking keys to each fetcher manager * fix(review-feedback-1981): 修正无证据情况下的个股层级判定,并按仓库规范补充可访问的 Web 页面截图、更新 PR 描述事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按 AGENTS.md 为新增市场结构卡片补充可直接查看的截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按仓库规范补充可直接审查的市场结构卡片截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:276 在 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts 将本 PR * fix(review-feedback-1981): 同步当前 Head 的 diff/CI 结论,并按仓库规范在 PR 描述或评论中附可访问的市场结构卡片截图 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:261 新增 async 空对象解构 * fix(review-feedback-1981): 按仓库规范在 PR 描述或评论中附市场结构卡片的可访问截图,并同步当前 diff 统计和 CI 结果 * fix(review-feedback-1981): 消除当前运行结果对历史持久化成功的依赖,补充对应回归测试,并补齐可访问的 UI 截图及更新失真的 PR 描述 * fix(review-feedback-1981): src/utils/data processing.py 提取榜单时丢弃了上游 boards / concept boards 的 * fix(review-feedback-1981): 修正 PR 描述并补齐可访问的视觉证据 * fix(review-feedback-1981): 关闭市场结构前置数据获取可能阻塞主分析的问题,并同步修正 PR 描述、范围清单和视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、当前 CI 证据、视觉证据及回滚表述同步到最新 Head * fix(review-feedback-1981): PR 描述与最新 Head 不一致:完整 diff 包含 38 个文件及 src/services/analysis * fix(review-feedback-1981): 按最新 Head 同步 PR 描述,并补充可访问的市场结构卡片截图证据 * fix(review-feedback-1981): 统一即时响应与历史响应的 details.raw result 契约并补最终 API 回归测试,同时更新 PR 描述和可访问的视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、CI 结果及可访问的 UI 截图同步到最新 Head,消除描述与实际改动的实质性不一致 * fix(review-feedback-1981): 同步 PR 描述与最新 Head,并补充可访问的 Web 页面截图 * fix(review-feedback-1981): 同步 PR 描述并按仓库规范附上可访问的 Web 页面截图 * fix(review-feedback-1981): 代码结论 :不可。已按 origin/main...76b5b596fdff 的完整 38 文件 diff 重新建立基线。上次 6 个 * fix(review-feedback-1981): 代码结论 :不可。最新修复已正确收窄无成分股、无龙头证据的原生榜单路径:该路径现在返回 edge/partial,不再直接输出 --- api/v1/endpoints/analysis.py | 97 +- api/v1/endpoints/history.py | 6 + api/v1/schemas/history.py | 32 +- .../e2e/market-structure-card-visual.spec.ts | 362 ++++++ .../components/report/MarketStructureCard.tsx | 319 +++++ .../src/components/report/ReportSummary.tsx | 4 + .../__tests__/MarketStructureCard.test.tsx | 117 ++ apps/dsa-web/src/components/report/index.ts | 1 + apps/dsa-web/src/types/analysis.ts | 104 +- .../src/utils/__tests__/chatFollowUp.test.ts | 104 ++ apps/dsa-web/src/utils/chatFollowUp.ts | 43 + docs/CHANGELOG.md | 2 + docs/alphasift-integration.md | 1 + docs/architecture/api_spec.json | 10 +- docs/decision-signals.md | 17 + docs/full-guide.md | 11 + docs/full-guide_EN.md | 11 + src/agent/agents/base_agent.py | 8 + src/agent/executor.py | 14 + src/agent/orchestrator.py | 3 + src/agent/stock_scope.py | 1 + src/analyzer.py | 10 + src/core/pipeline.py | 102 +- src/market_structure_prompt.py | 186 +++ src/schemas/market_structure.py | 119 ++ src/services/analysis_service.py | 6 +- src/services/decision_signal_extractor.py | 41 + src/services/decision_signal_service.py | 5 + src/services/market_hotspot_service.py | 691 ++++++++++ src/services/market_structure_service.py | 703 +++++++++++ src/utils/data_processing.py | 66 +- tests/test_analysis_api_contract.py | 405 +++++- tests/test_analysis_history.py | 59 + tests/test_decision_signal_extractor.py | 46 + tests/test_decision_signal_service.py | 43 + tests/test_market_structure_service.py | 1110 +++++++++++++++++ tests/test_multi_agent.py | 13 + tests/test_pipeline_related_boards.py | 103 +- 38 files changed, 4928 insertions(+), 47 deletions(-) create mode 100644 apps/dsa-web/e2e/market-structure-card-visual.spec.ts create mode 100644 apps/dsa-web/src/components/report/MarketStructureCard.tsx create mode 100644 apps/dsa-web/src/components/report/__tests__/MarketStructureCard.test.tsx create mode 100644 apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts create mode 100644 src/market_structure_prompt.py create mode 100644 src/schemas/market_structure.py create mode 100644 src/services/market_hotspot_service.py create mode 100644 src/services/market_structure_service.py create mode 100644 tests/test_market_structure_service.py diff --git a/api/v1/endpoints/analysis.py b/api/v1/endpoints/analysis.py index e8b886cff..4956ece82 100644 --- a/api/v1/endpoints/analysis.py +++ b/api/v1/endpoints/analysis.py @@ -90,6 +90,7 @@ from src.utils.data_processing import ( parse_json_field, extract_fundamental_detail_fields, extract_board_detail_fields, + extract_market_structure_detail_field, extract_realtime_detail_fields, ) @@ -480,7 +481,7 @@ def _handle_sync_analysis( # 构建报告结构 report_data = result.get("report", {}) - context_snapshot, fundamental_snapshot = _load_sync_fundamental_sources( + context_snapshot, fundamental_snapshot, raw_result_snapshot = _load_sync_fundamental_sources( query_id=query_id, stock_code=result.get("stock_code", stock_code), ) @@ -491,6 +492,7 @@ def _handle_sync_analysis( result.get("stock_name"), context_snapshot=context_snapshot, fallback_fundamental_payload=fundamental_snapshot, + fallback_raw_result_payload=raw_result_snapshot or result, ) return AnalysisResultResponse( @@ -958,11 +960,23 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse: report_enriched = False if isinstance(report_data, dict) and stock_code and query_id: - context_snapshot, fundamental_snapshot = _load_sync_fundamental_sources( + context_snapshot, fundamental_snapshot, raw_result_snapshot = _load_sync_fundamental_sources( query_id=query_id, stock_code=stock_code, ) - if context_snapshot is not None or fundamental_snapshot is not None: + report_task_details = report_data.get("details") + report_task_raw_result = ( + report_task_details.get("raw_result") + if isinstance(report_task_details, dict) + else None + ) + should_rebuild_report = ( + context_snapshot is not None + or fundamental_snapshot is not None + or raw_result_snapshot is not None + or report_task_raw_result is not None + ) + if should_rebuild_report: try: report = _build_analysis_report( _prepare_report_for_task_enrichment( @@ -974,6 +988,7 @@ def _build_task_analysis_result(task: Any) -> AnalysisResultResponse: payload.get("stock_name") or getattr(task, "stock_name", None), context_snapshot=context_snapshot, fallback_fundamental_payload=fundamental_snapshot, + fallback_raw_result_payload=raw_result_snapshot or payload, ) payload["report"] = report.model_dump() report_enriched = True @@ -1139,13 +1154,23 @@ def get_analysis_status(task_id: str) -> TaskStatus: context_snapshot=context_snapshot, fallback_fundamental_payload=fallback_fundamental, ) + market_structure = extract_market_structure_detail_field( + context_snapshot, + raw_result, + ) has_board_details = ( bool(extracted_boards.get("belong_boards")) or extracted_boards.get("sector_rankings") is not None or extracted_boards.get("concept_rankings") is not None ) details = None - if any(extracted_fundamental.values()) or has_board_details or context_snapshot is not None or analysis_context_pack_overview is not None: + if ( + any(extracted_fundamental.values()) + or has_board_details + or market_structure is not None + or context_snapshot is not None + or analysis_context_pack_overview is not None + ): details = ReportDetails( news_content=getattr(record, "news_content", None), raw_result=raw_result, @@ -1156,6 +1181,7 @@ def get_analysis_status(task_id: str) -> TaskStatus: belong_boards=extracted_boards.get("belong_boards"), sector_rankings=extracted_boards.get("sector_rankings"), concept_rankings=extracted_boards.get("concept_rankings"), + market_structure=market_structure, ) raw_dict = raw_result if isinstance(raw_result, dict) else {} @@ -1239,9 +1265,9 @@ def get_analysis_status(task_id: str) -> TaskStatus: def _load_sync_fundamental_sources( query_id: str, stock_code: str, -) -> tuple[Optional[Any], Optional[Dict[str, Any]]]: +) -> tuple[Optional[Any], Optional[Dict[str, Any]], Optional[Any]]: """ - Load context_snapshot and fallback fundamental snapshot for sync analyze response. + Load report enrichment payloads for sync analyze response. """ try: from src.storage import DatabaseManager @@ -1249,14 +1275,17 @@ def _load_sync_fundamental_sources( db = DatabaseManager.get_instance() records = db.get_analysis_history(query_id=query_id, code=stock_code, limit=1) context_snapshot = None + raw_result_snapshot = None if records: - context_snapshot = parse_json_field(getattr(records[0], "context_snapshot", None)) + latest_record = records[0] + context_snapshot = parse_json_field(getattr(latest_record, "context_snapshot", None)) + raw_result_snapshot = parse_json_field(getattr(latest_record, "raw_result", None)) fallback_fundamental = db.get_latest_fundamental_snapshot( query_id=query_id, code=stock_code, ) - return context_snapshot, fallback_fundamental + return context_snapshot, fallback_fundamental, raw_result_snapshot except Exception as e: logger.debug( "load sync fundamental sources failed (fail-open): query_id=%s stock_code=%s err=%s", @@ -1264,7 +1293,7 @@ def _load_sync_fundamental_sources( stock_code, e, ) - return None, None + return None, None, None def _stringify_report_strategy_value(value: Any) -> Optional[str]: @@ -1282,6 +1311,7 @@ def _build_analysis_report( stock_name: Optional[str] = None, context_snapshot: Optional[Any] = None, fallback_fundamental_payload: Optional[Dict[str, Any]] = None, + fallback_raw_result_payload: Optional[Any] = None, ) -> AnalysisReport: """ 构建符合 API 规范的分析报告 @@ -1293,6 +1323,7 @@ def _build_analysis_report( stock_name: 股票名称 context_snapshot: 上下文快照(可选) fallback_fundamental_payload: 基本面快照 payload(可选) + fallback_raw_result_payload: 原始分析结果 payload(可选) Returns: AnalysisReport: 结构化的分析报告 @@ -1342,7 +1373,31 @@ def _build_analysis_report( market_phase_summary=market_phase_summary, ) - raw_result_data = details_data.get("raw_result") if isinstance(details_data.get("raw_result"), dict) else {} + def _looks_like_raw_result_payload(candidate: Any) -> bool: + return ( + isinstance(candidate, dict) + and ( + "analysis_summary" in candidate + or "operation_advice" in candidate + or "trend_prediction" in candidate + or "sentiment_score" in candidate + or "market_structure_context" in candidate + or "model_used" in candidate + or "dashboard" in candidate + or "action" in candidate + ) + ) + + raw_result_data = details_data.get("raw_result") + if not isinstance(raw_result_data, dict): + raw_result_data = {} + if isinstance(fallback_raw_result_payload, dict): + if isinstance(fallback_raw_result_payload.get("raw_result"), dict): + raw_result_data = fallback_raw_result_payload["raw_result"] + elif _looks_like_raw_result_payload(fallback_raw_result_payload): + raw_result_data = fallback_raw_result_payload + if not raw_result_data and isinstance(details_data, dict): + raw_result_data = details_data action_fields = build_action_fields( operation_advice=( raw_result_data.get("operation_advice") @@ -1388,6 +1443,16 @@ def _build_analysis_report( context_snapshot=context_snapshot, fallback_fundamental_payload=fallback_fundamental_payload, ) + market_structure = None + for raw_candidate in (fallback_raw_result_payload, raw_result_data, details_data): + if raw_candidate is None: + continue + market_structure = extract_market_structure_detail_field( + context_snapshot, + raw_candidate, + ) + if market_structure is not None: + break analysis_context_pack_overview = extract_analysis_context_pack_overview(context_snapshot) api_context_snapshot = sanitize_context_snapshot_for_api(context_snapshot) details = None @@ -1396,10 +1461,17 @@ def _build_analysis_report( or extracted_boards.get("sector_rankings") is not None or extracted_boards.get("concept_rankings") is not None ) - if details_data or any(extracted_fundamental.values()) or has_board_details or context_snapshot is not None or analysis_context_pack_overview is not None: + if ( + details_data + or any(extracted_fundamental.values()) + or has_board_details + or market_structure is not None + or context_snapshot is not None + or analysis_context_pack_overview is not None + ): details = ReportDetails( news_content=details_data.get("news_summary") or details_data.get("news_content"), - raw_result=details_data, + raw_result=raw_result_data, context_snapshot=api_context_snapshot, analysis_context_pack_overview=analysis_context_pack_overview, financial_report=extracted_fundamental.get("financial_report"), @@ -1407,6 +1479,7 @@ def _build_analysis_report( belong_boards=extracted_boards.get("belong_boards"), sector_rankings=extracted_boards.get("sector_rankings"), concept_rankings=extracted_boards.get("concept_rankings"), + market_structure=market_structure, ) return AnalysisReport( diff --git a/api/v1/endpoints/history.py b/api/v1/endpoints/history.py index afd294fdf..ca7e24c23 100644 --- a/api/v1/endpoints/history.py +++ b/api/v1/endpoints/history.py @@ -48,6 +48,7 @@ from src.utils.data_processing import ( normalize_model_used, extract_fundamental_detail_fields, extract_board_detail_fields, + extract_market_structure_detail_field, extract_realtime_detail_fields, ) from src.analysis_context_pack_overview import ( @@ -539,6 +540,10 @@ def get_history_detail( context_snapshot=result.get("context_snapshot"), fallback_fundamental_payload=fallback_fundamental, ) + market_structure = extract_market_structure_detail_field( + result.get("context_snapshot"), + result.get("raw_result"), + ) details = ReportDetails( news_content=result.get("news_content"), @@ -550,6 +555,7 @@ def get_history_detail( belong_boards=extracted_boards.get("belong_boards"), sector_rankings=extracted_boards.get("sector_rankings"), concept_rankings=extracted_boards.get("concept_rankings"), + market_structure=market_structure, ) return AnalysisReport( diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py index cf30ab07d..dc9a6a665 100644 --- a/api/v1/schemas/history.py +++ b/api/v1/schemas/history.py @@ -138,7 +138,7 @@ class ReportMeta(BaseModel): change_pct: Optional[float] = Field(None, description="分析时涨跌幅(%)") model_used: Optional[str] = Field( None, - description="历史报告元数据中的模型快照,仅用于展示,不影响 Provider/Model/Base URL 运行时路由", + description="历史报告元数据中的模型快照,仅用于展示;不参与运行时模型调用路径或配置路由", ) market_phase_summary: Optional[MarketPhaseSummary] = Field( None, @@ -261,18 +261,28 @@ class ReportDetails(BaseModel): belong_boards: Optional[Any] = Field(None, description="关联板块列表") sector_rankings: Optional[Any] = Field(None, description="板块涨跌榜(结构 {top, bottom})") concept_rankings: Optional[Any] = Field(None, description="概念板块涨跌榜(结构 {top, bottom})") + market_structure: Optional[Any] = Field(None, description="市场结构上下文(题材层 + 个股位置层)") @model_validator(mode="after") - def populate_concept_rankings_from_context(self) -> "ReportDetails": - if self.concept_rankings is not None or self.context_snapshot is None: - return self - try: - from src.utils.data_processing import extract_board_detail_fields + def populate_context_derived_details(self) -> "ReportDetails": + if self.concept_rankings is None and self.context_snapshot is not None: + try: + from src.utils.data_processing import extract_board_detail_fields - extracted = extract_board_detail_fields(self.context_snapshot) - self.concept_rankings = extracted.get("concept_rankings") - except Exception: - self.concept_rankings = None + extracted = extract_board_detail_fields(self.context_snapshot) + self.concept_rankings = extracted.get("concept_rankings") + except Exception: + self.concept_rankings = None + if self.market_structure is None: + try: + from src.utils.data_processing import extract_market_structure_detail_field + + self.market_structure = extract_market_structure_detail_field( + self.context_snapshot, + self.raw_result, + ) + except Exception: + self.market_structure = None return self @@ -342,7 +352,7 @@ class StockBarItem(BaseModel): last_analysis_time: Optional[str] = Field(None, description="最近一次分析时间") model_used: Optional[str] = Field( None, - description="最新分析使用的模型快照", + description="最新分析使用的模型快照,仅用于列表展示;不改动运行时调用与配置路径", ) market_phase_summary: Optional[MarketPhaseSummary] = Field( None, diff --git a/apps/dsa-web/e2e/market-structure-card-visual.spec.ts b/apps/dsa-web/e2e/market-structure-card-visual.spec.ts new file mode 100644 index 000000000..d92a6a42e --- /dev/null +++ b/apps/dsa-web/e2e/market-structure-card-visual.spec.ts @@ -0,0 +1,362 @@ +import { chromium, expect, test, type TestInfo } from '@playwright/test'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import fs from 'node:fs'; +import { createServer } from 'node:http'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { build as viteBuild } from 'vite'; +import type { MarketStructureContext } from '../src/types/analysis'; + +test.use({ locale: 'zh-CN' }); + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const webRoot = path.resolve(currentDir, '..'); +const sourceRoot = path.join(webRoot, 'src'); + +const context: MarketStructureContext = { + schemaVersion: 'market-structure-v1', + status: 'partial', + market: 'cn', + tradeDate: '2026-07-04', + marketThemeContext: { + schemaVersion: 'market-theme-v1', + status: 'partial', + market: 'cn', + activeThemes: [ + { name: '机器人概念', changePct: 4.2, rank: 1, source: 'concept', phase: 'accelerating' }, + { name: 'AI 算力', changePct: 3.6, rank: 2, source: 'concept', phase: 'warming' }, + ], + leadingConcepts: [ + { name: '机器人概念', changePct: 4.2, rank: 1, source: 'concept' }, + { name: 'AI 算力', changePct: 3.6, rank: 2, source: 'concept' }, + ], + leadingIndustries: [ + { name: '通用设备', changePct: 2.1, rank: 2, source: 'industry' }, + { name: '软件开发', changePct: 1.8, rank: 4, source: 'industry' }, + ], + laggingThemes: [], + themeBreadth: { + activeCount: 2, + leadingConceptCount: 2, + leadingIndustryCount: 2, + laggingCount: 0, + }, + dataQuality: { + status: 'partial', + missingFields: ['industry_rankings'], + sources: [], + errors: [], + }, + }, + stockMarketPosition: { + schemaVersion: 'stock-market-position-v1', + status: 'partial', + stockCode: '300024', + stockName: '机器人', + market: 'cn', + primaryTheme: { + name: '机器人概念', + source: 'concept', + phase: 'accelerating', + rank: 1, + changePct: 4.2, + }, + relatedBoards: [ + { name: '机器人概念', type: '概念', source: 'concept', rank: 1, changePct: 4.2 }, + { name: '通用设备', type: '行业', source: 'industry', rank: 2, changePct: 2.1 }, + ], + stockRole: 'follower', + themePhase: 'accelerating', + riskTags: [ + { code: 'theme_data_partial', message: '题材主线数据不完整' }, + { code: 'stock_theme_evidence_partial', message: '个股板块未匹配到市场题材榜单,个股位置按降级证据处理' }, + ], + missingFields: ['hotspot_constituents', 'leader_stocks'], + }, +}; + +function toImportPath(fromDir: string, targetPath: string): string { + const relativePath = path.relative(fromDir, targetPath).split(path.sep).join('/'); + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +} + +function writeFile(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +async function buildRealComponentFixture(): Promise<{ + distIndexPath: string; + entryPath: string; +}> { + const fixtureDir = path.join(webRoot, 'test-results', 'market-structure-card-visual'); + const distDir = path.join(fixtureDir, 'dist'); + const entryPath = path.join(fixtureDir, 'MarketStructureVisualApp.tsx'); + const htmlPath = path.join(fixtureDir, 'index.html'); + const componentImport = toImportPath( + fixtureDir, + path.join(sourceRoot, 'components/report/MarketStructureCard.tsx'), + ); + const cssImport = toImportPath(fixtureDir, path.join(sourceRoot, 'index.css')); + const typeImport = toImportPath(fixtureDir, path.join(sourceRoot, 'types/analysis.ts')); + + writeFile( + entryPath, + ` + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import '${cssImport}'; + import { MarketStructureCard } from '${componentImport}'; + import type { MarketStructureContext } from '${typeImport}'; + + const context: MarketStructureContext = ${JSON.stringify(context, null, 8)}; + + createRoot(document.getElementById('root')!).render( + +
+
+ +
+
+
, + ); + `, + ); + writeFile( + htmlPath, + ` + + + + + + MarketStructureCard Real Component Visual Evidence + + +
+ + + + `, + ); + + await viteBuild({ + root: fixtureDir, + base: './', + configFile: false, + publicDir: false, + logLevel: 'warn', + plugins: [tailwindcss(), react()], + define: { + __APP_PACKAGE_VERSION__: JSON.stringify('visual-evidence'), + __APP_BUILD_TIME__: JSON.stringify('2026-07-05T00:00:00.000Z'), + }, + build: { + outDir: distDir, + emptyOutDir: true, + sourcemap: false, + }, + }); + + return { + distIndexPath: path.join(distDir, 'index.html'), + entryPath, + }; +} + +function isMissingPlaywrightBrowser(error: unknown): boolean { + return error instanceof Error && error.message.includes("Executable doesn't exist"); +} + +function isHttpUrl(value: string): boolean { + const lower = value.trim().toLowerCase(); + return lower.startsWith('http://') || lower.startsWith('https://'); +} + +async function startStaticServer(rootDir: string): Promise<{ + url: string; + close: () => Promise; +}> { + const server = createServer((request, response) => { + const requestPath = decodeURIComponent((request.url || '/').split('?', 1)[0]); + const relativePath = requestPath === '/' ? 'index.html' : requestPath.replace(/^\/+/, ''); + const filePath = path.resolve(rootDir, relativePath); + const relativeToRoot = path.relative(rootDir, filePath); + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + response.writeHead(403).end('Forbidden'); + return; + } + + fs.readFile(filePath, (error, content) => { + if (error) { + response.writeHead(error.code === 'ENOENT' ? 404 : 500).end('Not found'); + return; + } + const contentTypes: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + }; + response.writeHead(200, { + 'Content-Type': contentTypes[path.extname(filePath)] || 'application/octet-stream', + }); + response.end(content); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}/`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +async function renderMarketStructureCard(distIndexPath: string, testInfo: TestInfo): Promise { + let browser: { close: () => Promise } | null = null; + try { + browser = await chromium.launch(); + } catch (error) { + if (!isMissingPlaywrightBrowser(error)) { + throw error; + } + test.skip( + true, + 'Playwright Chromium is not installed in this environment; skip visual smoke check.', + ); + return; + } + + const staticServer = await startStaticServer(path.dirname(distIndexPath)); + try { + const page = await browser.newPage({ + locale: 'zh-CN', + viewport: { width: 1280, height: 900 }, + }); + await page.goto(staticServer.url, { waitUntil: 'networkidle' }); + const card = page.getByTestId('market-structure-visual-card'); + await expect(card).toBeVisible(); + await expect(card.getByRole('region', { name: '题材主线与个股位置' })).toBeVisible(); + await expect(card.getByText('大盘题材层')).toBeVisible(); + await expect(card.getByText('个股位置层')).toBeVisible(); + await expect(card.getByText(/机器人概念 \+4\.20%/).first()).toBeVisible(); + + const screenshotPath = testInfo.outputPath('market-structure-card-visual.png'); + const screenshot = await card.screenshot({ path: screenshotPath }); + expect(screenshot).toBeTruthy(); + expect(screenshot.length).toBeGreaterThan(1024); + const githubServer = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const githubRepository = process.env.GITHUB_REPOSITORY; + const githubRunId = process.env.GITHUB_RUN_ID; + const artifactName = 'market-structure-card-visual'; + const artifactRunHint = githubRepository && githubRunId + ? `${githubServer}/${githubRepository}/actions/runs/${githubRunId}` + : 'Unavailable (not running in GitHub Actions)'; + const artifactDownloadHint = githubRunId + ? `gh run download ${githubRunId} --name ${artifactName} --dir ./.market-structure-card-visual` + : ''; + const reproductionCommand = 'cd apps/dsa-web && npx playwright test e2e/market-structure-card-visual.spec.ts'; + const artifactHint = `${artifactRunHint}/artifacts`; + const artifactPageHint = githubRepository && githubRunId + ? `${githubServer}/${githubRepository}/actions/runs/${githubRunId}/jobs` + : ''; + const externalEvidenceDir = process.env.DSA_WEB_VISUAL_EVIDENCE + ? path.resolve(process.env.DSA_WEB_VISUAL_EVIDENCE) + : ''; + const externalEvidenceUrl = process.env.DSA_WEB_VISUAL_EVIDENCE && isHttpUrl(process.env.DSA_WEB_VISUAL_EVIDENCE) + ? process.env.DSA_WEB_VISUAL_EVIDENCE + : ''; + const artifactManifestPath = testInfo.outputPath('market-structure-card-visual-artifact.txt'); + const evidenceNotes = [ + 'MarketStructureCard visual evidence attached', + `Screenshot attachment: market-structure-card-visual.png`, + `Playwright attachment name (artifact evidence): ${artifactName}`, + `Repro command: ${reproductionCommand}`, + ]; + if (artifactDownloadHint) { + evidenceNotes.push(`If running in GitHub Actions, download artifacts by command: ${artifactDownloadHint}`); + } + if (externalEvidenceUrl) { + evidenceNotes.push(`External visual evidence URL: ${externalEvidenceUrl}`); + evidenceNotes.push(`可复用外部链接查看本次截图(复制后用于 PR 说明):${externalEvidenceUrl}`); + } + if (externalEvidenceDir && !externalEvidenceUrl) { + try { + fs.mkdirSync(externalEvidenceDir, { recursive: true }); + const externalScreenshot = path.join(externalEvidenceDir, 'market-structure-card-visual.png'); + fs.copyFileSync(screenshotPath, externalScreenshot); + evidenceNotes.push(`Local shareable evidence copy: ${externalScreenshot}`); + } catch (copyError) { + testInfo.annotations.push({ + type: 'warning', + description: `External visual evidence copy failed: ${String(copyError)}`, + }); + } + } + if (githubRepository && githubRunId) { + evidenceNotes.push( + `GitHub Actions run: ${artifactRunHint}`, + `GitHub Actions artifacts page: ${artifactHint}`, + `GitHub Actions jobs page: ${artifactPageHint}`, + `Download command: ${artifactDownloadHint}`, + `Evidence attachment name: ${artifactName}(请在 PR 说明/评论附上 screenshots 直接附件或该 run 的附件下载链接)`, + ); + } else { + evidenceNotes.push( + '未在 GitHub Actions 运行,不具备公开 artifact 链接;请补充可复现截图与复现场景。', + '可复现证据路径(本地):' + testInfo.outputPath('market-structure-card-visual.png'), + `复现命令:${reproductionCommand}`, + ); + } + evidenceNotes.push( + `若需外部可追溯复核,请在有 PR 权限的环境重跑该测试并在该动作页下载附件 ${artifactName} 后在 PR 中补充下载链接。`, + ); + writeFile( + artifactManifestPath, + evidenceNotes.join('\n'), + ); + + testInfo.annotations.push({ + type: 'info', + description: + `Market structure card visual evidence attached in Playwright artifacts. ` + + `Attachment: ${artifactName}。` + + (githubRepository && githubRunId + ? `请在 PR 说明/评论附上 ${artifactHint} 中的该附件。` + : '未在 GitHub Actions 运行,请将该截图附件或可访问外链输出到 PR 说明/评论。'), + }); + await testInfo.attach('market-structure-card-visual', { + path: screenshotPath, + contentType: 'image/png', + }); + await testInfo.attach('market-structure-card-visual-evidence', { + path: artifactManifestPath, + contentType: 'text/plain', + }); + await testInfo.attach('market-structure-card-visual-bytes', { + body: screenshot, + contentType: 'image/png', + }); + } finally { + if (browser) { + await browser.close(); + } + await staticServer.close(); + } +} + +test.describe('MarketStructureCard visual smoke', () => { + test('renders MarketStructureCard with expected sections', async ({ baseURL: _baseURL }, testInfo) => { + void _baseURL; + const { distIndexPath } = await buildRealComponentFixture(); + expect(fs.existsSync(distIndexPath)).toBe(true); + await renderMarketStructureCard(distIndexPath, testInfo); + }); +}); diff --git a/apps/dsa-web/src/components/report/MarketStructureCard.tsx b/apps/dsa-web/src/components/report/MarketStructureCard.tsx new file mode 100644 index 000000000..35cb310a4 --- /dev/null +++ b/apps/dsa-web/src/components/report/MarketStructureCard.tsx @@ -0,0 +1,319 @@ +import type React from 'react'; +import { AlertTriangle, Map, TrendingUp } from 'lucide-react'; +import type { + MarketStructureContext, + MarketStructureStatus, + MarketStructureThemePhase, + MarketStructureStockRole, + RankedThemeItem, + ReportLanguage, +} from '../../types/analysis'; +import { normalizeReportLanguage } from '../../utils/reportLanguage'; +import { Badge, Card } from '../common'; +import { DashboardPanelHeader } from '../dashboard'; + +interface MarketStructureCardProps { + context?: MarketStructureContext | null; + language?: ReportLanguage; +} + +type BadgeVariant = NonNullable['variant']>; + +const STATUS_VARIANT: Record = { + ok: 'success', + partial: 'warning', + unknown: 'default', + not_supported: 'default', +}; + +const TEXT = { + zh: { + eyebrow: '市场位置', + title: '题材主线与个股位置', + marketLayer: '大盘题材层', + stockLayer: '个股位置层', + activeThemes: '活跃题材', + leadingConcepts: '领涨概念', + leadingIndustries: '领涨行业', + primaryTheme: '主关联题材', + themePhase: '题材阶段', + stockRole: '个股位置', + riskTags: '风险标签', + dataQuality: '数据质量', + missingFields: '缺失证据', + empty: '暂无', + status: { + ok: '可用', + partial: '部分可用', + unknown: '未知', + not_supported: '不支持', + }, + phase: { + warming: '升温', + accelerating: '加速', + cooling: '降温', + unknown: '未知', + }, + role: { + leader: '龙头', + follower: '跟随', + edge: '边缘关联', + unknown: '未知', + }, + }, + en: { + eyebrow: 'MARKET POSITION', + title: 'Themes and Stock Position', + marketLayer: 'Market Theme Layer', + stockLayer: 'Stock Position Layer', + activeThemes: 'Active Themes', + leadingConcepts: 'Leading Concepts', + leadingIndustries: 'Leading Industries', + primaryTheme: 'Primary Theme', + themePhase: 'Theme Phase', + stockRole: 'Stock Role', + riskTags: 'Risk Tags', + dataQuality: 'Data Quality', + missingFields: 'Missing Evidence', + empty: 'None', + status: { + ok: 'Available', + partial: 'Partial', + unknown: 'Unknown', + not_supported: 'Not supported', + }, + phase: { + warming: 'Warming', + accelerating: 'Accelerating', + cooling: 'Cooling', + unknown: 'Unknown', + }, + role: { + leader: 'Leader', + follower: 'Follower', + edge: 'Edge', + unknown: 'Unknown', + }, + }, + ko: { + eyebrow: '시장 포지션', + title: '테마 라인 및 종목 포지션', + marketLayer: '시장 테마 레이어', + stockLayer: '종목 포지션 레이어', + activeThemes: '활성 테마', + leadingConcepts: '선도 테마', + leadingIndustries: '선도 산업', + primaryTheme: '주요 관련 테마', + themePhase: '테마 단계', + stockRole: '종목 역할', + riskTags: '리스크 태그', + dataQuality: '데이터 품질', + missingFields: '부족한 근거', + empty: '없음', + status: { + ok: '사용 가능', + partial: '일부 사용', + unknown: '알 수 없음', + not_supported: '미지원', + }, + phase: { + warming: '온도 상승', + accelerating: '가속', + cooling: '쿨다운', + unknown: '알 수 없음', + }, + role: { + leader: '리더', + follower: '추종', + edge: '엣지', + unknown: '알 수 없음', + }, + }, +} as const; + +const RISK_TAG_TEXT = { + zh: { + theme_data_partial: '题材主线数据不完整', + stock_theme_evidence_partial: '个股板块未匹配到市场题材榜单,个股位置按降级证据处理', + board_membership_missing: '缺少个股所属板块证据,无法判断题材位置', + }, + en: { + theme_data_partial: 'Market theme data is incomplete', + stock_theme_evidence_partial: 'Stock board did not match theme rankings', + board_membership_missing: 'Stock board membership evidence is missing', + }, + ko: { + theme_data_partial: '테마 데이터가 불완전합니다', + stock_theme_evidence_partial: '종목 보드가 테마 랭킹과 일치하지 않았습니다', + board_membership_missing: '종목 보드 근거가 없어 테마 위치를 판단할 수 없습니다', + }, +} as const; + +const formatItem = (item: RankedThemeItem): string => { + if (typeof item.changePct === 'number') { + return `${item.name} ${item.changePct > 0 ? '+' : ''}${item.changePct.toFixed(2)}%`; + } + return item.name; +}; + +const itemList = (items?: RankedThemeItem[], limit = 4): string[] => { + if (!Array.isArray(items)) { + return []; + } + return items.filter((item) => item?.name).slice(0, limit).map(formatItem); +}; + +const valueList = (items?: string[], limit = 4): string[] => { + if (!Array.isArray(items)) { + return []; + } + return items.filter(Boolean).slice(0, limit); +}; + +export const MarketStructureCard: React.FC = ({ context, language }) => { + if (!context || context.schemaVersion !== 'market-structure-v1' || context.status === 'not_supported') { + return null; + } + + const reportLanguage = normalizeReportLanguage(language); + const text = reportLanguage === 'en' + ? TEXT.en + : reportLanguage === 'ko' + ? TEXT.ko + : TEXT.zh; + const marketTheme = context.marketThemeContext; + const stockPosition = context.stockMarketPosition; + if (!marketTheme || !stockPosition) { + return null; + } + + const activeThemes = itemList(marketTheme.activeThemes); + const leadingConcepts = itemList(marketTheme.leadingConcepts); + const leadingIndustries = itemList(marketTheme.leadingIndustries); + const primaryTheme = stockPosition.primaryTheme?.name || text.empty; + const themePhase = (stockPosition.themePhase || 'unknown') as MarketStructureThemePhase; + const stockRole = (stockPosition.stockRole || 'unknown') as MarketStructureStockRole; + const themePhaseLabel = text.phase[themePhase] || stockPosition.themePhase || text.phase.unknown; + const stockRoleLabel = text.role[stockRole] || stockPosition.stockRole || text.role.unknown; + const riskTags = valueList( + stockPosition.riskTags?.map( + (tag) => + (RISK_TAG_TEXT[reportLanguage] as Record)[tag.code] + || tag.message + || tag.code, + ), + ); + const missingFields = valueList([ + ...(stockPosition.missingFields || []), + ...(marketTheme.dataQuality?.missingFields || []), + ]); + + const hasContent = [ + activeThemes, + leadingConcepts, + leadingIndustries, + riskTags, + missingFields, + ].some((items) => items.length > 0) || primaryTheme !== text.empty; + if (!hasContent) { + return null; + } + + return ( + +
+
+
+ ); +}; + +interface MetricLineProps { + label: string; + values: string[]; + emptyText: string; +} + +const MetricLine: React.FC = ({ label, values, emptyText }) => ( +
+ {label} + + {values.length > 0 ? values.join(' / ') : emptyText} + +
+); diff --git a/apps/dsa-web/src/components/report/ReportSummary.tsx b/apps/dsa-web/src/components/report/ReportSummary.tsx index 52506d07c..f3faf7775 100644 --- a/apps/dsa-web/src/components/report/ReportSummary.tsx +++ b/apps/dsa-web/src/components/report/ReportSummary.tsx @@ -6,6 +6,7 @@ import { ReportNews } from './ReportNews'; import { ReportDetails } from './ReportDetails'; import { ReportDiagnostics } from './ReportDiagnostics'; import { AnalysisContextSummary } from './AnalysisContextSummary'; +import { MarketStructureCard } from './MarketStructureCard'; import { MarketReviewReportView } from './MarketReviewReportView'; import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage'; @@ -68,6 +69,9 @@ export const ReportSummary: React.FC = ({ watchlist={watchlist} /> + {/* 市场结构位置 */} + + {/* 策略点位区 */} diff --git a/apps/dsa-web/src/components/report/__tests__/MarketStructureCard.test.tsx b/apps/dsa-web/src/components/report/__tests__/MarketStructureCard.test.tsx new file mode 100644 index 000000000..acd2aed62 --- /dev/null +++ b/apps/dsa-web/src/components/report/__tests__/MarketStructureCard.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { MarketStructureContext } from '../../../types/analysis'; +import { MarketStructureCard } from '../MarketStructureCard'; + +const context: MarketStructureContext = { + schemaVersion: 'market-structure-v1', + status: 'partial', + market: 'cn', + tradeDate: '2026-07-04', + marketThemeContext: { + schemaVersion: 'market-theme-v1', + status: 'partial', + market: 'cn', + activeThemes: [ + { name: '机器人概念', changePct: 4.2, rank: 1, source: 'concept', phase: 'accelerating' }, + ], + leadingConcepts: [ + { name: '机器人概念', changePct: 4.2, rank: 1, source: 'concept' }, + ], + leadingIndustries: [ + { name: '通用设备', changePct: 2.1, rank: 2, source: 'industry' }, + ], + laggingThemes: [], + themeBreadth: { + activeCount: 1, + leadingConceptCount: 1, + leadingIndustryCount: 1, + laggingCount: 0, + }, + dataQuality: { + status: 'partial', + missingFields: ['industry_rankings'], + sources: [], + errors: [], + }, + }, + stockMarketPosition: { + schemaVersion: 'stock-market-position-v1', + status: 'partial', + stockCode: '300024', + stockName: '机器人', + market: 'cn', + primaryTheme: { + name: '机器人概念', + source: 'concept', + phase: 'accelerating', + rank: 1, + changePct: 4.2, + }, + relatedBoards: [ + { name: '机器人概念', type: '概念', source: 'concept', rank: 1, changePct: 4.2 }, + ], + stockRole: 'follower', + themePhase: 'accelerating', + riskTags: [ + { code: 'theme_data_partial', message: '题材主线数据不完整' }, + { code: 'stock_theme_evidence_partial', message: '个股板块未匹配到市场题材榜单,个股位置按降级证据处理' }, + ], + missingFields: ['hotspot_constituents', 'leader_stocks'], + }, +}; + +describe('MarketStructureCard', () => { + it('renders market layer and stock layer in Chinese', () => { + render(); + + expect(screen.getByRole('region', { name: '题材主线与个股位置' })).toBeInTheDocument(); + expect(screen.getByText('大盘题材层')).toBeVisible(); + expect(screen.getByText('个股位置层')).toBeVisible(); + expect(screen.getAllByText('部分可用')).toHaveLength(3); + expect(screen.getAllByText(/机器人概念/)).toHaveLength(3); + expect(screen.getByText('加速')).toBeVisible(); + expect(screen.getByText('跟随')).toBeVisible(); + expect(screen.getByText('题材主线数据不完整')).toBeVisible(); + expect(screen.getByText('leader_stocks')).toBeVisible(); + }); + + it('renders English labels', () => { + render(); + + expect(screen.getByRole('region', { name: 'Themes and Stock Position' })).toBeInTheDocument(); + expect(screen.getByText('Market Theme Layer')).toBeVisible(); + expect(screen.getByText('Stock Position Layer')).toBeVisible(); + expect(screen.getByText('Accelerating')).toBeVisible(); + expect(screen.getByText('Follower')).toBeVisible(); + expect(screen.getByText('Missing Evidence')).toBeVisible(); + expect(screen.getByText('Market theme data is incomplete')).toBeVisible(); + expect(screen.getByText('Stock board did not match theme rankings')).toBeVisible(); + expect(screen.queryByText('题材主线数据不完整')).not.toBeInTheDocument(); + }); + + it('renders Korean labels', () => { + render(); + + expect(screen.getByRole('region', { name: '테마 라인 및 종목 포지션' })).toBeInTheDocument(); + expect(screen.getByText('시장 테마 레이어')).toBeVisible(); + expect(screen.getByText('종목 포지션 레이어')).toBeVisible(); + expect(screen.getByText('가속')).toBeVisible(); + expect(screen.getByText('추종')).toBeVisible(); + expect(screen.getByText('테마 데이터가 불완전합니다')).toBeVisible(); + expect(screen.getByText('종목 보드가 테마 랭킹과 일치하지 않았습니다')).toBeVisible(); + }); + + it('does not render unsupported or invalid context', () => { + const unsupported = { + ...context, + status: 'not_supported', + } satisfies MarketStructureContext; + + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + + rerender(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/dsa-web/src/components/report/index.ts b/apps/dsa-web/src/components/report/index.ts index 9c7d90d18..ce3190879 100644 --- a/apps/dsa-web/src/components/report/index.ts +++ b/apps/dsa-web/src/components/report/index.ts @@ -1,6 +1,7 @@ export * from './ReportSummary'; export * from './ReportDiagnostics'; export * from './AnalysisContextSummary'; +export * from './MarketStructureCard'; export * from './ReportOverview'; export * from './ReportStrategy'; export * from './ReportNews'; diff --git a/apps/dsa-web/src/types/analysis.ts b/apps/dsa-web/src/types/analysis.ts index ed2ce2f8a..a69618384 100644 --- a/apps/dsa-web/src/types/analysis.ts +++ b/apps/dsa-web/src/types/analysis.ts @@ -77,7 +77,7 @@ export interface ReportMeta { createdAt: string; currentPrice?: number; changePct?: number; - modelUsed?: string; // Display-only model snapshot from persisted history; not used for runtime model selection + modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择 marketPhaseSummary?: MarketPhaseSummary | null; } @@ -139,6 +139,105 @@ export interface SectorRankings { bottom?: SectorRankingItem[]; } +export type MarketStructureStatus = 'ok' | 'partial' | 'unknown' | 'not_supported'; +export type MarketStructureThemeSource = 'industry' | 'concept' | 'mixed' | 'unknown'; +export type MarketStructureThemePhase = 'warming' | 'accelerating' | 'cooling' | 'unknown'; +export type MarketStructureStockRole = 'leader' | 'follower' | 'edge' | 'unknown'; + +export interface MarketStructureSource { + provider: string; + dataset: string; + status: string; + message?: string | null; +} + +export interface MarketStructureDataQuality { + status: MarketStructureStatus; + missingFields?: string[]; + sources?: MarketStructureSource[]; + errors?: string[]; +} + +export interface RankedThemeItem { + name: string; + changePct?: number | null; + rank?: number | null; + source?: MarketStructureThemeSource; + code?: string | null; + updatedAt?: string | null; +} + +export interface MarketThemeItem extends RankedThemeItem { + phase?: MarketStructureThemePhase; + strengthScore?: number | null; + reason?: string | null; +} + +export interface ThemeBreadth { + activeCount?: number; + leadingIndustryCount?: number; + leadingConceptCount?: number; + laggingCount?: number; +} + +export interface MarketThemeContext { + schemaVersion: 'market-theme-v1'; + status: MarketStructureStatus; + market: string; + tradeDate?: string | null; + activeThemes?: MarketThemeItem[]; + leadingIndustries?: RankedThemeItem[]; + leadingConcepts?: RankedThemeItem[]; + laggingThemes?: RankedThemeItem[]; + themeBreadth?: ThemeBreadth; + dataQuality?: MarketStructureDataQuality; +} + +export interface StockBoardPosition { + name: string; + type?: string | null; + code?: string | null; + rank?: number | null; + changePct?: number | null; + source?: MarketStructureThemeSource; +} + +export interface PrimaryTheme { + name: string; + source?: MarketStructureThemeSource; + phase?: MarketStructureThemePhase; + rank?: number | null; + changePct?: number | null; +} + +export interface MarketStructureRiskTag { + code: string; + message: string; +} + +export interface StockMarketPosition { + schemaVersion: 'stock-market-position-v1'; + status: MarketStructureStatus; + stockCode: string; + stockName?: string | null; + market: string; + primaryTheme?: PrimaryTheme | null; + relatedBoards?: StockBoardPosition[]; + stockRole?: MarketStructureStockRole; + themePhase?: MarketStructureThemePhase; + riskTags?: MarketStructureRiskTag[]; + missingFields?: string[]; +} + +export interface MarketStructureContext { + schemaVersion: 'market-structure-v1'; + status: MarketStructureStatus; + market: string; + tradeDate?: string | null; + marketThemeContext: MarketThemeContext; + stockMarketPosition: StockMarketPosition; +} + export interface MarketReviewPayloadSection { key?: string; title: string; @@ -262,6 +361,7 @@ export interface ReportDetails { belongBoards?: RelatedBoard[]; sectorRankings?: SectorRankings; conceptRankings?: SectorRankings; + marketStructure?: MarketStructureContext | null; } /** Full analysis report */ @@ -422,7 +522,7 @@ export interface HistoryItem { changePct?: number; volumeRatio?: number; turnoverRate?: number; - modelUsed?: string; // Display-only model snapshot from persisted history; runtime provider/model/base URL still come from analyzer configuration + modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由 marketPhaseSummary?: MarketPhaseSummary | null; createdAt: string; } diff --git a/apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts b/apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts new file mode 100644 index 000000000..83e4c002f --- /dev/null +++ b/apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'vitest'; + +import { buildChatFollowUpContext } from '../chatFollowUp'; +import type { AnalysisReport } from '../../types/analysis'; + +describe('chat follow-up context', () => { + test('includes market_structure_context in snake_case for history follow-up', () => { + const report = { + meta: { + queryId: 'q-123', + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'full', + createdAt: '2026-07-05T00:00:00Z', + }, + summary: { + analysisSummary: 'summary', + operationAdvice: '持有', + trendPrediction: '中性', + sentimentScore: 55, + }, + details: { + marketStructure: { + schemaVersion: 'market-structure-v1', + status: 'ok', + market: 'A股', + tradeDate: '2026-07-04', + marketThemeContext: { + schemaVersion: 'market-theme-v1', + status: 'ok', + market: 'A股', + activeThemes: [{ name: 'AI', changePct: 1.2 }], + leadingIndustries: [{ name: '白酒', changePct: 0.8 }], + }, + stockMarketPosition: { + schemaVersion: 'stock-market-position-v1', + status: 'ok', + stockCode: '600519', + stockRole: 'leader', + themePhase: 'warming', + primaryTheme: { + name: 'AI', + phase: 'warming', + }, + }, + }, + }, + } as AnalysisReport; + + const context = buildChatFollowUpContext('600519', '贵州茅台', report); + + expect(context).toMatchObject({ + stock_code: '600519', + stock_name: '贵州茅台', + market_structure_context: expect.objectContaining({ + schema_version: 'market-structure-v1', + market: 'A股', + trade_date: '2026-07-04', + status: 'ok', + market_theme_context: expect.objectContaining({ + schema_version: 'market-theme-v1', + status: 'ok', + market: 'A股', + active_themes: [{ name: 'AI', change_pct: 1.2 }], + leading_industries: [{ name: '白酒', change_pct: 0.8 }], + }), + stock_market_position: expect.objectContaining({ + schema_version: 'stock-market-position-v1', + status: 'ok', + stock_code: '600519', + stock_role: 'leader', + theme_phase: 'warming', + primary_theme: expect.objectContaining({ + name: 'AI', + phase: 'warming', + }), + }), + }), + }); + }); + + test('omits market_structure_context when history report has none', () => { + const report = { + meta: { + queryId: 'q-456', + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'full', + createdAt: '2026-07-05T00:00:00Z', + }, + summary: { + analysisSummary: 'summary', + operationAdvice: '持有', + trendPrediction: '中性', + sentimentScore: 55, + }, + details: {}, + } as AnalysisReport; + + const context = buildChatFollowUpContext('600519', '贵州茅台', report); + + expect(context).not.toHaveProperty('market_structure_context'); + }); +}); diff --git a/apps/dsa-web/src/utils/chatFollowUp.ts b/apps/dsa-web/src/utils/chatFollowUp.ts index bd5b3e123..2c1249759 100644 --- a/apps/dsa-web/src/utils/chatFollowUp.ts +++ b/apps/dsa-web/src/utils/chatFollowUp.ts @@ -9,6 +9,7 @@ export interface ChatFollowUpContext { previous_strategy?: unknown; previous_price?: number; previous_change_pct?: number; + market_structure_context?: unknown; } type ResolveChatFollowUpContextParams = { @@ -51,6 +52,43 @@ export function sanitizeFollowUpStockName(stockName: string | null): string | nu return normalized; } +function toSnakeCaseKey(value: string): string { + return value + .replace(/([a-z\d])([A-Z])/g, '$1_$2') + .replace(/-/g, '_') + .toLowerCase(); +} + +function convertMarketStructureToSnakeCase(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => convertMarketStructureToSnakeCase(item)); + } + if (value === null || typeof value !== 'object') { + return value; + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + toSnakeCaseKey(key), + convertMarketStructureToSnakeCase(item), + ]), + ); +} + +function getMarketStructureContextForAgent(report?: AnalysisReport | null): unknown | undefined { + const marketStructure = report?.details?.marketStructure; + if (marketStructure === null || typeof marketStructure !== 'object') { + return undefined; + } + if (Array.isArray(marketStructure)) { + return undefined; + } + if (!Object.keys(marketStructure).length) { + return undefined; + } + return convertMarketStructureToSnakeCase(marketStructure); +} + export function parseFollowUpRecordId(recordId: string | null): number | undefined { if (!recordId || !/^\d+$/.test(recordId)) { return undefined; @@ -96,6 +134,11 @@ export function buildChatFollowUpContext( context.previous_change_pct = report.meta.changePct; } + const marketStructureContext = getMarketStructureContextForAgent(report); + if (marketStructureContext) { + context.market_structure_context = marketStructureContext; + } + return context; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1f5db9e80..7afe7a59f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [改进] 补充本次设置页布局收敛:移动端分类导航改为横向滚动列表并保证设置内容首屏可见,桌面端保留分类说明并收紧字段布局层级与间距,提升首屏效率与可配置信息密度。 - [文档] 在 README 快速开始中补充行情数据源配置说明(TUSHARE_TOKEN / Longbridge),明确未配置时仍可走 AkShare、Baostock、YFinance 等免费兜底源,日志中相关提示不影响运行。同步更新docs下的中英双份 README - [改进] 新增 #1743 Phase 6a 内部 DSA Tool Surface 契约,统一工具 schema、stock scope fail-closed guard、结构化错误、审计摘要和脱敏诊断边界,并明确外部 AgentBackend 工具能力仍需 wire-level probe 证明。 +- [新功能] 新增 A 股市场结构与题材主线上下文,并在报告、Agent、DecisionSignal 和 Web 市场位置卡中复用。 +- [改进] `src/services/analysis_service.py` 在 `report` 详情层新增 `details.raw_result` 回填,补齐与 API/历史详情的报告载荷一致性;该变更为可见性增强,`provider/model/base URL/配置迁移` 语义未变更,回滚方式为回退本次提交。 diff --git a/docs/alphasift-integration.md b/docs/alphasift-integration.md index a02e65e65..b775fade0 100644 --- a/docs/alphasift-integration.md +++ b/docs/alphasift-integration.md @@ -20,6 +20,7 @@ AlphaSift 作为独立仓库维护的选股引擎接入 DSA。DSA 默认不启 - 缺失依赖边界:如果运行环境缺少 `alphasift.dsa_adapter`,`status` 返回 `available=false + diagnostics.reason=missing_module`;`strategies` 和 `screen` 返回 `424` 并提示执行 `pip install -r requirements.txt` 或重建 Docker/桌面后端产物,不会在业务请求中自动 `pip install`。 - 运行异常边界:若适配层可导入但 `get_status()` 报错或返回 `available=false`,DSA 返回 `424 + diagnostics`,保留故障诊断,防止用重装掩盖真实运行时错误。 - 策略归属:策略列表、策略参数、全市场快照、初筛、因子评分和 LLM 重排由 AlphaSift 负责;DSA 负责开关、API 壳、数据 provider、展示和错误提示。 +- 普通报告市场结构边界:`market_structure_context` 由 DSA 原生 `MarketHotspotService` / `MarketStructureService` 生成,首版基于 DSA 行业/概念榜单和个股所属板块,输出大盘题材层 `market_theme_context` 与个股位置层 `stock_market_position`。该能力不依赖 AlphaSift runtime;AlphaSift 的热点详情、发酵路线、成分股和 leader stocks 后续可作为可选数据源迁移,但未迁移前普通个股分析不会隐式调用 AlphaSift 或把缺失字段解释为龙头证据。 ## 外部契约来源与迁移边界 diff --git a/docs/architecture/api_spec.json b/docs/architecture/api_spec.json index 2012301d2..70f4787dd 100644 --- a/docs/architecture/api_spec.json +++ b/docs/architecture/api_spec.json @@ -3127,6 +3127,10 @@ "description": "建议动作展示标签", "nullable": true }, + "model_used": { + "type": "string", + "description": "历史记录中展示的模型快照,仅用于展示与排障,不影响 provider/model/base URL 运行时路由" + }, "created_at": { "type": "string", "format": "date-time" @@ -3164,7 +3168,7 @@ }, "model_used": { "type": "string", - "description": "分析使用的 LLM 模型(完整名,如 gemini/gemini-2.0-flash)" + "description": "历史快照中的模型名称,仅供展示,不参与运行时 provider/model/base URL 路由、清理或迁移" } } }, @@ -3258,6 +3262,10 @@ "context_snapshot": { "type": "object", "description": "分析时上下文快照(JSON)" + }, + "market_structure": { + "type": "object", + "description": "市场结构上下文(题材主线与个股市场位置信息,JSON)" } } } diff --git a/docs/decision-signals.md b/docs/decision-signals.md index 4ef03f041..acee1ed6c 100644 --- a/docs/decision-signals.md +++ b/docs/decision-signals.md @@ -148,6 +148,23 @@ Web 入口位于 `/decision-signals`: - Legacy / unknown 只用数据库 `NULL` 表示。`profile_policy_version` 只表示默认 profile metadata contract version,不代表已经实现独立 profile policy engine、scoring engine 或多 profile 生成。P1/P2 不写入 `scoring_version` 或 `scoring_breakdown`;这些字段如需引入,应由后续 reassess / scoring issue 定义。 - Lazy backfill 语义:省略 profile 保留旧的 `source_type=analysis + source_report_id` 懒回填;`decision_profile=balanced` 可生成 balanced 回填;`decision_profile=unknown`、`conservative`、`aggressive` 不自动创建行。 +## 市场结构 metadata + +普通个股分析和 Agent 个股分析如果携带 `market_structure_context`,自动提取 `DecisionSignal` 时会把以下低敏字段追加到 metadata: + +- `market_structure_version` +- `market_theme_version` +- `stock_market_position_version` +- `market_structure_status` +- `primary_theme` +- `theme_phase` +- `stock_role` +- `market_structure_risk_tags` + +这些字段只用于解释信号所处题材背景,不参与 `action`、`score`、`horizon`、同源去重键或生命周期计算。它们也不是题材龙头证明;当 `market_structure_risk_tags` 或缺失证据显示成分股、leader stocks 不完整时,客户端和后验分析应按降级题材证据处理。 + +快照字段中的 `provider` / `dataset` 来自市场结构抽取链路元数据,属于运行后持久化证据,不参与 LLM provider/model 路由、`base URL` 解析、`.env` 写回或配置迁移;可核验范围见 `src/schemas/market_structure.py`。 + ## 告警、通知与组合风险 - 股票级真实告警触发会优先关联同标的 latest active 信号,并把低敏 `decision_signal_summary` 写入 `alert_triggers.diagnostics`。 diff --git a/docs/full-guide.md b/docs/full-guide.md index 590463b62..aa394ea60 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -899,6 +899,16 @@ P6 只做文档与配置可见性收口,不新增 pack runtime、不新增 pac 当前没有运行时 pack 总开关;如果需要关闭 P3-P5 的 pack Prompt 摘要、overview 或数据质量接入,只能通过发布回滚或代码回滚完成。旧历史记录没有 `analysis_context_pack_overview` / `data_quality` 时继续返回空字段,报告读取保持兼容。 +#### 市场结构上下文(Issue #1909) + +个股分析现在新增低敏 `market_structure_context`,并通过 `AnalysisReport.details.market_structure` 对历史详情、同步分析响应和 completed 任务状态暴露。该字段采用两层结构:`market_theme_context` 表示大盘/题材层,包含 A 股行业/概念榜单、活跃题材、领涨行业/概念、题材宽度和数据质量;`stock_market_position` 表示个股位置层,包含个股所属板块、主关联题材、题材阶段、个股位置、风险标签和缺失证据。 + +首版市场结构由 DSA 原生服务基于 `DataFetcherManager.get_sector_rankings()`、`get_concept_rankings()` 和 `fundamental_context.belong_boards` 生成,不依赖 AlphaSift runtime。AlphaSift 中已有的热点详情、发酵路线、成分股和 leader stocks 可作为后续可选数据源迁移,但在未迁移前不会被普通个股分析隐式调用。缺少成分股或 leader 证据时,`stock_role` 默认保持 `follower/edge/unknown`,并在 `missing_fields` 中标记 `hotspot_constituents`、`leader_stocks`,避免把普通关联股误写成题材龙头。 + +兼容性边界:`market_structure_context` 中的 provider / model 快照字段(含 `model_used`、`market_structure_context.*.source.provider` 等)仅用于历史回溯和页面展示,不构成 LLM provider 路由、`base URL`、`provider/model` 运行时配置输入;不会触发 `.env` 配置清理、回写、迁移或静默变更。 + +普通 LLM、single Agent 和 multi-agent prompt 会注入市场结构低敏摘要;DecisionSignal 自动提取会把 `primary_theme`、`theme_phase`、`stock_role`、版本号和风险标签写入 metadata,不改变主字段、去重键或生命周期规则。Web 报告页在概览后展示“市场位置”卡片,分别呈现大盘题材层和个股位置层;旧历史记录缺少该字段时不展示。非 A 股市场首版返回 `not_supported`,不影响原有报告。 + #### 盘中决策护栏与质量校验(Issue #1386 P5) P5 在个股分析报告的 `dashboard.phase_decision` 中追加阶段化决策字段:`phase_context`、`action_window`、`immediate_action`、`watch_conditions`、`next_check_time`、`confidence_reason` 和 `data_limitations`。该字段只作为报告 JSON 的向后兼容扩展进入历史 `raw_result`;不新增 `analysis_phase` API 参数、不改变 Web 阶段入口、不新增配置项,也不影响每日收盘复盘默认行为。 @@ -1508,6 +1518,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 - 🗂️ **大盘复盘任务可见性** - 首页触发大盘复盘后会返回 `task_id` 并轮询 `GET /api/v1/analysis/status/{task_id}`,在进行中/完成/失败场景给出可见反馈,失败时直接透出报错内容 - 🗂️ **市场复盘历史独立入口** - 大盘复盘历史通过专用入口与普通个股历史隔离;建议通过 `stock_code=MARKET` + `report_type=market_review` 直接查询与回放大盘复盘记录 - 🧾 **市场复盘历史可复用** - 大盘复盘任务会持久化到分析历史,`report_type` 为 `market_review`,可直接通过历史列表/详情打开对应 Markdown 或详情页,不会重新触发分析重算 +- 🧭 **市场位置卡片** - A 股普通分析报告会展示市场题材层和个股位置层,区分大盘主线、主关联题材、题材阶段、个股位置和缺失证据 - 🧩 **输入数据块可见** - 普通分析报告会在历史详情、同步响应和 completed 任务状态中返回低敏 `AnalysisContextPack` overview,Web 报告页在策略点位和资讯之后默认折叠展示数据块状态、来源、缺失原因和降级摘要 - 💬 **问股追问上下文** - 从历史报告进入问股后,后续追问会持续携带当前 `stock_code/stock_name`;切回或重载已有问股会话时,会从已加载的历史用户消息恢复基础当前标的;只有用户明确切换标的时才切换上下文,含比较/对比/vs/差异/相比等明确比较意图或多个非当前明确股票代码的问题不会污染当前标的 - 📈 **回测验证** - 评估历史分析准确率,查询方向胜率与模拟收益 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 41af74215..572c547f4 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -765,6 +765,16 @@ P6 is a documentation and configuration-visibility closure only. It does not add There is no runtime pack master switch. Disabling the P3-P5 pack prompt summary, overview, or data-quality integration requires a release rollback or code rollback. Older history records without `analysis_context_pack_overview` / `data_quality` continue to return empty fields and remain readable. +### Market Structure Context (Issue #1909) + +Stock analysis now builds a low-sensitivity `market_structure_context` and exposes it as `AnalysisReport.details.market_structure` in history detail, sync analysis responses, and completed task status responses. The contract has two layers: `market_theme_context` for the market/theme layer, and `stock_market_position` for the individual stock's position inside those themes. + +The first version is DSA-native: it uses `DataFetcherManager.get_sector_rankings()`, `get_concept_rankings()`, and `fundamental_context.belong_boards`. It does not require AlphaSift at runtime. AlphaSift hotspot details, route timelines, constituents, and leader stocks can be migrated later as optional sources; until then, missing constituent/leader evidence is explicit and the stock role stays conservative (`follower`, `edge`, or `unknown`). Non-A-share markets return `not_supported`. + +Compatibility boundary: provider/model snapshot fields in this change (including `model_used` and market structure source provider markers) are display/history metadata only. They do not participate in runtime provider routing, `base URL`, model selection, `.env` config cleanup, or migration/overwrite logic. + +Regular LLM, single Agent, and multi-agent prompts receive the low-sensitivity summary. DecisionSignal extraction writes `primary_theme`, `theme_phase`, `stock_role`, contract versions, and risk tags into metadata without changing primary fields or deduplication keys. The Web report page shows a market-position card after the overview; older reports without this field simply omit the card. + ### Intraday Decision Guardrails and Quality Checks (Issue #1386 P5) P5 adds a phase-aware decision block under `dashboard.phase_decision` for individual stock analysis reports: `phase_context`, `action_window`, `immediate_action`, `watch_conditions`, `next_check_time`, `confidence_reason`, and `data_limitations`. This is a backward-compatible report JSON addition stored in historical `raw_result`; it does not add an `analysis_phase` API parameter, change Web phase entrypoints, add configuration, or change the default post-market daily review behavior. @@ -1341,6 +1351,7 @@ FastAPI provides RESTful API service for configuration management and triggering - **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area. - **Market review history dedicated entry** - Market review history is shown in a dedicated history entry and isolated from regular stock history; use `stock_code=MARKET` and `report_type=market_review` to view and replay only market-review records. - **Market review history replay** - Market review results are persisted with `report_type=market_review` and can be reopened from history list/detail or Markdown endpoints directly, without re-triggering a fresh analysis run. +- **Market-position card** - A-share stock reports show a market theme layer and a stock position layer, separating market themes, primary theme, theme phase, stock role, and missing evidence. - **Input data-block visibility** - Regular analysis reports expose a low-sensitivity `AnalysisContextPack` overview through history details, sync responses, and completed task status; the Web report page shows the data-block summary collapsed after Strategy and News, with block status, source, missing reasons, and fallback summaries available on expansion. - **Ask-stock follow-up context** - When Ask Stock is opened from a historical report, follow-up messages keep sending the active `stock_code/stock_name`; reopening an existing chat can recover the base stock from loaded user messages, and comparison-style prompts do not overwrite the current stock context. - **Backtest Validation** - Evaluate historical analysis accuracy, query direction win rate and simulated returns diff --git a/src/agent/agents/base_agent.py b/src/agent/agents/base_agent.py index 54fb735bf..4ed4108e7 100644 --- a/src/agent/agents/base_agent.py +++ b/src/agent/agents/base_agent.py @@ -22,6 +22,7 @@ from src.agent.runner import RunLoopResult, run_agent_loop from src.agent.skills.defaults import extract_skill_id from src.agent.tools.registry import ToolRegistry from src.market_phase_prompt import format_market_phase_prompt_section +from src.market_structure_prompt import format_market_structure_prompt_section from src.report_language import normalize_report_language from src.services.daily_market_context import format_daily_market_context_prompt_section @@ -189,6 +190,13 @@ class BaseAgent(ABC): if daily_market_context_section: messages.append({"role": "user", "content": daily_market_context_section}) + market_structure_section = format_market_structure_prompt_section( + ctx.meta.get("market_structure_context"), + report_language=report_language, + ) + if market_structure_section: + messages.append({"role": "user", "content": market_structure_section}) + analysis_context_pack_summary = ctx.meta.get("analysis_context_pack_summary") if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary: messages.append({"role": "user", "content": analysis_context_pack_summary}) diff --git a/src/agent/executor.py b/src/agent/executor.py index 52751c821..00b12d722 100644 --- a/src/agent/executor.py +++ b/src/agent/executor.py @@ -31,6 +31,7 @@ from src.agent.tools.registry import ToolRegistry from src.report_language import normalize_report_language from src.market_context import get_market_role, get_market_guidelines from src.market_phase_prompt import format_market_phase_prompt_section +from src.market_structure_prompt import format_market_structure_prompt_section from src.services.daily_market_context import format_daily_market_context_prompt_section logger = logging.getLogger(__name__) @@ -642,6 +643,12 @@ class AgentExecutor: ) if daily_market_context_section: context_parts.append(daily_market_context_section.strip()) + market_structure_section = format_market_structure_prompt_section( + context.get("market_structure_context"), + report_language=report_language, + ) + if market_structure_section: + context_parts.append(market_structure_section.strip()) if context_parts: context_msg = "[系统提供的历史分析上下文,可供参考对比]\n" + "\n".join(context_parts) messages.append({"role": "user", "content": context_msg}) @@ -837,6 +844,13 @@ class AgentExecutor: if daily_market_context_section: parts.append(daily_market_context_section) + market_structure_section = format_market_structure_prompt_section( + context.get("market_structure_context"), + report_language=report_language, + ) + if market_structure_section: + parts.append(market_structure_section) + analysis_context_pack_summary = context.get("analysis_context_pack_summary") if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary: parts.append(analysis_context_pack_summary) diff --git a/src/agent/orchestrator.py b/src/agent/orchestrator.py index 41d56cc63..54c074aae 100644 --- a/src/agent/orchestrator.py +++ b/src/agent/orchestrator.py @@ -795,6 +795,9 @@ class AgentOrchestrator: daily_market_context = context.get("daily_market_context") if isinstance(daily_market_context, dict) and daily_market_context: ctx.meta["daily_market_context"] = dict(daily_market_context) + market_structure_context = context.get("market_structure_context") + if isinstance(market_structure_context, dict) and market_structure_context: + ctx.meta["market_structure_context"] = dict(market_structure_context) analysis_context_pack_summary = context.get("analysis_context_pack_summary") if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary: ctx.meta["analysis_context_pack_summary"] = analysis_context_pack_summary diff --git a/src/agent/stock_scope.py b/src/agent/stock_scope.py index f26c6c336..836b1c751 100644 --- a/src/agent/stock_scope.py +++ b/src/agent/stock_scope.py @@ -20,6 +20,7 @@ SWITCH_CLEANUP_KEYS = { "trend_result", "news_context", "fundamental_context", + "market_structure_context", "analysis_context_pack_summary", "market_phase_context", } diff --git a/src/analyzer.py b/src/analyzer.py index 107c3c298..9d6aab23e 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -101,6 +101,7 @@ from src.schemas.report_schema import AnalysisReportSchema from src.market_context import detect_market, get_market_role, get_market_guidelines from src.services.daily_market_context import format_daily_market_context_prompt_section from src.market_phase_prompt import format_market_phase_prompt_section +from src.market_structure_prompt import format_market_structure_prompt_section logger = logging.getLogger(__name__) @@ -248,6 +249,7 @@ def _legacy_audit_marker_specs( add("analysis_date", context.get("date")) add("market_phase", "## Market Phase Context" if report_language in ("en", "ko") else "## 市场阶段上下文") add("daily_market_context", "## Daily Market Context" if report_language in ("en", "ko") else "## 大盘环境摘要") + add("market_structure_context", "## Market Structure Context" if report_language in ("en", "ko") else "## 市场结构上下文") add("analysis_context_pack", analysis_context_pack_summary) add("quote", "## 📈 技术面数据") add("news_context", "## 📰 舆情情报" if news_context else None) @@ -1733,6 +1735,7 @@ class AnalysisResult: # ========== 基本面上下文(仅运行时,用于通知拼装;不持久化到 to_dict)========== fundamental_context: Optional[Dict[str, Any]] = None + market_structure_context: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: """转换为字典""" @@ -1772,6 +1775,7 @@ class AnalysisResult: 'current_price': self.current_price, 'change_pct': self.change_pct, 'model_used': self.model_used, + 'market_structure_context': self.market_structure_context, } def get_core_conclusion(self) -> str: @@ -3745,6 +3749,12 @@ class GeminiAnalyzer: ) if daily_market_context_section: prompt += daily_market_context_section + market_structure_section = format_market_structure_prompt_section( + context.get("market_structure_context"), + report_language=report_language, + ) + if market_structure_section: + prompt += market_structure_section if isinstance(analysis_context_pack_summary, str) and analysis_context_pack_summary: prompt += analysis_context_pack_summary prompt += f""" diff --git a/src/core/pipeline.py b/src/core/pipeline.py index 196bf532c..5d7fb92f8 100644 --- a/src/core/pipeline.py +++ b/src/core/pipeline.py @@ -59,10 +59,12 @@ from src.services.daily_market_context import ( ) from src.services.social_sentiment_service import SocialSentimentService from src.services.intelligence_service import IntelligenceService +from src.services.market_hotspot_service import MarketHotspotService from src.services.analysis_context_builder import ( AnalysisContextBuilder, PipelineAnalysisArtifacts, ) +from src.services.market_structure_service import MarketStructureService from src.services.run_diagnostics import ( activate_run_diagnostic_context, current_diagnostic_snapshot, @@ -222,6 +224,14 @@ class StockAnalysisPipeline: self.trend_analyzer = StockTrendAnalyzer() # 技术分析器 self.analyzer = GeminiAnalyzer(config=self.config, skills=self.analysis_skills) self.notifier = NotificationService(source_message=source_message) + self.market_structure_service = MarketStructureService(fetcher_manager=self.fetcher_manager) + self.market_hotspot_service: Optional[MarketHotspotService] = None + try: + self.market_hotspot_service = MarketHotspotService( + fetcher_manager=self.fetcher_manager, + ) + except Exception as exc: + logger.debug("market hotspot service init failed (fail-open): %s", exc) self._single_stock_notify_lock = threading.Lock() self._daily_market_context_service_lock = threading.Lock() self._concept_rankings_cache_lock = threading.Lock() @@ -495,6 +505,14 @@ class StockAnalysisPipeline: code, fundamental_context, ) + market_structure_context = self._build_market_structure_context( + code=code, + stock_name=stock_name, + market=market, + fundamental_context=fundamental_context, + trade_date=daily_market_target_date, + market_phase_summary=market_phase_summary, + ) # P0: write-only snapshot, fail-open, no read dependency on this table. try: @@ -544,6 +562,7 @@ class StockAnalysisPipeline: market_phase_summary=market_phase_summary, daily_market_context=daily_market_context, portfolio_context=portfolio_context, + market_structure_context=market_structure_context, ) # Step 4: 多维度情报搜索(最新消息+风险排查+业绩预期) @@ -650,6 +669,8 @@ class StockAnalysisPipeline: ) if portfolio_context is not None: enhanced_context["portfolio_context"] = dict(portfolio_context) + if isinstance(market_structure_context, dict): + enhanced_context["market_structure_context"] = market_structure_context # Step 7: 调用 AI 分析(传入增强的上下文和新闻) ( @@ -770,6 +791,8 @@ class StockAnalysisPipeline: ) if isinstance(fundamental_context, dict): result.fundamental_context = fundamental_context + if isinstance(market_structure_context, dict): + result.market_structure_context = market_structure_context result.market_phase_summary = market_phase_summary result.analysis_context_pack_overview = analysis_context_pack_overview self._refresh_decision_action_for_final_result( @@ -1139,6 +1162,19 @@ class StockAnalysisPipeline: if market != "cn": return [], [] + service = getattr(self, "market_hotspot_service", None) + if service is None: + try: + service = MarketHotspotService(fetcher_manager=self.fetcher_manager) + except Exception as exc: + logger.debug( + "market hotspot service init failed in concept ranking path (fail-open): %s", + exc, + ) + service = None + else: + self.market_hotspot_service = service + cache = getattr(self, "_concept_rankings_cache", None) if not isinstance(cache, dict): cache = {} @@ -1157,21 +1193,61 @@ class StockAnalysisPipeline: top_concepts: List[Dict[str, Any]] = [] bottom_concepts: List[Dict[str, Any]] = [] try: - fetch_rankings = getattr(self.fetcher_manager, "get_concept_rankings", None) - if callable(fetch_rankings): - rankings = fetch_rankings(5) - if isinstance(rankings, tuple) and len(rankings) == 2: - raw_top, raw_bottom = rankings - if isinstance(raw_top, list): - top_concepts = list(raw_top) - if isinstance(raw_bottom, list): - bottom_concepts = list(raw_bottom) + if service is None: + fetch_rankings = getattr(self.fetcher_manager, "get_concept_rankings", None) + if callable(fetch_rankings): + rankings = fetch_rankings(5) + if isinstance(rankings, tuple) and len(rankings) == 2: + raw_top, raw_bottom = rankings + if isinstance(raw_top, list): + top_concepts = list(raw_top) + if isinstance(raw_bottom, list): + bottom_concepts = list(raw_bottom) + else: + top_concepts, bottom_concepts = service.get_concept_rankings(5) except Exception as e: logger.debug("attach concept_rankings failed (fail-open): %s", e) cache[market] = (top_concepts, bottom_concepts) return list(top_concepts), list(bottom_concepts) + def _build_market_structure_context( + self, + *, + code: str, + stock_name: str, + market: str, + fundamental_context: Optional[Dict[str, Any]], + trade_date: Any = None, + market_phase_summary: Optional[Dict[str, Any]] = None, + ) -> Optional[Dict[str, Any]]: + """Build market structure context without blocking the main analysis.""" + service = getattr(self, "market_structure_service", None) + if service is None: + try: + service = MarketStructureService(fetcher_manager=self.fetcher_manager) + self.market_structure_service = service + except Exception as exc: + logger.debug("market structure service init failed (fail-open): %s", exc) + return None + try: + return service.build_context( + code=code, + stock_name=stock_name, + market=market, + fundamental_context=fundamental_context, + trade_date=trade_date, + market_phase_summary=market_phase_summary, + ) + except Exception as exc: + logger.debug( + "%s market structure context build failed (fail-open): %s", + code, + exc, + exc_info=True, + ) + return None + def _ensure_agent_history(self, code: str, min_days: int = 240) -> None: """Ensure at least *min_days* of K-line history is in DB for agent tools.""" from src.services.history_loader import get_frozen_target_date @@ -1207,6 +1283,7 @@ class StockAnalysisPipeline: market_phase_summary: Optional[Dict[str, Any]] = None, daily_market_context: Optional[DailyMarketContext] = None, portfolio_context: Optional[Dict[str, Any]] = None, + market_structure_context: Optional[Dict[str, Any]] = None, ) -> Optional[AnalysisResult]: """ 使用 Agent 模式分析单只股票。 @@ -1237,6 +1314,8 @@ class StockAnalysisPipeline: initial_context["skills"] = self.analysis_skills if market_phase_context is not None: initial_context["market_phase_context"] = market_phase_context + if isinstance(market_structure_context, dict): + initial_context["market_structure_context"] = market_structure_context self._attach_daily_market_context( initial_context, daily_market_context, @@ -1407,6 +1486,8 @@ class StockAnalysisPipeline: ) if isinstance(fundamental_context, dict): result.fundamental_context = fundamental_context + if isinstance(market_structure_context, dict): + result.market_structure_context = market_structure_context result.market_phase_summary = market_phase_summary result.analysis_context_pack_overview = analysis_context_pack_overview self._refresh_decision_action_for_final_result( @@ -2341,6 +2422,9 @@ class StockAnalysisPipeline: "realtime_quote_raw": self._safe_to_dict(realtime_quote), "chip_distribution_raw": self._safe_to_dict(chip_data), } + market_structure_context = enhanced_context.get("market_structure_context") + if isinstance(market_structure_context, dict): + snapshot["market_structure_context"] = market_structure_context if news_content is not None: snapshot["news_retrieval_content"] = news_content if news_result_count is not None: diff --git a/src/market_structure_prompt.py b/src/market_structure_prompt.py new file mode 100644 index 000000000..b7f22c768 --- /dev/null +++ b/src/market_structure_prompt.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""Prompt rendering for the shared market-structure context.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, List + +from src.report_language import normalize_report_language +from src.schemas.market_structure import MARKET_STRUCTURE_SCHEMA_VERSION + + +def format_market_structure_prompt_section( + context: Any, + report_language: str = "zh", +) -> str: + """Render a compact, low-sensitive market structure section for LLM prompts.""" + if not isinstance(context, dict): + return "" + if context.get("schema_version") != MARKET_STRUCTURE_SCHEMA_VERSION: + return "" + if context.get("status") == "not_supported": + return "" + + market_theme = context.get("market_theme_context") + stock_position = context.get("stock_market_position") + if not isinstance(market_theme, dict) or not isinstance(stock_position, dict): + return "" + + language = normalize_report_language(report_language) + active_themes = _item_names(market_theme.get("active_themes"), limit=5) + leading_concepts = _item_names(market_theme.get("leading_concepts"), limit=5) + leading_industries = _item_names(market_theme.get("leading_industries"), limit=5) + primary_theme = stock_position.get("primary_theme") + primary_name = ( + str(primary_theme.get("name")).strip() + if isinstance(primary_theme, dict) and primary_theme.get("name") + else "" + ) + risk_tags = [ + str(item.get("code") or item.get("message") or "").strip() + for item in stock_position.get("risk_tags") or [] + if isinstance(item, dict) and str(item.get("code") or item.get("message") or "").strip() + ] + missing_fields = _string_values(stock_position.get("missing_fields")) + data_quality = market_theme.get("data_quality") + if isinstance(data_quality, dict): + missing_fields.extend(_string_values(data_quality.get("missing_fields"))) + missing_fields = list(dict.fromkeys(missing_fields)) + + if language == "en": + lines = _format_en(context, stock_position, active_themes, leading_concepts, leading_industries, primary_name, risk_tags, missing_fields) + return "\n".join(lines) + "\n" + if language == "ko": + lines = _format_ko(context, stock_position, active_themes, leading_concepts, leading_industries, primary_name, risk_tags, missing_fields) + return "\n".join(lines) + "\n" + + lines = _format_zh(context, stock_position, active_themes, leading_concepts, leading_industries, primary_name, risk_tags, missing_fields) + return "\n".join(lines) + "\n" + + +def _format_en( + context: Any, + stock_position: dict[str, Any], + active_themes: List[str], + leading_concepts: List[str], + leading_industries: List[str], + primary_name: str, + risk_tags: List[str], + missing_fields: List[str], +) -> List[str]: + lines = [ + "\n## Market Structure Context", + f"- Status: {context.get('status', 'unknown')}", + ] + if active_themes: + lines.append(f"- Active themes: {', '.join(active_themes)}") + if leading_concepts: + lines.append(f"- Leading concepts: {', '.join(leading_concepts)}") + if leading_industries: + lines.append(f"- Leading industries: {', '.join(leading_industries)}") + if primary_name: + lines.append(f"- Stock primary theme: {primary_name}") + lines.append(f"- Theme phase: {stock_position.get('theme_phase', 'unknown')}") + lines.append(f"- Stock role: {stock_position.get('stock_role', 'unknown')}") + if risk_tags: + lines.append(f"- Risk tags: {', '.join(risk_tags)}") + if missing_fields: + lines.append(f"- Missing evidence: {', '.join(missing_fields)}") + lines.append("- Guardrail: do not claim leader-stock status without constituent or leader evidence.") + return lines + + +def _format_zh( + context: Any, + stock_position: dict[str, Any], + active_themes: List[str], + leading_concepts: List[str], + leading_industries: List[str], + primary_name: str, + risk_tags: List[str], + missing_fields: List[str], +) -> List[str]: + lines = [ + "\n## 市场结构上下文", + f"- 状态:{context.get('status', 'unknown')}", + ] + if active_themes: + lines.append(f"- 活跃题材:{','.join(active_themes)}") + if leading_concepts: + lines.append(f"- 领涨概念:{','.join(leading_concepts)}") + if leading_industries: + lines.append(f"- 领涨行业:{','.join(leading_industries)}") + if primary_name: + lines.append(f"- 个股主关联题材:{primary_name}") + lines.append(f"- 题材阶段:{stock_position.get('theme_phase', 'unknown')}") + lines.append(f"- 个股位置:{stock_position.get('stock_role', 'unknown')}") + if risk_tags: + lines.append(f"- 风险标签:{','.join(risk_tags)}") + if missing_fields: + lines.append(f"- 缺失证据:{','.join(missing_fields)}") + lines.append("- 约束:没有成分股或 leader_stocks 证据时,不要断言个股是题材龙头。") + return lines + + +def _format_ko( + context: Any, + stock_position: dict[str, Any], + active_themes: List[str], + leading_concepts: List[str], + leading_industries: List[str], + primary_name: str, + risk_tags: List[str], + missing_fields: List[str], +) -> List[str]: + lines = [ + "\n## 시장 구조 컨텍스트", + f"- 상태: {context.get('status', '알 수 없음')}", + ] + if active_themes: + lines.append(f"- 활성 테마: {', '.join(active_themes)}") + if leading_concepts: + lines.append(f"- 선도 테마: {', '.join(leading_concepts)}") + if leading_industries: + lines.append(f"- 선도 산업: {', '.join(leading_industries)}") + if primary_name: + lines.append(f"- 개별 종목 주력 테마: {primary_name}") + lines.append(f"- 테마 단계: {stock_position.get('theme_phase', '알 수 없음')}") + lines.append(f"- 종목 위치: {stock_position.get('stock_role', '알 수 없음')}") + if risk_tags: + lines.append(f"- 리스크 태그: {', '.join(risk_tags)}") + if missing_fields: + lines.append(f"- 부족한 근거: {', '.join(missing_fields)}") + lines.append("- 제약 규칙: 구성종목이나 leader_stocks 근거가 없다면 종목을 테마 선도주로 단정하지 마십시오.") + return lines + + +def _item_names(value: Any, *, limit: int) -> List[str]: + if not isinstance(value, list): + return [] + names: List[str] = [] + for item in value: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + change_pct = item.get("change_pct") + if isinstance(change_pct, (int, float)): + names.append(f"{name}({change_pct:+.2f}%)") + else: + names.append(name) + if len(names) >= limit: + break + return names + + +def _string_values(value: Any) -> List[str]: + if not isinstance(value, Iterable) or isinstance(value, (str, bytes, dict)): + return [] + normalized: List[str] = [] + for item in value: + text = str(item or "").strip() + if text: + normalized.append(text) + return normalized diff --git a/src/schemas/market_structure.py b/src/schemas/market_structure.py new file mode 100644 index 000000000..c52438a26 --- /dev/null +++ b/src/schemas/market_structure.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Versioned market-structure context shared by reports, Agent and API.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + + +MARKET_THEME_SCHEMA_VERSION = "market-theme-v1" +STOCK_MARKET_POSITION_SCHEMA_VERSION = "stock-market-position-v1" +MARKET_STRUCTURE_SCHEMA_VERSION = "market-structure-v1" + +MarketStructureStatus = Literal["ok", "partial", "unknown", "not_supported"] +ThemeRankSource = Literal["industry", "concept", "mixed", "unknown"] +ThemePhase = Literal["warming", "accelerating", "cooling", "unknown"] +StockRole = Literal["leader", "follower", "edge", "unknown"] + + +class MarketStructureSource(BaseModel): + provider: str = Field(..., description="数据源标识,仅作快照元数据,不参与运行时 provider/model 路由") + dataset: str = Field(..., description="数据集标识,仅用于历史可追溯快照") + status: str = Field("ok", description="来源可用性快照") + message: Optional[str] = Field(None, description="来源提示,仅展示/排障用") + + +class MarketStructureDataQuality(BaseModel): + status: MarketStructureStatus = Field("unknown", description="数据质量快照状态(展示语义)") + missing_fields: List[str] = Field(default_factory=list) + sources: List[MarketStructureSource] = Field(default_factory=list) + errors: List[str] = Field(default_factory=list) + + +class RankedThemeItem(BaseModel): + name: str + change_pct: Optional[float] = None + rank: Optional[int] = None + source: ThemeRankSource = "unknown" + code: Optional[str] = None + updated_at: Optional[str] = None + + +class MarketThemeItem(RankedThemeItem): + phase: ThemePhase = "unknown" + strength_score: Optional[int] = None + reason: Optional[str] = None + + +class ThemeBreadth(BaseModel): + active_count: int = 0 + leading_industry_count: int = 0 + leading_concept_count: int = 0 + lagging_count: int = 0 + + +class MarketThemeContext(BaseModel): + schema_version: str = MARKET_THEME_SCHEMA_VERSION + status: MarketStructureStatus = "unknown" + market: str = "cn" + trade_date: Optional[str] = None + active_themes: List[MarketThemeItem] = Field(default_factory=list) + leading_industries: List[RankedThemeItem] = Field(default_factory=list) + leading_concepts: List[RankedThemeItem] = Field(default_factory=list) + lagging_themes: List[RankedThemeItem] = Field(default_factory=list) + hotspot_constituents: List[Any] = Field(default_factory=list) + leader_stocks: List[Any] = Field(default_factory=list) + theme_breadth: ThemeBreadth = Field(default_factory=ThemeBreadth) + data_quality: MarketStructureDataQuality = Field(default_factory=MarketStructureDataQuality) + + +class StockBoardPosition(BaseModel): + name: str + type: Optional[str] = None + code: Optional[str] = None + rank: Optional[int] = None + change_pct: Optional[float] = None + source: ThemeRankSource = "unknown" + + +class PrimaryTheme(BaseModel): + name: str + source: ThemeRankSource = "unknown" + phase: ThemePhase = "unknown" + rank: Optional[int] = None + change_pct: Optional[float] = None + + +class MarketStructureRiskTag(BaseModel): + code: str + message: str + + +class StockMarketPosition(BaseModel): + schema_version: str = STOCK_MARKET_POSITION_SCHEMA_VERSION + status: MarketStructureStatus = "unknown" + stock_code: str + stock_name: Optional[str] = None + market: str = "cn" + primary_theme: Optional[PrimaryTheme] = None + related_boards: List[StockBoardPosition] = Field(default_factory=list) + stock_role: StockRole = "unknown" + theme_phase: ThemePhase = "unknown" + risk_tags: List[MarketStructureRiskTag] = Field(default_factory=list) + missing_fields: List[str] = Field(default_factory=list) + + +class MarketStructureContext(BaseModel): + schema_version: str = MARKET_STRUCTURE_SCHEMA_VERSION + status: MarketStructureStatus = "unknown" + market: str = "cn" + trade_date: Optional[str] = None + market_theme_context: MarketThemeContext + stock_market_position: StockMarketPosition + + +def dump_market_structure_model(model: BaseModel) -> Dict[str, Any]: + """Return a low-sensitive dict using stable snake_case keys.""" + return model.model_dump(exclude_none=True) diff --git a/src/services/analysis_service.py b/src/services/analysis_service.py index c07960ba0..64f60da10 100644 --- a/src/services/analysis_service.py +++ b/src/services/analysis_service.py @@ -241,7 +241,11 @@ class AnalysisService: "risk_warning": result.risk_warning, } } - + if hasattr(result, "to_dict"): + raw_result_payload = result.to_dict() + if isinstance(raw_result_payload, dict): + report["details"]["raw_result"] = raw_result_payload + return { "query_id": query_id, "trace_id": trace_id, diff --git a/src/services/decision_signal_extractor.py b/src/services/decision_signal_extractor.py index da31d04d8..39ce48d95 100644 --- a/src/services/decision_signal_extractor.py +++ b/src/services/decision_signal_extractor.py @@ -137,6 +137,9 @@ def build_decision_signal_payload_from_report( market_phase_summary = _extract_market_phase_summary(context_snapshot, result) if market_phase_summary: metadata["market_phase_summary"] = market_phase_summary + market_structure_summary = _extract_market_structure_summary(context_snapshot, result) + if market_structure_summary: + metadata.update(market_structure_summary) metadata["holding_state"] = _extract_holding_state(portfolio_context) payload: Dict[str, Any] = { @@ -366,6 +369,44 @@ def _extract_market_phase_summary( return summary or None +def _extract_market_structure_summary( + context_snapshot: Optional[Mapping[str, Any]], + result: AnalysisResult, +) -> Optional[Dict[str, Any]]: + payload = _as_mapping(getattr(result, "market_structure_context", None)) + if not payload: + snapshot = _as_mapping(context_snapshot) + payload = _as_mapping(snapshot.get("market_structure_context")) + if not payload: + payload = _as_mapping(_as_mapping(snapshot.get("enhanced_context")).get("market_structure_context")) + if payload.get("schema_version") != "market-structure-v1": + return None + + market_theme = _as_mapping(payload.get("market_theme_context")) + stock_position = _as_mapping(payload.get("stock_market_position")) + primary_theme = _as_mapping(stock_position.get("primary_theme")) + risk_tags = stock_position.get("risk_tags") + risk_codes = [] + if isinstance(risk_tags, list): + for item in risk_tags: + tag = _as_mapping(item) + code = tag.get("code") + if code: + risk_codes.append(str(code)) + + summary = { + "market_structure_version": payload.get("schema_version"), + "market_theme_version": market_theme.get("schema_version"), + "stock_market_position_version": stock_position.get("schema_version"), + "market_structure_status": payload.get("status"), + "primary_theme": primary_theme.get("name"), + "theme_phase": stock_position.get("theme_phase"), + "stock_role": stock_position.get("stock_role"), + "market_structure_risk_tags": risk_codes or None, + } + return {key: value for key, value in summary.items() if value not in (None, "", [], {})} + + def _extract_data_quality(context_snapshot: Optional[Mapping[str, Any]], result: AnalysisResult) -> Optional[Any]: snapshot_quality = _as_mapping( _as_mapping(context_snapshot).get("analysis_context_pack_overview") diff --git a/src/services/decision_signal_service.py b/src/services/decision_signal_service.py index 85d507d9d..306de6c76 100644 --- a/src/services/decision_signal_service.py +++ b/src/services/decision_signal_service.py @@ -409,6 +409,11 @@ class DecisionSignalService: change_pct=self._history_float(raw.get("change_pct")), model_used=raw.get("model_used"), query_id=getattr(record, "query_id", None), + market_structure_context=( + raw.get("market_structure_context") + if isinstance(raw.get("market_structure_context"), dict) + else None + ), ) payload = build_decision_signal_payload_from_report( result, diff --git a/src/services/market_hotspot_service.py b/src/services/market_hotspot_service.py new file mode 100644 index 000000000..04ee6cad9 --- /dev/null +++ b/src/services/market_hotspot_service.py @@ -0,0 +1,691 @@ +# -*- coding: utf-8 -*- +"""DSA-native market hotspot context service. + +This service intentionally does not import AlphaSift. It builds the first +market-theme layer from DSA's existing industry/concept ranking providers and +returns explicit data-quality markers when richer hotspot evidence is missing. +""" + +from __future__ import annotations + +import logging +import copy +import threading +from concurrent.futures import Future, TimeoutError as FutureTimeoutError +from datetime import date +import time +from typing import Any, Callable, Dict, Hashable, List, Optional, Set, Tuple + +from data_provider import DataFetcherManager + +from src.schemas.market_structure import ( + MarketStructureDataQuality, + MarketStructureSource, + MarketThemeContext, + MarketThemeItem, + RankedThemeItem, + ThemeBreadth, + ThemeRankSource, + dump_market_structure_model, +) + + +logger = logging.getLogger(__name__) + +DEFAULT_RANKING_FETCH_TIMEOUT_SECONDS = 3.0 +DEFAULT_RANKING_CACHE_FAILURE_TTL_SECONDS = 30.0 +DEFAULT_RANKING_CACHE_SUCCESS_TTL_SECONDS = 60.0 +RANKING_FETCH_MAX_WORKERS = 2 +RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS = 0.2 + + +class MarketHotspotService: + """Build low-sensitive A-share market/theme context from DSA rankings.""" + + _ranking_fetch_slots = threading.BoundedSemaphore(RANKING_FETCH_MAX_WORKERS) + _ranking_fetch_futures: Dict[Hashable, Future] = {} + _ranking_fetch_detached_futures: Set[Future] = set() + _ranking_fetch_retry_after: Dict[Hashable, Tuple[Future, float]] = {} + _ranking_fetch_futures_lock = threading.Lock() + + def __init__( + self, + fetcher_manager: Optional[DataFetcherManager] = None, + ranking_fetch_timeout_seconds: Optional[float] = None, + failure_cache_ttl_seconds: Optional[float] = None, + success_cache_ttl_seconds: Optional[float] = None, + ) -> None: + self.fetcher_manager = fetcher_manager or DataFetcherManager() + self._ranking_fetch_timeout_seconds = ranking_fetch_timeout_seconds + self._failure_cache_ttl_seconds = self._coerce_cache_ttl( + DEFAULT_RANKING_CACHE_FAILURE_TTL_SECONDS + if failure_cache_ttl_seconds is None + else failure_cache_ttl_seconds + ) + self._success_cache_ttl_seconds = self._coerce_cache_ttl( + DEFAULT_RANKING_CACHE_SUCCESS_TTL_SECONDS + if success_cache_ttl_seconds is None + else success_cache_ttl_seconds + ) + self._hotspots_cache: Dict[ + Tuple[str, Optional[str], int], + Dict[str, Any], + ] = {} + self._hotspots_cache_lock = threading.Lock() + + def get_hotspots( + self, + *, + market: str, + trade_date: Any = None, + limit: int = 5, + sector_rankings: Any = None, + concept_rankings: Any = None, + ) -> Dict[str, Any]: + normalized_market = str(market or "cn").strip().lower() or "cn" + trade_date_text = self._format_trade_date(trade_date) + try: + limit = max(1, int(limit or 5)) + except (TypeError, ValueError): + limit = 5 + + uses_preloaded_rankings = sector_rankings is not None or concept_rankings is not None + cache_key = (normalized_market, trade_date_text, limit) + if not uses_preloaded_rankings: + cached = self._get_cached_hotspots(cache_key) + if cached is not None: + return cached + + if normalized_market != "cn": + context = MarketThemeContext( + status="not_supported", + market=normalized_market, + trade_date=trade_date_text, + data_quality=MarketStructureDataQuality( + status="not_supported", + missing_fields=["industry_rankings", "concept_rankings"], + sources=[ + MarketStructureSource( + provider="dsa", + dataset="sector_rankings", + status="not_supported", + message="market structure hotspots are only supported for A-share first version", + ) + ], + ), + ) + return self._store_cached_hotspots(cache_key, dump_market_structure_model(context)) + + errors: List[str] = [] + sources: List[MarketStructureSource] = [] + top_industries, bottom_industries = self._resolve_rankings( + "get_sector_rankings", + "sector_rankings", + limit, + errors, + sources, + preloaded_rankings=sector_rankings, + ) + top_concepts, bottom_concepts = self._resolve_rankings( + "get_concept_rankings", + "concept_rankings", + limit, + errors, + sources, + preloaded_rankings=concept_rankings, + ) + + leading_industries = self._normalize_ranked_items(top_industries, "industry") + leading_concepts = self._normalize_ranked_items(top_concepts, "concept") + lagging_themes = list( + self._normalize_ranked_items(bottom_industries, "industry") + ) + list(self._normalize_ranked_items(bottom_concepts, "concept")) + active_themes = self._build_active_themes( + list(leading_industries) + list(leading_concepts), + limit=limit, + ) + + missing_fields: List[str] = [] + if not leading_industries and not bottom_industries: + missing_fields.append("industry_rankings") + if not leading_concepts and not bottom_concepts: + missing_fields.append("concept_rankings") + + has_any_ranking = bool(leading_industries or leading_concepts or lagging_themes) + has_partial_source = any( + source.status == "partial" + for source in sources + if source.provider == "dsa" and source.dataset in {"sector_rankings", "concept_rankings"} + ) + if not missing_fields and not errors and not has_partial_source: + status = "ok" + elif has_any_ranking: + status = "partial" + else: + status = "unknown" + + context = MarketThemeContext( + status=status, + market=normalized_market, + trade_date=trade_date_text, + active_themes=active_themes, + leading_industries=leading_industries, + leading_concepts=leading_concepts, + # Keep both ranking families. Each provider result is already bounded + # by ``limit``; truncating the combined list here can discard every + # lagging concept whenever the industry list fills the limit, which + # removes valid evidence from downstream stock-board matching. + lagging_themes=lagging_themes, + theme_breadth=ThemeBreadth( + active_count=len(active_themes), + leading_industry_count=len(leading_industries), + leading_concept_count=len(leading_concepts), + lagging_count=len(lagging_themes), + ), + data_quality=MarketStructureDataQuality( + status=status, + missing_fields=missing_fields, + sources=sources, + errors=errors, + ), + ) + payload = dump_market_structure_model(context) + if uses_preloaded_rankings: + return payload + return self._store_cached_hotspots(cache_key, payload) + + def _resolve_rankings( + self, + fetch_name: str, + dataset: str, + limit: int, + errors: List[str], + sources: List[MarketStructureSource], + *, + preloaded_rankings: Any = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + preloaded = self._rankings_from_payload(preloaded_rankings, dataset, sources) + if preloaded is not None: + return preloaded + return self._fetch_rankings(fetch_name, dataset, limit, errors, sources) + + @staticmethod + def _rankings_from_payload( + rankings: Any, + dataset: str, + sources: List[MarketStructureSource], + ) -> Optional[Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]]: + if rankings is None: + return None + if not isinstance(rankings, dict): + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status="invalid", + message="preloaded ranking payload is invalid", + ) + ) + return [], [] + + top = rankings.get("top") + bottom = rankings.get("bottom") + top_items = list(top) if isinstance(top, list) else [] + bottom_items = list(bottom) if isinstance(bottom, list) else [] + if isinstance(rankings.get("status"), str): + raw_status = rankings.get("status").strip().lower() + status = raw_status if raw_status in {"ok", "partial", "not_supported", "unknown"} else "ok" + else: + status = "ok" + if not top_items and not bottom_items: + status = "empty" + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status=status, + message="reused fundamental_context ranking payload", + ) + ) + return top_items, bottom_items + + def get_hotspot_detail(self, theme_name: str, market: str = "cn") -> Dict[str, Any]: + """Return an explicit placeholder for richer hotspot detail evidence.""" + normalized_market = str(market or "cn").strip().lower() or "cn" + status = "unknown" if normalized_market == "cn" else "not_supported" + return { + "theme_name": str(theme_name or "").strip(), + "market": normalized_market, + "status": status, + "missing_fields": ["hotspot_route", "hotspot_constituents", "leader_stocks"], + } + + def get_concept_rankings( + self, + limit: int = 5, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Get concept ranking top/bottom with timeout + concurrency protection.""" + errors: List[str] = [] + sources: List[MarketStructureSource] = [] + return self._resolve_rankings( + "get_concept_rankings", + "concept_rankings", + limit, + errors, + sources, + ) + + def _get_cached_hotspots( + self, + cache_key: Tuple[str, Optional[str], int], + ) -> Optional[Dict[str, Any]]: + with self._hotspots_cache_lock: + cached = self._hotspots_cache.get(cache_key) + if not isinstance(cached, dict): + return None + + payload = cached.get("payload") + if not isinstance(payload, dict): + return None + + expires_at = cached.get("expires_at") + if isinstance(expires_at, (int, float)) and expires_at < time.time(): + self._hotspots_cache.pop(cache_key, None) + return None + + return copy.deepcopy(payload) + + def _store_cached_hotspots( + self, + cache_key: Tuple[str, Optional[str], int], + payload: Dict[str, Any], + ) -> Dict[str, Any]: + if payload.get("status") == "ok": + success_ttl = self._success_cache_ttl_seconds + if success_ttl <= 0: + return copy.deepcopy(payload) + expires_at = time.time() + success_ttl + else: + status_error = payload.get("data_quality", {}).get("errors", []) + has_missing = bool(payload.get("data_quality", {}).get("missing_fields", [])) + if status_error or has_missing or payload.get("status") != "ok": + failure_ttl = self._failure_cache_ttl_seconds + if failure_ttl <= 0: + return copy.deepcopy(payload) + expires_at = time.time() + failure_ttl + else: + expires_at = None + + entry: Dict[str, Any] = { + "payload": copy.deepcopy(payload), + "expires_at": expires_at, + } + with self._hotspots_cache_lock: + self._hotspots_cache[cache_key] = copy.deepcopy(entry) + return copy.deepcopy(payload) + + @staticmethod + def _coerce_cache_ttl(value: Any) -> float: + try: + return max(0.0, float(value)) + except (TypeError, ValueError): + return DEFAULT_RANKING_CACHE_FAILURE_TTL_SECONDS + + def _fetch_rankings( + self, + fetch_name: str, + dataset: str, + limit: int, + errors: List[str], + sources: List[MarketStructureSource], + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + fetch_rankings = getattr(self.fetcher_manager, fetch_name, None) + if not callable(fetch_rankings): + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status="missing", + message=f"{fetch_name} is unavailable", + ) + ) + return [], [] + + try: + rankings = self._call_with_timeout( + lambda: fetch_rankings(limit), + timeout_seconds=self._resolve_ranking_fetch_timeout_seconds(), + task_name=dataset, + inflight_key=( + type(self.fetcher_manager), + id(self.fetcher_manager), + fetch_name, + limit, + ), + ) + if isinstance(rankings, tuple) and len(rankings) == 2: + top, bottom = rankings + top_items = list(top) if isinstance(top, list) else [] + bottom_items = list(bottom) if isinstance(bottom, list) else [] + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status="ok" if top_items or bottom_items else "empty", + ) + ) + return top_items, bottom_items + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status="invalid", + message="ranking provider returned an invalid payload", + ) + ) + except Exception as exc: + logger.debug("market hotspot ranking fetch failed dataset=%s: %s", dataset, exc) + errors.append(f"{dataset}: {exc}") + sources.append( + MarketStructureSource( + provider="dsa", + dataset=dataset, + status="failed", + message=str(exc), + ) + ) + return [], [] + + def _resolve_ranking_fetch_timeout_seconds(self) -> float: + if self._ranking_fetch_timeout_seconds is not None: + try: + return max(0.0, float(self._ranking_fetch_timeout_seconds)) + except (TypeError, ValueError): + return DEFAULT_RANKING_FETCH_TIMEOUT_SECONDS + try: + from src.config import get_config + + value = getattr( + get_config(), + "fundamental_fetch_timeout_seconds", + DEFAULT_RANKING_FETCH_TIMEOUT_SECONDS, + ) + return max(0.0, float(value)) + except Exception: + return DEFAULT_RANKING_FETCH_TIMEOUT_SECONDS + + @classmethod + def _call_with_timeout( + cls, + task: Callable[[], Any], + *, + timeout_seconds: float, + task_name: str, + inflight_key: Optional[Hashable] = None, + ) -> Any: + timeout_value = max(0.0, float(timeout_seconds)) + if timeout_value <= 0: + raise TimeoutError(f"{task_name} ranking fetch timeout") + + effective_inflight_key = inflight_key or task_name + future = cls._get_or_submit_ranking_fetch( + task, + inflight_key=effective_inflight_key, + task_name=task_name, + ) + try: + return future.result(timeout=timeout_value) + except FutureTimeoutError as exc: + if future.done() and future.exception(timeout=0) is exc: + raise + cls._mark_ranking_fetch_timeout( + effective_inflight_key, + future, + retry_after=time.monotonic() + + max(timeout_value, RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS), + ) + raise TimeoutError( + f"{task_name} ranking fetch timeout after {timeout_value:g}s" + ) from exc + + @classmethod + def _get_or_submit_ranking_fetch( + cls, + task: Callable[[], Any], + *, + inflight_key: Hashable, + task_name: str, + ) -> Future: + submitted: Future + worker: threading.Thread + with cls._ranking_fetch_futures_lock: + retry_entry = cls._ranking_fetch_retry_after.get(inflight_key) + now = time.monotonic() + if retry_entry is not None: + retry_future, retry_after = retry_entry + if retry_after > now: + raise TimeoutError( + f"{task_name} ranking fetch cooling down after previous timeout" + ) + cls._ranking_fetch_retry_after.pop(inflight_key, None) + if cls._ranking_fetch_futures.get(inflight_key) is retry_future: + cls._ranking_fetch_futures.pop(inflight_key, None) + if retry_future.done() or retry_future.cancelled(): + cls._ranking_fetch_slots.release() + else: + cls._ranking_fetch_detached_futures.add(retry_future) + + current = cls._ranking_fetch_futures.get(inflight_key) + if current is not None: + if not current.done(): + return current + cls._ranking_fetch_futures.pop(inflight_key, None) + cls._ranking_fetch_slots.release() + + if not cls._ranking_fetch_slots.acquire(blocking=False): + raise TimeoutError(f"{task_name} ranking fetch in-flight limit reached") + + future: Future = Future() + cls._ranking_fetch_retry_after.pop(inflight_key, None) + cls._ranking_fetch_futures[inflight_key] = future + future.add_done_callback( + lambda done_future: cls._forget_ranking_fetch(inflight_key, done_future) + ) + worker = threading.Thread( + target=cls._run_ranking_fetch, + args=(future, task), + daemon=True, + name=f"market-hotspot-{task_name}", + ) + submitted = future + try: + worker.start() + except BaseException as exc: + cls._drop_unstarted_ranking_fetch(inflight_key, submitted) + submitted.set_exception(exc) + raise + return submitted + + @classmethod + def _forget_ranking_fetch(cls, inflight_key: Hashable, future: Future) -> None: + with cls._ranking_fetch_futures_lock: + should_release_slot = False + if cls._ranking_fetch_futures.get(inflight_key) is future: + cls._ranking_fetch_futures.pop(inflight_key, None) + should_release_slot = True + elif future in cls._ranking_fetch_detached_futures: + cls._ranking_fetch_detached_futures.remove(future) + should_release_slot = True + + retry_entry = cls._ranking_fetch_retry_after.get(inflight_key) + if retry_entry is not None and retry_entry[0] is future: + cls._ranking_fetch_retry_after.pop(inflight_key, None) + + if should_release_slot: + cls._ranking_fetch_slots.release() + + @classmethod + def _mark_ranking_fetch_timeout( + cls, + inflight_key: Hashable, + future: Future, + *, + retry_after: float, + ) -> None: + with cls._ranking_fetch_futures_lock: + if cls._ranking_fetch_futures.get(inflight_key) is future: + cls._ranking_fetch_futures.pop(inflight_key, None) + if future.done() or future.cancelled(): + cls._ranking_fetch_retry_after.pop(inflight_key, None) + cls._ranking_fetch_slots.release() + return + cls._ranking_fetch_detached_futures.add(future) + cls._ranking_fetch_retry_after[inflight_key] = (future, retry_after) + + @classmethod + def _drop_unstarted_ranking_fetch( + cls, + inflight_key: Hashable, + future: Future, + ) -> None: + with cls._ranking_fetch_futures_lock: + if cls._ranking_fetch_futures.get(inflight_key) is future: + cls._ranking_fetch_futures.pop(inflight_key, None) + cls._ranking_fetch_slots.release() + + @staticmethod + def _run_ranking_fetch(future: Future, task: Callable[[], Any]) -> None: + if not future.set_running_or_notify_cancel(): + return + try: + result = task() + except BaseException as exc: + future.set_exception(exc) + else: + future.set_result(result) + + def _normalize_ranked_items( + self, + items: Any, + source: ThemeRankSource, + ) -> List[RankedThemeItem]: + if not isinstance(items, list): + return [] + + normalized: List[RankedThemeItem] = [] + for index, item in enumerate(items, 1): + if not isinstance(item, dict): + continue + name = self._optional_text( + item.get("name") + or item.get("板块名称") + or item.get("概念名称") + or item.get("行业名称") + ) + if not name: + continue + change_pct = self._safe_float( + item.get("change_pct") + if "change_pct" in item + else item.get("pct_chg") + if "pct_chg" in item + else item.get("涨跌幅") + if "涨跌幅" in item + else item.get("涨跌幅%") + ) + normalized.append( + RankedThemeItem( + name=name, + code=self._optional_text(item.get("code") or item.get("板块代码")), + change_pct=change_pct, + rank=self._safe_int(item.get("rank")) or index, + source=source, + updated_at=self._optional_text(item.get("updated_at")), + ) + ) + return normalized + + def _build_active_themes( + self, + items: List[RankedThemeItem], + *, + limit: int, + ) -> List[MarketThemeItem]: + positive_items = [ + item for item in items if item.change_pct is not None and item.change_pct > 0 + ] + positive_items.sort(key=lambda item: item.change_pct or 0, reverse=True) + + active: List[MarketThemeItem] = [] + for item in positive_items[:limit]: + active.append( + MarketThemeItem( + name=item.name, + code=item.code, + change_pct=item.change_pct, + rank=item.rank, + source=item.source, + updated_at=item.updated_at, + phase=self._phase_from_change(item.change_pct), + strength_score=self._strength_from_change(item.change_pct), + reason="industry/concept ranking gain", + ) + ) + return active + + @staticmethod + def _phase_from_change(value: Optional[float]) -> str: + if value is None: + return "unknown" + if value >= 3: + return "accelerating" + if value > 0: + return "warming" + return "cooling" + + @staticmethod + def _strength_from_change(value: Optional[float]) -> Optional[int]: + if value is None: + return None + return max(0, min(100, int(round(50 + value * 8)))) + + @staticmethod + def _format_trade_date(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, date): + return value.isoformat() + text = str(value).strip() + return text or None + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("%"): + text = text[:-1].strip() + return float(text) + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/src/services/market_structure_service.py b/src/services/market_structure_service.py new file mode 100644 index 000000000..dd70ef881 --- /dev/null +++ b/src/services/market_structure_service.py @@ -0,0 +1,703 @@ +# -*- coding: utf-8 -*- +"""Market structure context composer for stock reports.""" + +from __future__ import annotations + +import logging +from datetime import date +from typing import Any, Dict, List, Optional + +from data_provider import DataFetcherManager + +from src.schemas.market_structure import ( + MARKET_STRUCTURE_SCHEMA_VERSION, + MARKET_THEME_SCHEMA_VERSION, + STOCK_MARKET_POSITION_SCHEMA_VERSION, + MarketStructureContext, + MarketStructureDataQuality, + MarketStructureRiskTag, + MarketStructureSource, + MarketThemeContext, + PrimaryTheme, + StockBoardPosition, + StockMarketPosition, + ThemePhase, + ThemeRankSource, + dump_market_structure_model, +) +from src.services.market_hotspot_service import MarketHotspotService +from src.utils.data_processing import extract_board_detail_fields + + +logger = logging.getLogger(__name__) + +_VALID_THEME_SOURCES = {"industry", "concept", "mixed", "unknown"} +_VALID_THEME_PHASES = {"warming", "accelerating", "cooling", "unknown"} + + +class MarketStructureService: + """Compose market-theme and stock-position layers into one context.""" + + def __init__( + self, + fetcher_manager: Optional[DataFetcherManager] = None, + hotspot_service: Optional[MarketHotspotService] = None, + ) -> None: + self.fetcher_manager = fetcher_manager or DataFetcherManager() + self.hotspot_service = hotspot_service or MarketHotspotService( + fetcher_manager=self.fetcher_manager, + ) + + def build_context( + self, + *, + code: str, + stock_name: Optional[str], + market: str, + fundamental_context: Optional[Dict[str, Any]], + trade_date: Any = None, + market_phase_summary: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + normalized_market = str(market or "cn").strip().lower() or "cn" + trade_date_text = self._resolve_trade_date(trade_date, market_phase_summary) + stock_code = str(code or "").strip() + + if normalized_market != "cn": + return self._build_not_supported_context( + market=normalized_market, + trade_date=trade_date_text, + stock_code=stock_code, + stock_name=stock_name, + missing_fields=["a_share_theme_context"], + message="stock market structure is only supported for A-share first version", + ) + + if self._is_unsupported_fundamental_context(fundamental_context): + return self._build_not_supported_context( + market=normalized_market, + trade_date=trade_date_text, + stock_code=stock_code, + stock_name=stock_name, + missing_fields=["fundamental_boards"], + message="fundamental board context is not supported for this stock", + ) + + board_details = extract_board_detail_fields( + {"fundamental_context": fundamental_context or {}} + ) + sector_rankings_payload = board_details.get("sector_rankings") + concept_rankings_payload = board_details.get("concept_rankings") + sector_rankings = sector_rankings_payload or {} + concept_rankings = concept_rankings_payload or {} + market_theme_payload = self.hotspot_service.get_hotspots( + market=normalized_market, + trade_date=trade_date_text, + sector_rankings=sector_rankings_payload, + concept_rankings=concept_rankings_payload, + ) + market_theme_context = MarketThemeContext.model_validate(market_theme_payload) + + related_sector_rankings = self._merge_rankings_for_board_matching( + sector_rankings=sector_rankings, + leading_items=market_theme_payload.get("leading_industries", []), + lagging_items=market_theme_payload.get("lagging_themes", []), + lagging_allowed_sources={"industry", "unknown"}, + ) + related_concept_rankings = self._merge_rankings_for_board_matching( + sector_rankings=concept_rankings, + leading_items=market_theme_payload.get("leading_concepts", []), + lagging_items=market_theme_payload.get("lagging_themes", []), + lagging_allowed_sources={"concept", "unknown"}, + ) + + related_boards = self._build_related_boards( + board_details.get("belong_boards") or [], + sector_rankings=related_sector_rankings, + concept_rankings=related_concept_rankings, + ) + primary_theme, primary_theme_has_market_match = self._infer_primary_theme( + market_theme_payload, + related_boards, + ) + has_primary_market_evidence = self._has_primary_market_evidence(primary_theme) + hotspot_constituents = self._safe_cast_market_list( + market_theme_payload.get("hotspot_constituents"), + ) + leader_stocks = self._safe_cast_market_list( + market_theme_payload.get("leader_stocks"), + ) + has_stock_role_evidence = self._has_stock_role_evidence( + stock_code, + primary_theme, + hotspot_constituents, + leader_stocks, + ) + stock_role = self._infer_stock_role( + stock_code=stock_code, + primary_theme=primary_theme, + related_boards=related_boards, + has_market_match=primary_theme_has_market_match, + has_primary_market_evidence=has_primary_market_evidence, + has_stock_role_evidence=has_stock_role_evidence, + hotspot_constituents=hotspot_constituents, + leader_stocks=leader_stocks, + ) + theme_phase: ThemePhase = primary_theme.phase if primary_theme is not None else "unknown" + + missing_fields: List[str] = [] + if not self._is_non_empty_list(market_theme_payload.get("hotspot_constituents")): + missing_fields.append("hotspot_constituents") + if not self._is_non_empty_list(market_theme_payload.get("leader_stocks")): + missing_fields.append("leader_stocks") + risk_tags: List[MarketStructureRiskTag] = [] + if market_theme_context.status != "ok": + risk_tags.append( + MarketStructureRiskTag( + code="theme_data_partial", + message="市场题材数据不完整,题材强弱仅作降级参考", + ) + ) + if related_boards and not primary_theme_has_market_match: + missing_fields.append("theme_ranking_match") + risk_tags.append( + MarketStructureRiskTag( + code="stock_theme_evidence_partial", + message="个股板块未匹配到市场题材榜单,个股位置按降级证据处理", + ) + ) + if not related_boards: + missing_fields.append("belong_boards") + risk_tags.append( + MarketStructureRiskTag( + code="board_membership_missing", + message="缺少个股所属板块证据,无法判断题材位置", + ) + ) + + if stock_role in {"leader", "follower"}: + stock_status = "ok" + elif primary_theme is not None or related_boards: + stock_status = "partial" + else: + stock_status = "unknown" + + if market_theme_context.status == "ok" and stock_status == "ok": + combined_status = "ok" + elif market_theme_context.status in {"ok", "partial"} or stock_status in {"ok", "partial"}: + combined_status = "partial" + else: + combined_status = "unknown" + + stock_position = StockMarketPosition( + status=stock_status, + stock_code=stock_code, + stock_name=stock_name, + market=normalized_market, + primary_theme=primary_theme, + related_boards=related_boards, + stock_role=stock_role, + theme_phase=theme_phase, + risk_tags=risk_tags, + missing_fields=missing_fields, + ) + context = MarketStructureContext( + status=combined_status, + market=normalized_market, + trade_date=trade_date_text, + market_theme_context=market_theme_context, + stock_market_position=stock_position, + ) + return dump_market_structure_model(context) + + @staticmethod + def _is_unsupported_fundamental_context( + fundamental_context: Optional[Dict[str, Any]], + ) -> bool: + if not isinstance(fundamental_context, dict): + return False + + if MarketStructureService._is_not_supported_status(fundamental_context.get("status")): + return True + + boards_block = fundamental_context.get("boards") + boards_status = boards_block.get("status") if isinstance(boards_block, dict) else None + if MarketStructureService._is_not_supported_status(boards_status): + return True + + coverage = fundamental_context.get("coverage") + boards_coverage = coverage.get("boards") if isinstance(coverage, dict) else None + return MarketStructureService._is_not_supported_status(boards_coverage) + + @staticmethod + def _is_not_supported_status(value: Any) -> bool: + return str(value or "").strip().lower() == "not_supported" + + @staticmethod + def _build_not_supported_context( + *, + market: str, + trade_date: Optional[str], + stock_code: str, + stock_name: Optional[str], + missing_fields: List[str], + message: str, + ) -> Dict[str, Any]: + theme_context = MarketThemeContext( + status="not_supported", + market=market, + trade_date=trade_date, + data_quality=MarketStructureDataQuality( + status="not_supported", + missing_fields=missing_fields, + sources=[ + MarketStructureSource( + provider="dsa", + dataset="market_structure", + status="not_supported", + message=message, + ) + ], + ), + ) + stock_position = StockMarketPosition( + status="not_supported", + stock_code=stock_code, + stock_name=stock_name, + market=market, + missing_fields=missing_fields, + ) + return dump_market_structure_model( + MarketStructureContext( + status="not_supported", + market=market, + trade_date=trade_date, + market_theme_context=theme_context, + stock_market_position=stock_position, + ) + ) + + def _build_related_boards( + self, + boards: Any, + *, + sector_rankings: Dict[str, Any], + concept_rankings: Dict[str, Any], + ) -> List[StockBoardPosition]: + if not isinstance(boards, list): + return [] + + related: List[StockBoardPosition] = [] + for board in boards: + if not isinstance(board, dict): + continue + name = self._optional_text(board.get("name")) + if not name: + continue + board_type = self._optional_text(board.get("type")) + source, ranking_item = self._resolve_board_rank_source( + name, + board_type=board_type, + sector_rankings=sector_rankings, + concept_rankings=concept_rankings, + ) + related.append( + StockBoardPosition( + name=name, + type=board_type, + code=self._optional_text(board.get("code")), + rank=self._safe_int((ranking_item or {}).get("rank")), + change_pct=self._safe_float((ranking_item or {}).get("change_pct")), + source=source, + ) + ) + return related + + def _resolve_board_rank_source( + self, + name: str, + *, + board_type: Optional[str], + sector_rankings: Dict[str, Any], + concept_rankings: Dict[str, Any], + ) -> tuple[ThemeRankSource, Optional[Dict[str, Any]]]: + if board_type is not None: + source: ThemeRankSource = "concept" if self._is_concept_type(board_type) else "industry" + ranking_payload = concept_rankings if source == "concept" else sector_rankings + return source, self._find_ranking_item(name, ranking_payload) + + concept_item = self._find_ranking_item(name, concept_rankings) + if concept_item is not None: + return "concept", concept_item + + sector_item = self._find_ranking_item(name, sector_rankings) + if sector_item is not None: + return "industry", sector_item + + if self._is_concept_type(name): + return "concept", None + return "industry", None + + @staticmethod + def _merge_rankings_for_board_matching( + *, + sector_rankings: Any, + leading_items: Any, + lagging_items: Any, + lagging_allowed_sources: set[str], + ) -> Dict[str, List[Dict[str, Any]]]: + top: List[Dict[str, Any]] = [] + bottom: List[Dict[str, Any]] = [] + + def append_if_dict(target: List[Dict[str, Any]], item: Any) -> None: + if not isinstance(item, dict): + return + name = item.get("name") + if not name: + return + target.append(dict(item)) + + if isinstance(sector_rankings, dict): + for item in sector_rankings.get("top", []): + append_if_dict(top, item) + for item in sector_rankings.get("bottom", []): + append_if_dict(bottom, item) + + for item in leading_items if isinstance(leading_items, list) else []: + append_if_dict(top, item) + + for item in lagging_items if isinstance(lagging_items, list) else []: + source = str(item.get("source") or "unknown").strip().lower() + if source not in lagging_allowed_sources: + continue + append_if_dict(bottom, item) + + return {"top": top, "bottom": bottom} + + def _infer_primary_theme( + self, + market_theme_payload: Dict[str, Any], + related_boards: List[StockBoardPosition], + ) -> tuple[Optional[PrimaryTheme], bool]: + if not related_boards: + return None, False + + related_names = {board.name for board in related_boards} + candidates: List[Dict[str, Any]] = [] + for field in ( + "active_themes", + "leading_concepts", + "leading_industries", + "lagging_themes", + ): + value = market_theme_payload.get(field) + if isinstance(value, list): + candidates.extend(item for item in value if isinstance(item, dict)) + + for item in candidates: + name = self._optional_text(item.get("name")) + if not name or name not in related_names: + continue + source = self._theme_source(item.get("source")) + if source in {"concept", "industry"}: + if not any( + board.name == name and board.source == source + for board in related_boards + ): + continue + phase = self._theme_phase(item.get("phase")) + if phase == "unknown": + phase = self._phase_from_change(self._safe_float(item.get("change_pct"))) + return PrimaryTheme( + name=name, + source=source, + phase=phase, + rank=self._safe_int(item.get("rank")), + change_pct=self._safe_float(item.get("change_pct")), + ), True + + first = self._select_ranked_related_board(related_boards) + return PrimaryTheme( + name=first.name, + source=first.source, + phase=self._phase_from_change(first.change_pct), + rank=first.rank, + change_pct=first.change_pct, + ), False + + @staticmethod + def _select_ranked_related_board( + related_boards: List[StockBoardPosition], + ) -> StockBoardPosition: + for board in related_boards: + if board.rank is not None or board.change_pct is not None: + return board + return related_boards[0] + + @classmethod + def _infer_stock_role( + cls, + stock_code: str, + primary_theme: Optional[PrimaryTheme], + related_boards: List[StockBoardPosition], + has_market_match: bool, + has_primary_market_evidence: bool, + has_stock_role_evidence: bool, + *, + hotspot_constituents: List[Dict[str, Any]], + leader_stocks: List[Dict[str, Any]], + ) -> str: + if primary_theme is None: + return "edge" if related_boards else "unknown" + if not has_market_match: + return "edge" if related_boards else "unknown" + if not has_primary_market_evidence or not has_stock_role_evidence: + return "edge" if related_boards else "unknown" + for board in related_boards: + if board.name == primary_theme.name: + if cls._is_stock_leader_for_theme( + stock_code, + primary_theme.name, + leader_stocks, + ): + return "leader" + if cls._is_stock_in_constituents_for_theme( + stock_code, + primary_theme.name, + hotspot_constituents, + ): + return "follower" + break + return "edge" if related_boards else "unknown" + + @staticmethod + def _is_non_empty_list(value: Any) -> bool: + return isinstance(value, list) and bool(value) + + @classmethod + def _has_stock_role_evidence( + cls, + stock_code: str, + primary_theme: Optional[PrimaryTheme], + hotspot_constituents: List[Dict[str, Any]], + leader_stocks: List[Dict[str, Any]], + ) -> bool: + if primary_theme is None: + return False + if not stock_code: + return False + return ( + cls._is_stock_in_constituents_for_theme( + stock_code, + primary_theme.name, + hotspot_constituents, + ) + or cls._is_stock_leader_for_theme( + stock_code, + primary_theme.name, + leader_stocks, + ) + ) + + @staticmethod + def _safe_cast_market_list(value: Any) -> List[Dict[str, Any]]: + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + @classmethod + def _is_stock_in_constituents_for_theme( + cls, + stock_code: str, + theme_name: str, + items: List[Dict[str, Any]], + ) -> bool: + return cls._match_stock_in_thematic_list( + stock_code, + theme_name, + items, + ) + + @classmethod + def _is_stock_leader_for_theme( + cls, + stock_code: str, + theme_name: str, + items: List[Dict[str, Any]], + ) -> bool: + return cls._match_stock_in_thematic_list( + stock_code, + theme_name, + items, + ) + + @classmethod + def _match_stock_in_thematic_list( + cls, + stock_code: str, + theme_name: str, + items: List[Dict[str, Any]], + ) -> bool: + normalized_stock_code = cls._normalize_stock_code(stock_code) + normalized_theme = cls._normalize_theme_name(theme_name) + if not normalized_stock_code or not normalized_theme: + return False + + for item in items: + candidate_code = cls._extract_stock_code(item) + if not candidate_code or candidate_code != normalized_stock_code: + continue + themes = cls._extract_item_themes(item) + if not themes: + continue + if normalized_theme in themes: + return True + return False + + @staticmethod + def _extract_stock_code(item: Dict[str, Any]) -> str: + for key in ("code", "stock_code", "ts_code", "ticker", "symbol"): + value = item.get(key) + if value is None: + continue + normalized = str(value).strip() + if normalized: + return normalized.upper() + return "" + + @staticmethod + def _extract_item_themes(item: Dict[str, Any]) -> set[str]: + themes: set[str] = set() + for key in ( + "theme", + "theme_name", + "topic", + "topic_name", + "industry", + "industry_name", + "concept", + "concept_name", + "board", + "board_name", + "theme_name_cn", + "theme_name_en", + "topic_name_cn", + "topic_name_en", + ): + value = str(item.get(key) or "").strip().lower() + if value: + themes.add(value) + return themes + + @staticmethod + def _normalize_theme_name(value: str) -> str: + return str(value or "").strip().lower() + + @staticmethod + def _normalize_stock_code(value: str) -> str: + return str(value or "").strip().upper() + + @staticmethod + def _has_primary_market_evidence(primary_theme: Optional[PrimaryTheme]) -> bool: + if primary_theme is None: + return False + return ( + primary_theme.rank is not None + or primary_theme.change_pct is not None + or primary_theme.phase != "unknown" + ) + + @staticmethod + def _resolve_trade_date( + trade_date: Any, + market_phase_summary: Optional[Dict[str, Any]], + ) -> Optional[str]: + if trade_date is not None: + if isinstance(trade_date, date): + return trade_date.isoformat() + text = str(trade_date).strip() + if text: + return text + if isinstance(market_phase_summary, dict): + for key in ("effective_daily_bar_date", "trade_date", "market_date"): + value = market_phase_summary.get(key) + if value: + return str(value) + return None + + @staticmethod + def _find_ranking_item(name: str, rankings: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(rankings, dict): + return None + for field in ("top", "bottom"): + items = rankings.get(field) + if not isinstance(items, list): + continue + for item in items: + if isinstance(item, dict) and str(item.get("name") or "").strip() == name: + return item + return None + + @staticmethod + def _is_concept_type(value: Optional[str]) -> bool: + text = str(value or "").strip().lower() + return any(keyword in text for keyword in ("概念", "题材", "concept", "theme")) + + @staticmethod + def _theme_source(value: Any) -> ThemeRankSource: + text = str(value or "unknown").strip() + return text if text in _VALID_THEME_SOURCES else "unknown" + + @staticmethod + def _theme_phase(value: Any) -> ThemePhase: + text = str(value or "unknown").strip() + return text if text in _VALID_THEME_PHASES else "unknown" + + @staticmethod + def _phase_from_change(value: Optional[float]) -> ThemePhase: + if value is None: + return "unknown" + if value >= 3: + return "accelerating" + if value > 0: + return "warming" + return "cooling" + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("%"): + text = text[:-1].strip() + return float(text) + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _optional_text(value: Any) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None + + +__all__ = [ + "MARKET_THEME_SCHEMA_VERSION", + "MARKET_STRUCTURE_SCHEMA_VERSION", + "STOCK_MARKET_POSITION_SCHEMA_VERSION", + "MarketStructureService", +] diff --git a/src/utils/data_processing.py b/src/utils/data_processing.py index 294d3fa73..aac2718fc 100644 --- a/src/utils/data_processing.py +++ b/src/utils/data_processing.py @@ -94,6 +94,15 @@ def _safe_float(value: Any) -> Optional[float]: return None +def _safe_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + def _normalize_sector_ranking_items(value: Any) -> List[Dict[str, Any]]: if not isinstance(value, list): return [] @@ -117,32 +126,47 @@ def _normalize_sector_ranking_items(value: Any) -> List[Dict[str, Any]]: change_pct = _safe_float(item.get("change_pct")) if change_pct is not None: ranking_item["change_pct"] = change_pct + rank = _safe_int(item.get("rank")) + if rank is not None: + ranking_item["rank"] = rank normalized.append(ranking_item) return normalized -def _normalize_sector_rankings(value: Any) -> Optional[Dict[str, List[Dict[str, Any]]]]: +def _normalize_sector_rankings(value: Any) -> Optional[Dict[str, Any]]: if not isinstance(value, dict): return None - return { + status = value.get("status") + status_text = status.strip().lower() if isinstance(status, str) else None + normalized: Dict[str, Any] = { "top": _normalize_sector_ranking_items(value.get("top")), "bottom": _normalize_sector_ranking_items(value.get("bottom")), } + if status_text: + normalized["status"] = status_text + return normalized -def _extract_ranking_payload_from_block(value: Any) -> Any: +def _extract_ranking_payload_from_block(value: Any) -> Optional[Dict[str, Any]]: if not isinstance(value, dict): return None if "top" in value or "bottom" in value: return value status = value.get("status") - if status not in {"ok", "partial", None}: + if not isinstance(status, str): + status_is_valid = status is None + else: + status_is_valid = status.strip().lower() in {"ok", "partial"} + if not status_is_valid: return None data = value.get("data") if isinstance(data, dict): - return data + payload = dict(data) + if isinstance(status, str): + payload["status"] = status.strip().lower() + return payload return None @@ -294,6 +318,38 @@ def extract_board_detail_fields( } +def extract_market_structure_detail_field( + context_snapshot: Any, + fallback_raw_result_payload: Any = None, +) -> Optional[Dict[str, Any]]: + """Extract the stable market_structure detail payload from persisted payloads.""" + snapshot_obj = parse_json_field(context_snapshot) + + candidates = [] + if isinstance(snapshot_obj, dict): + candidates.append(snapshot_obj.get("market_structure_context")) + enhanced = snapshot_obj.get("enhanced_context") + if isinstance(enhanced, dict): + candidates.append(enhanced.get("market_structure_context")) + + raw_result_obj = parse_json_field(fallback_raw_result_payload) + if isinstance(raw_result_obj, dict): + candidates.append(raw_result_obj.get("market_structure_context")) + + for candidate in candidates: + payload = parse_json_field(candidate) + if not isinstance(payload, dict): + continue + if payload.get("schema_version") != "market-structure-v1": + continue + if not isinstance(payload.get("market_theme_context"), dict): + continue + if not isinstance(payload.get("stock_market_position"), dict): + continue + return payload + return None + + def normalize_signal_attribution_values(signal_attr: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: """ Normalize signal_attribution values in-place. diff --git a/tests/test_analysis_api_contract.py b/tests/test_analysis_api_contract.py index 8dde85ba7..3cc5a8b64 100644 --- a/tests/test_analysis_api_contract.py +++ b/tests/test_analysis_api_contract.py @@ -122,6 +122,27 @@ def _analysis_context_pack_overview() -> dict: } +def _market_structure_context() -> dict: + return { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + "active_themes": [{"name": "机器人概念"}], + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + }, + } + + def _market_phase_summary() -> dict: return { "market": "cn", @@ -749,7 +770,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \ patch( "api.v1.endpoints.analysis._load_sync_fundamental_sources", - return_value=({}, None), + return_value=({}, None, None), ): status = get_analysis_status("task-queue-zero-score-enriched") @@ -759,6 +780,144 @@ class AnalysisApiContractTestCase(unittest.TestCase): self.assertEqual(status.result.report["summary"]["action"], "sell") self.assertEqual(status.result.report["summary"]["action_label"], "卖出") + def test_get_analysis_status_enriches_in_memory_market_structure_from_raw_result(self) -> None: + if get_analysis_status is None or analysis_endpoint_module is None: + self.skipTest("analysis endpoint helpers unavailable in this environment") + + market_structure = _market_structure_context() + created_at = datetime(2026, 5, 21, 17, 40, 0) + queue = MagicMock() + queue.get_task.return_value = SimpleNamespace( + task_id="task-queue-market-structure-raw", + stock_code="300024", + stock_name="机器人", + status=analysis_endpoint_module.TaskStatusEnum.COMPLETED, + progress=100, + result={ + "stock_code": "300024", + "stock_name": "机器人", + "report": { + "meta": { + "query_id": "task-queue-market-structure-raw", + "stock_code": "300024", + "report_type": "detailed", + "report_language": "zh", + }, + "summary": {"analysis_summary": "summary"}, + "details": {"news_summary": "news"}, + }, + }, + error=None, + original_query=None, + selection_source=None, + analysis_phase="auto", + created_at=created_at, + completed_at=datetime(2026, 5, 21, 17, 45, 0), + ) + + with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \ + patch( + "api.v1.endpoints.analysis._load_sync_fundamental_sources", + return_value=( + None, + None, + { + "model_used": "test-model", + "report_language": "zh", + "market_structure_context": market_structure, + }, + ), + ) as load_sources: + status = get_analysis_status("task-queue-market-structure-raw") + + self.assertEqual(status.status, "completed") + self.assertIsNotNone(status.result) + self.assertEqual( + status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertEqual( + status.result.report["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertNotIn( + "raw_result", + status.result.report["details"]["raw_result"], + ) + load_sources.assert_called_once_with( + query_id="task-queue-market-structure-raw", + stock_code="300024", + ) + + def test_get_analysis_status_enriches_in_memory_market_structure_without_history_snapshot(self) -> None: + if get_analysis_status is None or analysis_endpoint_module is None: + self.skipTest("analysis endpoint helpers unavailable in this environment") + + market_structure = _market_structure_context() + service = AnalysisService() + task_result = service._build_analysis_response( + SimpleNamespace( + code="300024", + name="机器人", + current_price=999.9, + change_pct=1.1, + model_used="test-model", + analysis_summary="summary", + operation_advice="持有", + trend_prediction="震荡", + sentiment_score=80, + news_summary="news", + technical_analysis="tech", + fundamental_analysis="fundamental", + risk_warning="risk", + market_structure_context=market_structure, + to_dict=lambda: { + "analysis_summary": "summary", + "operation_advice": "持有", + "trend_prediction": "震荡", + "sentiment_score": 80, + "report_language": "zh", + "news_summary": "news", + "technical_analysis": "tech", + "fundamental_analysis": "fundamental", + "risk_warning": "risk", + "market_structure_context": market_structure, + }, + ), + "task-in-memory-no-history", + report_type="detailed", + ) + created_at = datetime(2026, 5, 21, 17, 40, 0) + queue = MagicMock() + queue.get_task.return_value = SimpleNamespace( + task_id="task-in-memory-no-history", + stock_code="300024", + stock_name="机器人", + status=analysis_endpoint_module.TaskStatusEnum.COMPLETED, + progress=100, + result=task_result, + error=None, + original_query=None, + selection_source=None, + analysis_phase="auto", + created_at=created_at, + completed_at=datetime(2026, 5, 21, 17, 45, 0), + ) + + with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \ + patch( + "api.v1.endpoints.analysis._load_sync_fundamental_sources", + return_value=(None, None, None), + ): + status = get_analysis_status("task-in-memory-no-history") + + self.assertEqual(status.status, "completed") + self.assertIsNotNone(status.result) + self.assertEqual( + status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + def test_get_analysis_status_preserves_queue_report_created_at_when_enriching(self) -> None: if get_analysis_status is None or analysis_endpoint_module is None: self.skipTest("analysis endpoint helpers unavailable in this environment") @@ -790,7 +949,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \ patch( "api.v1.endpoints.analysis._load_sync_fundamental_sources", - return_value=({}, None), + return_value=({}, None, None), ): status = get_analysis_status("task-queue-2") @@ -1179,6 +1338,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): "market_phase_summary": phase_summary, }, None, + None, ), ): result = _handle_sync_analysis( @@ -1209,6 +1369,125 @@ class AnalysisApiContractTestCase(unittest.TestCase): self.assertNotIn("analysis_context_pack_overview", details["context_snapshot"]) self.assertNotIn("market_phase_summary", details["context_snapshot"]) + def test_handle_sync_analysis_restores_market_structure_from_raw_result_snapshot(self) -> None: + if _handle_sync_analysis is None: + self.skipTest("analysis endpoint helpers unavailable in this environment") + + market_structure = _market_structure_context() + service_instance = MagicMock() + service_instance.analyze_stock.return_value = { + "stock_code": "300024", + "stock_name": "机器人", + "report": { + "meta": {"stock_code": "300024", "report_language": "zh"}, + "summary": {"analysis_summary": "summary"}, + "strategy": {}, + "details": {"news_summary": "news"}, + }, + } + + with patch("uuid.uuid4", return_value=SimpleNamespace(hex="q-sync-market-structure")), \ + patch("src.services.analysis_service.AnalysisService", return_value=service_instance), \ + patch( + "api.v1.endpoints.analysis._load_sync_fundamental_sources", + return_value=( + None, + None, + { + "model_used": "test-model", + "report_language": "zh", + "market_structure_context": market_structure, + }, + ), + ): + result = _handle_sync_analysis( + "300024", + SimpleNamespace( + report_type="detailed", + force_refresh=False, + notify=True, + skills=None, + analysis_phase="intraday", + ), + ) + + self.assertEqual( + result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + + def test_handle_sync_analysis_carries_market_structure_from_service_without_fallback(self) -> None: + if _handle_sync_analysis is None: + self.skipTest("analysis endpoint helpers unavailable in this environment") + + market_structure = _market_structure_context() + service = AnalysisService() + service_result = service._build_analysis_response( + SimpleNamespace( + code="300024", + name="机器人", + current_price=999.9, + change_pct=1.1, + model_used="test-model", + analysis_summary="summary", + operation_advice="持有", + trend_prediction="震荡", + sentiment_score=80, + news_summary="news", + technical_analysis="tech", + fundamental_analysis="fundamental", + risk_warning="risk", + market_structure_context=market_structure, + to_dict=lambda: { + "analysis_summary": "summary", + "operation_advice": "持有", + "trend_prediction": "震荡", + "sentiment_score": 80, + "report_language": "zh", + "news_summary": "news", + "technical_analysis": "tech", + "fundamental_analysis": "fundamental", + "risk_warning": "risk", + "market_structure_context": market_structure, + }, + ), + "q-sync-no-history", + report_type="detailed", + ) + service_instance = MagicMock() + service_instance.analyze_stock.return_value = service_result + + with patch("uuid.uuid4", return_value=SimpleNamespace(hex="q-sync-no-history")), \ + patch("src.services.analysis_service.AnalysisService", return_value=service_instance), \ + patch( + "api.v1.endpoints.analysis._load_sync_fundamental_sources", + return_value=(None, None, None), + ): + result = _handle_sync_analysis( + "300024", + SimpleNamespace( + report_type="detailed", + force_refresh=False, + notify=True, + skills=None, + analysis_phase="intraday", + ), + ) + + self.assertIsNotNone(result) + self.assertEqual( + result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertEqual( + result.report["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertNotIn( + "raw_result", + result.report["details"]["raw_result"], + ) + def test_build_analysis_response_localizes_placeholder_stock_name_for_english(self) -> None: service = AnalysisService() result = service._build_analysis_response( @@ -1293,6 +1572,51 @@ class AnalysisApiContractTestCase(unittest.TestCase): "intraday", ) + def test_build_analysis_response_includes_market_structure_in_raw_result(self) -> None: + service = AnalysisService() + market_structure = _market_structure_context() + + def _raw_result() -> dict: + return { + "analysis_summary": "summary", + "operation_advice": "持有", + "trend_prediction": "震荡", + "sentiment_score": 80, + "report_language": "zh", + "news_summary": "news", + "technical_analysis": "tech", + "fundamental_analysis": "fundamental", + "risk_warning": "risk", + "market_structure_context": market_structure, + } + + result = service._build_analysis_response( + SimpleNamespace( + code="300024", + name="机器人", + current_price=999.9, + change_pct=1.01, + model_used="test-model", + analysis_summary="summary", + operation_advice="持有", + trend_prediction="震荡", + sentiment_score=80, + news_summary="news", + technical_analysis="tech", + fundamental_analysis="fundamental", + risk_warning="risk", + market_structure_context=market_structure, + to_dict=_raw_result, + ), + "q-build-response", + report_type="detailed", + ) + + self.assertEqual( + result["report"]["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + def test_analysis_service_passes_analysis_phase_to_pipeline(self) -> None: service = AnalysisService() pipeline_instance = MagicMock() @@ -1911,7 +2235,17 @@ class AnalysisApiContractTestCase(unittest.TestCase): self.skipTest("analysis endpoint helpers unavailable in this environment") mock_db = MagicMock() - mock_db.get_analysis_history.return_value = [SimpleNamespace(context_snapshot=None)] + raw_result_payload = { + "model_used": "test-model", + "report_language": "zh", + "market_structure_context": _market_structure_context(), + } + mock_db.get_analysis_history.return_value = [ + SimpleNamespace( + context_snapshot=None, + raw_result=json.dumps(raw_result_payload, ensure_ascii=False), + ) + ] fallback_payload = { "earnings": { "data": { @@ -1923,13 +2257,14 @@ class AnalysisApiContractTestCase(unittest.TestCase): mock_db.get_latest_fundamental_snapshot.return_value = fallback_payload with patch("src.storage.DatabaseManager.get_instance", return_value=mock_db): - context_snapshot, fundamental_snapshot = _load_sync_fundamental_sources( + context_snapshot, fundamental_snapshot, raw_result_snapshot = _load_sync_fundamental_sources( query_id="q_sync_001", stock_code="600519", ) self.assertIsNone(context_snapshot) self.assertEqual(fundamental_snapshot, fallback_payload) + self.assertEqual(raw_result_snapshot, raw_result_payload) mock_db.get_analysis_history.assert_called_once_with( query_id="q_sync_001", code="600519", @@ -1989,6 +2324,66 @@ class AnalysisApiContractTestCase(unittest.TestCase): self.assertEqual(status.result.report["meta"]["change_pct"], 0.0) self.assertEqual(status.result.report["meta"]["model_used"], "test-model") + def test_get_analysis_status_restores_market_structure_from_raw_result_without_snapshot(self) -> None: + if get_analysis_status is None: + self.skipTest("analysis endpoint helpers unavailable in this environment") + + market_structure = { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + "active_themes": [{"name": "机器人概念"}], + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + }, + } + record = SimpleNamespace( + id=1, + code="300024", + name="机器人", + report_type="detailed", + created_at=datetime(2026, 4, 10, 12, 0, 0), + raw_result=json.dumps( + { + "model_used": "test-model", + "report_language": "zh", + "market_structure_context": market_structure, + } + ), + context_snapshot=None, + sentiment_score=80, + operation_advice="持有", + trend_prediction="震荡上行", + analysis_summary="summary", + ideal_buy=None, + secondary_buy=None, + stop_loss=None, + take_profit=None, + ) + mock_db = MagicMock() + mock_db.get_analysis_history.return_value = [record] + mock_db.get_latest_fundamental_snapshot.return_value = None + + with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \ + patch("src.storage.DatabaseManager.get_instance", return_value=mock_db): + queue_mock.return_value.get_task.return_value = None + status = get_analysis_status("task_market_structure_raw_1") + + self.assertEqual(status.status, "completed") + self.assertEqual( + status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + def test_get_analysis_status_completed_db_snapshot_includes_agent_snapshot_board_details(self) -> None: if get_analysis_status is None: self.skipTest("analysis endpoint helpers unavailable in this environment") @@ -2213,7 +2608,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \ patch( "api.v1.endpoints.analysis._load_sync_fundamental_sources", - return_value=(None, None), + return_value=(None, None, None), ) as load_sources: queue_mock.return_value.get_task.return_value = task status = get_analysis_status("task_no_snapshot_in_memory_1") diff --git a/tests/test_analysis_history.py b/tests/test_analysis_history.py index 19b3c4eed..552734999 100644 --- a/tests/test_analysis_history.py +++ b/tests/test_analysis_history.py @@ -1568,6 +1568,65 @@ class AnalysisHistoryTestCase(unittest.TestCase): self.assertIsNone(report.details.analysis_context_pack_overview) self.assertIsNone(report.details.context_snapshot) + def test_history_detail_restores_market_structure_from_raw_result_without_snapshot(self) -> None: + """SAVE_CONTEXT_SNAPSHOT=false should still expose market_structure saved in raw_result.""" + if get_history_detail is None: + self.skipTest("fastapi is not installed in this test environment") + + market_structure = { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + "active_themes": [{"name": "机器人概念"}], + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + }, + } + result = self._build_result() + result.market_structure_context = market_structure + query_id = "query_market_structure_snapshot_disabled_001" + saved = self.db.save_analysis_history( + result=result, + query_id=query_id, + report_type="simple", + news_content="新闻摘要", + context_snapshot={"market_structure_context": {"ignored": True}}, + 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() + if row is None: + self.fail("未找到保存的历史记录") + self.assertEqual(row.id, saved) + self.assertIsNone(row.context_snapshot) + record_id = row.id + + report = get_history_detail(str(record_id), db_manager=self.db) + self.assertIsNone(report.details.context_snapshot) + self.assertEqual( + report.details.market_structure["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertEqual( + report.details.raw_result["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"], + "机器人概念", + ) + self.assertNotIn( + "raw_result", + report.details.raw_result, + ) + def test_history_markdown_localizes_english_report_and_placeholder_name(self) -> None: """History markdown should preserve report_language for English reports.""" result = AnalysisResult( diff --git a/tests/test_decision_signal_extractor.py b/tests/test_decision_signal_extractor.py index 7d744d311..f92d8efe7 100644 --- a/tests/test_decision_signal_extractor.py +++ b/tests/test_decision_signal_extractor.py @@ -177,6 +177,52 @@ def test_build_payload_maps_report_context_and_price_plan() -> None: assert payload["metadata"]["holding_state"] == "holding" +def test_build_payload_adds_market_structure_metadata() -> None: + context_snapshot = { + "market_structure_context": { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + "theme_phase": "accelerating", + "stock_role": "follower", + "risk_tags": [{"code": "theme_data_partial", "message": "partial"}], + }, + } + } + + payload = build_decision_signal_payload_from_report( + _result(code="300024", name="机器人"), + context_snapshot=context_snapshot, + source_report_id=91, + trace_id="trace-market-structure", + query_source="api", + report_type="full", + profile_source=BUILD_PROFILE_SOURCE, + ) + + assert payload is not None + metadata = payload["metadata"] + assert metadata["market_structure_version"] == "market-structure-v1" + assert metadata["market_theme_version"] == "market-theme-v1" + assert metadata["stock_market_position_version"] == "stock-market-position-v1" + assert metadata["market_structure_status"] == "partial" + assert metadata["primary_theme"] == "机器人概念" + assert metadata["theme_phase"] == "accelerating" + assert metadata["stock_role"] == "follower" + assert metadata["market_structure_risk_tags"] == ["theme_data_partial"] + + def test_build_payload_uses_result_fallbacks_and_optional_catalysts() -> None: result = _result(confidence_level="低") result.dashboard = { diff --git a/tests/test_decision_signal_service.py b/tests/test_decision_signal_service.py index cf9e6ff6f..2c3608cb3 100644 --- a/tests/test_decision_signal_service.py +++ b/tests/test_decision_signal_service.py @@ -408,6 +408,49 @@ def test_list_signals_profile_filter_controls_lazy_backfill(isolated_db) -> None assert balanced["items"][0]["decision_profile"] == "balanced" with isolated_db.get_session() as session: assert session.query(DecisionSignalRecord).count() == 1 +def test_list_signals_backfill_uses_raw_result_market_structure_without_snapshot(isolated_db) -> None: + market_structure = { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + "theme_phase": "accelerating", + "stock_role": "follower", + "risk_tags": [{"code": "theme_data_partial"}], + }, + } + record_id = isolated_db.save_analysis_history( + result=_history_result(code="300024", name="机器人", market_structure_context=market_structure), + query_id="query-lazy-signal-market-structure", + report_type="simple", + news_content="新闻摘要", + context_snapshot={"market_structure_context": {"ignored": True}}, + save_snapshot=False, + ) + service = DecisionSignalService(db_manager=isolated_db) + + listed = service.list_signals(source_type="analysis", source_report_id=record_id) + + assert listed["total"] == 1 + metadata = listed["items"][0]["metadata"] + assert metadata["market_structure_version"] == "market-structure-v1" + assert metadata["market_theme_version"] == "market-theme-v1" + assert metadata["stock_market_position_version"] == "stock-market-position-v1" + assert metadata["market_structure_status"] == "partial" + assert metadata["primary_theme"] == "机器人概念" + assert metadata["theme_phase"] == "accelerating" + assert metadata["stock_role"] == "follower" + assert metadata["market_structure_risk_tags"] == ["theme_data_partial"] @pytest.mark.parametrize( diff --git a/tests/test_market_structure_service.py b/tests/test_market_structure_service.py new file mode 100644 index 000000000..72d4c8778 --- /dev/null +++ b/tests/test_market_structure_service.py @@ -0,0 +1,1110 @@ +# -*- coding: utf-8 -*- +"""Market structure service regression tests.""" + +from __future__ import annotations + +from concurrent.futures import Future +import threading +import time + +from src.services.market_hotspot_service import ( + RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS, + MarketHotspotService, +) +from src.services.market_structure_service import MarketStructureService + + +class _FakeFetcherManager: + def __init__(self, *, fail: bool = False) -> None: + self.fail = fail + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + if self.fail: + raise RuntimeError("sector down") + return ( + [{"name": "通用设备", "change_pct": 2.1}], + [{"name": "旅游酒店", "change_pct": -1.8}], + ) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + if self.fail: + raise RuntimeError("concept down") + return ( + [{"name": "机器人概念", "change_pct": 4.2}], + [{"name": "转基因", "change_pct": -2.0}], + ) + + +class _DownTrendFetcherManager: + def __init__(self) -> None: + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + return ( + [{"name": "通用设备", "change_pct": -1.8}], + [{"name": "旅游酒店", "change_pct": -2.1}], + ) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + return ( + [{"name": "机器人概念", "change_pct": 0.0}], + [{"name": "转基因", "change_pct": -2.0}], + ) + + +class _OverlappingLaggingThemeFetcherManager: + def get_sector_rankings(self, n: int = 5): + return ( + [], + [{"name": "旅游酒店", "rank": 1, "change_pct": -4.1}], + ) + + def get_concept_rankings(self, n: int = 5): + return ( + [], + [{"name": "转基因", "rank": 1, "change_pct": -2.0}], + ) + + +class _FullLaggingFamiliesFetcherManager: + def get_sector_rankings(self, n: int = 5): + return ( + [], + [ + {"name": f"Industry {index}", "rank": index, "change_pct": -float(index)} + for index in range(1, n + 1) + ], + ) + + def get_concept_rankings(self, n: int = 5): + return ( + [], + [{"name": "Concept Target", "rank": 1, "change_pct": -6.0}], + ) + + +class _RecoverableFailureFetcherManager: + def __init__(self) -> None: + self.fail = True + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + if self.fail: + raise RuntimeError("sector down") + return ( + [{"name": "通用设备", "change_pct": -1.8}], + [{"name": "旅游酒店", "change_pct": -2.1}], + ) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + if self.fail: + raise RuntimeError("concept down") + return ( + [{"name": "机器人概念", "change_pct": 0.0}], + [{"name": "转基因", "change_pct": -2.0}], + ) + + +class _EmptyHotspotService: + def get_hotspots( + self, + *, + market: str, + trade_date=None, + limit: int = 5, + sector_rankings=None, + concept_rankings=None, + ): + return { + "status": "ok", + "market": market, + "trade_date": trade_date, + "active_themes": [], + "leading_industries": [], + "leading_concepts": [], + "lagging_themes": [], + } + + +class _SourceConflictHotspotService: + def get_hotspots( + self, + *, + market: str, + trade_date=None, + limit: int = 5, + sector_rankings=None, + concept_rankings=None, + ): + return { + "status": "ok", + "market": market, + "trade_date": trade_date, + "active_themes": [], + "leading_industries": [ + { + "name": "新能源", + "rank": 5, + "change_pct": 2.0, + "source": "industry", + }, + ], + "leading_concepts": [ + { + "name": "新能源", + "rank": 1, + "change_pct": 10.0, + "source": "concept", + }, + ], + "lagging_themes": [], + } + + +class _ThemedHotspotService: + def __init__( + self, + *, + active_themes=None, + leading_concepts=None, + leading_industries=None, + lagging_themes=None, + hotspot_constituents=None, + leader_stocks=None, + ) -> None: + self.active_themes = active_themes or [] + self.leading_concepts = leading_concepts or [] + self.leading_industries = leading_industries or [] + self.lagging_themes = lagging_themes or [] + self.hotspot_constituents = hotspot_constituents or [] + self.leader_stocks = leader_stocks or [] + + def get_hotspots( + self, + *, + market: str, + trade_date=None, + limit: int = 5, + sector_rankings=None, + concept_rankings=None, + ): + return { + "status": "ok", + "market": market, + "trade_date": trade_date, + "active_themes": self.active_themes, + "leading_industries": self.leading_industries, + "leading_concepts": self.leading_concepts, + "lagging_themes": self.lagging_themes, + "hotspot_constituents": self.hotspot_constituents, + "leader_stocks": self.leader_stocks, + } + + +class _BlockingRankingFetcherManager: + def __init__(self) -> None: + self.release = threading.Event() + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + self.release.wait(timeout=1) + return ([{"name": "通用设备", "change_pct": 2.1}], []) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + self.release.wait(timeout=1) + return ([{"name": "机器人概念", "change_pct": 4.2}], []) + + +class _ManagerScopedBlockingRankingFetcher: + def __init__(self, name: str, release: threading.Event) -> None: + self.name = name + self.release = release + self.sector_started = threading.Event() + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + self.sector_started.set() + self.release.wait(timeout=1) + return ([{"name": f"{self.name}行业", "change_pct": 2.1}], []) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + return ([{"name": f"{self.name}概念", "change_pct": 4.2}], []) + + +class _RecoveringTimeoutRankingFetcherManager: + def __init__(self) -> None: + self.release_first_sector = threading.Event() + self.first_sector_started = threading.Event() + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + if self.sector_calls == 1: + self.first_sector_started.set() + self.release_first_sector.wait() + return ([{"name": "通用设备", "change_pct": 2.1}], []) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + return ([{"name": "机器人概念", "change_pct": 4.2}], []) + + +class _PermanentlyBlockingRankingFetcherManager: + def __init__(self, *, block: bool) -> None: + self.block = block + self.release = threading.Event() + self.sector_started = threading.Event() + self.concept_started = threading.Event() + self.sector_calls = 0 + self.concept_calls = 0 + + def get_sector_rankings(self, n: int = 5): + self.sector_calls += 1 + if self.block: + self.sector_started.set() + self.release.wait() + return ([{"name": "通用设备", "change_pct": 2.1}], []) + + def get_concept_rankings(self, n: int = 5): + self.concept_calls += 1 + if self.block: + self.concept_started.set() + self.release.wait() + return ([{"name": "机器人概念", "change_pct": 4.2}], []) + + +class _UnexpectedRankingFetcherManager: + def get_sector_rankings(self, n: int = 5): + raise AssertionError("sector rankings should be reused from fundamental_context") + + def get_concept_rankings(self, n: int = 5): + raise AssertionError("concept rankings should be reused from fundamental_context") + + +def _wait_for_market_hotspot_workers_to_drain(timeout: float = 0.5) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with MarketHotspotService._ranking_fetch_futures_lock: + pending = [ + future + for future in MarketHotspotService._ranking_fetch_futures.values() + if not future.done() + ] + pending.extend( + future + for future in MarketHotspotService._ranking_fetch_detached_futures + if not future.done() + ) + if not pending: + return True + time.sleep(0.01) + return False + + +def test_market_hotspot_service_builds_theme_context_from_dsa_rankings() -> None: + service = MarketHotspotService(fetcher_manager=_FakeFetcherManager()) + + context = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert context["schema_version"] == "market-theme-v1" + assert context["status"] == "ok" + assert context["active_themes"][0]["name"] == "机器人概念" + assert context["leading_concepts"][0]["change_pct"] == 4.2 + assert context["theme_breadth"]["leading_concept_count"] == 1 + + +def test_market_hotspot_service_caches_rankings_per_instance() -> None: + fetcher = _FakeFetcherManager() + service = MarketHotspotService(fetcher_manager=fetcher) + + first = service.get_hotspots(market="cn", trade_date="2026-07-04") + second = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert first == second + assert fetcher.sector_calls == 1 + assert fetcher.concept_calls == 1 + + +def test_market_hotspot_service_refreshes_cached_ok_after_ttl() -> None: + fetcher = _FakeFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + success_cache_ttl_seconds=0.02, + ) + + service.get_hotspots(market="cn", trade_date="2026-07-04") + time.sleep(0.05) + service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert fetcher.sector_calls == 2 + assert fetcher.concept_calls == 2 + + +def test_market_hotspot_service_fails_open_when_rankings_unavailable() -> None: + service = MarketHotspotService(fetcher_manager=_FakeFetcherManager(fail=True)) + + context = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert context["status"] == "unknown" + assert context["data_quality"]["errors"] + assert "industry_rankings" in context["data_quality"]["missing_fields"] + assert "concept_rankings" in context["data_quality"]["missing_fields"] + + +def test_market_hotspot_service_bounds_ranking_fetches() -> None: + fetcher = _BlockingRankingFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.01, + ) + + started_at = time.monotonic() + try: + context = service.get_hotspots(market="cn", trade_date="2026-07-04") + finally: + fetcher.release.set() + _wait_for_market_hotspot_workers_to_drain() + + assert time.monotonic() - started_at < 0.2 + assert context["status"] == "unknown" + assert fetcher.sector_calls == 1 + assert fetcher.concept_calls == 1 + assert any("timeout" in error for error in context["data_quality"]["errors"]) + + +def test_market_hotspot_service_does_not_stack_workers_after_timeout() -> None: + fetcher = _BlockingRankingFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.01, + failure_cache_ttl_seconds=0.0, + ) + + try: + first = service.get_hotspots(market="cn", trade_date="2026-07-04") + second = service.get_hotspots(market="cn", trade_date="2026-07-04") + finally: + fetcher.release.set() + _wait_for_market_hotspot_workers_to_drain() + + assert first["status"] == "unknown" + assert second["status"] == "unknown" + assert fetcher.sector_calls == 1 + assert fetcher.concept_calls == 1 + assert any("timeout" in error for error in first["data_quality"]["errors"]) + assert any("timeout" in error for error in second["data_quality"]["errors"]) + + +def test_market_hotspot_service_scopes_inflight_fetches_to_manager_instance() -> None: + release = threading.Event() + first_fetcher = _ManagerScopedBlockingRankingFetcher("甲", release) + second_fetcher = _ManagerScopedBlockingRankingFetcher("乙", release) + services = ( + MarketHotspotService(fetcher_manager=first_fetcher), + MarketHotspotService(fetcher_manager=second_fetcher), + ) + contexts = [None, None] + + def fetch(index: int) -> None: + contexts[index] = services[index].get_hotspots( + market="cn", + trade_date="2026-07-04", + ) + + threads = [threading.Thread(target=fetch, args=(index,)) for index in range(2)] + try: + threads[0].start() + assert first_fetcher.sector_started.wait(timeout=0.2) + threads[1].start() + assert second_fetcher.sector_started.wait(timeout=0.2) + finally: + release.set() + for thread in threads: + if thread.ident is not None: + thread.join(timeout=1) + + assert contexts[0]["leading_industries"][0]["name"] == "甲行业" + assert contexts[1]["leading_industries"][0]["name"] == "乙行业" + assert first_fetcher.sector_calls == 1 + assert second_fetcher.sector_calls == 1 + + +def test_market_hotspot_service_retries_after_ranking_timeout_cooldown() -> None: + fetcher = _RecoveringTimeoutRankingFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.01, + failure_cache_ttl_seconds=0.0, + ) + drained = False + + try: + first = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert first["status"] == "partial" + assert fetcher.first_sector_started.is_set() + assert fetcher.sector_calls == 1 + assert any( + "sector_rankings" in error and "timeout" in error + for error in first["data_quality"]["errors"] + ) + + time.sleep(RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS + 0.05) + recovered = service.get_hotspots(market="cn", trade_date="2026-07-04") + finally: + fetcher.release_first_sector.set() + drained = _wait_for_market_hotspot_workers_to_drain() + + assert drained + assert recovered["status"] == "ok" + assert recovered["leading_industries"][0]["name"] == "通用设备" + assert fetcher.sector_calls == 2 + assert fetcher.concept_calls == 2 + + +def test_market_hotspot_service_drops_stale_timeout_future_before_retry() -> None: + fetcher = _FakeFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.01, + failure_cache_ttl_seconds=0.0, + ) + stale_future: Future = Future() + inflight_key = (type(fetcher), id(fetcher), "get_sector_rankings", 5) + stale_future.add_done_callback( + lambda done_future: MarketHotspotService._forget_ranking_fetch( + inflight_key, done_future + ) + ) + + with MarketHotspotService._ranking_fetch_futures_lock: + acquired = MarketHotspotService._ranking_fetch_slots.acquire(blocking=False) + assert acquired + MarketHotspotService._ranking_fetch_futures[inflight_key] = stale_future + MarketHotspotService._ranking_fetch_retry_after[inflight_key] = ( + stale_future, + time.monotonic() - 0.01, + ) + + try: + context = service.get_hotspots(market="cn", trade_date="2026-07-04") + finally: + if not stale_future.done(): + stale_future.set_result(None) + _wait_for_market_hotspot_workers_to_drain() + + assert context["status"] == "ok" + assert fetcher.sector_calls == 1 + assert fetcher.concept_calls == 1 + with MarketHotspotService._ranking_fetch_futures_lock: + assert ( + MarketHotspotService._ranking_fetch_futures.get(inflight_key) + is not stale_future + ) + + +def test_market_hotspot_service_keeps_permanent_timeouts_under_worker_cap() -> None: + fetcher = _PermanentlyBlockingRankingFetcherManager(block=True) + service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.05, + failure_cache_ttl_seconds=0.0, + ) + drained = False + + try: + first = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert first["status"] == "unknown" + assert fetcher.sector_started.wait(timeout=0.2) + assert fetcher.concept_started.wait(timeout=0.2) + + time.sleep(RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS + 0.05) + second = service.get_hotspots(market="cn", trade_date="2026-07-04") + + time.sleep(RANKING_FETCH_TIMEOUT_RETRY_DELAY_SECONDS + 0.05) + third = service.get_hotspots(market="cn", trade_date="2026-07-04") + finally: + fetcher.release.set() + drained = _wait_for_market_hotspot_workers_to_drain() + + assert drained + assert second["status"] == "unknown" + assert third["status"] == "unknown" + assert fetcher.sector_calls == 1 + assert fetcher.concept_calls == 1 + + +def test_market_hotspot_service_marks_flat_down_rankings_as_ok_without_active_themes() -> None: + service = MarketHotspotService(fetcher_manager=_DownTrendFetcherManager()) + + context = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert context["status"] == "ok" + assert context["active_themes"] == [] + assert context["theme_breadth"]["active_count"] == 0 + assert context["data_quality"]["missing_fields"] == [] + assert not context["data_quality"]["errors"] + + +def test_market_hotspot_service_recovers_after_failed_cache_ttl_expiry() -> None: + fetcher = _RecoverableFailureFetcherManager() + service = MarketHotspotService( + fetcher_manager=fetcher, + failure_cache_ttl_seconds=0.0, + ) + + failed = service.get_hotspots(market="cn", trade_date="2026-07-04") + assert failed["status"] == "unknown" + + fetcher.fail = False + recovered = service.get_hotspots(market="cn", trade_date="2026-07-04") + + assert recovered["status"] == "ok" + assert recovered["active_themes"] == [] + assert fetcher.sector_calls == 2 + assert fetcher.concept_calls == 2 + + +def test_market_structure_service_reuses_fundamental_rankings_for_theme_layer() -> None: + service = MarketStructureService(fetcher_manager=_UnexpectedRankingFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": { + "top": [{"name": "机器人概念", "rank": 1, "change_pct": 4.2}], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": { + "top": [{"name": "通用设备", "rank": 2, "change_pct": 2.1}], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + assert context["market_theme_context"]["active_themes"][0]["name"] == "机器人概念" + assert context["market_theme_context"]["leading_concepts"][0]["rank"] == 1 + + +def test_market_structure_service_marks_partial_if_fundamental_rankings_partial_with_missing_bottom() -> None: + service = MarketStructureService(fetcher_manager=_FakeFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "partial", + "data": { + "top": [{"name": "机器人概念", "rank": 1, "change_pct": 4.2}], + "bottom": [], + }, + }, + "boards": { + "status": "partial", + "data": { + "top": [{"name": "通用设备", "rank": 2, "change_pct": 2.1}], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + market_theme = context["market_theme_context"] + position = context["stock_market_position"] + assert market_theme["status"] == "partial" + assert market_theme["data_quality"]["status"] == "partial" + assert {source["status"] for source in market_theme["data_quality"]["sources"]} == { + "partial" + } + assert any(tag["code"] == "theme_data_partial" for tag in position["risk_tags"]) + + +def test_market_structure_service_skips_hotspots_for_unsupported_board_context() -> None: + unsupported_payloads = [ + { + "market": "cn", + "status": "not_supported", + "coverage": {"boards": "not_supported"}, + "boards": {"status": "not_supported", "data": {}}, + "errors": ["fundamental pipeline disabled"], + }, + { + "market": "cn", + "status": "partial", + "coverage": {"boards": "not_supported"}, + "boards": {"status": "not_supported", "data": {}}, + "errors": ["etf not fully supported"], + }, + ] + + for fundamental_context in unsupported_payloads: + fetcher = _FakeFetcherManager() + service = MarketStructureService(fetcher_manager=fetcher) + + context = service.build_context( + code="159915", + stock_name="创业板ETF", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + assert context["status"] == "not_supported" + assert context["market_theme_context"]["status"] == "not_supported" + assert context["stock_market_position"]["status"] == "not_supported" + assert fetcher.sector_calls == 0 + assert fetcher.concept_calls == 0 + + +def test_market_structure_service_combines_market_and_stock_layers() -> None: + service = MarketStructureService(fetcher_manager=_FakeFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": { + "top": [{"name": "机器人概念", "change_pct": 4.2}], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": { + "top": [{"name": "通用设备", "change_pct": 2.1}], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + assert context["schema_version"] == "market-structure-v1" + assert context["market_theme_context"]["active_themes"][0]["name"] == "机器人概念" + position = context["stock_market_position"] + assert position["primary_theme"]["name"] == "机器人概念" + assert position["theme_phase"] == "accelerating" + assert position["stock_role"] == "edge" + assert "leader_stocks" in position["missing_fields"] + + +def test_market_structure_service_recognizes_leader_only_for_matching_theme() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_ThemedHotspotService( + leading_concepts=[{"name": "机器人概念", "change_pct": 4.2}], + hotspot_constituents=[{"code": "300024", "topic": "机器人概念"}], + leader_stocks=[{"code": "300024", "topic": "机器人概念"}], + ), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": {"top": [{"name": "机器人概念", "change_pct": 4.2}], "bottom": []}, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["stock_role"] == "leader" + assert position["status"] == "ok" + + +def test_market_structure_service_recognizes_follower_when_constituent_theme_matches() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_ThemedHotspotService( + leading_concepts=[{"name": "机器人概念", "change_pct": 4.2}], + hotspot_constituents=[{"code": "300024", "topic": "机器人概念"}], + leader_stocks=[{"code": "300010", "topic": "机器人概念"}], + ), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": {"top": [{"name": "机器人概念", "change_pct": 4.2}], "bottom": []}, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["stock_role"] == "follower" + assert position["status"] == "ok" + + +def test_market_structure_service_rejects_role_evidence_from_unmatched_theme() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_ThemedHotspotService( + leading_concepts=[{"name": "机器人概念", "change_pct": 4.2}], + hotspot_constituents=[{"code": "300024", "topic": "新能源"}, + {"code": "300024", "topic": "半导体"}], + leader_stocks=[{"code": "300024", "theme": "新能源"}], + ), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": {"top": [{"name": "机器人概念", "change_pct": 4.2}], "bottom": []}, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["stock_role"] == "edge" + assert "leader_stocks" not in position["missing_fields"] + assert "hotspot_constituents" not in position["missing_fields"] + + +def test_market_structure_service_infers_concept_board_from_missing_type_name() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_EmptyHotspotService(), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "机器人概念"}], + "concept_boards": { + "status": "ok", + "data": { + "top": [{"name": "机器人概念", "rank": 1, "change_pct": 4.2}], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": { + "top": [{"name": "通用设备", "rank": 2, "change_pct": 2.1}], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["status"] == "partial" + assert position["primary_theme"]["source"] == "concept" + assert position["primary_theme"]["change_pct"] == 4.2 + assert position["theme_phase"] == "accelerating" + assert position["related_boards"][0]["source"] == "concept" + assert position["stock_role"] == "edge" + assert "theme_ranking_match" in position["missing_fields"] + + +def test_market_structure_service_resolves_missing_type_board_from_concept_rankings() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_EmptyHotspotService(), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "新能源"}], + "concept_boards": { + "status": "ok", + "data": { + "top": [{"name": "新能源", "rank": 1, "change_pct": 5.6}], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": { + "top": [{"name": "通用设备", "rank": 2, "change_pct": 2.1}], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["status"] == "partial" + assert position["primary_theme"]["source"] == "concept" + assert position["primary_theme"]["change_pct"] == 5.6 + assert position["theme_phase"] == "accelerating" + assert position["stock_role"] == "edge" + assert "theme_ranking_match" in position["missing_fields"] + assert position["related_boards"][0]["source"] == "concept" + assert position["related_boards"][0]["change_pct"] == 5.6 + + +def test_market_structure_service_keeps_stock_layer_partial_without_ranking_evidence() -> None: + service = MarketStructureService(fetcher_manager=_FakeFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "未上榜概念", "type": "概念"}], + "concept_boards": { + "status": "ok", + "data": {"top": [{"name": "机器人概念", "change_pct": 4.2}], "bottom": []}, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["status"] == "partial" + assert position["stock_role"] == "edge" + assert position["primary_theme"]["name"] == "未上榜概念" + assert position["theme_phase"] == "unknown" + assert "theme_ranking_match" in position["missing_fields"] + assert {tag["code"] for tag in position["risk_tags"]} == {"stock_theme_evidence_partial"} + + +def test_market_structure_service_uses_lagging_themes_for_board_match() -> None: + service = MarketStructureService(fetcher_manager=_DownTrendFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "转基因", "type": "概念"}], + } + + context = service.build_context( + code="300024", + stock_name="转基因", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["status"] == "partial" + assert position["primary_theme"]["name"] == "转基因" + assert position["theme_phase"] == "cooling" + assert position["stock_role"] == "edge" + assert position["related_boards"][0]["name"] == "转基因" + assert position["related_boards"][0]["source"] == "concept" + assert position["related_boards"][0]["change_pct"] == -2.0 + assert "theme_ranking_match" not in position["missing_fields"] + + +def test_market_structure_service_keeps_lagging_sources_before_board_match() -> None: + service = MarketStructureService(fetcher_manager=_OverlappingLaggingThemeFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "旅游酒店"}], + } + + context = service.build_context( + code="000001", + stock_name="平安银行", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + lagging_themes = context["market_theme_context"]["lagging_themes"] + assert lagging_themes[0]["source"] == "industry" + position = context["stock_market_position"] + assert position["primary_theme"]["source"] == "industry" + assert position["primary_theme"]["change_pct"] == -4.1 + assert position["related_boards"][0]["source"] == "industry" + assert "theme_ranking_match" not in position["missing_fields"] + + +def test_market_structure_service_preserves_lagging_concepts_after_full_industry_list() -> None: + service = MarketStructureService(fetcher_manager=_FullLaggingFamiliesFetcherManager()) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "Concept Target", "type": "concept"}], + } + + context = service.build_context( + code="300024", + stock_name="Example Stock", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-10", + ) + + lagging_themes = context["market_theme_context"]["lagging_themes"] + assert len(lagging_themes) == 6 + assert lagging_themes[-1]["name"] == "Concept Target" + assert lagging_themes[-1]["source"] == "concept" + position = context["stock_market_position"] + assert position["primary_theme"]["name"] == "Concept Target" + assert position["primary_theme"]["source"] == "concept" + assert position["primary_theme"]["change_pct"] == -6.0 + assert "theme_ranking_match" not in position["missing_fields"] + + +def test_market_structure_service_prefers_ranked_related_board_fallback() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_EmptyHotspotService(), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [ + {"name": "宽基板块", "type": "行业"}, + {"name": "机器人概念", "type": "概念"}, + ], + "concept_boards": { + "status": "ok", + "data": { + "top": [{"name": "机器人概念", "rank": 3}], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": {"top": [], "bottom": []}, + }, + } + + context = service.build_context( + code="300024", + stock_name="机器人", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["status"] == "partial" + assert position["primary_theme"]["name"] == "机器人概念" + assert position["primary_theme"]["rank"] == 3 + assert position["stock_role"] == "edge" + assert "theme_ranking_match" in position["missing_fields"] + + +def test_market_structure_service_prefers_board_source_for_primary_theme() -> None: + service = MarketStructureService( + fetcher_manager=_FakeFetcherManager(), + hotspot_service=_SourceConflictHotspotService(), + ) + fundamental_context = { + "market": "cn", + "belong_boards": [{"name": "新能源", "type": "行业"}], + "concept_boards": { + "status": "ok", + "data": { + "top": [], + "bottom": [], + }, + }, + "boards": { + "status": "ok", + "data": { + "top": [], + "bottom": [], + }, + }, + } + + context = service.build_context( + code="000001", + stock_name="新能源相关股", + market="cn", + fundamental_context=fundamental_context, + trade_date="2026-07-04", + ) + + position = context["stock_market_position"] + assert position["primary_theme"]["name"] == "新能源" + assert position["primary_theme"]["source"] == "industry" + assert position["primary_theme"]["change_pct"] == 2.0 + assert position["primary_theme"]["phase"] == "warming" + assert position["stock_role"] == "edge" + assert "theme_ranking_match" not in position["missing_fields"] + + +def test_market_structure_service_returns_not_supported_for_non_cn() -> None: + service = MarketStructureService(fetcher_manager=_FakeFetcherManager()) + + context = service.build_context( + code="AAPL", + stock_name="Apple", + market="us", + fundamental_context={"market": "us", "belong_boards": [{"name": "Technology"}]}, + ) + + assert context["status"] == "not_supported" + assert context["market_theme_context"]["status"] == "not_supported" + assert context["stock_market_position"]["status"] == "not_supported" diff --git a/tests/test_multi_agent.py b/tests/test_multi_agent.py index 507f7ae87..b248cdc8d 100644 --- a/tests/test_multi_agent.py +++ b/tests/test_multi_agent.py @@ -817,6 +817,10 @@ class TestOrchestratorModes(unittest.TestCase): orch = self._make_orchestrator() phase_context = {"phase": "intraday", "is_partial_bar": True} pack_summary = "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n" + market_structure_context = { + "market_theme_context": {"status": "ok", "active_themes": []}, + "stock_market_position": {"status": "ok", "primary_theme": {"name": "机器人概念"}}, + } ctx = orch._build_context( "Analyze 600519", @@ -825,13 +829,16 @@ class TestOrchestratorModes(unittest.TestCase): "stock_name": "贵州茅台", "market_phase_context": phase_context, "analysis_context_pack_summary": pack_summary, + "market_structure_context": market_structure_context, }, ) self.assertEqual(ctx.meta["market_phase_context"], phase_context) self.assertEqual(ctx.meta["analysis_context_pack_summary"], pack_summary) + self.assertEqual(ctx.meta["market_structure_context"], market_structure_context) self.assertNotIn("market_phase_context", ctx.data) self.assertNotIn("analysis_context_pack_summary", ctx.data) + self.assertNotIn("market_structure_context", ctx.data) def test_build_context_extracts_code_from_query(self): orch = self._make_orchestrator() @@ -2379,6 +2386,10 @@ class TestBaseAgentMemoryIntegration(unittest.TestCase): agent = self._make_agent(memory) ctx = AgentContext(query="test", stock_code="600519") ctx.meta["market_phase_context"] = {"phase": "intraday"} + ctx.meta["market_structure_context"] = { + "market_theme_context": {"status": "ok"}, + "stock_market_position": {"status": "ok"}, + } ctx.meta["analysis_context_pack_summary"] = "\n## 分析上下文包摘要\n- 数据块状态:行情 available\n" ctx.set_data("realtime_quote", {"price": 1880.0}) @@ -2387,6 +2398,8 @@ class TestBaseAgentMemoryIntegration(unittest.TestCase): self.assertIn("[Pre-fetched: realtime_quote]", injected) self.assertNotIn("market_phase_context", injected) self.assertNotIn("[Pre-fetched: market_phase_context]", injected) + self.assertNotIn("market_structure_context", injected) + self.assertNotIn("[Pre-fetched: market_structure_context]", injected) self.assertNotIn("analysis_context_pack_summary", injected) self.assertNotIn("[Pre-fetched: analysis_context_pack_summary]", injected) self.assertNotIn("分析上下文包摘要", injected) diff --git a/tests/test_pipeline_related_boards.py b/tests/test_pipeline_related_boards.py index b903a1ec5..2ba8706f9 100644 --- a/tests/test_pipeline_related_boards.py +++ b/tests/test_pipeline_related_boards.py @@ -10,7 +10,11 @@ from unittest.mock import MagicMock from api.v1.schemas.history import ReportDetails from data_provider.base import DataFetcherManager from src.core.pipeline import StockAnalysisPipeline -from src.utils.data_processing import extract_board_detail_fields +from src.services.market_hotspot_service import MarketHotspotService +from src.utils.data_processing import ( + extract_board_detail_fields, + extract_market_structure_detail_field, +) class _SlowConceptRankingFetcher: @@ -30,6 +34,20 @@ class _SlowConceptRankingFetcher: ) +class _HangingConceptRankingFetcher: + def __init__(self, delay_seconds: float = 0.5) -> None: + self.calls = 0 + self.delay_seconds = delay_seconds + + def get_concept_rankings(self, n: int = 5): + self.calls += 1 + time.sleep(self.delay_seconds) + return ( + [{"name": f"top-{n}", "change_pct": 1.0}], + [{"name": f"bottom-{n}", "change_pct": -1.0}], + ) + + class PipelineRelatedBoardsTestCase(unittest.TestCase): def tearDown(self) -> None: DataFetcherManager.clear_concept_rankings_cache_for_tests() @@ -99,6 +117,30 @@ class PipelineRelatedBoardsTestCase(unittest.TestCase): self.assertEqual(first["concept_boards"]["data"]["top"][0]["name"], "Robot Theme") self.assertEqual(second["concept_boards"]["data"]["top"][0]["name"], "Robot Theme") + def test_get_concept_rankings_for_market_no_long_block_with_hanging_fetcher(self) -> None: + fetcher = _HangingConceptRankingFetcher() + pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline) + pipeline.fetcher_manager = fetcher + pipeline.market_hotspot_service = MarketHotspotService( + fetcher_manager=fetcher, + ranking_fetch_timeout_seconds=0.05, + ) + pipeline._concept_rankings_cache = {} + pipeline._concept_rankings_cache_lock = threading.Lock() + + start = time.perf_counter() + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(pipeline._get_concept_rankings_for_market, "cn") + second = executor.submit(pipeline._get_concept_rankings_for_market, "cn") + first_result = first.result() + second_result = second.result() + + duration = time.perf_counter() - start + self.assertLess(duration, 0.30) + self.assertEqual(first_result, ([], [])) + self.assertEqual(second_result, ([], [])) + self.assertGreaterEqual(fetcher.calls, 1) + def test_concept_rankings_cache_is_shared_across_manager_instances(self) -> None: fetcher = _SlowConceptRankingFetcher() first_manager = DataFetcherManager.__new__(DataFetcherManager) @@ -145,6 +187,65 @@ class PipelineRelatedBoardsTestCase(unittest.TestCase): self.assertEqual(extracted["concept_rankings"]["top"][0]["source"], "akshare") self.assertEqual(details.concept_rankings["top"][0]["name"], "机器人概念") + def test_extract_market_structure_details_from_enhanced_context(self) -> None: + snapshot = { + "enhanced_context": { + "market_structure_context": { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + "active_themes": [{"name": "机器人概念"}], + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + }, + } + } + } + + extracted = extract_market_structure_detail_field(snapshot) + details = ReportDetails(context_snapshot=snapshot) + + self.assertEqual(extracted["stock_market_position"]["primary_theme"]["name"], "机器人概念") + self.assertEqual(details.market_structure["market_theme_context"]["active_themes"][0]["name"], "机器人概念") + + def test_extract_market_structure_details_from_raw_result_fallback(self) -> None: + payload = { + "schema_version": "market-structure-v1", + "status": "partial", + "market": "cn", + "market_theme_context": { + "schema_version": "market-theme-v1", + "status": "partial", + "market": "cn", + "active_themes": [{"name": "机器人概念"}], + }, + "stock_market_position": { + "schema_version": "stock-market-position-v1", + "status": "partial", + "stock_code": "300024", + "market": "cn", + "primary_theme": {"name": "机器人概念"}, + }, + } + + extracted = extract_market_structure_detail_field( + None, + {"market_structure_context": payload}, + ) + details = ReportDetails(raw_result={"market_structure_context": payload}) + + self.assertEqual(extracted["stock_market_position"]["primary_theme"]["name"], "机器人概念") + self.assertEqual(details.market_structure["market_theme_context"]["active_themes"][0]["name"], "机器人概念") + def test_attach_belong_boards_copies_existing_board_list(self) -> None: pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline) pipeline.fetcher_manager = MagicMock()