fix: 收敛多股通知摘要的市场状态展示 (#1612)

* fix: compact aggregate report market status

* fix(review-feedback-1612): preserve input order when selecting market status

* chore: rerun review after clarifying PR scope
This commit is contained in:
mumu
2026-06-05 22:47:39 +08:00
committed by GitHub
parent 8a77960bcb
commit 3153c764a4
10 changed files with 210 additions and 51 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [改进] 多股通知报告将市场阶段收敛为总览下方单行 `市场状态`,不再在每只股票摘要下重复展示数据质量和限制详情。
- [修复] Web 个股栏和历史卡片在窄布局下不再让市场阶段标签遮挡股票名称。
- [修复] 问股自由文本追问不再将 TTM、PE、YOY 等金融缩写误识别为新股票代码。
- [修复] GitHub Actions 每日分析工作流读取 SearXNG 自建实例地址时支持 Variables 优先、Secrets 回退,修复仅配置 Variables 时 URL 不生效的问题。

View File

@@ -45,6 +45,38 @@ _PUBLIC_SOURCE_LABELS_EN = {
"evaluator_snapshot": "evaluator snapshot",
"legacy_text": "legacy text",
}
_MARKET_STATUS_PREFIX = {
"zh": "市场状态",
"en": "Market status",
}
_MARKET_LABELS_ZH = {
"cn": "A股",
"hk": "港股",
"us": "美股",
}
_MARKET_LABELS_EN = {
"cn": "A-shares",
"hk": "Hong Kong",
"us": "US",
}
_PHASE_LABELS_ZH = {
"premarket": "盘前",
"intraday": "盘中",
"lunch_break": "午间休市",
"closing_auction": "临近收盘",
"postmarket": "盘后",
"non_trading": "非交易日",
"unknown": "阶段未知",
}
_PHASE_LABELS_EN = {
"premarket": "Pre-market",
"intraday": "Intraday",
"lunch_break": "Lunch break",
"closing_auction": "Near close",
"postmarket": "Post-market",
"non_trading": "Non-trading",
"unknown": "Unknown phase",
}
def render_market_phase_summary(phase_context: Any) -> Optional[Dict[str, Any]]:
@@ -146,6 +178,34 @@ def format_public_phase_pack_excerpt(
return "\n".join(lines)
def format_public_market_status_line(
market_phase_summary: Any,
*,
report_language: str = "zh",
) -> str:
"""Format one compact market/phase line for aggregate reports."""
phase_summary = _as_mapping(market_phase_summary)
if not phase_summary:
return ""
phase = _safe_phase(phase_summary.get("phase"))
if phase is None:
return ""
lang = "en" if str(report_language or "").lower().startswith("en") else "zh"
phase_labels = _PHASE_LABELS_EN if lang == "en" else _PHASE_LABELS_ZH
market_labels = _MARKET_LABELS_EN if lang == "en" else _MARKET_LABELS_ZH
phase_label = phase_labels.get(phase, phase)
market = _safe_text(phase_summary.get("market"))
market_key = market.lower()
if market_key:
market_label = market_labels.get(market_key, market.upper() if lang == "en" else market)
value = f"{market_label} · {phase_label}"
else:
value = phase_label
separator = ": " if lang == "en" else ""
return f"{_MARKET_STATUS_PREFIX[lang]}{separator}{value}"
def _as_mapping(value: Any) -> Optional[Mapping[str, Any]]:
if isinstance(value, Mapping):
return value

View File

@@ -25,7 +25,7 @@ from enum import Enum
from src.config import Config, get_config
from src.enums import ReportType
from src.market_phase_summary import format_public_phase_pack_excerpt
from src.market_phase_summary import format_public_market_status_line, format_public_phase_pack_excerpt
from src.notification_routing import (
get_notification_route_config,
split_notification_route_channels,
@@ -346,6 +346,28 @@ class NotificationService(
report_language=report_language,
)
def _public_market_status_line(self, results: List[AnalysisResult], report_language: str) -> str:
for result in results or []:
line = format_public_market_status_line(
getattr(result, "market_phase_summary", None),
report_language=report_language,
)
if line:
return line
return ""
def _append_market_status_line(
self,
lines: List[str],
results: List[AnalysisResult],
report_language: str,
) -> None:
status_line = self._public_market_status_line(results, report_language)
if status_line:
lines.extend([status_line, ""])
elif lines and lines[-1] != "":
lines.append("")
def _should_show_llm_model(self) -> bool:
return bool(getattr(self._config, "report_show_llm_model", self._report_show_llm_model))
@@ -779,10 +801,9 @@ class NotificationService(
"",
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit']} | "
f"{labels['generated_at_label']}{datetime.now().strftime('%H:%M:%S')}",
"",
"---",
"",
]
self._append_market_status_line(report_lines, results, report_language)
report_lines.extend(["---", ""])
# 按评分排序(高分在前)
sorted_results = sorted(
@@ -822,9 +843,6 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
excerpt = self._public_phase_pack_excerpt(r, report_language)
if excerpt:
report_lines.append(excerpt)
else:
report_lines.extend([f"## 📈 {labels['report_title']}", ""])
# 逐个股票的详细分析
@@ -841,10 +859,6 @@ class NotificationService(
f"**Confidence{confidence_stars}**",
"",
])
excerpt = self._public_phase_pack_excerpt(result, report_language)
if excerpt:
report_lines.extend([excerpt, ""])
self._append_market_snapshot(report_lines, result)
# 核心看点
@@ -1060,8 +1074,8 @@ class NotificationService(
"",
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit']} | "
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count}",
"",
]
self._append_market_status_line(report_lines, results, report_language)
# === 新增:分析结果摘要 (Issue #112) ===
if results:
@@ -1078,9 +1092,6 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
excerpt = self._public_phase_pack_excerpt(r, report_language)
if excerpt:
report_lines.append(excerpt)
report_lines.extend([
"",
"---",
@@ -1367,8 +1378,8 @@ class NotificationService(
"",
f"> {len(results)} {labels['stock_unit']} | "
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count}",
"",
]
self._append_market_status_line(lines, results, report_language)
# Issue #262: summary_only 时仅输出摘要列表
if self._report_summary_only:
@@ -1519,8 +1530,8 @@ class NotificationService(
f"> {labels['analyzed_prefix']} **{len(results)}** {labels['stock_unit_compact']} | "
f"🟢{labels['buy_label']}:{buy_count} 🟡{labels['watch_label']}:{hold_count} 🔴{labels['sell_label']}:{sell_count} | "
f"{labels['avg_score_label']}:{avg_score:.0f}",
"",
]
self._append_market_status_line(lines, results, report_language)
# 每只股票精简信息(控制长度)
for result in sorted_results:
@@ -1607,8 +1618,8 @@ class NotificationService(
f"# {report_date} {labels['brief_title']}",
"",
f"> {len(results)} {labels['stock_unit_compact']} | 🟢{buy_count} 🟡{hold_count} 🔴{sell_count}",
"",
]
self._append_market_status_line(lines, results, report_language)
for r in sorted_results:
_, emoji, _ = self._get_signal_level(r)
name = self._get_display_name(r, report_language)
@@ -1620,9 +1631,6 @@ class NotificationService(
f"{localize_operation_advice(r.operation_advice, report_language)} | "
f"{labels['score_label']} {r.sentiment_score} | {one}"
)
excerpt = self._public_phase_pack_excerpt(r, report_language)
if excerpt:
lines.append(excerpt)
lines.append("")
lines.append(f"*{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
models = self._collect_models_used(results)

View File

@@ -15,7 +15,7 @@ from typing import Any, Dict, List, Optional
from src.analyzer import AnalysisResult
from src.config import get_config
from src.market_phase_summary import format_public_phase_pack_excerpt
from src.market_phase_summary import format_public_market_status_line, format_public_phase_pack_excerpt
from src.report_language import (
get_localized_stock_name,
get_report_labels,
@@ -157,6 +157,17 @@ def render(
report_language=report_language,
)
def market_status_line() -> str:
for source_results in (results or [], sorted_results):
for result in source_results:
line = format_public_market_status_line(
getattr(result, "market_phase_summary", None),
report_language=report_language,
)
if line:
return line
return ""
context: Dict[str, Any] = {
"report_date": report_date,
"report_timestamp": report_timestamp,
@@ -170,6 +181,7 @@ def render(
"report_language": report_language,
"models_used": models_used,
"show_llm_model": show_llm_model,
"market_status_line": market_status_line(),
"escape_md": _escape_md,
"clean_sniper": _clean_sniper_value,
"failed_checks": failed_checks,

View File

@@ -1,16 +1,15 @@
# 🎯 {{ report_date }} {{ labels.brief_title }}
> {{ results|length }} {{ labels.stock_unit_compact }} | 🟢{{ buy_count }} 🟡{{ hold_count }} 🔴{{ sell_count }}
{% if market_status_line %}
{{ market_status_line }}
{% endif %}
{% for e in enriched %}
{% set dash = e.result.dashboard or {} %}
{% set core = (dash.get('core_conclusion') or {}) if dash else {} %}
{% set one = (core.get('one_sentence') or e.result.analysis_summary or '')[:60] %}
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ one }}
{% set phase_excerpt = phase_pack_excerpt(e.result) %}
{% if phase_excerpt %}
{{ phase_excerpt }}
{% endif %}
{% endfor %}
*{{ report_timestamp }}*

View File

@@ -2,15 +2,14 @@
# 🎯 {{ report_date }} {{ labels.dashboard_title }}
> {{ labels.analyzed_prefix }} **{{ results|length }}** {{ labels.stock_unit }} | 🟢{{ labels.buy_label }}:{{ buy_count }} 🟡{{ labels.watch_label }}:{{ hold_count }} 🔴{{ labels.sell_label }}:{{ sell_count }}
{% if market_status_line %}
{{ market_status_line }}
{% endif %}
## 📊 {{ labels.summary_heading }}
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% set phase_excerpt = phase_pack_excerpt(e.result) %}
{% if phase_excerpt %}
{{ phase_excerpt }}
{% endif %}
{% endfor %}
---

View File

@@ -1,15 +1,14 @@
## 🎯 {{ report_date }} {{ labels.dashboard_title }}
> {{ results|length }} {{ labels.stock_unit }} | 🟢{{ labels.buy_label }}:{{ buy_count }} 🟡{{ labels.watch_label }}:{{ hold_count }} 🔴{{ labels.sell_label }}:{{ sell_count }}
{% if market_status_line %}
{{ market_status_line }}
{% endif %}
{% if summary_only %}
**📊 {{ labels.summary_heading }}**
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% set phase_excerpt = phase_pack_excerpt(e.result) %}
{% if phase_excerpt %}
{{ phase_excerpt }}
{% endif %}
{% endfor %}
{% else %}
{% for e in enriched %}
@@ -20,10 +19,6 @@
{% set intel = dashboard.get('intelligence') or {} %}
### {{ e.signal_emoji }} **{{ e.signal_text }}** | {{ e.stock_name }}({{ result.code }})
{% set phase_excerpt = phase_pack_excerpt(result) %}
{% if phase_excerpt %}
{{ phase_excerpt }}
{% endif %}
{% set one_sentence = core.get('one_sentence', result.analysis_summary) if core else result.analysis_summary %}
{% if one_sentence %}

View File

@@ -6,6 +6,7 @@ import json
from src.market_phase_summary import (
MARKET_PHASE_SUMMARY_KEY,
extract_market_phase_summary,
format_public_market_status_line,
format_public_phase_pack_excerpt,
normalize_analysis_phase_bucket,
render_market_phase_summary,
@@ -159,3 +160,32 @@ def test_format_public_phase_pack_excerpt_limits_and_redacts_public_fields() ->
def test_format_public_phase_pack_excerpt_returns_empty_without_summary_or_pack() -> None:
assert format_public_phase_pack_excerpt(None, None, source="evaluator_snapshot") == ""
def test_format_public_market_status_line_localizes_compact_summary() -> None:
assert (
format_public_market_status_line(
{"market": "cn", "phase": "postmarket"},
report_language="zh",
)
== "市场状态A股 · 盘后"
)
assert (
format_public_market_status_line(
{"market": "us", "phase": "premarket"},
report_language="en",
)
== "Market status: US · Pre-market"
)
def test_format_public_market_status_line_returns_empty_without_valid_phase() -> None:
assert format_public_market_status_line(None, report_language="zh") == ""
assert format_public_market_status_line({"market": "cn"}, report_language="zh") == ""
assert (
format_public_market_status_line(
{"market": "cn", "phase": "bad_phase"},
report_language="zh",
)
== ""
)

View File

@@ -630,7 +630,7 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertIn("*分析模型gemini/gemini-2.5-flash*", out)
@mock.patch("src.notification.get_config")
def test_generated_reports_include_public_phase_pack_excerpt_only(self, mock_get_config: mock.MagicMock):
def test_aggregate_reports_show_compact_market_status_only(self, mock_get_config: mock.MagicMock):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = AnalysisResult(
@@ -657,16 +657,50 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
out = service.generate_brief_report([result], report_date="2026-02-01")
self.assertIn("阶段intraday", out)
self.assertIn("触发来源portfolio", out)
self.assertIn("盘中数据提示", out)
self.assertIn("数据质量: limited", out)
self.assertIn("限制: quote: stale", out)
self.assertIn("限制: news: missing", out)
self.assertIn("市场状态A股 · 盘中", out)
self.assertNotIn("阶段intraday", out)
self.assertNotIn("触发来源portfolio", out)
self.assertNotIn("盘中数据提示", out)
self.assertNotIn("数据质量: limited", out)
self.assertNotIn("限制: quote: stale", out)
self.assertNotIn("限制: news: missing", out)
self.assertNotIn("portfolio_context: hidden", out)
self.assertNotIn("raw context pack", out)
self.assertNotIn("prompt", out.lower())
@mock.patch("src.notification.get_config")
def test_template_dashboard_report_uses_single_market_status_line(self, mock_get_config: mock.MagicMock):
mock_get_config.return_value = _make_config(report_renderer_enabled=True)
service = NotificationService()
result = AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
)
result.market_phase_summary = {
"phase": "postmarket",
"market": "cn",
"trigger_source": "cli",
}
result.analysis_context_pack_overview = {
"data_quality": {
"level": "good",
"limitations": ["technical: partial"],
}
}
out = service.generate_dashboard_report([result], report_date="2026-02-01")
self.assertIn("市场状态A股 · 盘后", out)
self.assertEqual(out.count("市场状态:"), 1)
self.assertNotIn("阶段postmarket", out)
self.assertNotIn("触发来源cli", out)
self.assertNotIn("数据质量: good", out)
self.assertNotIn("technical: partial", out)
@mock.patch("src.notification.get_config")
def test_generated_reports_skip_phase_pack_excerpt_when_summary_missing(self, mock_get_config: mock.MagicMock):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)

View File

@@ -159,7 +159,7 @@ class TestReportRenderer(unittest.TestCase):
self.assertNotIn("分析模型", hidden)
self.assertNotIn("gemini/gemini-2.5-flash", hidden)
def test_render_templates_include_public_phase_pack_excerpt(self) -> None:
def test_render_templates_show_compact_market_status_only(self) -> None:
r = _make_result()
r.market_phase_summary = {
"phase": "intraday",
@@ -178,11 +178,12 @@ class TestReportRenderer(unittest.TestCase):
out = render("brief", [r])
self.assertIsNotNone(out)
self.assertIn("阶段intraday", out)
self.assertIn("盘中数据提示", out)
self.assertIn("数据质量: limited", out)
self.assertIn("限制: quote: stale", out)
self.assertIn("限制: news: missing", out)
self.assertIn("市场状态A股 · 盘中", out)
self.assertNotIn("阶段intraday", out)
self.assertNotIn("盘中数据提示", out)
self.assertNotIn("数据质量: limited", out)
self.assertNotIn("限制: quote: stale", out)
self.assertNotIn("限制: news: missing", out)
self.assertNotIn("technical: fallback", out)
self.assertNotIn("raw context pack", out)
@@ -195,6 +196,26 @@ class TestReportRenderer(unittest.TestCase):
self.assertNotIn("摘要来源", out)
self.assertNotIn("evaluator snapshot", out)
def test_render_market_status_preserves_input_order(self) -> None:
cn = _make_result(
code="600519",
name="贵州茅台",
sentiment_score=60,
)
cn.market_phase_summary = {"market": "cn", "phase": "postmarket"}
us = _make_result(
code="AAPL",
name="Apple",
sentiment_score=90,
)
us.market_phase_summary = {"market": "us", "phase": "premarket"}
out = render("markdown", [cn, us], summary_only=True)
self.assertIsNotNone(out)
self.assertIn("市场状态A股 · 盘后", out)
self.assertNotIn("市场状态:美股 · 盘前", out)
def test_render_markdown_footer_uses_consistent_separator(self) -> None:
r = _make_result(model_used="gemini/gemini-2.5-flash")