mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
feat: 重建市场结构上下文 (#1981)
* 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,不再直接输出
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
362
apps/dsa-web/e2e/market-structure-card-visual.spec.ts
Normal file
362
apps/dsa-web/e2e/market-structure-card-visual.spec.ts
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<main className="min-h-screen bg-background p-8 text-foreground">
|
||||
<div className="mx-auto max-w-5xl" data-testid="market-structure-visual-card">
|
||||
<MarketStructureCard context={context} language="zh" />
|
||||
</div>
|
||||
</main>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
`,
|
||||
);
|
||||
writeFile(
|
||||
htmlPath,
|
||||
`
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MarketStructureCard Real Component Visual Evidence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/MarketStructureVisualApp.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
);
|
||||
|
||||
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<void>;
|
||||
}> {
|
||||
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<string, string> = {
|
||||
'.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<void>((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<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function renderMarketStructureCard(distIndexPath: string, testInfo: TestInfo): Promise<void> {
|
||||
let browser: { close: () => Promise<void> } | 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);
|
||||
});
|
||||
});
|
||||
319
apps/dsa-web/src/components/report/MarketStructureCard.tsx
Normal file
319
apps/dsa-web/src/components/report/MarketStructureCard.tsx
Normal file
@@ -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<React.ComponentProps<typeof Badge>['variant']>;
|
||||
|
||||
const STATUS_VARIANT: Record<MarketStructureStatus, BadgeVariant> = {
|
||||
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<MarketStructureCardProps> = ({ 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<string, string>)[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 (
|
||||
<Card padding="md" className="rounded-lg">
|
||||
<section aria-label={text.title}>
|
||||
<DashboardPanelHeader
|
||||
leading={<Map className="h-4 w-4 text-cyan" aria-hidden="true" />}
|
||||
eyebrow={text.eyebrow}
|
||||
title={text.title}
|
||||
actions={
|
||||
<Badge variant={STATUS_VARIANT[context.status] || 'default'}>
|
||||
{text.status[context.status] || context.status}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<TrendingUp className="h-4 w-4 text-success" aria-hidden="true" />
|
||||
<span>{text.marketLayer}</span>
|
||||
<Badge variant={STATUS_VARIANT[marketTheme.status] || 'default'}>
|
||||
{text.status[marketTheme.status] || marketTheme.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<MetricLine label={text.activeThemes} values={activeThemes} emptyText={text.empty} />
|
||||
<MetricLine label={text.leadingConcepts} values={leadingConcepts} emptyText={text.empty} />
|
||||
<MetricLine label={text.leadingIndustries} values={leadingIndustries} emptyText={text.empty} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Map className="h-4 w-4 text-cyan" aria-hidden="true" />
|
||||
<span>{text.stockLayer}</span>
|
||||
<Badge variant={STATUS_VARIANT[stockPosition.status] || 'default'}>
|
||||
{text.status[stockPosition.status] || stockPosition.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<MetricLine label={text.primaryTheme} values={[primaryTheme]} emptyText={text.empty} />
|
||||
<MetricLine
|
||||
label={text.themePhase}
|
||||
values={[themePhaseLabel]}
|
||||
emptyText={text.empty}
|
||||
/>
|
||||
<MetricLine
|
||||
label={text.stockRole}
|
||||
values={[stockRoleLabel]}
|
||||
emptyText={text.empty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(riskTags.length > 0 || missingFields.length > 0) && (
|
||||
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2">
|
||||
{riskTags.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-secondary-text">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning" aria-hidden="true" />
|
||||
<span>{text.riskTags}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{riskTags.map((item) => (
|
||||
<Badge key={item} variant="warning">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{missingFields.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-secondary-text">
|
||||
{text.missingFields}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missingFields.map((item) => (
|
||||
<Badge key={item} variant="default">{item}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface MetricLineProps {
|
||||
label: string;
|
||||
values: string[];
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
const MetricLine: React.FC<MetricLineProps> = ({ label, values, emptyText }) => (
|
||||
<div className="grid gap-1 text-sm sm:grid-cols-[7rem_1fr]">
|
||||
<span className="text-secondary-text">{label}</span>
|
||||
<span className="min-w-0 break-words text-foreground">
|
||||
{values.length > 0 ? values.join(' / ') : emptyText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -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<ReportSummaryProps> = ({
|
||||
watchlist={watchlist}
|
||||
/>
|
||||
|
||||
{/* 市场结构位置 */}
|
||||
<MarketStructureCard context={details?.marketStructure} language={reportLanguage} />
|
||||
|
||||
{/* 策略点位区 */}
|
||||
<ReportStrategy strategy={strategy} language={reportLanguage} />
|
||||
|
||||
|
||||
@@ -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(<MarketStructureCard context={context} language="zh" />);
|
||||
|
||||
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(<MarketStructureCard context={context} language="en" />);
|
||||
|
||||
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(<MarketStructureCard context={context} language="ko" />);
|
||||
|
||||
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(<MarketStructureCard context={unsupported} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<MarketStructureCard context={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
104
apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts
Normal file
104
apps/dsa-web/src/utils/__tests__/chatFollowUp.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/配置迁移` 语义未变更,回滚方式为回退本次提交。
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
@@ -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 或把缺失字段解释为龙头证据。
|
||||
|
||||
## 外部契约来源与迁移边界
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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/差异/相比等明确比较意图或多个非当前明确股票代码的问题不会污染当前标的
|
||||
- 📈 **回测验证** - 评估历史分析准确率,查询方向胜率与模拟收益
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ SWITCH_CLEANUP_KEYS = {
|
||||
"trend_result",
|
||||
"news_context",
|
||||
"fundamental_context",
|
||||
"market_structure_context",
|
||||
"analysis_context_pack_summary",
|
||||
"market_phase_context",
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
186
src/market_structure_prompt.py
Normal file
186
src/market_structure_prompt.py
Normal file
@@ -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
|
||||
119
src/schemas/market_structure.py
Normal file
119
src/schemas/market_structure.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
691
src/services/market_hotspot_service.py
Normal file
691
src/services/market_hotspot_service.py
Normal file
@@ -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
|
||||
703
src/services/market_structure_service.py
Normal file
703
src/services/market_structure_service.py
Normal file
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
1110
tests/test_market_structure_service.py
Normal file
1110
tests/test_market_structure_service.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user