fix: 稳定展示关联板块模块 (#1836)

* fix: stabilize related board rendering

* fix(review-feedback-1836): Recognize existing Chinese sector tables before appending

* fix(review-feedback-1836): Use unique segment keys for combined reviews

* fix(review-feedback-1836): return or otherwise propagate the rendered markdown for the merge path

* fix(review-feedback-1836): Insert sector fallback inside each market segment

* fix: preserve market review segment boundaries
This commit is contained in:
zhulinsen
2026-06-30 18:58:07 +08:00
committed by GitHub
parent a448886f67
commit 9bd1604f2c
12 changed files with 599 additions and 45 deletions

View File

@@ -80,23 +80,25 @@ const buildRankingSignalMap = (rankings?: ReportDetailsType['sectorRankings']):
topBoards.forEach((item) => {
const normalizedName = normalizeBoardName(item?.name);
if (!normalizedName) {
const changePct = coerceFiniteNumber(item?.changePct);
if (!normalizedName || changePct === undefined) {
return;
}
signalMap.set(normalizedName, {
status: 'leading',
changePct: coerceFiniteNumber(item.changePct),
changePct,
});
});
bottomBoards.forEach((item) => {
const normalizedName = normalizeBoardName(item?.name);
if (!normalizedName) {
const changePct = coerceFiniteNumber(item?.changePct);
if (!normalizedName || changePct === undefined) {
return;
}
signalMap.set(normalizedName, {
status: 'lagging',
changePct: coerceFiniteNumber(item.changePct),
changePct,
});
});

View File

@@ -196,6 +196,9 @@ describe('MarketReviewReportView', () => {
expect(screen.getByText('Structured Market Data')).toBeInTheDocument();
expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.getByText('S&P 500')).toBeInTheDocument();
expect(screen.getAllByText('Industry Sectors').length).toBeGreaterThan(0);
expect(screen.getByText('Technology')).toBeInTheDocument();
expect(screen.getByText('Energy')).toBeInTheDocument();
expect(screen.queryByText('Advancers')).not.toBeInTheDocument();
expect(screen.queryByText('Decliners')).not.toBeInTheDocument();
});

View File

@@ -260,6 +260,28 @@ describe('ReportOverview', () => {
expect(screen.queryByText('领跌')).not.toBeInTheDocument();
});
it('shows only the board when a matching ranking has no change percent', () => {
render(
<ReportOverview
meta={baseMeta}
summary={baseSummary}
details={{
belongBoards: [{ name: '白酒', type: '行业' }],
sectorRankings: {
top: [{ name: '白酒' }],
bottom: [],
},
}}
/>,
);
expect(screen.getByText('关联板块')).toBeInTheDocument();
expect(screen.getByText('白酒')).toBeInTheDocument();
expect(screen.queryByText('行业')).not.toBeInTheDocument();
expect(screen.queryByText('领涨')).not.toBeInTheDocument();
expect(screen.queryByText('领跌')).not.toBeInTheDocument();
});
it('hides related boards section when no boards are available', () => {
render(<ReportOverview meta={baseMeta} summary={baseSummary} details={{ belongBoards: [] }} />);

View File

@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [修复] A 股个股分析遇到空 `belong_boards` 占位时会继续补查所属板块,关联板块模块在已有板块时稳定展示;对应涨跌幅缺失时只显示板块,不再输出占位涨跌幅。
- [修复] 大盘复盘在 LLM 标题漂移或正文缺少板块段时,会从结构化 `sectors` 兜底渲染板块表,避免 Web 与推送报告偶发缺少板块主线。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -11,6 +11,7 @@
"""
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, Optional
@@ -132,6 +133,15 @@ def _get_market_review_text(language: str) -> dict[str, str]:
}
def _get_market_review_market_heading(language: Any, market: str) -> str:
review_text = _get_market_review_text(str(language or "zh"))
title_key = next(
(candidate_title_key for mkt, candidate_title_key, _ in _MARKET_REVIEW_MARKETS if mkt == market),
"",
)
return str(review_text.get(title_key) or market.upper()).lstrip("#").strip()
def _resolve_market_review_regions(raw_region: Optional[str]) -> list[str]:
"""Normalize MARKET_REVIEW_REGION into an ordered, non-empty region list."""
@@ -289,6 +299,10 @@ def run_market_review(
market_review_payload,
wrapper_title=review_text["root_title"],
)
merge_markdown_report = _render_market_review_merge_markdown(
market_review_payload,
review_report=review_report,
)
if save_report_file:
# 保存报告到文件
date_str = datetime.now().strftime('%Y%m%d')
@@ -399,6 +413,8 @@ def run_market_review(
report=review_report,
market_review_payload=market_review_payload,
)
if merge_notification:
return merge_markdown_report
return review_report
except GenerationError:
@@ -484,12 +500,41 @@ def _render_market_review_payload_markdown(
return body.strip()
def _render_market_review_merge_markdown(
payload: Dict[str, Any],
*,
review_report: str,
) -> str:
"""Render market-review body for the outer combined notification wrapper."""
markets = payload.get("markets")
if isinstance(markets, dict) and markets:
return _render_market_review_payload_markdown(payload)
return _append_missing_sector_payload_block(review_report, payload)
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()
original_markdown = markdown_report.strip()
rendered = original_markdown
for market in _MARKET_REVIEW_REGION_ORDER:
market_payload = markets.get(market)
if not isinstance(market_payload, dict):
continue
title_prefix = str(market_payload.get("title") or market.upper()).strip()
wrapper_title = _get_market_review_market_heading(payload.get("language"), market)
segment_title_prefix = title_prefix
if wrapper_title and _extract_market_markdown_segment(original_markdown, wrapper_title):
segment_title_prefix = wrapper_title
rendered = _append_missing_sector_payload_block_to_market_segment(
rendered,
market_payload,
title_prefix=title_prefix,
segment_title_prefix=segment_title_prefix,
)
return rendered
parts = []
for market in _MARKET_REVIEW_REGION_ORDER:
market_payload = markets.get(market)
@@ -503,7 +548,8 @@ 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 ""
rendered = markdown if isinstance(markdown, str) else ""
return _append_missing_sector_payload_block(rendered, payload)
title = payload.get("title")
normalized_title = _normalize_market_review_heading(title)
@@ -525,9 +571,183 @@ def _render_single_market_review_payload(payload: Dict[str, Any]) -> str:
if should_render_section_title:
lines.extend([f"### {section_title}", ""])
lines.extend([markdown, ""])
return _append_missing_sector_payload_block("\n".join(lines).strip(), payload)
def _append_missing_sector_payload_block(
markdown: str,
payload: Dict[str, Any],
*,
title_prefix: str = "",
existing_markdown: Optional[Any] = None,
segment_title_prefix: str = "",
) -> str:
sector_block = _render_sector_payload_markdown_block(payload, title_prefix=title_prefix)
if not sector_block:
return markdown.strip()
markdown_to_check = markdown if existing_markdown is None else existing_markdown
check_title_prefix = segment_title_prefix or title_prefix
if _markdown_has_sector_table(markdown_to_check, title_prefix=check_title_prefix):
return markdown.strip()
base = markdown.strip()
if not base:
return sector_block
return f"{base}\n\n{sector_block}".strip()
def _append_missing_sector_payload_block_to_market_segment(
markdown: str,
payload: Dict[str, Any],
*,
title_prefix: str = "",
segment_title_prefix: str = "",
) -> str:
base = markdown.strip()
check_title_prefix = segment_title_prefix or title_prefix
sector_block = _render_sector_payload_markdown_block(payload, title_prefix=title_prefix)
if not sector_block:
return base
if _markdown_has_sector_table(base, title_prefix=check_title_prefix):
return base
segment_span = _find_market_markdown_segment_span(base, check_title_prefix)
if segment_span is None:
return _append_missing_sector_payload_block(
base,
payload,
title_prefix=title_prefix,
existing_markdown=base,
segment_title_prefix=check_title_prefix,
)
start, end = segment_span
segment = base[start:end].strip()
rendered_segment = f"{segment}\n\n{sector_block}".strip() if segment else sector_block
suffix = base[end:]
if suffix and not suffix.startswith(("\n", "\r")):
rendered_segment = f"{rendered_segment}\n\n"
return f"{base[:start]}{rendered_segment}{suffix}".strip()
def _render_sector_payload_markdown_block(
payload: Dict[str, Any],
*,
title_prefix: str = "",
) -> str:
sector_block = _render_sector_payload_block(payload)
if not sector_block:
return ""
language = normalize_report_language(payload.get("language"))
title = "Sector Highlights" if language == "en" else "板块主线"
heading = f"{title_prefix} / {title}" if title_prefix else title
return f"### {heading}\n\n{sector_block}".strip()
def _markdown_has_sector_table(markdown: Any, *, title_prefix: str = "") -> bool:
text = str(markdown or "")
if title_prefix:
title = title_prefix.strip()
prefixed_markers = (
f"### {title} / 板块主线",
f"### {title} / Sector Highlights",
)
if any(marker in text for marker in prefixed_markers):
return True
segment = _extract_market_markdown_segment(text, title)
if segment is None:
return False
text = segment
return _markdown_contains_sector_markers(text)
def _extract_market_markdown_segment(markdown: str, title: str) -> Optional[str]:
segment_span = _find_market_markdown_segment_span(markdown, title)
if segment_span is None:
return None
start, end = segment_span
return markdown[start:end]
def _find_market_markdown_segment_span(markdown: str, title: str) -> Optional[tuple[int, int]]:
if not title:
return None
heading_pattern = re.compile(rf"(?m)^(#{{1,2}})\s+{re.escape(title)}\s*$")
match = heading_pattern.search(markdown)
if not match:
return None
heading_level = len(match.group(1))
next_heading = re.search(
rf"(?m)^(?:#{{1,{heading_level}}}\s+|---\s*$)",
markdown[match.end():],
)
end = match.end() + next_heading.start() if next_heading else len(markdown)
return match.start(), end
def _markdown_contains_sector_markers(text: str) -> bool:
markers = (
"#### 领涨板块",
"#### 领跌板块",
"#### 行业板块领涨",
"#### 行业板块领跌",
"#### Leading Sectors",
"#### Lagging Sectors",
"#### Leading Industry Sectors",
"#### Lagging Industry Sectors",
"| 排名 | 板块 |",
"| 排名 | 行业板块 |",
"| Rank | Sector |",
)
return any(marker in text for marker in markers)
def _render_sector_payload_block(payload: Dict[str, Any]) -> str:
sectors = payload.get("sectors")
if not isinstance(sectors, dict):
return ""
top = sectors.get("top") if isinstance(sectors.get("top"), list) else []
bottom = sectors.get("bottom") if isinstance(sectors.get("bottom"), list) else []
if not top and not bottom:
return ""
language = normalize_report_language(payload.get("language"))
lines = []
if top:
if language == "en":
lines.extend(["#### Leading Sectors", "| Rank | Sector | Change |", "|------|--------|--------|"])
else:
lines.extend(["#### 领涨板块 Top 5", "| 排名 | 板块 | 涨跌幅 |", "|------|------|--------|"])
for rank, sector in enumerate(top[:5], 1):
if not isinstance(sector, dict):
continue
name = str(sector.get("name") or "-").strip() or "-"
lines.append(f"| {rank} | {name} | {_format_sector_change_pct(sector)} |")
if bottom:
if lines:
lines.append("")
if language == "en":
lines.extend(["#### Lagging Sectors", "| Rank | Sector | Change |", "|------|--------|--------|"])
else:
lines.extend(["#### 领跌板块 Top 5", "| 排名 | 板块 | 涨跌幅 |", "|------|------|--------|"])
for rank, sector in enumerate(bottom[:5], 1):
if not isinstance(sector, dict):
continue
name = str(sector.get("name") or "-").strip() or "-"
lines.append(f"| {rank} | {name} | {_format_sector_change_pct(sector)} |")
return "\n".join(lines).strip()
def _format_sector_change_pct(sector: Dict[str, Any]) -> str:
raw = sector.get("change_pct", sector.get("changePct"))
try:
value = float(raw)
except (TypeError, ValueError):
return "--"
return f"{value:+.2f}%"
def _normalize_market_review_heading(value: Any) -> str:
if not isinstance(value, str):
return ""

View File

@@ -1068,12 +1068,14 @@ class StockAnalysisPipeline:
"invalid fundamental context",
)
market = enriched_context.get("market")
if not isinstance(market, str) or not market.strip():
market = get_market_for_stock(normalize_stock_code(code))
existing_boards = enriched_context.get("belong_boards")
if isinstance(existing_boards, list):
enriched_context["belong_boards"] = list(existing_boards)
market = enriched_context.get("market")
if not isinstance(market, str) or not market.strip():
market = get_market_for_stock(normalize_stock_code(code))
existing_board_list = list(existing_boards) if isinstance(existing_boards, list) else None
if existing_board_list:
enriched_context["belong_boards"] = existing_board_list
self._attach_concept_rankings_to_fundamental_context(code, enriched_context, market)
return enriched_context
@@ -1081,20 +1083,17 @@ class StockAnalysisPipeline:
boards_status = boards_block.get("status") if isinstance(boards_block, dict) else None
coverage = enriched_context.get("coverage")
boards_coverage = coverage.get("boards") if isinstance(coverage, dict) else None
market = enriched_context.get("market")
if not isinstance(market, str) or not market.strip():
market = get_market_for_stock(normalize_stock_code(code))
# For HK/US: the offshore adapter already populates belong_boards from
# yfinance sector/industry. Don't overwrite it (and we have no AkShare
# 板块 endpoint for those markets anyway). Default to [] when callers
# pass a minimal context without the key.
if market != "cn":
enriched_context.setdefault("belong_boards", [])
enriched_context["belong_boards"] = existing_board_list or []
return enriched_context
if boards_status == "not_supported" or boards_coverage == "not_supported":
enriched_context["belong_boards"] = []
enriched_context["belong_boards"] = existing_board_list or []
return enriched_context
boards: List[Dict[str, Any]] = []
@@ -1105,7 +1104,7 @@ class StockAnalysisPipeline:
except Exception as e:
logger.debug("%s attach belong_boards failed (fail-open): %s", code, e)
enriched_context["belong_boards"] = boards
enriched_context["belong_boards"] = boards or existing_board_list or []
self._attach_concept_rankings_to_fundamental_context(code, enriched_context, market)
return enriched_context

View File

@@ -912,11 +912,19 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
)
if sector_block:
original_review = review
review = self._insert_after_section(
review,
patterns["sector_highlights"],
sector_block,
)
if review == original_review and sector_block not in review:
fallback_heading = (
"### 4. Sector Highlights"
if self._get_review_language() == "en"
else "### 三、板块主线"
)
review = f"{review.rstrip()}\n\n{fallback_heading}\n{sector_block}\n"
return review

View File

@@ -2187,17 +2187,19 @@ class NotificationService(
if not belong_boards:
return
sector_signals: Dict[str, Tuple[str, Optional[float]]] = {}
concept_signals: Dict[str, Tuple[str, Optional[float]]] = {}
sector_signals: Dict[str, Tuple[str, float]] = {}
concept_signals: Dict[str, Tuple[str, float]] = {}
def add_signals(target: Dict[str, Tuple[str, Optional[float]]], rows: Any, label: str) -> None:
def add_signals(target: Dict[str, Tuple[str, float]], rows: Any, label: str) -> None:
for item in rows or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name or name in target:
continue
target[name] = (label, _safe_float(item.get("change_pct")))
change_pct = _safe_float(item.get("change_pct"))
if change_pct is not None:
target[name] = (label, change_pct)
add_signals(sector_signals, blocks.get("sector_top"), labels["leading_board_label"])
add_signals(sector_signals, blocks.get("sector_bottom"), labels["lagging_board_label"])
@@ -2245,8 +2247,8 @@ class NotificationService(
return labels["industry_boards_heading"]
return labels["concept_boards_heading"]
# Pre-resolve rows so signal-bearing reports can show type/status columns,
# while plain related-board lists keep the original compact line.
# Pre-resolve rows so signal-bearing boards can show their own
# percentage, while boards without a matching change stay plain.
prepared: List[Tuple[str, str, Optional[str], Optional[float]]] = []
for raw in belong_boards[:5]:
if not isinstance(raw, dict):
@@ -2264,17 +2266,14 @@ class NotificationService(
lines.append(f"### 🧩 {labels['related_boards_heading']}")
lines.append("")
has_signal = any(status is not None for _, _, status, _ in prepared)
has_signal = any(status is not None and change_pct is not None for _, _, status, change_pct in prepared)
if has_signal:
lines.append(
f"| {labels['board_name_label']} | {labels['board_type_label']} | "
f"{labels['board_status_label']} | {labels['board_change_pct_label']} |"
)
lines.append("|:-----|:-----:|:------:|------:|")
for name, board_type, status_text, change_pct in prepared:
status = status_text if status_text is not None else "--"
change = "--" if change_pct is None else f"{change_pct:+.2f}%"
lines.append(f"| {name} | {board_type} | {status} | {change} |")
details = []
if status_text is not None and change_pct is not None:
details.append(f"{board_type} {status_text} {change_pct:+.2f}%")
suffix = f" ({', '.join(details)})" if details else ""
lines.append(f"- {name}{suffix}")
else:
lines.append(" / ".join(name for name, _, _, _ in prepared))
lines.append("")

View File

@@ -2864,6 +2864,29 @@ Sector text.
assert "新闻。" in result
assert "算力产业链延续活跃" not in result
def test_inject_data_into_review_appends_sector_block_when_heading_drifts(self):
from src.market_analyzer import MarketOverview
ma = self._make_market_analyzer_with_mock_generate_text(return_value="review")
overview = MarketOverview(
date="2026-03-05",
top_sectors=[{"name": "AI算力", "change_pct": 3.25}],
bottom_sectors=[{"name": "煤炭", "change_pct": -1.12}],
)
review = """## 2026-03-05 大盘复盘
### 今日主线观察
正文。
"""
result = ma._inject_data_into_review(review, overview)
assert "### 三、板块主线" in result
assert "#### 行业板块领涨 Top 5" in result
assert "| 1 | AI算力 | +3.25% |" in result
assert "#### 行业板块领跌 Top 5" in result
assert "| 1 | 煤炭 | -1.12% |" in result
def test_market_review_payload_sections_skip_top_report_title(self):
from src.market_analyzer import MarketAnalyzer

View File

@@ -169,6 +169,52 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
self.assertEqual(result.market_review_payload["language"], "en")
self.assertEqual(result.report, "English market review body")
def test_run_market_review_returns_sector_fallback_for_merged_notification(self) -> None:
notifier = self._make_notifier()
market_analyzer = MagicMock()
market_analyzer.run_daily_review_with_snapshot.return_value = SimpleNamespace(
report="## 今日大盘\n\n盘面正文。",
market_light_snapshot={"region": "cn", "trade_date": "2026-06-03", "score": 60},
structured_payload={
"kind": "market_review",
"region": "cn",
"language": "zh",
"title": "今日大盘",
"sections": [
{
"key": "overview",
"title": "概览",
"markdown": "盘面正文。",
}
],
"sectors": {
"top": [{"name": "AI算力", "change_pct": 3.25}],
"bottom": [{"name": "煤炭", "change_pct": -1.12}],
},
},
)
with patch.object(
market_review_module,
"get_config",
return_value=SimpleNamespace(report_language="zh", market_review_region="cn"),
), patch.object(
market_review_module,
"MarketAnalyzer",
return_value=market_analyzer,
), patch.object(market_review_module, "_persist_market_review_history"):
result = run_market_review(
notifier,
send_notification=True,
merge_notification=True,
)
self.assertIn("## 今日大盘", result)
self.assertIn("### 板块主线", result)
self.assertIn("| 1 | AI算力 | +3.25% |", result)
self.assertIn("| 1 | 煤炭 | -1.12% |", result)
notifier.send.assert_not_called()
def test_run_market_review_reraises_generation_backend_config_error(self) -> None:
notifier = self._make_notifier()
backend_error = GenerationError(
@@ -514,6 +560,184 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
self.assertEqual(markdown.count("2026-06-03 大盘复盘"), 1)
self.assertTrue(markdown.startswith("🎯 大盘复盘\n\n## 2026-06-03 大盘复盘"))
def test_render_market_review_payload_markdown_appends_structured_sector_fallback(self) -> None:
markdown = market_review_module._render_market_review_payload_markdown(
{
"title": "2026-06-03 大盘复盘",
"language": "zh",
"sections": [
{
"key": "overview",
"title": "Overview",
"markdown": "> 今日指数强弱分化。",
}
],
"sectors": {
"top": [{"name": "AI算力", "change_pct": 3.25}],
"bottom": [{"name": "煤炭", "change_pct": -1.12}],
},
},
wrapper_title="🎯 大盘复盘",
)
self.assertIn("### 板块主线", markdown)
self.assertIn("#### 领涨板块 Top 5", markdown)
self.assertIn("| 1 | AI算力 | +3.25% |", markdown)
self.assertIn("#### 领跌板块 Top 5", markdown)
self.assertIn("| 1 | 煤炭 | -1.12% |", markdown)
def test_render_market_review_payload_markdown_keeps_injected_chinese_sector_block_once(self) -> None:
markdown = market_review_module._render_market_review_payload_markdown(
{
"title": "2026-06-03 大盘复盘",
"language": "zh",
"markdown_report": (
"## 2026-06-03 大盘复盘\n\n"
"### 板块表现\n\n"
"#### 行业板块领涨 Top 5\n"
"| 排名 | 行业板块 | 涨跌幅 |\n"
"|------|------|--------|\n"
"| 1 | AI算力 | +3.25% |"
),
"sectors": {
"top": [{"name": "AI算力", "change_pct": 3.25}],
"bottom": [{"name": "煤炭", "change_pct": -1.12}],
},
}
)
self.assertEqual(markdown.count("#### 行业板块领涨 Top 5"), 1)
self.assertNotIn("### 板块主线", markdown)
self.assertNotIn("#### 领涨板块 Top 5", markdown)
self.assertNotIn("#### 领跌板块 Top 5", markdown)
def test_render_market_review_payload_markdown_appends_each_market_sector_fallback(self) -> None:
markdown = market_review_module._render_market_review_payload_markdown(
{
"language": "zh",
"markdown_report": (
"## A 股大盘\n\n今日震荡。\n\n"
"---\n\n"
"## 港股大盘\n\n今日反弹。\n\n"
"---\n\n"
"## 美股大盘\n\n科技走强。"
),
"markets": {
"cn": {
"title": "A 股大盘",
"language": "zh",
"sectors": {"top": [{"name": "AI算力", "change_pct": 3.25}]},
},
"hk": {
"title": "港股大盘",
"language": "zh",
"sectors": {"top": [{"name": "科技", "change_pct": 2.18}]},
},
"us": {
"title": "美股大盘",
"language": "zh",
"sectors": {"top": [{"name": "半导体", "change_pct": 1.86}]},
},
},
}
)
self.assertIn("### A 股大盘 / 板块主线", markdown)
self.assertIn("| 1 | AI算力 | +3.25% |", markdown)
self.assertIn("### 港股大盘 / 板块主线", markdown)
self.assertIn("| 1 | 科技 | +2.18% |", markdown)
self.assertIn("### 美股大盘 / 板块主线", markdown)
self.assertIn("| 1 | 半导体 | +1.86% |", markdown)
self.assertLess(markdown.index("### A 股大盘 / 板块主线"), markdown.index("## 港股大盘"))
self.assertLess(markdown.index("### 港股大盘 / 板块主线"), markdown.index("## 美股大盘"))
def test_render_market_review_payload_markdown_checks_duplicate_titles_by_market_wrapper(self) -> None:
duplicate_title = "2026-06-03 大盘复盘"
markdown = market_review_module._render_market_review_payload_markdown(
{
"language": "zh",
"markdown_report": (
"# A股大盘复盘\n\n"
f"## {duplicate_title}\n\n"
"### 板块表现\n\n"
"#### 行业板块领涨 Top 5\n"
"| 排名 | 行业板块 | 涨跌幅 |\n"
"|------|------|--------|\n"
"| 1 | AI算力 | +3.25% |\n\n"
"---\n\n"
"> 以下为下一市场大盘复盘\n\n"
"# 港股大盘复盘\n\n"
f"## {duplicate_title}\n\n"
"港股正文。\n\n"
"---\n\n"
"> 以下为下一市场大盘复盘\n\n"
"# 美股大盘复盘\n\n"
f"## {duplicate_title}\n\n"
"美股正文。"
),
"markets": {
"cn": {
"title": duplicate_title,
"language": "zh",
"sectors": {"top": [{"name": "AI算力", "change_pct": 3.25}]},
},
"hk": {
"title": duplicate_title,
"language": "zh",
"sectors": {"top": [{"name": "科技", "change_pct": 2.18}]},
},
"us": {
"title": duplicate_title,
"language": "zh",
"sectors": {"top": [{"name": "半导体", "change_pct": 1.86}]},
},
},
}
)
self.assertEqual(markdown.count("#### 行业板块领涨 Top 5"), 1)
self.assertEqual(markdown.count(f"### {duplicate_title} / 板块主线"), 2)
self.assertIn("| 1 | 科技 | +2.18% |", markdown)
self.assertIn("| 1 | 半导体 | +1.86% |", markdown)
def test_render_market_review_payload_markdown_preserves_segment_boundaries_after_fallback(self) -> None:
markdown = market_review_module._render_market_review_payload_markdown(
{
"language": "en",
"markdown_report": (
"## CN Market\n\n"
"CN overview.\n\n"
"## HK Market\n\n"
"HK overview.\n\n"
"---\n\n"
"## US Market\n\n"
"US overview."
),
"markets": {
"cn": {
"title": "CN Market",
"language": "en",
"sectors": {"top": [{"name": "AI", "change_pct": 3.25}]},
},
"hk": {
"title": "HK Market",
"language": "en",
"sectors": {"top": [{"name": "Tech", "change_pct": 2.18}]},
},
"us": {
"title": "US Market",
"language": "en",
"sectors": {},
},
},
}
)
self.assertIn("| 1 | AI | +3.25% |\n\n## HK Market", markdown)
self.assertIn("| 1 | Tech | +2.18% |\n\n---\n\n## US Market", markdown)
self.assertNotIn("+3.25% |## HK Market", markdown)
self.assertNotIn("+2.18% |---", markdown)
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")

View File

@@ -1160,14 +1160,14 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertIn("2024-06-26", out)
# 关联板块白酒带行业信号MSCI中国 带概念信号)
self.assertIn("关联板块", out)
self.assertIn("| 板块 | 类型 | 板块表现 | 板块涨跌幅 |", out)
self.assertIn("行业板块", out)
self.assertIn("概念板块", out)
self.assertIn("白酒", out)
self.assertIn("领涨", out)
self.assertIn("+3.42%", out)
self.assertIn("MSCI中国", out)
self.assertIn("- 白酒 (行业板块 领涨 +3.42%)", out)
self.assertIn("- MSCI中国 (概念板块 领涨 +1.23%)", out)
self.assertIn("+1.23%", out)
self.assertNotIn("| 板块 | 类型 | 板块表现 | 板块涨跌幅 |", out)
@mock.patch("src.notification.get_config")
def test_related_boards_uses_concept_rankings_for_concept_boards(
@@ -1198,8 +1198,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
out = service.generate_single_stock_report(result)
self.assertIn("关联板块", out)
self.assertIn("| 板块 | 类型 | 板块表现 | 板块涨跌幅 |", out)
self.assertIn("| 白酒 | 概念板块 | 领跌 | -3.20% |", out)
self.assertIn("- 白酒 (概念板块 领跌 -3.20%)", out)
self.assertNotIn("| 白酒 | 概念 |", out)
self.assertNotIn("+2.31%", out)
@@ -1397,10 +1396,10 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertNotIn("| 白酒Ⅲ | N/A |", out)
@mock.patch("src.notification.get_config")
def test_related_boards_keeps_signal_columns_when_any_board_has_data(
def test_related_boards_renders_each_board_signal_without_placeholder(
self, mock_get_config: mock.MagicMock
):
"""When any industry board has ranking data, keep signal columns for that group."""
"""Rows without a matching change_pct stay as plain board entries."""
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = AnalysisResult(
@@ -1426,15 +1425,49 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
out = service.generate_single_stock_report(result)
self.assertIn("板块表现", out)
self.assertIn("板块涨跌幅", out)
self.assertIn("| 板块 | 类型 | 板块表现 | 板块涨跌幅 |", out)
self.assertIn("| 白酒 | 行业板块 | 领涨 | +3.42% |", out)
self.assertIn("| MSCI中国 | 概念板块 | -- | -- |", out)
self.assertNotIn("板块表现", out)
self.assertNotIn("板块涨跌幅", out)
self.assertIn("领涨", out)
self.assertIn("+3.42%", out)
# MSCI中国 stays in the concept group without borrowing the industry signal.
self.assertIn("- 白酒 (行业板块 领涨 +3.42%)", out)
self.assertIn("MSCI中国", out)
self.assertIn("- MSCI中国", out)
self.assertNotIn("- MSCI中国 (", out)
self.assertNotIn("| MSCI中国 | 概念 | -- | -- |", out)
@mock.patch("src.notification.get_config")
def test_related_boards_ignores_matching_signal_without_change_pct(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
)
result.fundamental_context = {
"earnings": {"status": "ok", "data": {}},
"growth": {"status": "ok", "data": {}},
"boards": {"status": "ok", "data": {
"top": [{"name": "白酒"}],
"bottom": [],
}},
"belong_boards": [
{"name": "白酒", "code": "BK0596", "type": "行业"},
],
}
out = service.generate_single_stock_report(result)
self.assertIn("关联板块", out)
self.assertIn("白酒", out)
self.assertNotIn("| 白酒 | 行业 |", out)
self.assertNotIn("领涨", out)
self.assertNotIn("板块涨跌幅", out)
@mock.patch("src.notification.get_config")
def test_generate_single_stock_report_uses_currency_for_hk(

View File

@@ -165,6 +165,24 @@ class PipelineRelatedBoardsTestCase(unittest.TestCase):
self.assertIsNot(enriched["belong_boards"], existing_boards)
pipeline.fetcher_manager.get_belong_boards.assert_not_called()
def test_attach_belong_boards_refetches_empty_cn_board_list(self) -> None:
pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline)
pipeline.fetcher_manager = MagicMock()
pipeline.fetcher_manager.get_belong_boards.return_value = [{"name": "白酒", "type": "行业"}]
context = {
"market": "cn",
"status": "ok",
"belong_boards": [],
"coverage": {"boards": "ok"},
"boards": {"status": "ok", "data": {"top": [], "bottom": []}},
}
enriched = pipeline._attach_belong_boards_to_fundamental_context("600519", context)
self.assertEqual(enriched["belong_boards"], [{"name": "白酒", "type": "行业"}])
pipeline.fetcher_manager.get_belong_boards.assert_called_once_with("600519")
def test_attach_belong_boards_skips_provider_for_non_cn(self) -> None:
pipeline = StockAnalysisPipeline.__new__(StockAnalysisPipeline)
pipeline.fetcher_manager = MagicMock()