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