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:
zhulinsen
2026-07-12 11:30:59 +08:00
committed by GitHub
parent 01ebf70a10
commit 6da1360a0b
38 changed files with 4928 additions and 47 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View 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);
});
});

View 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>
);

View File

@@ -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} />

View File

@@ -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();
});
});

View File

@@ -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';

View File

@@ -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;
}

View 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');
});
});

View File

@@ -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;
}

View File

@@ -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-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -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 runtimeAlphaSift 的热点详情、发酵路线、成分股和 leader stocks 后续可作为可选数据源迁移,但未迁移前普通个股分析不会隐式调用 AlphaSift 或把缺失字段解释为龙头证据。
## 外部契约来源与迁移边界

View File

@@ -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"
}
}
}

View File

@@ -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`

View File

@@ -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` overviewWeb 报告页在策略点位和资讯之后默认折叠展示数据块状态、来源、缺失原因和降级摘要
- 💬 **问股追问上下文** - 从历史报告进入问股后,后续追问会持续携带当前 `stock_code/stock_name`;切回或重载已有问股会话时,会从已加载的历史用户消息恢复基础当前标的;只有用户明确切换标的时才切换上下文,含比较/对比/vs/差异/相比等明确比较意图或多个非当前明确股票代码的问题不会污染当前标的
- 📈 **回测验证** - 评估历史分析准确率,查询方向胜率与模拟收益

View File

@@ -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

View File

@@ -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})

View File

@@ -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)

View File

@@ -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

View File

@@ -20,6 +20,7 @@ SWITCH_CLEANUP_KEYS = {
"trend_result",
"news_context",
"fundamental_context",
"market_structure_context",
"analysis_context_pack_summary",
"market_phase_context",
}

View File

@@ -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"""

View File

@@ -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:

View 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

View 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)

View File

@@ -241,6 +241,10 @@ 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,

View File

@@ -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")

View File

@@ -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,

View 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

View 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",
]

View File

@@ -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.

View File

@@ -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")

View File

@@ -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(

View File

@@ -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 = {

View File

@@ -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(

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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()