diff --git a/api/v1/endpoints/analysis.py b/api/v1/endpoints/analysis.py index 9a53c5c32..741fa3d2e 100644 --- a/api/v1/endpoints/analysis.py +++ b/api/v1/endpoints/analysis.py @@ -148,12 +148,18 @@ def _run_market_review_background( "search_service": search_service, "send_notification": send_notification, "override_region": override_region, + "return_structured": True, } if query_id: review_kwargs["query_id"] = query_id report = run_market_review(**review_kwargs) if not report: raise RuntimeError("大盘复盘未返回可持久化报告") + if hasattr(report, "report"): + return { + "result": report.report, + "market_review_payload": getattr(report, "market_review_payload", None), + } return {"result": report} finally: _release_market_review_lock(lock_token) @@ -849,12 +855,16 @@ def get_analysis_status(task_id: str) -> TaskStatus: if task: result: Optional[AnalysisResultResponse] = None market_review_report = None + market_review_payload = None if task.status == TaskStatusEnum.COMPLETED and isinstance(task.result, dict): if task.stock_code == "market_review": report_text = task.result.get("result") if isinstance(report_text, str) and report_text.strip(): market_review_report = report_text + payload = task.result.get("market_review_payload") + if isinstance(payload, dict): + market_review_payload = payload else: try: result = _build_task_analysis_result(task) @@ -871,6 +881,7 @@ def get_analysis_status(task_id: str) -> TaskStatus: progress=task.progress, result=result, market_review_report=market_review_report, + market_review_payload=market_review_payload, error=task.error, stock_name=task.stock_name, original_query=task.original_query, @@ -890,6 +901,12 @@ def get_analysis_status(task_id: str) -> TaskStatus: raw_result = parse_json_field(record.raw_result) if getattr(record, "report_type", None) == "market_review": market_review_report = None + context_snapshot = parse_json_field(getattr(record, "context_snapshot", None)) + market_review_payload = None + if isinstance(context_snapshot, dict): + payload = context_snapshot.get("market_review_payload") + if isinstance(payload, dict): + market_review_payload = payload if isinstance(raw_result, dict): report_text = raw_result.get("raw_response") or raw_result.get("market_review_report") if isinstance(report_text, str) and report_text.strip(): @@ -904,6 +921,7 @@ def get_analysis_status(task_id: str) -> TaskStatus: progress=100, result=None, market_review_report=market_review_report, + market_review_payload=market_review_payload, error=None, stock_name=record.name, ) diff --git a/api/v1/schemas/analysis.py b/api/v1/schemas/analysis.py index ecc3f063c..4add4250a 100644 --- a/api/v1/schemas/analysis.py +++ b/api/v1/schemas/analysis.py @@ -271,6 +271,10 @@ class TaskStatus(BaseModel): None, description="大盘复盘任务返回的报告文本(仅大盘复盘任务)", ) + market_review_payload: Optional[Any] = Field( + None, + description="Structured market-review payload for API/Web consumers.", + ) error: Optional[str] = Field( None, description="错误信息(仅在 failed 时存在)" diff --git a/apps/dsa-web/src/components/report/MarketReviewReportView.tsx b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx new file mode 100644 index 000000000..56de9679f --- /dev/null +++ b/apps/dsa-web/src/components/report/MarketReviewReportView.tsx @@ -0,0 +1,553 @@ +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards } from 'lucide-react'; +import { historyApi } from '../../api/history'; +import type { + AnalysisReport, + MarketReviewPayload, + MarketReviewPayloadSection, + ReportLanguage, +} from '../../types/analysis'; +import { markdownToPlainText } from '../../utils/markdown'; +import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage'; +import { Card } from '../common'; +import { Tooltip } from '../common/Tooltip'; +import { ReportMarkdownBody } from './ReportMarkdownBody'; + +interface MarketReviewReportViewProps { + report?: AnalysisReport; + recordId?: number; + content?: string; + payload?: MarketReviewPayload | null; + reportLanguage?: ReportLanguage; + className?: string; +} + +type CopyType = 'markdown' | 'text'; +type LoadedMarkdown = { + recordId: number; + content: string; +}; +type LoadError = { + recordId: number; + message: string; +}; +type MarketReviewSection = { + id: string; + title: string; + content: string; + icon: typeof FileText; +}; +type StructuredMarketData = { + id: string; + title?: string; + breadth?: MarketReviewPayload['breadth']; + indices: NonNullable; +}; + +const isMarketReviewPayload = (value: unknown): value is MarketReviewPayload => + Boolean(value && typeof value === 'object'); + +const TOP_HEADING_PATTERN = /^\s*#\s+(.+?)\s*(?:\n+|$)/; +const SECTION_HEADING_PATTERN = /^(#{2,3})\s+(.+?)\s*$/gm; + +const normalizeHeading = (value: string): string => + value.trim().replace(/\s+/g, ' ').toLowerCase(); + +const stripTopHeading = (markdown: string, title?: string): string => { + const match = markdown.match(TOP_HEADING_PATTERN); + if (!match) { + return markdown.trim(); + } + + const heading = normalizeHeading(match[1]); + const reportTitle = normalizeHeading(title || ''); + const genericTitles = new Set([ + 'market review', + '大盘复盘', + '大盘复盘详情', + 'a股市场复盘', + 'a 股市场复盘', + ]); + + if (heading === reportTitle || genericTitles.has(heading)) { + return markdown.slice(match[0].length).trim(); + } + + return markdown.trim(); +}; + +const getSectionIcon = (title: string): typeof FileText => { + const normalized = normalizeHeading(title); + if (/指数|index|overview|大盘/.test(normalized)) { + return BarChart3; + } + if (/情绪|赚钱|sentiment|breadth|temperature/.test(normalized)) { + return Gauge; + } + if (/行业|板块|主题|轮动|sector|theme|rotation/.test(normalized)) { + return TrendingUp; + } + if (/资金|成交|量能|flow|turnover|volume|capital/.test(normalized)) { + return WalletCards; + } + if (/风险|机会|观察|risk|watch|next/.test(normalized)) { + return ShieldAlert; + } + return FileText; +}; + +const splitMarketReviewSections = (markdown: string): MarketReviewSection[] => { + const matches = Array.from(markdown.matchAll(SECTION_HEADING_PATTERN)); + if (matches.length === 0) { + return [{ + id: 'full-review', + title: '复盘正文', + content: markdown, + icon: FileText, + }]; + } + + const intro = markdown.slice(0, matches[0].index).trim(); + const sections: MarketReviewSection[] = intro + ? [{ + id: 'overview', + title: '复盘概览', + content: intro, + icon: FileText, + }] + : []; + + matches.forEach((match, index) => { + const start = (match.index ?? 0) + match[0].length; + const end = index + 1 < matches.length ? matches[index + 1].index ?? markdown.length : markdown.length; + const title = match[2].trim(); + const content = markdown.slice(start, end).trim(); + if (!content) { + return; + } + sections.push({ + id: `${index}-${normalizeHeading(title).replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') || 'section'}`, + title, + content, + icon: getSectionIcon(title), + }); + }); + + return sections; +}; + +const getPayloadSections = (payload?: MarketReviewPayload | null): MarketReviewSection[] => { + if (!payload) { + return []; + } + + if (payload.markets) { + return Object.entries(payload.markets).flatMap(([region, marketPayload]) => { + const marketTitle = marketPayload.title || region.toUpperCase(); + return getPayloadSections(marketPayload).map((section) => ({ + ...section, + id: `${region}-${section.id}`, + title: `${marketTitle} / ${section.title}`, + })); + }); + } + + const payloadTitle = normalizeHeading(payload.title || ''); + return (payload.sections || []) + .filter((section: MarketReviewPayloadSection) => section.markdown?.trim()) + .filter((section: MarketReviewPayloadSection) => normalizeHeading(section.title || '') !== payloadTitle) + .map((section, index) => ({ + id: `${section.key || index}-${normalizeHeading(section.title).replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') || 'section'}`, + title: section.title || 'Review', + content: section.markdown, + icon: getSectionIcon(section.title || ''), + })); +}; + +const hasStructuredMarketData = (payload?: MarketReviewPayload | null): boolean => + Boolean(payload?.breadth || payload?.indices?.length); + +const getStructuredMarketData = (payload?: MarketReviewPayload | null): StructuredMarketData[] => { + if (!payload) { + return []; + } + + if (payload.markets) { + return Object.entries(payload.markets) + .filter(([, marketPayload]) => hasStructuredMarketData(marketPayload)) + .map(([region, marketPayload]) => ({ + id: region, + title: marketPayload.title || region.toUpperCase(), + breadth: marketPayload.breadth, + indices: marketPayload.indices || [], + })); + } + + if (!hasStructuredMarketData(payload)) { + return []; + } + + return [{ + id: payload.region || 'market', + title: payload.title, + breadth: payload.breadth, + indices: payload.indices || [], + }]; +}; + +const MARKET_REVIEW_TEXT: Record = { + zh: { + reviewSummary: '复盘摘要', + noReviewSummary: '暂无摘要', + noSentimentScore: '暂无评分', + rotationAndFunds: '轮动与资金', + noRotationView: '暂无轮动观点', + riskAndWatch: '风险与观察', + noRiskWatch: '暂无观察重点', + structuredMarketData: '结构化大盘数据', + noBreadthData: '暂无数据', + advancers: '上涨家数', + decliners: '下跌家数', + limitUpDown: '涨停/跌停', + turnover: '成交额', + index: '指数', + last: '最新', + change: '涨跌幅', + highLow: '高/低', + }, + en: { + reviewSummary: 'Review Summary', + noReviewSummary: 'No review summary yet', + noSentimentScore: 'No score yet', + rotationAndFunds: 'Rotation & Funds', + noRotationView: 'No rotation view yet', + riskAndWatch: 'Risks & Watchlist', + noRiskWatch: 'No key observations yet', + structuredMarketData: 'Structured Market Data', + noBreadthData: 'No data', + advancers: 'Advancers', + decliners: 'Decliners', + limitUpDown: 'Limit Up/Down', + turnover: 'Turnover', + index: 'Index', + last: 'Last', + change: 'Change', + highLow: 'High/Low', + }, +}; + +export const MarketReviewReportView: React.FC = ({ + report, + recordId, + content: providedContent, + payload: providedPayload, + reportLanguage = 'zh', + className = '', +}) => { + const normalizedReportLanguage = normalizeReportLanguage(reportLanguage); + const text = getReportText(normalizedReportLanguage); + const marketReviewText = MARKET_REVIEW_TEXT[normalizedReportLanguage]; + const [loadedMarkdown, setLoadedMarkdown] = useState(null); + const [loadError, setLoadError] = useState(null); + const [copiedType, setCopiedType] = useState(null); + const summary = report?.summary; + const meta = report?.meta; + const contextPayload = report?.details?.contextSnapshot?.marketReviewPayload; + const marketReviewPayload = providedPayload ?? (isMarketReviewPayload(contextPayload) ? contextPayload : null); + const loadedContent = loadedMarkdown && loadedMarkdown.recordId === recordId ? loadedMarkdown.content : ''; + const content = providedContent ?? marketReviewPayload?.markdownReport ?? loadedContent; + const error = loadError && loadError.recordId === recordId ? loadError.message : null; + const hasStructuredContent = Boolean(marketReviewPayload?.sections?.length || marketReviewPayload?.markets); + const isLoading = Boolean(recordId && !providedContent && !hasStructuredContent && loadedMarkdown?.recordId !== recordId && !error); + const displayTitle = marketReviewPayload?.rootTitle || marketReviewPayload?.title || meta?.stockName || 'Market Review'; + const structuredContent = useMemo( + () => stripTopHeading(content, displayTitle), + [content, displayTitle], + ); + const sections = useMemo( + () => { + const payloadSections = getPayloadSections(marketReviewPayload); + return payloadSections.length > 0 ? payloadSections : splitMarketReviewSections(structuredContent); + }, + [marketReviewPayload, structuredContent], + ); + const structuredMarketData = useMemo( + () => getStructuredMarketData(marketReviewPayload), + [marketReviewPayload], + ); + const showStructuredMarketTitles = Boolean(marketReviewPayload?.markets); + + useEffect(() => { + if (!recordId || providedContent || hasStructuredContent) { + return undefined; + } + + let isMounted = true; + + historyApi.getMarkdown(recordId) + .then((markdownContent) => { + if (isMounted) { + setLoadedMarkdown({ recordId, content: markdownContent }); + setLoadError(null); + } + }) + .catch((err: unknown) => { + if (isMounted) { + setLoadError({ + recordId, + message: err instanceof Error ? err.message : text.loadReportFailed, + }); + } + }); + + return () => { + isMounted = false; + }; + }, [hasStructuredContent, providedContent, recordId, text.loadReportFailed]); + + const handleCopy = useCallback(async (type: CopyType) => { + if (!content) { + return; + } + try { + const value = type === 'markdown' ? content : markdownToPlainText(content); + await navigator.clipboard.writeText(value); + setCopiedType(type); + window.setTimeout(() => setCopiedType(null), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }, [content]); + + const insightCards = useMemo(() => [ + { + icon: FileText, + label: marketReviewText.reviewSummary, + value: summary?.analysisSummary || marketReviewText.noReviewSummary, + }, + { + icon: Gauge, + label: text.marketSentiment, + value: summary?.sentimentScore !== undefined + ? `${summary.sentimentScore} / 100` + : marketReviewText.noSentimentScore, + }, + { + icon: Layers, + label: marketReviewText.rotationAndFunds, + value: summary?.operationAdvice || marketReviewText.noRotationView, + }, + { + icon: ShieldAlert, + label: marketReviewText.riskAndWatch, + value: summary?.trendPrediction || marketReviewText.noRiskWatch, + }, + ], [marketReviewText, summary, text.marketSentiment]); + + return ( +
+ +
+
+
+
+

+ {displayTitle} +

+
+ {meta?.stockCode ? ( + {meta.stockCode} + ) : null} + {meta?.createdAt ? {new Date(meta.createdAt).toLocaleString()} : null} +
+
+ +
+ + + + + + + + + + +
+
+
+ + {summary ? ( +
+ {insightCards.map(({ icon: Icon, label, value }) => ( + +
+
+
+
+

{label}

+

{value}

+
+
+
+ ))} +
+ ) : null} + + {structuredMarketData.length > 0 ? ( + +
+ + +

{marketReviewText.structuredMarketData}

+
+
+ {structuredMarketData.map((marketData) => ( +
+ {showStructuredMarketTitles ? ( +

{marketData.title}

+ ) : null} + {marketData.breadth ? ( +
+
+

{marketReviewText.advancers}

+

{marketData.breadth.upCount ?? '-'}

+
+
+

{marketReviewText.decliners}

+

{marketData.breadth.downCount ?? '-'}

+
+
+

{marketReviewText.limitUpDown}

+

+ {marketData.breadth.limitUpCount ?? '-'} / {marketData.breadth.limitDownCount ?? '-'} +

+
+
+

{marketReviewText.turnover}

+

+ {marketData.breadth.totalAmount ?? '-'} {marketData.breadth.turnoverUnit || ''} +

+
+
+ ) : ( +

{marketReviewText.noBreadthData}

+ )} + {marketData.indices.length > 0 ? ( +
+ + + + + + + + + + + {marketData.indices.map((index) => ( + + + + + + + ))} + +
{marketReviewText.index}{marketReviewText.last}{marketReviewText.change}{marketReviewText.highLow}
{index.name}{index.current ?? '-'}{index.changePct !== undefined ? `${index.changePct}%` : '-'}{index.high ?? '-'} / {index.low ?? '-'}
+
+ ) : null} +
+ ))} +
+
+ ) : null} + + {isLoading ? ( + +
+
+

{text.loadingReport}

+
+ + ) : error ? ( + +
+
+
+

{error}

+
+
+ ) : ( +
+ {sections.map(({ id, title, content: sectionContent, icon: Icon }) => ( + +
+ + +

{title}

+
+ +
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/dsa-web/src/components/report/ReportMarkdownBody.tsx b/apps/dsa-web/src/components/report/ReportMarkdownBody.tsx new file mode 100644 index 000000000..26cfe31b4 --- /dev/null +++ b/apps/dsa-web/src/components/report/ReportMarkdownBody.tsx @@ -0,0 +1,40 @@ +import type React from 'react'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface ReportMarkdownBodyProps { + content: string; + className?: string; + testId?: string; +} + +export const ReportMarkdownBody: React.FC = ({ + content, + className = '', + testId, +}) => ( +
+ + {content} + +
+); diff --git a/apps/dsa-web/src/components/report/ReportMarkdownPanel.tsx b/apps/dsa-web/src/components/report/ReportMarkdownPanel.tsx index fd5aaab4e..29e3fd358 100644 --- a/apps/dsa-web/src/components/report/ReportMarkdownPanel.tsx +++ b/apps/dsa-web/src/components/report/ReportMarkdownPanel.tsx @@ -1,12 +1,11 @@ import type React from 'react'; import { useCallback, useEffect, useState } from 'react'; -import Markdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { historyApi } from '../../api/history'; import type { ReportLanguage } from '../../types/analysis'; import { markdownToPlainText } from '../../utils/markdown'; import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage'; import { Tooltip } from '../common/Tooltip'; +import { ReportMarkdownBody } from './ReportMarkdownBody'; export interface ReportMarkdownPanelProps { recordId: number; @@ -166,28 +165,7 @@ export const ReportMarkdownPanel: React.FC = ({
) : ( -
- - {content} - -
+ )}
diff --git a/apps/dsa-web/src/components/report/ReportSummary.tsx b/apps/dsa-web/src/components/report/ReportSummary.tsx index 101605343..5ba0df0d7 100644 --- a/apps/dsa-web/src/components/report/ReportSummary.tsx +++ b/apps/dsa-web/src/components/report/ReportSummary.tsx @@ -6,6 +6,7 @@ import { ReportNews } from './ReportNews'; import { ReportDetails } from './ReportDetails'; import { ReportDiagnostics } from './ReportDiagnostics'; import { AnalysisContextSummary } from './AnalysisContextSummary'; +import { MarketReviewReportView } from './MarketReviewReportView'; import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage'; interface ReportSummaryProps { @@ -43,6 +44,16 @@ export const ReportSummary: React.FC = ({ modelUsed && !['unknown', 'error', 'none', 'null', 'n/a'].includes(modelUsed.toLowerCase()), ); + if (meta.reportType === 'market_review') { + return ( + + ); + } + return (
{/* 概览区(首屏) */} diff --git a/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx b/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx new file mode 100644 index 000000000..f1147a64c --- /dev/null +++ b/apps/dsa-web/src/components/report/__tests__/MarketReviewReportView.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { AnalysisReport, MarketReviewPayload } from '../../../types/analysis'; +import { MarketReviewReportView } from '../MarketReviewReportView'; + +vi.mock('../../../api/history', () => ({ + historyApi: { + getMarkdown: vi.fn(), + }, +})); + +const englishMarketReviewReport: AnalysisReport = { + meta: { + queryId: 'market-review-q-1', + stockCode: 'MARKET', + stockName: 'Market Review', + reportType: 'market_review', + reportLanguage: 'en', + createdAt: '2026-03-18T08:00:00Z', + }, + summary: { + analysisSummary: '', + operationAdvice: '', + trendPrediction: '', + sentimentScore: undefined as unknown as number, + }, +}; + +const combinedMarketReviewPayload: MarketReviewPayload = { + version: 1, + kind: 'market_review', + region: 'cn,hk', + language: 'zh', + rootTitle: '大盘复盘', + markets: { + cn: { + title: 'A股市场', + breadth: { + upCount: 3120, + downCount: 1420, + limitUpCount: 72, + limitDownCount: 4, + totalAmount: 9600, + turnoverUnit: '亿元', + }, + indices: [{ + code: '000300', + name: '沪深300', + current: 3920.2, + changePct: 1.2, + high: 3940.5, + low: 3860.1, + }], + }, + hk: { + title: '港股市场', + breadth: { + upCount: 680, + downCount: 410, + limitUpCount: 0, + limitDownCount: 0, + totalAmount: 1180, + turnoverUnit: '亿港元', + }, + indices: [{ + code: 'HSI', + name: '恒生指数', + current: 18920.4, + changePct: -0.5, + high: 19050.2, + low: 18780.3, + }], + }, + }, +}; + +const noBreadthMarketReviewPayload: MarketReviewPayload = { + version: 1, + kind: 'market_review', + region: 'us', + language: 'en', + title: 'Market Review', + rootTitle: 'Market Review', + indices: [{ + code: 'SPX', + name: 'S&P 500', + current: 5200, + changePct: 0.68, + high: 5235.2, + low: 5170.4, + }], + sectors: { + top: [{ name: 'Technology', changePct: 1.9 }], + bottom: [{ name: 'Energy', changePct: -0.8 }], + }, + news: [], + sections: [], +}; + +describe('MarketReviewReportView', () => { + it('uses localized summary card labels and fallbacks for English reports', () => { + render( + , + ); + + expect(screen.getByText('Review Summary')).toBeInTheDocument(); + expect(screen.getByText('No review summary yet')).toBeInTheDocument(); + expect(screen.getByText('Market Sentiment')).toBeInTheDocument(); + expect(screen.getByText('No score yet')).toBeInTheDocument(); + expect(screen.getByText('Rotation & Funds')).toBeInTheDocument(); + expect(screen.getByText('No rotation view yet')).toBeInTheDocument(); + expect(screen.getByText('Risks & Watchlist')).toBeInTheDocument(); + expect(screen.getByText('No key observations yet')).toBeInTheDocument(); + expect(screen.queryByText('复盘摘要')).not.toBeInTheDocument(); + expect(screen.queryByText('暂无摘要')).not.toBeInTheDocument(); + }); + + it('renders structured data for every market in a combined market review payload', () => { + render( + , + ); + + expect(screen.getByText('A股市场')).toBeInTheDocument(); + expect(screen.getByText('港股市场')).toBeInTheDocument(); + expect(screen.getByText('沪深300')).toBeInTheDocument(); + expect(screen.getByText('恒生指数')).toBeInTheDocument(); + expect(screen.getByText('3120')).toBeInTheDocument(); + expect(screen.getByText('680')).toBeInTheDocument(); + }); + + it('localizes structured market data labels for Chinese reports', () => { + render( + , + ); + + expect(screen.getByText('结构化大盘数据')).toBeInTheDocument(); + expect(screen.getAllByText('上涨家数')).toHaveLength(2); + expect(screen.getAllByText('下跌家数')).toHaveLength(2); + expect(screen.getAllByText('涨停/跌停')).toHaveLength(2); + expect(screen.getAllByText('成交额')).toHaveLength(2); + expect(screen.getAllByText('指数')).toHaveLength(2); + expect(screen.getAllByText('最新')).toHaveLength(2); + expect(screen.getAllByText('涨跌幅')).toHaveLength(2); + expect(screen.getAllByText('高/低')).toHaveLength(2); + expect(screen.queryByText('Structured Market Data')).not.toBeInTheDocument(); + expect(screen.queryByText('Advancers')).not.toBeInTheDocument(); + expect(screen.queryByText('Index')).not.toBeInTheDocument(); + }); + + it('shows "No data" when breadth is not available for a market review payload', () => { + render( + , + ); + + expect(screen.getByText('Structured Market Data')).toBeInTheDocument(); + expect(screen.getByText('No data')).toBeInTheDocument(); + expect(screen.getByText('S&P 500')).toBeInTheDocument(); + expect(screen.queryByText('Advancers')).not.toBeInTheDocument(); + expect(screen.queryByText('Decliners')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dsa-web/src/components/report/index.ts b/apps/dsa-web/src/components/report/index.ts index b409c8fee..9c7d90d18 100644 --- a/apps/dsa-web/src/components/report/index.ts +++ b/apps/dsa-web/src/components/report/index.ts @@ -6,3 +6,5 @@ export * from './ReportStrategy'; export * from './ReportNews'; export * from './ReportDetails'; export * from './ReportMarkdown'; +export * from './ReportMarkdownBody'; +export * from './MarketReviewReportView'; diff --git a/apps/dsa-web/src/index.css b/apps/dsa-web/src/index.css index 7f04d135d..cacf83cd1 100644 --- a/apps/dsa-web/src/index.css +++ b/apps/dsa-web/src/index.css @@ -1913,6 +1913,13 @@ textarea { padding: 4px 6px; } +.home-markdown-prose :where(table) { + display: block; + width: 100%; + overflow-x: auto; + white-space: nowrap; +} + .home-markdown-prose :where(th) { background: hsl(var(--elevated) / 0.92); color: hsl(var(--foreground)); diff --git a/apps/dsa-web/src/pages/HomePage.tsx b/apps/dsa-web/src/pages/HomePage.tsx index ca68b135b..8ff536cd5 100644 --- a/apps/dsa-web/src/pages/HomePage.tsx +++ b/apps/dsa-web/src/pages/HomePage.tsx @@ -12,12 +12,14 @@ import { DashboardStateBlock } from '../components/dashboard'; import { StockAutocomplete } from '../components/StockAutocomplete'; import { StockHistoryTrendDrawer, StockBar } from '../components/history'; import { ReportMarkdownDrawer } from '../components/report/ReportMarkdownDrawer'; +import { MarketReviewReportView } from '../components/report/MarketReviewReportView'; import { ReportSummary } from '../components/report/ReportSummary'; import { TaskPanel } from '../components/tasks'; import { useDashboardLifecycle, useHomeDashboardState } from '../hooks'; import { useWatchlist } from '../hooks/useWatchlist'; import type { SetupStatusResponse } from '../types/systemConfig'; import { getReportText, normalizeReportLanguage } from '../utils/reportLanguage'; +import type { MarketReviewPayload } from '../types/analysis'; type MarketReviewNotice = { variant: 'success' | 'warning' | 'danger'; @@ -32,7 +34,7 @@ const HomePage: React.FC = () => { const [marketReviewNotice, setMarketReviewNotice] = useState(null); const [marketReviewError, setMarketReviewError] = useState(null); const [marketReviewReport, setMarketReviewReport] = useState(null); - const [marketReviewReportCopied, setMarketReviewReportCopied] = useState(false); + const [marketReviewPayload, setMarketReviewPayload] = useState(null); const [analysisSkills, setAnalysisSkills] = useState([]); const [selectedStrategyId, setSelectedStrategyId] = useState(''); const [strategyMenuOpen, setStrategyMenuOpen] = useState(false); @@ -176,6 +178,7 @@ const HomePage: React.FC = () => { }, [analysisSkills, selectedStrategyId]); const reportLanguage = normalizeReportLanguage(selectedReport?.meta.reportLanguage); + const liveMarketReviewLanguage = normalizeReportLanguage(marketReviewPayload?.language); const reportText = getReportText(reportLanguage); const isMarketReviewHistoryReport = selectedReport?.meta.reportType === 'market_review'; const isHistoryTrendUnavailable = !selectedReport || selectedReport.meta.reportType === 'market_review' @@ -316,10 +319,19 @@ const HomePage: React.FC = () => { const watchlistState = useWatchlist(); + const clearMarketReviewState = useCallback(() => { + stopMarketReviewPolling(); + setMarketReviewReport(null); + setMarketReviewPayload(null); + setMarketReviewNotice(null); + setMarketReviewError(null); + }, [stopMarketReviewPolling]); + const handleHistoryItemClick = useCallback((recordId: number) => { + clearMarketReviewState(); void selectHistoryItem(recordId); setSidebarOpen(false); - }, [selectHistoryItem]); + }, [clearMarketReviewState, selectHistoryItem]); const [isDeletingStock, setIsDeletingStock] = useState(false); const handleDeleteStock = useCallback(async (stockCode: string) => { @@ -391,6 +403,7 @@ const HomePage: React.FC = () => { if (attempts >= maxAttempts) { stopMarketReviewPolling(); setMarketReviewReport(null); + setMarketReviewPayload(null); setMarketReviewNotice({ variant: 'danger', title: '大盘复盘已超时', @@ -406,6 +419,7 @@ const HomePage: React.FC = () => { const status = await analysisApi.getStatus(taskId); if (status.status === 'pending' || status.status === 'processing') { setMarketReviewReport(null); + setMarketReviewPayload(null); const progress = typeof status.progress === 'number' ? `${status.progress}%` : '进行中'; @@ -423,6 +437,7 @@ const HomePage: React.FC = () => { ? status.marketReviewReport : ''; setMarketReviewReport(marketReviewText ? marketReviewText.trim() : null); + setMarketReviewPayload(status.marketReviewPayload ?? null); setMarketReviewNotice({ variant: 'success', title: '大盘复盘已完成', @@ -436,6 +451,7 @@ const HomePage: React.FC = () => { if (status.status === 'failed') { stopMarketReviewPolling(); setMarketReviewReport(null); + setMarketReviewPayload(null); setMarketReviewError( getParsedApiError({ response: { @@ -454,6 +470,7 @@ const HomePage: React.FC = () => { stopMarketReviewPolling(); setMarketReviewReport(null); + setMarketReviewPayload(null); setMarketReviewNotice({ variant: 'danger', title: '大盘复盘状态异常', @@ -466,6 +483,7 @@ const HomePage: React.FC = () => { if (attempts >= maxAttempts) { stopMarketReviewPolling(); setMarketReviewReport(null); + setMarketReviewPayload(null); setMarketReviewError(parsed); setMarketReviewNotice(null); scrollMarketReviewFeedbackIntoView(); @@ -495,6 +513,7 @@ const HomePage: React.FC = () => { setMarketReviewNotice(null); setMarketReviewError(null); setMarketReviewReport(null); + setMarketReviewPayload(null); scrollMarketReviewFeedbackIntoView(); try { const result = await analysisApi.triggerMarketReview({ sendNotification: notify }); @@ -517,22 +536,6 @@ const HomePage: React.FC = () => { } }, [notify, pollMarketReviewStatus, scrollMarketReviewFeedbackIntoView]); - const handleCopyMarketReviewReport = useCallback(() => { - if (!marketReviewReport) { - return; - } - - void navigator.clipboard.writeText(marketReviewReport).then( - () => { - setMarketReviewReportCopied(true); - setTimeout(() => setMarketReviewReportCopied(false), 2000); - }, - (err) => { - console.error('复制失败:', err); - }, - ); - }, [marketReviewReport]); - const sidebarContent = useMemo( () => (
@@ -778,25 +781,12 @@ const HomePage: React.FC = () => { ) : null} {marketReviewReport ? ( -
-
-

大盘复盘报告

- -
-
-                  {marketReviewReport}
-                
-
+ ) : null} {error ? ( @@ -806,12 +796,13 @@ const HomePage: React.FC = () => { onDismiss={clearError} /> ) : null} - {isLoadingReport ? ( + {!marketReviewReport && isLoadingReport ? (
- ) : selectedReport ? ( + ) : !marketReviewReport && selectedReport ? (
+ {!isMarketReviewHistoryReport ? (
+ ) : null} {isHistoryTrendOpen ? ( { /> )}
- ) : ( + ) : !marketReviewReport ? (
{ )} />
- )} + ) : null}
diff --git a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx index 66cf9147f..d9f17ebc4 100644 --- a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx +++ b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx @@ -290,6 +290,51 @@ describe('HomePage', () => { expect(analysisApi.getStatus).toHaveBeenCalledWith('task-1'); }); + it('uses the payload language for live market review controls', async () => { + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + vi.mocked(analysisApi.triggerMarketReview).mockResolvedValue({ + status: 'accepted', + sendNotification: true, + message: 'Market review task submitted', + taskId: 'task-1', + }); + vi.mocked(analysisApi.getStatus).mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + marketReviewReport: '# US Market Recap\n\n## Summary\n\nUS market review body', + marketReviewPayload: { + kind: 'market_review', + region: 'us', + language: 'en', + title: 'US Market Recap', + sections: [ + { + key: 'summary', + title: 'Summary', + markdown: 'US market review body', + }, + ], + }, + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '大盘复盘' })); + + expect(await screen.findByRole('button', { name: 'Copy Markdown Source' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy Plain Text' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '复制 Markdown 源码' })).not.toBeInTheDocument(); + }); + it('scrolls the dashboard to market review feedback after toolbar clicks', async () => { vi.mocked(historyApi.getList).mockResolvedValue({ total: 1, @@ -354,7 +399,56 @@ describe('HomePage', () => { vi.mocked(analysisApi.getStatus).mockResolvedValue({ taskId: 'task-1', status: 'completed', - marketReviewReport: Array.from({ length: 30 }, (_, index) => `第 ${index + 1} 行复盘内容`).join('\n'), + marketReviewReport: [ + '# A股市场复盘', + '', + '> 市场情绪修复', + '', + '## 指数概览', + '', + '| 指数 | 表现 |', + '| --- | --- |', + '| 上证指数 | 震荡走强 |', + '', + '## 风险提示', + '', + '- 资金回流核心资产', + ].join('\n'), + marketReviewPayload: { + kind: 'market_review', + region: 'cn', + title: 'A股市场复盘', + breadth: { + upCount: 3200, + downCount: 1700, + limitUpCount: 60, + limitDownCount: 8, + totalAmount: 9800, + turnoverUnit: '亿', + }, + indices: [ + { + code: '000001', + name: '上证指数', + current: 3150.2, + changePct: 0.62, + high: 3168.4, + low: 3120.8, + }, + ], + sections: [ + { + key: 'index_overview', + title: '指数概览', + markdown: '| 指数 | 表现 |\n| --- | --- |\n| 上证指数 | 震荡走强 |', + }, + { + key: 'risk', + title: '风险提示', + markdown: '- 资金回流核心资产', + }, + ], + }, }); render( @@ -370,7 +464,15 @@ describe('HomePage', () => { expect(dashboardScroll).toContainElement(marketReviewReport); expect(marketReviewReport.className).not.toContain('max-h-64'); expect(marketReviewReport.className).not.toContain('overflow-y-auto'); - expect(await screen.findByText('开始分析')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '结构化大盘数据' })).toBeInTheDocument(); + expect(screen.getByText('3200')).toBeInTheDocument(); + expect(screen.getByText('3150.2')).toBeInTheDocument(); + expect(marketReviewReport.querySelector('h2, h3')?.textContent).not.toBe('A股市场复盘'); + expect(screen.getByRole('heading', { name: '指数概览' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '风险提示' })).toBeInTheDocument(); + expect(screen.getAllByRole('table').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText('# A股市场复盘')).not.toBeInTheDocument(); + expect(screen.queryByText('开始分析')).not.toBeInTheDocument(); }); it('shows first-run setup gaps and links to settings', async () => { @@ -701,7 +803,7 @@ describe('HomePage', () => { expect(trigger).toHaveFocus(); }); - it('disables stock reanalysis and follow-up for market review history reports', async () => { + it('renders market review history reports with a dedicated markdown view', async () => { vi.mocked(historyApi.getList).mockResolvedValue({ total: 1, page: 1, @@ -709,6 +811,19 @@ describe('HomePage', () => { items: [marketReviewHistoryItem], }); vi.mocked(historyApi.getDetail).mockResolvedValue(marketReviewHistoryReport); + vi.mocked(historyApi.getMarkdown).mockResolvedValue([ + '# 大盘复盘详情', + '', + '## 市场情绪与赚钱效应', + '', + '**赚钱效应** 改善', + '', + '## 行业/主题轮动', + '', + '| 方向 | 状态 |', + '| --- | --- |', + '| 半导体 | 轮动增强 |', + ].join('\n')); render( @@ -717,16 +832,107 @@ describe('HomePage', () => { ); await screen.findByText('大盘复盘摘要'); - const reanalyzeButton = screen.getByRole('button', { name: '重新分析' }); - const followUpButton = screen.getByRole('button', { name: '追问 AI' }); - - expect(reanalyzeButton).toBeDisabled(); - expect(followUpButton).toBeDisabled(); - - fireEvent.click(reanalyzeButton); - fireEvent.click(followUpButton); + expect(screen.queryByRole('heading', { name: '大盘复盘详情' })).not.toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: '市场情绪与赚钱效应' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '行业/主题轮动' })).toBeInTheDocument(); + expect(screen.getByText('赚钱效应')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '重新分析' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '追问 AI' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '历史趋势' })).not.toBeInTheDocument(); + expect(historyApi.getMarkdown).toHaveBeenCalledWith(marketReviewHistoryReport.meta.id); expect(analysisApi.analyzeAsync).not.toHaveBeenCalled(); expect(navigateMock).not.toHaveBeenCalled(); }); + + it('clears live market review output when switching to a history report', async () => { + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 1, + page: 1, + limit: 20, + items: [historyItem], + }); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ + total: 2, + items: [ + { + id: 2, + stockCode: 'MARKET', + stockName: '大盘复盘', + sentimentScore: 50, + operationAdvice: '查看复盘', + analysisCount: 1, + lastAnalysisTime: '2026-03-18T08:30:00Z', + reportType: 'market_review', + }, + { + id: 1, + stockCode: '600519', + stockName: '贵州茅台', + sentimentScore: 82, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-18T08:00:00Z', + reportType: 'detailed', + }, + ], + }); + vi.mocked(historyApi.getDetail).mockImplementation((recordId: number) => { + if (recordId === 2) { + return Promise.resolve(marketReviewHistoryReport); + } + return Promise.resolve(historyReport); + }); + vi.mocked(historyApi.getMarkdown).mockResolvedValue([ + '# 大盘复盘详情', + '', + '## 市场情绪与赚钱效应', + '', + '**赚钱效应** 改善', + '', + '## 行业/主题轮动', + '', + '| 方向 | 状态 |', + '| --- | --- |', + '| 半导体 | 轮动增强 |', + ].join('\n')); + vi.mocked(analysisApi.triggerMarketReview).mockResolvedValue({ + status: 'accepted', + sendNotification: true, + message: '大盘复盘任务已提交', + taskId: 'task-1', + }); + vi.mocked(analysisApi.getStatus).mockResolvedValue({ + taskId: 'task-1', + status: 'completed', + marketReviewReport: '市场复盘报告示例文本', + }); + + render( + + + , + ); + + await screen.findByText('趋势维持强势'); + + fireEvent.click(screen.getByRole('button', { name: '大盘复盘' })); + + await waitFor(() => { + expect(screen.getByText('大盘复盘已完成')).toBeInTheDocument(); + expect(screen.getByText('市场复盘报告示例文本')).toBeInTheDocument(); + }); + + const marketHistoryButton = screen.getByRole('button', { name: /MARKET/ }); + fireEvent.click(marketHistoryButton); + + await waitFor(() => { + expect(screen.queryByText('市场复盘报告示例文本')).not.toBeInTheDocument(); + expect(screen.queryByText('大盘复盘已完成')).not.toBeInTheDocument(); + }); + expect(await screen.findByText('大盘复盘摘要')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '市场情绪与赚钱效应' })).toBeInTheDocument(); + expect(vi.mocked(historyApi.getDetail)).toHaveBeenCalledWith(2); + }); }); diff --git a/apps/dsa-web/src/types/analysis.ts b/apps/dsa-web/src/types/analysis.ts index 80b6129dc..79b0b3a2b 100644 --- a/apps/dsa-web/src/types/analysis.ts +++ b/apps/dsa-web/src/types/analysis.ts @@ -125,6 +125,56 @@ export interface SectorRankings { bottom?: SectorRankingItem[]; } +export interface MarketReviewPayloadSection { + key?: string; + title: string; + markdown: string; +} + +export interface MarketReviewIndex { + code: string; + name: string; + current?: number; + change?: number; + changePct?: number; + open?: number; + high?: number; + low?: number; + volume?: number; + amount?: number; + amplitude?: number; +} + +export interface MarketReviewBreadth { + upCount?: number; + downCount?: number; + flatCount?: number; + limitUpCount?: number; + limitDownCount?: number; + totalAmount?: number; + turnoverUnit?: string; +} + +export interface MarketReviewPayload { + version?: number; + kind?: 'market_review' | string; + region?: string; + language?: ReportLanguage | string; + title?: string; + rootTitle?: string; + generatedAt?: string; + date?: string; + marketScope?: string; + marketLight?: Record; + breadth?: MarketReviewBreadth; + indices?: MarketReviewIndex[]; + sectors?: SectorRankings; + news?: Array>; + sections?: MarketReviewPayloadSection[]; + markets?: Record; + markdownReport?: string; +} + export type AnalysisContextPackBlockStatus = | 'available' | 'missing' @@ -190,7 +240,7 @@ export interface AnalysisContextPackOverview { export interface ReportDetails { newsContent?: string; rawResult?: Record; - contextSnapshot?: Record; + contextSnapshot?: Record & { marketReviewPayload?: MarketReviewPayload }; analysisContextPackOverview?: AnalysisContextPackOverview | null; financialReport?: Record; dividendMetrics?: Record; @@ -292,6 +342,7 @@ export interface TaskStatus { progress?: number; result?: AnalysisResult; marketReviewReport?: string; + marketReviewPayload?: MarketReviewPayload; error?: string; stockName?: string; originalQuery?: string; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ff163626c..38250ca96 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] - [修复] Web/桌面端左侧导航选中态改用 border 实现,避免蓝色竖条指示器溢出侧栏边界;侧栏展开宽度 116px → 136px,新增 rail 紧凑模式。 +- [修复] Agent 分析路径生成 AnalysisContextPack overview 前复用已落库日线分析上下文,避免日线已抓取成功仍显示 `daily_bars_missing`。 +- [新功能] Web 大盘复盘报告新增专用展示视图,历史入口和首页即时结果统一使用 Markdown/GFM 渲染并隐藏个股专属模块。 +- [新功能] 大盘复盘新增结构化 `market_review_payload`,Web、历史详情和推送统一基于结构化数据渲染,并保留 Markdown 兼容展示。 +- [文档] 本次迭代仅重构大盘复盘展示链路(统一 Markdown/GFM 渲染与结构化 payload 渲染),不涉及 `LITELLM_*`、`LLM_*`、`provider/model/base_url` 等运行时配置语义;如需回退采用常规发布回滚。 +- [修复] 修正大盘复盘结构化 `breadth` 的可用性判断:当市场不支持/抓取失败(如美股、港股或 A 股 breadth 不可用)时不下发 `breadth`,前端展示“暂无数据”,避免误导性 0 值。 +- [修复] 明确大盘复盘语言行为调整为遵循全局 `report_language`,并在回退场景保持原语种提示(如美股/港股默认会按配置语言展示);兼容性变化说明见该条款,无需额外改动 provider/model/base_url。 +- [修复] 美股中文场景下,市场标签与策略蓝图(`Strategy Blueprint/Strategy Framework`)已本地化为中文显示,避免 `report_language=zh` 下混入英文策略段落与市场标签;与 Issue #1555 的历史/即时结果一致。 diff --git a/docs/assets/issue-1555-market-review.png b/docs/assets/issue-1555-market-review.png new file mode 100644 index 000000000..d2e4af785 Binary files /dev/null and b/docs/assets/issue-1555-market-review.png differ diff --git a/docs/full-guide.md b/docs/full-guide.md index 5df7f1a0d..e6b3fba3e 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -1347,6 +1347,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 > 说明:`POST /api/v1/analysis/market-review` 采用后端与 CLI/Bot 共用的配置路径(`GeminiAnalyzer(config=...)` 与同样的搜索/提示词构造入口)。Provider 兼容路由会优先识别并使用 `litellm_model`、`llm_model_list`,若未配置则回退 legacy `GEMINI_*`、`OPENAI_*`、`ANTHROPIC_*`、`DEEPSEEK_*` 键;不会新增/调整 provider、Base URL 或 LiteLLM 路由语义。 > 审计依据:优先级与回退语义以 `src/config.py` 的 `Config._load_from_env()` 为准(`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy)。配套回归见 `tests/test_llm_channel_config.py`(配置源解析)与 `tests/test_market_review_runtime.py`(共享装配路径)。该接口当前仅提供单进程/单机级防重复能力,若为多实例部署需通过外部任务队列或分布式锁补齐全局幂等。 > 说明:`POST /api/v1/analysis/market-review` 触发后,报告会以 `report_type=market_review` 写入历史库;你可直接查询 `/api/v1/history` 或 `/api/v1/history/{record_id}` 获取历史 Markdown,避免再次触发分析重算。 +> 说明:`POST /api/v1/analysis/market-review` 的返回与历史持久化都会包含 `market_review_payload`:`market_scope`、`sections`、`sectors`、`news`、`market_light`、`indices` 等结构化字段。Web 端 Markdown 渲染与历史详情会复用该结构化字段;若结构化字段为空则回退到原始 Markdown。 +> 说明:`market_review_payload` 中的 `breadth` 仅在行情宽度数据真实可用时下发;当美股/港股或接口暂不可用时不下发该字段。前端显示层需按“字段缺失”降级为“暂无数据”而不是展示 0。 > 说明:该端点若返回 `task_id`,WebUI 会轮询 `GET /api/v1/analysis/status/{task_id}` 展示状态。状态为 `completed` 时给出完成提示(报告已生成并按配置推送),状态为 `failed` 时在前端错误区域显示 `error` 原因。 > 说明:`GET /api/v1/history/{record_id}/diagnostics` 支持历史记录主键 ID 或 `query_id`,返回 `normal/degraded/failed/unknown` 摘要、关键链路组件和可复制的脱敏 `copy_text`;旧报告缺少诊断快照时返回 `unknown`,不影响报告读取。 > 说明:`GET /api/v1/history` 的列表摘要可按 `stock_code` 分页查询同一股票历史,并返回趋势判断、分析摘要、模型名与分析时价格/涨跌幅等可选字段;旧记录缺少快照字段时返回空值。Web 报告页的“历史趋势”抽屉复用该接口加载同股历史。 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 223a93957..b5d7b223e 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -1179,6 +1179,8 @@ FastAPI provides RESTful API service for configuration management and triggering > Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged. > Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls. > Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis. +> Note: `/api/v1/analysis/market-review` responses and persisted history include a structured `market_review_payload` with fields like `market_scope`, `sections`, `sectors`, `news`, `market_light`, `indices`, etc. Web rendering and history detail use the same structure and fall back to raw `markdown_report` only if the structure is unavailable. +> Note: `market_review_payload.breadth` is emitted only when breadth data is truly available. For markets/feeds without usable breadth, the field is omitted and UI should display `No data` (not a misleading zero value). > Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`. > Note: `GET /api/v1/history/{record_id}/diagnostics` accepts either the history primary key ID or `query_id`, and returns a `normal/degraded/failed/unknown` summary, key pipeline components, and sanitized `copy_text`. Older reports without `context_snapshot.diagnostics` return `unknown` without affecting normal report reads. > Note: `GET /api/v1/history` list summaries can be paginated by `stock_code` for same-stock history and now include optional trend, summary, model, and analysis-time price/change fields. Older rows without persisted snapshots return empty values. The Web report page's "History Trend" drawer reuses this endpoint. diff --git a/src/core/market_review.py b/src/core/market_review.py index 5eb08c508..f9ad560d3 100644 --- a/src/core/market_review.py +++ b/src/core/market_review.py @@ -11,6 +11,7 @@ """ import logging +from dataclasses import dataclass, field from datetime import datetime from typing import Any, Dict, Optional import uuid @@ -36,6 +37,14 @@ _MARKET_REVIEW_REGION_ORDER = tuple(market for market, _, _ in _MARKET_REVIEW_MA _VALID_MARKET_REVIEW_REGIONS = frozenset(_MARKET_REVIEW_REGION_ORDER) +@dataclass +class MarketReviewRunResult: + """Structured result for API/Web consumers while keeping Markdown compatibility.""" + + report: str + market_review_payload: Dict[str, Any] = field(default_factory=dict) + + def _get_market_review_text(language: str) -> dict[str, str]: normalized = normalize_report_language(language) if normalized == "en": @@ -83,7 +92,8 @@ def run_market_review( merge_notification: bool = False, override_region: Optional[str] = None, query_id: Optional[str] = None, -) -> Optional[str]: + return_structured: bool = False, +) -> Optional[str] | Optional[MarketReviewRunResult]: """ 执行大盘复盘分析 @@ -115,6 +125,7 @@ def run_market_review( # 多市场顺序执行,合并报告 parts = [] market_light_snapshots: Dict[str, Dict[str, Any]] = {} + market_review_payloads: Dict[str, Dict[str, Any]] = {} for mkt, title_key, label in _MARKET_REVIEW_MARKETS: if mkt not in run_markets: continue @@ -125,6 +136,11 @@ def run_market_review( review_result = mkt_analyzer.run_daily_review_with_snapshot() mkt_report = review_result.report market_light_snapshots[mkt] = review_result.market_light_snapshot + market_review_payloads[mkt] = _coerce_market_review_payload( + review_result, + region=mkt, + report=mkt_report, + ) if mkt_report: parts.append(f"{review_text[title_key]}\n\n{mkt_report}") if parts: @@ -141,24 +157,43 @@ def run_market_review( review_result = market_analyzer.run_daily_review_with_snapshot() review_report = review_result.report market_light_snapshots = {run_region: review_result.market_light_snapshot} + market_review_payloads = { + run_region: _coerce_market_review_payload( + review_result, + region=run_region, + report=review_report, + ) + } if review_report: + market_review_payload = _build_combined_market_review_payload( + review_report=review_report, + payloads=market_review_payloads, + region=persist_region, + language=getattr(config, "report_language", "zh"), + root_title=review_text["root_title"], + ) + markdown_report = _render_market_review_payload_markdown( + market_review_payload, + wrapper_title=review_text["root_title"], + ) # 保存报告到文件 date_str = datetime.now().strftime('%Y%m%d') report_filename = f"market_review_{date_str}.md" filepath = notifier.save_report_to_file( - f"{review_text['root_title']}\n\n{review_report}", + markdown_report, report_filename ) logger.info(f"大盘复盘报告已保存: {filepath}") _persist_market_review_history( review_report=review_report, - markdown_report=f"{review_text['root_title']}\n\n{review_report}", + markdown_report=markdown_report, region=persist_region, config=config, query_id=query_id, market_light_snapshots=market_light_snapshots, + market_review_payload=market_review_payload, ) # 推送通知(合并模式下跳过,由 main 层统一发送) @@ -166,7 +201,10 @@ def run_market_review( logger.info("合并推送模式:跳过大盘复盘单独推送,将在个股+大盘复盘后统一发送") elif send_notification and notifier.is_available(): # 添加标题 - report_content = f"{review_text['push_title']}\n\n{review_report}" + report_content = _render_market_review_payload_markdown( + market_review_payload, + wrapper_title=review_text["push_title"], + ) success = notifier.send(report_content, email_send_to_all=True, route_type="report") if success: @@ -176,6 +214,11 @@ def run_market_review( elif not send_notification: logger.info("已跳过推送通知 (--no-notify)") + if return_structured: + return MarketReviewRunResult( + report=review_report, + market_review_payload=market_review_payload, + ) return review_report except Exception as e: @@ -184,6 +227,118 @@ def run_market_review( return None +def _coerce_market_review_payload( + review_result: Any, + *, + region: str, + report: Optional[str], +) -> Dict[str, Any]: + payload = getattr(review_result, "structured_payload", None) + if isinstance(payload, dict) and payload: + return payload + return { + "version": 1, + "kind": MARKET_REVIEW_REPORT_TYPE, + "region": region, + "title": "", + "sections": [{"key": "full_review", "title": "Review", "markdown": report or ""}], + "markdown_report": report or "", + } + + +def _build_combined_market_review_payload( + *, + review_report: str, + payloads: Dict[str, Dict[str, Any]], + region: str, + language: str, + root_title: str, +) -> Dict[str, Any]: + normalized_language = normalize_report_language(language) + title = root_title.lstrip("#").strip() + if len(payloads) == 1: + payload = dict(next(iter(payloads.values()))) + payload["version"] = payload.get("version") or 1 + payload["kind"] = MARKET_REVIEW_REPORT_TYPE + payload["region"] = region + payload["language"] = payload.get("language") or normalized_language + payload["root_title"] = title + payload["markdown_report"] = review_report + return payload + return { + "version": 1, + "kind": MARKET_REVIEW_REPORT_TYPE, + "region": region, + "language": normalized_language, + "title": title, + "root_title": title, + "markets": payloads, + "markdown_report": review_report, + } + + +def _render_market_review_payload_markdown( + payload: Dict[str, Any], + *, + wrapper_title: Optional[str] = None, +) -> str: + """Render Markdown from the structured market-review payload for file/push compatibility.""" + body = _render_market_review_payload_body(payload) + if wrapper_title: + return f"{wrapper_title}\n\n{body}".strip() + return body.strip() + + +def _render_market_review_payload_body(payload: Dict[str, Any]) -> str: + markets = payload.get("markets") + if isinstance(markets, dict) and markets: + markdown_report = payload.get("markdown_report") + if isinstance(markdown_report, str) and markdown_report.strip(): + return markdown_report.strip() + parts = [] + for market in _MARKET_REVIEW_REGION_ORDER: + market_payload = markets.get(market) + if isinstance(market_payload, dict): + parts.append(_render_single_market_review_payload(market_payload)) + return "\n\n---\n\n".join(part for part in parts if part).strip() + return _render_single_market_review_payload(payload) + + +def _render_single_market_review_payload(payload: Dict[str, Any]) -> str: + sections = payload.get("sections") + if not isinstance(sections, list) or not sections: + markdown = payload.get("markdown_report") + return markdown if isinstance(markdown, str) else "" + + title = payload.get("title") + normalized_title = _normalize_market_review_heading(title) + lines = [] + if isinstance(title, str) and title.strip(): + lines.extend([f"## {title.strip()}", ""]) + for section in sections: + if not isinstance(section, dict): + continue + section_title = str(section.get("title") or "").strip() + markdown = str(section.get("markdown") or "").strip() + if not markdown: + continue + should_render_section_title = ( + section_title + and section.get("key") != "overview" + and _normalize_market_review_heading(section_title) != normalized_title + ) + if should_render_section_title: + lines.extend([f"### {section_title}", ""]) + lines.extend([markdown, ""]) + return "\n".join(lines).strip() + + +def _normalize_market_review_heading(value: Any) -> str: + if not isinstance(value, str): + return "" + return " ".join(value.lstrip("#").strip().lower().split()) + + def _persist_market_review_history( *, review_report: str, @@ -192,6 +347,7 @@ def _persist_market_review_history( config: object, query_id: Optional[str] = None, market_light_snapshots: Optional[Dict[str, Dict[str, Any]]] = None, + market_review_payload: Optional[Dict[str, Any]] = None, ) -> int: """Persist market review output into the existing analysis history table.""" try: @@ -229,6 +385,8 @@ def _persist_market_review_history( } if market_light_snapshots: context_snapshot["market_light_snapshots"] = market_light_snapshots + if market_review_payload: + context_snapshot["market_review_payload"] = market_review_payload saved = DatabaseManager.get_instance().save_analysis_history( result=result, diff --git a/src/market_analyzer.py b/src/market_analyzer.py index 939b7803c..f54e1f22e 100644 --- a/src/market_analyzer.py +++ b/src/market_analyzer.py @@ -11,6 +11,7 @@ """ import logging +import re import time from dataclasses import dataclass, field from datetime import datetime @@ -101,6 +102,7 @@ class MarketLightReviewResult: overview: MarketOverview report: str market_light_snapshot: Dict[str, Any] + structured_payload: Dict[str, Any] = field(default_factory=dict) class MarketAnalyzer: @@ -138,12 +140,9 @@ class MarketAnalyzer: self.strategy = get_market_strategy_blueprint(self.region) def _get_review_language(self) -> str: - configured = normalize_report_language( + return normalize_report_language( getattr(getattr(self, "config", None), "report_language", "zh") ) - if self.region == "us": - return "en" - return configured def _get_template_review_language(self) -> str: return normalize_report_language( @@ -153,7 +152,7 @@ class MarketAnalyzer: def _get_market_scope_name(self, review_language: str | None = None) -> str: review_language = review_language or self._get_review_language() if self.region == "us": - return "US market" + return "US market" if review_language == "en" else "美股市场" if self.region == "hk": return "Hong Kong market" if review_language == "en" else "港股市场" if review_language == "en": @@ -230,6 +229,24 @@ Focus on HSI trend, southbound flow dynamics, and sector rotation to define next - Risk-on: broad index breakout with expanding southbound participation. - Neutral: mixed index signals; focus on selective relative strength. - Risk-off: failed breakouts and rising volatility; prioritize capital preservation.""" + if self.region == "us" and self._get_review_language() == "zh": + return """## 美股市场三段式复盘策略 +聚焦指数趋势、宏观叙事与板块轮动,给出次日风控与仓位框架。 + +### 策略原则 +- 先看标普500、纳斯达克、道琼斯是否同向,确认主线是否一致。 +- 结合宏观与流动性指标,识别风险偏好是修复还是转弱。 +- 将复盘输出映射为“进攻/均衡/防守”动作建议,并给出明确触发失效条件。 + +### 分析维度 +- 趋势结构:明确市场处于上冲、震荡还是防守转向,判断是否存在关键支撑位背离。 +- 资金与情绪:区分宏观政策、货币面与波动率对权益风险的影响。 +- 主题线索:识别持续性最强的主题与板块轮动是否形成可交易主线。 + +### 行动框架 +- 进攻:主板块联动上行且量能/风险位同步改善。 +- 均衡:指数分化或量能未明显放大,仓位保守执行。 +- 防守:突破失守且波动率抬升时,优先减码并保留反弹可交易性。""" if not (self.region == "cn" and self._get_review_language() == "en"): return self.strategy.to_prompt_block() return """## Strategy Blueprint: A-share Three-Phase Recap Strategy @@ -266,6 +283,12 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t - **Trend Regime**: Classify the market as momentum, range, or risk-off based on HSI/HSTECH/HSCEI alignment. - **Capital Flows**: Track southbound flow direction and macro narrative for risk appetite signals. - **Sector Themes**: Focus on tech/internet platform persistence and financials/property policy sensitivity. +""" + if self.region == "us" and review_language == "zh": + return """### 六、策略框架 +- **趋势结构**:判断市场在进攻、震荡与防守中的状态是否一致。 +- **资金与情绪**:结合波动率、宽度和主题轮动评估风险偏好。 +- **主题主线**:识别可延续和可放大的行业主线与防守线索。 """ if not (self.region == "cn" and review_language == "en"): return self.strategy.to_markdown_block() @@ -435,12 +458,17 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t # 按 region 使用不同的新闻搜索词 search_queries = self.profile.news_queries + review_language = self._get_review_language() + market_names = { + "cn": "大盘" if review_language == "zh" else "A-share market", + "us": "美股市场" if review_language == "zh" else "US market", + "hk": "港股市场" if review_language == "zh" else "HK market", + } try: logger.info("[大盘] 开始搜索市场新闻...") # 根据 region 设置搜索上下文名称,避免美股搜索被解读为 A 股语境 - market_names = {"cn": "大盘", "us": "US market", "hk": "HK market"} market_name = market_names.get(self.region, "大盘") for query in search_queries: response = self.search_service.search_stock_news( @@ -474,21 +502,141 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t if not self.analyzer or not self.analyzer.is_available(): logger.warning("[大盘] AI分析器未配置或不可用,使用模板生成报告") return self._generate_template_review(overview, news) - + # 构建 Prompt prompt = self._build_review_prompt(overview, news) - + logger.info("[大盘] 调用大模型生成复盘报告...") - # Use the public generate_text() entry point — never access private analyzer attributes. + # Use the public generate_text() entry point - never access private analyzer attributes. review = self.analyzer.generate_text(prompt, max_tokens=8192, temperature=0.7) if review: logger.info("[大盘] 复盘报告生成成功,长度: %d 字符", len(review)) # Inject structured data tables into LLM prose sections return self._inject_data_into_review(review, overview, news) - else: - logger.warning("[大盘] 大模型返回为空,使用模板报告") - return self._generate_template_review(overview, news) + + logger.warning("[大盘] 大模型返回为空,使用模板报告") + return self._generate_template_review(overview, news) + + def build_market_review_payload( + self, + overview: MarketOverview, + news: List, + report: str, + market_light_snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Build the structured market-review contract consumed by API, Web, and notifications.""" + language = self._get_review_language() + sections = self._split_report_sections(report) + title = self._extract_report_title(report) or self._get_review_title(overview.date).lstrip("# ").strip() + light = market_light_snapshot or self.build_market_light_snapshot(overview) + breadth_dimensions = None + if isinstance(light, dict): + dimensions = light.get("dimensions") + if isinstance(dimensions, dict): + breadth_dimensions = dimensions.get("breadth") + + breadth_supported = bool(self.profile.has_market_stats) + if breadth_supported and isinstance(breadth_dimensions, dict) and "available" in breadth_dimensions: + breadth_supported = bool(breadth_dimensions.get("available")) + + has_breadth_data = False + if breadth_supported: + if isinstance(breadth_dimensions, dict) and "available" in breadth_dimensions: + has_breadth_data = bool(breadth_dimensions.get("available")) + else: + breadth_available = overview.up_count + overview.down_count + overview.flat_count > 0 + limit_available = overview.limit_up_count + overview.limit_down_count > 0 + has_breadth_data = bool(breadth_available or limit_available) + + payload = { + "version": 1, + "kind": "market_review", + "region": self.region, + "language": language, + "title": title, + "generated_at": datetime.now().isoformat(), + "date": overview.date, + "market_scope": self._get_market_scope_name(language), + "market_light": light, + "indices": [idx.to_dict() for idx in overview.indices], + "sectors": { + "top": list(overview.top_sectors or []), + "bottom": list(overview.bottom_sectors or []), + }, + "news": [self._normalize_news_item(item) for item in (news or [])[:8]], + "sections": sections, + "markdown_report": report, + } + + if has_breadth_data: + payload["breadth"] = { + "up_count": overview.up_count, + "down_count": overview.down_count, + "flat_count": overview.flat_count, + "limit_up_count": overview.limit_up_count, + "limit_down_count": overview.limit_down_count, + "total_amount": overview.total_amount, + "turnover_unit": self._get_turnover_unit_label(), + } + + return payload + + @staticmethod + def _extract_report_title(report: str) -> str: + for line in (report or "").splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("#").strip() + return "" + + @classmethod + def _split_report_sections(cls, report: str) -> List[Dict[str, str]]: + text = (report or "").strip() + if not text: + return [] + matches = list(re.finditer(r"^(#{2,3})\s+(.+?)\s*$", text, flags=re.MULTILINE)) + if not matches: + return [{"key": "full_review", "title": "Review", "markdown": text}] + + sections: List[Dict[str, str]] = [] + first_match = matches[0] + starts_with_report_title = first_match.start() == 0 and first_match.group(1) == "##" + content_start_index = 1 if starts_with_report_title else 0 + intro_start = first_match.end() if starts_with_report_title else 0 + intro_end = ( + matches[1].start() + if starts_with_report_title and len(matches) > 1 + else (len(text) if starts_with_report_title else matches[0].start()) + ) + intro = text[intro_start:intro_end].strip() + if intro: + sections.append({"key": "overview", "title": "Overview", "markdown": intro}) + + for index, match in enumerate(matches[content_start_index:], start=content_start_index): + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + title = match.group(2).strip() + markdown = text[start:end].strip() + if not markdown: + continue + key = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "_", title).strip("_").lower() + sections.append({ + "key": key or f"section_{index + 1}", + "title": title, + "markdown": markdown, + }) + return sections + + @classmethod + def _normalize_news_item(cls, item: Any) -> Dict[str, str]: + return { + "title": cls._compact_news_text(cls._get_news_field(item, "title"), limit=120), + "snippet": cls._compact_news_text(cls._get_news_field(item, "snippet"), limit=260), + "source": cls._compact_news_text(cls._get_news_field(item, "source"), limit=80), + "published_date": cls._compact_news_text(cls._get_news_field(item, "published_date"), limit=40), + "url": cls._compact_news_text(cls._get_news_field(item, "url"), limit=240), + } def _inject_data_into_review( self, @@ -501,7 +649,6 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t stats_block = self._build_stats_block(overview) indices_block = self._build_indices_block(overview) sector_block = self._build_sector_block(overview) - news_block = self._build_news_block(news or []) patterns = ( _ENGLISH_SECTION_PATTERNS if self._get_review_language() == "en" @@ -529,13 +676,6 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t sector_block, ) - if news_block and "news_catalysts" in patterns: - review = self._insert_after_section( - review, - patterns["news_catalysts"], - news_block, - ) - return review @staticmethod @@ -1221,11 +1361,7 @@ Market conditions can change quickly. The data above is for reference only and d ### 五、消息催化 - 暂无可用新闻时,应降低对题材持续性的确定性判断。 -### 六、明日交易计划 -- **结论**:均衡观察。 -- **仓位**:控制在中性区间,等待指数与主线共振。 -- **关注方向**:{top_text or "强于指数的主线板块"}。 -- **回避方向**:{bottom_text or "连续走弱且缺少修复信号的方向"}。 +{self._get_strategy_markdown_block(template_language)} ### 七、风险提示 - 市场有风险,投资需谨慎。以上数据仅供参考,不构成投资建议。 @@ -1247,6 +1383,12 @@ Market conditions can change quickly. The data above is for reference only and d # 3. 生成复盘报告 report = self.generate_market_review(overview, news) snapshot = self.build_market_light_snapshot(overview) + structured_payload = self.build_market_review_payload( + overview, + news, + report, + snapshot, + ) logger.info("========== 大盘复盘分析完成 ==========") @@ -1254,6 +1396,7 @@ Market conditions can change quickly. The data above is for reference only and d overview=overview, report=report, market_light_snapshot=snapshot, + structured_payload=structured_payload, ) def run_daily_review(self) -> str: diff --git a/tests/test_analysis_api_contract.py b/tests/test_analysis_api_contract.py index f3344cab2..a58cec45c 100644 --- a/tests/test_analysis_api_contract.py +++ b/tests/test_analysis_api_contract.py @@ -280,6 +280,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): search_service=runtime_search, send_notification=False, override_region="cn,us", + return_structured=True, ) def test_market_review_runtime_initializes_analyzer_for_litellm_provider(self) -> None: @@ -331,6 +332,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): search_service=runtime_search, send_notification=False, override_region="cn", + return_structured=True, ) def test_get_analysis_status_returns_market_review_report_from_queue(self) -> None: @@ -344,7 +346,10 @@ class AnalysisApiContractTestCase(unittest.TestCase): stock_name="大盘复盘", status=analysis_endpoint_module.TaskStatusEnum.COMPLETED, progress=100, - result={"result": "市场复盘报告示例文本"}, + result={ + "result": "市场复盘报告示例文本", + "market_review_payload": {"kind": "market_review", "sections": []}, + }, error=None, original_query=None, selection_source=None, @@ -356,6 +361,7 @@ class AnalysisApiContractTestCase(unittest.TestCase): self.assertEqual(status.status, "completed") self.assertEqual(status.market_review_report, "市场复盘报告示例文本") + self.assertEqual(status.market_review_payload["kind"], "market_review") self.assertIsNone(status.result) def test_get_analysis_status_normalizes_completed_queue_result_contract(self) -> None: diff --git a/tests/test_market_analyzer_generate_text.py b/tests/test_market_analyzer_generate_text.py index 4500b2ae1..c48f6e8ab 100644 --- a/tests/test_market_analyzer_generate_text.py +++ b/tests/test_market_analyzer_generate_text.py @@ -1022,6 +1022,8 @@ class TestMarketAnalyzerBypassFix: assert "## 2026-03-05 大盘复盘" in result assert "### 一、盘面总览" in result assert "今日美股市场整体呈现**小幅下跌**态势" in result + assert "### 6. Strategy Framework" not in result + assert "### 六、策略框架" in result assert "### 1. Market Summary" not in result assert "US Market Recap" not in result @@ -1141,10 +1143,27 @@ Sector text. assert "| 上证指数 | 3300.00 | 🟢 +0.36% | 3288.00 | 3312.00 | 3276.00 | 1.10% | 1450 |" in result assert "#### 领涨板块 Top 5" in result assert "| 1 | AI算力 | +3.25% |" in result - assert "#### 近三日市场线索" in result - assert "AI算力板块走强" in result + assert "#### 近三日市场线索" not in result + assert "AI算力板块走强" not in result + assert "新闻。" in result assert "算力产业链延续活跃" not in result + def test_market_review_payload_sections_skip_top_report_title(self): + from src.market_analyzer import MarketAnalyzer + + ma = MarketAnalyzer.__new__(MarketAnalyzer) + sections = ma._split_report_sections("""## 2026-06-03 大盘复盘 + +> 今日指数分化。 + +### 一、盘面总览 +正文 +""") + + assert sections[0]["key"] == "overview" + assert "今日指数分化" in sections[0]["markdown"] + assert all(section["title"] != "2026-06-03 大盘复盘" for section in sections) + def test_news_block_renders_title_source_and_link_only(self): from src.market_analyzer import MarketAnalyzer @@ -1322,6 +1341,87 @@ Sector text. assert snapshot["dimensions"]["index"]["available"] is True assert snapshot["dimensions"]["limit"] == {"score": 50, "available": False} + def test_market_review_payload_omits_breadth_for_markets_without_stats(self): + from src.core.market_profile import US_PROFILE + from src.market_analyzer import MarketIndex, MarketOverview + + ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果") + ma.region = "us" + ma.profile = US_PROFILE + + payload = ma.build_market_review_payload( + MarketOverview( + date="2026-03-18", + indices=[ + MarketIndex(code="SPX", name="S&P 500", current=5200.0, change_pct=0.6), + ], + up_count=1000, + down_count=400, + limit_up_count=10, + limit_down_count=0, + total_amount=9800.0, + ), + [], + "美股复盘报告", + market_light_snapshot={"dimensions": {"breadth": {"score": 60, "available": True}}}, + ) + + assert "breadth" not in payload + assert payload["indices"][0]["code"] == "SPX" + + def test_market_review_payload_omits_breadth_for_cn_market_without_available_stats(self): + from src.market_analyzer import MarketIndex, MarketOverview + + ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果") + payload = ma.build_market_review_payload( + MarketOverview( + date="2026-03-18", + indices=[ + MarketIndex(code="000001", name="上证指数", current=3200.0, change_pct=0.6), + ], + up_count=0, + down_count=0, + flat_count=0, + limit_up_count=0, + limit_down_count=0, + total_amount=0.0, + ), + [], + "A股复盘报告", + market_light_snapshot={"dimensions": {"breadth": {"score": 55, "available": False}}}, + ) + + assert "breadth" not in payload + assert payload["indices"][0]["name"] == "上证指数" + + def test_market_review_payload_includes_breadth_only_when_stats_available(self): + from src.market_analyzer import MarketIndex, MarketOverview + + ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果") + payload = ma.build_market_review_payload( + MarketOverview( + date="2026-03-18", + indices=[ + MarketIndex(code="000001", name="上证指数", current=3200.0, change_pct=0.6), + ], + up_count=1200, + down_count=900, + flat_count=60, + limit_up_count=12, + limit_down_count=4, + total_amount=12345.0, + ), + [], + "A股复盘报告", + market_light_snapshot={"dimensions": {"breadth": {"score": 62, "available": True}}}, + ) + + assert payload["breadth"] is not None + assert payload["breadth"]["up_count"] == 1200 + assert payload["breadth"]["down_count"] == 900 + assert payload["breadth"]["limit_up_count"] == 12 + assert payload["breadth"]["total_amount"] == 12345.0 + def test_us_english_indices_do_not_label_turnover_as_cny(self): from src.core.market_profile import US_PROFILE from src.core.market_strategy import get_market_strategy_blueprint diff --git a/tests/test_market_review.py b/tests/test_market_review.py index 0d8c36e72..40001f044 100644 --- a/tests/test_market_review.py +++ b/tests/test_market_review.py @@ -128,8 +128,8 @@ class MarketReviewLocalizationTestCase(unittest.TestCase): market_review_module, "MarketAnalyzer", side_effect=[cn_analyzer, hk_analyzer, us_analyzer], - ), patch.object(market_review_module, "_persist_market_review_history"): - result = run_market_review(notifier, send_notification=False) + ), patch.object(market_review_module, "_persist_market_review_history") as persist_history: + result = run_market_review(notifier, send_notification=True) self.assertIn("# A-share Market Recap\n\nCN body", result) self.assertIn("# HK Market Recap\n\nHK body", result) @@ -137,7 +137,17 @@ class MarketReviewLocalizationTestCase(unittest.TestCase): self.assertIn("# US Market Recap\n\nUS body", result) saved_content = notifier.save_report_to_file.call_args.args[0] self.assertTrue(saved_content.startswith("# 🎯 Market Review\n\n")) - notifier.send.assert_not_called() + self.assertIn("# A-share Market Recap\n\nCN body", saved_content) + self.assertIn("> Next market recap follows", saved_content) + self.assertIn("# HK Market Recap\n\nHK body", saved_content) + self.assertIn("# US Market Recap\n\nUS body", saved_content) + self.assertIn( + "# A-share Market Recap\n\nCN body", + persist_history.call_args.kwargs["markdown_report"], + ) + sent_content = notifier.send.call_args.args[0] + self.assertTrue(sent_content.startswith("🎯 Market Review\n\n")) + self.assertIn("# US Market Recap\n\nUS body", sent_content) def test_run_market_review_comma_joined_subset_cn_us(self) -> None: """Regression: compute_effective_region("both", {"cn","us"}) -> "cn,us" @@ -300,6 +310,24 @@ class MarketReviewLocalizationTestCase(unittest.TestCase): snapshots = persist_history.call_args.kwargs["market_light_snapshots"] self.assertEqual(set(snapshots), {"cn"}) + def test_render_market_review_payload_markdown_does_not_repeat_title(self) -> None: + markdown = market_review_module._render_market_review_payload_markdown( + { + "title": "2026-06-03 大盘复盘", + "sections": [ + { + "key": "daily_review", + "title": "2026-06-03 大盘复盘", + "markdown": "> 今日指数强弱分化。\n\n### 一、盘面总览\n正文", + } + ], + }, + wrapper_title="🎯 大盘复盘", + ) + + self.assertEqual(markdown.count("2026-06-03 大盘复盘"), 1) + self.assertTrue(markdown.startswith("🎯 大盘复盘\n\n## 2026-06-03 大盘复盘")) + def test_persist_market_review_history_saves_markdown_report(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: old_db_path = os.environ.get("DATABASE_PATH") @@ -331,6 +359,12 @@ class MarketReviewLocalizationTestCase(unittest.TestCase): "data_quality": "ok", } }, + market_review_payload={ + "version": 1, + "kind": "market_review", + "region": "cn", + "sections": [{"title": "今日大盘", "markdown": "复盘正文"}], + }, ) self.assertEqual(saved, 1) @@ -346,6 +380,7 @@ class MarketReviewLocalizationTestCase(unittest.TestCase): self.assertEqual(row.news_content, "## 今日大盘\n\n复盘正文") self.assertIn("# 🎯 大盘复盘", row.raw_result) self.assertIn('"market_light_snapshots"', row.context_snapshot) + self.assertIn('"market_review_payload"', row.context_snapshot) self.assertIn('"trade_date": "2026-03-06"', row.context_snapshot) finally: DatabaseManager.reset_instance() diff --git a/tests/test_market_strategy.py b/tests/test_market_strategy.py index 0f9167c70..0148d5549 100644 --- a/tests/test_market_strategy.py +++ b/tests/test_market_strategy.py @@ -40,12 +40,25 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase): self.assertIn("A股市场三段式复盘策略", prompt) def test_us_prompt_contains_strategy_plan_section(self): - analyzer = MarketAnalyzer(region="us") + with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="en")): + analyzer = MarketAnalyzer(region="us") + prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), []) self.assertIn("Strategy Plan", prompt) self.assertIn("US Market Regime Strategy", prompt) + def test_us_prompt_localizes_strategy_markdown_when_report_language_is_zh(self): + with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="zh")): + analyzer = MarketAnalyzer(region="us") + + prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), []) + + self.assertIn("美股市场", prompt) + self.assertNotIn("US Market Regime Strategy", prompt) + self.assertNotIn("Strategy Blueprint", prompt) + self.assertIn("风险偏好", prompt) + def test_cn_prompt_uses_english_shell_when_report_language_is_en(self): with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="en")): analyzer = MarketAnalyzer(region="cn")