mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
@@ -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 不生效的问题。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}*
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
---
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
== ""
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user