mirror of
https://github.com/ZhuLinsen/daily_stock_analysis.git
synced 2026-10-06 15:13:28 +08:00
fix: 结构化大盘复盘报告展示 (#1576)
* feat: add dedicated market review report view * fix(review-feedback-1576): Use localized labels for English market reviews * fix: structure market review payload rendering * fix(review-feedback-1576): preserve market labels when rendering combined reviews and Use the * fix(review-feedback-1576): Render structured data for every combined market and Localize * fix: dedupe market review rendering * test: align market review language prompt expectation * fix(review-feedback-1576): 澄清并补足 LLM/运行时配置相关检测命中的兼容性证据 * fix(review-feedback-1576): 解决冲突并重新确认 CI * fix(review-feedback-1576): docs/CHANGELOG.md:[Unreleased] 新增内容中除 1555 大盘复盘展示外,还包含 Agent * fix(review-feedback-1576): 修复 * fix(review-feedback-1576): 修复美股中文大盘复盘仍混入英文策略/市场标签的问题,并补齐对应回归测试
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 时存在)"
|
||||
|
||||
@@ -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<MarketReviewPayload['indices']>;
|
||||
};
|
||||
|
||||
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<ReportLanguage, {
|
||||
reviewSummary: string;
|
||||
noReviewSummary: string;
|
||||
noSentimentScore: string;
|
||||
rotationAndFunds: string;
|
||||
noRotationView: string;
|
||||
riskAndWatch: string;
|
||||
noRiskWatch: string;
|
||||
structuredMarketData: string;
|
||||
noBreadthData: string;
|
||||
advancers: string;
|
||||
decliners: string;
|
||||
limitUpDown: string;
|
||||
turnover: string;
|
||||
index: string;
|
||||
last: string;
|
||||
change: string;
|
||||
highLow: string;
|
||||
}> = {
|
||||
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<MarketReviewReportViewProps> = ({
|
||||
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<LoadedMarkdown | null>(null);
|
||||
const [loadError, setLoadError] = useState<LoadError | null>(null);
|
||||
const [copiedType, setCopiedType] = useState<CopyType | null>(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 (
|
||||
<div className={`animate-fade-in space-y-4 pb-8 ${className}`}>
|
||||
<Card variant="gradient" padding="md" className="home-report-hero text-left">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 inline-flex items-center gap-2 text-xs font-semibold text-secondary-text">
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
<span>MARKET REVIEW</span>
|
||||
</div>
|
||||
<h2 className="text-[26px] font-bold leading-tight text-foreground sm:text-[30px]">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-text">
|
||||
{meta?.stockCode ? (
|
||||
<span className="home-accent-chip px-2 py-0.5 font-mono">{meta.stockCode}</span>
|
||||
) : null}
|
||||
{meta?.createdAt ? <span>{new Date(meta.createdAt).toLocaleString()}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip content={text.copyMarkdownSource}>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy('markdown')}
|
||||
disabled={isLoading || !content || copiedType !== null}
|
||||
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground disabled:opacity-50"
|
||||
aria-label={text.copyMarkdownSource}
|
||||
>
|
||||
{copiedType === 'markdown' ? (
|
||||
<svg className="h-5 w-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<Clipboard className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip content={text.copyPlainText}>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy('text')}
|
||||
disabled={isLoading || !content || copiedType !== null}
|
||||
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground disabled:opacity-50"
|
||||
aria-label={text.copyPlainText}
|
||||
>
|
||||
{copiedType === 'text' ? (
|
||||
<svg className="h-5 w-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<FileText className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{summary ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{insightCards.map(({ icon: Icon, label, value }) => (
|
||||
<Card key={label} variant="bordered" padding="sm" className="home-panel-card text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="label-uppercase">{label}</p>
|
||||
<p className="mt-2 line-clamp-4 text-sm leading-6 text-foreground">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{structuredMarketData.length > 0 ? (
|
||||
<Card variant="bordered" padding="md" className="home-panel-card text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-foreground">{marketReviewText.structuredMarketData}</h3>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
{structuredMarketData.map((marketData) => (
|
||||
<div key={marketData.id} className="space-y-3">
|
||||
{showStructuredMarketTitles ? (
|
||||
<h4 className="text-sm font-semibold text-foreground">{marketData.title}</h4>
|
||||
) : null}
|
||||
{marketData.breadth ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
|
||||
<div className="rounded-lg border border-subtle p-3">
|
||||
<p className="label-uppercase">{marketReviewText.advancers}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{marketData.breadth.upCount ?? '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-subtle p-3">
|
||||
<p className="label-uppercase">{marketReviewText.decliners}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">{marketData.breadth.downCount ?? '-'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-subtle p-3">
|
||||
<p className="label-uppercase">{marketReviewText.limitUpDown}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">
|
||||
{marketData.breadth.limitUpCount ?? '-'} / {marketData.breadth.limitDownCount ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-subtle p-3">
|
||||
<p className="label-uppercase">{marketReviewText.turnover}</p>
|
||||
<p className="mt-1 font-semibold text-foreground">
|
||||
{marketData.breadth.totalAmount ?? '-'} {marketData.breadth.turnoverUnit || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-secondary-text">{marketReviewText.noBreadthData}</p>
|
||||
)}
|
||||
{marketData.indices.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-muted-text">
|
||||
<tr>
|
||||
<th className="px-2 py-2">{marketReviewText.index}</th>
|
||||
<th className="px-2 py-2">{marketReviewText.last}</th>
|
||||
<th className="px-2 py-2">{marketReviewText.change}</th>
|
||||
<th className="px-2 py-2">{marketReviewText.highLow}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-subtle">
|
||||
{marketData.indices.map((index) => (
|
||||
<tr key={index.code || index.name}>
|
||||
<td className="px-2 py-2 font-medium text-foreground">{index.name}</td>
|
||||
<td className="px-2 py-2 text-secondary-text">{index.current ?? '-'}</td>
|
||||
<td className="px-2 py-2 text-secondary-text">{index.changePct !== undefined ? `${index.changePct}%` : '-'}</td>
|
||||
<td className="px-2 py-2 text-secondary-text">{index.high ?? '-'} / {index.low ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Card variant="bordered" padding="md" className="home-panel-card text-left">
|
||||
<div className="flex h-64 flex-col items-center justify-center">
|
||||
<div className="home-spinner h-10 w-10 animate-spin border-[3px]" />
|
||||
<p className="mt-4 text-sm text-secondary-text">{text.loadingReport}</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card variant="bordered" padding="md" className="home-panel-card text-left">
|
||||
<div className="flex h-64 flex-col items-center justify-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-danger/10">
|
||||
<ShieldAlert className="h-6 w-6 text-danger" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div data-testid="market-review-report" className="space-y-4">
|
||||
{sections.map(({ id, title, content: sectionContent, icon: Icon }) => (
|
||||
<Card key={id} variant="bordered" padding="md" className="home-panel-card text-left">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
</div>
|
||||
<ReportMarkdownBody
|
||||
content={sectionContent}
|
||||
className="market-review-markdown"
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<ReportMarkdownBodyProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
testId,
|
||||
}) => (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={`home-markdown-prose prose prose-invert prose-sm max-w-none
|
||||
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
|
||||
prose-h1:text-xl
|
||||
prose-h2:text-lg
|
||||
prose-h3:text-base
|
||||
prose-p:leading-relaxed prose-p:mb-3 prose-p:last:mb-0
|
||||
prose-strong:text-foreground prose-strong:font-semibold
|
||||
prose-ul:my-2 prose-ol:my-2 prose-li:my-1
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
|
||||
prose-pre:border
|
||||
prose-table:border-collapse
|
||||
prose-hr:my-4
|
||||
prose-a:no-underline hover:prose-a:underline
|
||||
prose-blockquote:text-secondary-text
|
||||
whitespace-pre-line break-words
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
@@ -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<ReportMarkdownPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="home-markdown-prose prose prose-invert prose-sm max-w-none
|
||||
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
|
||||
prose-h1:text-xl
|
||||
prose-h2:text-lg
|
||||
prose-h3:text-base
|
||||
prose-p:leading-relaxed prose-p:mb-3 prose-p:last:mb-0
|
||||
prose-strong:text-foreground prose-strong:font-semibold
|
||||
prose-ul:my-2 prose-ol:my-2 prose-li:my-1
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
|
||||
prose-pre:border
|
||||
prose-table:border-collapse
|
||||
prose-hr:my-4
|
||||
prose-a:no-underline hover:prose-a:underline
|
||||
prose-blockquote:text-secondary-text
|
||||
whitespace-pre-line break-words
|
||||
"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
<ReportMarkdownBody content={content} />
|
||||
)}
|
||||
|
||||
<div className="home-divider mt-6 flex justify-end border-t pt-4">
|
||||
|
||||
@@ -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<ReportSummaryProps> = ({
|
||||
modelUsed && !['unknown', 'error', 'none', 'null', 'n/a'].includes(modelUsed.toLowerCase()),
|
||||
);
|
||||
|
||||
if (meta.reportType === 'market_review') {
|
||||
return (
|
||||
<MarketReviewReportView
|
||||
report={report}
|
||||
recordId={recordId}
|
||||
reportLanguage={reportLanguage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-8 animate-fade-in">
|
||||
{/* 概览区(首屏) */}
|
||||
|
||||
@@ -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(
|
||||
<MarketReviewReportView
|
||||
report={englishMarketReviewReport}
|
||||
content="# Market Review"
|
||||
reportLanguage="en"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MarketReviewReportView
|
||||
payload={combinedMarketReviewPayload}
|
||||
content="# 大盘复盘"
|
||||
reportLanguage="zh"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MarketReviewReportView
|
||||
payload={combinedMarketReviewPayload}
|
||||
content="# 大盘复盘"
|
||||
reportLanguage="zh"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MarketReviewReportView
|
||||
payload={noBreadthMarketReviewPayload}
|
||||
content="# Market Review"
|
||||
reportLanguage="en"
|
||||
/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -6,3 +6,5 @@ export * from './ReportStrategy';
|
||||
export * from './ReportNews';
|
||||
export * from './ReportDetails';
|
||||
export * from './ReportMarkdown';
|
||||
export * from './ReportMarkdownBody';
|
||||
export * from './MarketReviewReportView';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<MarketReviewNotice>(null);
|
||||
const [marketReviewError, setMarketReviewError] = useState<ParsedApiError | null>(null);
|
||||
const [marketReviewReport, setMarketReviewReport] = useState<string | null>(null);
|
||||
const [marketReviewReportCopied, setMarketReviewReportCopied] = useState(false);
|
||||
const [marketReviewPayload, setMarketReviewPayload] = useState<MarketReviewPayload | null>(null);
|
||||
const [analysisSkills, setAnalysisSkills] = useState<SkillInfo[]>([]);
|
||||
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(
|
||||
() => (
|
||||
<div className="flex min-h-0 h-full flex-col gap-3 overflow-hidden">
|
||||
@@ -778,25 +781,12 @@ const HomePage: React.FC = () => {
|
||||
) : null}
|
||||
|
||||
{marketReviewReport ? (
|
||||
<div className="mb-3 rounded-xl border border-subtle bg-surface/70 px-3 py-3 text-xs text-secondary-text shadow-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="font-semibold text-foreground">大盘复盘报告</p>
|
||||
<button
|
||||
type="button"
|
||||
className="home-surface-button h-7 rounded-md px-3 py-1 text-xs text-foreground"
|
||||
disabled={marketReviewReportCopied}
|
||||
onClick={() => void handleCopyMarketReviewReport()}
|
||||
>
|
||||
{marketReviewReportCopied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
data-testid="market-review-report"
|
||||
className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background px-3 py-2 leading-relaxed"
|
||||
>
|
||||
{marketReviewReport}
|
||||
</pre>
|
||||
</div>
|
||||
<MarketReviewReportView
|
||||
content={marketReviewReport}
|
||||
payload={marketReviewPayload}
|
||||
reportLanguage={liveMarketReviewLanguage}
|
||||
className="mb-3"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
@@ -806,12 +796,13 @@ const HomePage: React.FC = () => {
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
) : null}
|
||||
{isLoadingReport ? (
|
||||
{!marketReviewReport && isLoadingReport ? (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<DashboardStateBlock title="加载报告中..." loading />
|
||||
</div>
|
||||
) : selectedReport ? (
|
||||
) : !marketReviewReport && selectedReport ? (
|
||||
<div className={isHistoryTrendOpen ? 'max-w-6xl space-y-4 pb-8' : 'max-w-4xl space-y-4 pb-8'}>
|
||||
{!isMarketReviewHistoryReport ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="home-action-ai"
|
||||
@@ -863,6 +854,7 @@ const HomePage: React.FC = () => {
|
||||
{reportText.fullReport}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{isHistoryTrendOpen ? (
|
||||
<StockHistoryTrendDrawer
|
||||
key={`stock-history-${selectedReport.meta.id}`}
|
||||
@@ -893,7 +885,7 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : !marketReviewReport ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
title="开始分析"
|
||||
@@ -906,7 +898,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
@@ -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(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
breadth?: MarketReviewBreadth;
|
||||
indices?: MarketReviewIndex[];
|
||||
sectors?: SectorRankings;
|
||||
news?: Array<Record<string, unknown>>;
|
||||
sections?: MarketReviewPayloadSection[];
|
||||
markets?: Record<string, MarketReviewPayload>;
|
||||
markdownReport?: string;
|
||||
}
|
||||
|
||||
export type AnalysisContextPackBlockStatus =
|
||||
| 'available'
|
||||
| 'missing'
|
||||
@@ -190,7 +240,7 @@ export interface AnalysisContextPackOverview {
|
||||
export interface ReportDetails {
|
||||
newsContent?: string;
|
||||
rawResult?: Record<string, unknown>;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
contextSnapshot?: Record<string, unknown> & { marketReviewPayload?: MarketReviewPayload };
|
||||
analysisContextPackOverview?: AnalysisContextPackOverview | null;
|
||||
financialReport?: Record<string, unknown>;
|
||||
dividendMetrics?: Record<string, unknown>;
|
||||
@@ -292,6 +342,7 @@ export interface TaskStatus {
|
||||
progress?: number;
|
||||
result?: AnalysisResult;
|
||||
marketReviewReport?: string;
|
||||
marketReviewPayload?: MarketReviewPayload;
|
||||
error?: string;
|
||||
stockName?: string;
|
||||
originalQuery?: string;
|
||||
|
||||
@@ -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 的历史/即时结果一致。
|
||||
|
||||
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
|
||||
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -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 报告页的“历史趋势”抽屉复用该接口加载同股历史。
|
||||
|
||||
@@ -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.
|
||||
|
||||
+162
-4
@@ -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,
|
||||
|
||||
+168
-25
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user