fix: address P6 signal linkage gaps (#1724)

This commit is contained in:
Alfred
2026-06-19 23:04:52 +08:00
committed by GitHub
parent ec6c8fbbcf
commit dda5269123
10 changed files with 365 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
- [新功能] #1390 P6 将 DecisionSignal 复用到告警、通知和组合风险:告警触发关联 latest active 信号或创建最小 alert 信号,通知追加低敏信号摘要,持仓风险聚合 active sell/reduce/alert 信号并保持 fail-open。 - [新功能] #1390 P6 将 DecisionSignal 复用到告警、通知和组合风险:告警触发关联 latest active 信号或创建最小 alert 信号,通知追加低敏信号摘要,持仓风险聚合 active sell/reduce/alert 信号并保持 fail-open。
- [修复] #1722 修复 #1390 P6 DecisionSignal 在组合风险快照语义和默认聚合通知展示中的遗漏。
- [新功能] #1707 资讯源新增 `newsnow` 类型、`NEWSNOW_BASE_URL` 配置和 `/api/v1/intelligence/sources/defaults` 默认源初始化接口,内置财联社热门、雪球热门股票、华尔街见闻快讯、金十数据和格隆汇事件等财经热点源,可直接拉取落库并进入既有分析证据链路;官方 NewsNow 部署指南见 https://github.com/qqhann/newsnow生产环境建议自建实例而非使用公开示例。 - [新功能] #1707 资讯源新增 `newsnow` 类型、`NEWSNOW_BASE_URL` 配置和 `/api/v1/intelligence/sources/defaults` 默认源初始化接口,内置财联社热门、雪球热门股票、华尔街见闻快讯、金十数据和格隆汇事件等财经热点源,可直接拉取落库并进入既有分析证据链路;官方 NewsNow 部署指南见 https://github.com/qqhann/newsnow生产环境建议自建实例而非使用公开示例。
- [修复] AlphaSift 热点题材刷新在 EastMoney 瞬断且无缓存时返回友好空态,并让桌面更新保留 AlphaSift 热点缓存。 - [修复] AlphaSift 热点题材刷新在 EastMoney 瞬断且无缓存时返回友好空态,并让桌面更新保留 AlphaSift 热点缓存。

View File

@@ -850,6 +850,9 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | " f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}" f"{localize_trend_prediction(r.trend_prediction, report_language)}"
) )
signal_excerpt = self._decision_signal_excerpt(r, report_language)
if signal_excerpt:
report_lines.append(signal_excerpt)
else: else:
report_lines.extend([f"## 📈 {labels['report_title']}", ""]) report_lines.extend([f"## 📈 {labels['report_title']}", ""])
# 逐个股票的详细分析 # 逐个股票的详细分析
@@ -866,6 +869,9 @@ class NotificationService(
f"**Confidence{confidence_stars}**", f"**Confidence{confidence_stars}**",
"", "",
]) ])
signal_excerpt = self._decision_signal_excerpt(result, report_language)
if signal_excerpt:
report_lines.extend([signal_excerpt, ""])
self._append_market_snapshot(report_lines, result) self._append_market_snapshot(report_lines, result)
# 核心看点 # 核心看点
@@ -1099,6 +1105,9 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | " f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}" f"{localize_trend_prediction(r.trend_prediction, report_language)}"
) )
signal_excerpt = self._decision_signal_excerpt(r, report_language)
if signal_excerpt:
report_lines.append(signal_excerpt)
report_lines.extend([ report_lines.extend([
"", "",
"---", "---",
@@ -1401,6 +1410,9 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | " f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}" f"{localize_trend_prediction(r.trend_prediction, report_language)}"
) )
signal_excerpt = self._decision_signal_excerpt(r, report_language)
if signal_excerpt:
lines.append(signal_excerpt)
else: else:
for result in sorted_results: for result in sorted_results:
signal_text, signal_emoji, _ = self._get_signal_level(result) signal_text, signal_emoji, _ = self._get_signal_level(result)
@@ -1421,6 +1433,10 @@ class NotificationService(
if one_sentence: if one_sentence:
lines.append(f"📌 **{one_sentence[:80]}**") lines.append(f"📌 **{one_sentence[:80]}**")
lines.append("") lines.append("")
signal_excerpt = self._decision_signal_excerpt(result, report_language)
if signal_excerpt:
lines.append(signal_excerpt)
lines.append("")
# 重要信息区(舆情+基本面) # 重要信息区(舆情+基本面)
info_lines = [] info_lines = []
@@ -1551,6 +1567,9 @@ class NotificationService(
f"{labels['score_label']}:{result.sentiment_score} | " f"{labels['score_label']}:{result.sentiment_score} | "
f"{localize_trend_prediction(result.trend_prediction, report_language)}" f"{localize_trend_prediction(result.trend_prediction, report_language)}"
) )
signal_excerpt = self._decision_signal_excerpt(result, report_language)
if signal_excerpt:
lines.append(signal_excerpt)
# 操作理由(截断) # 操作理由(截断)
if hasattr(result, 'buy_reason') and result.buy_reason: if hasattr(result, 'buy_reason') and result.buy_reason:

View File

@@ -115,6 +115,7 @@ class DecisionSignalService:
expires_to: Optional[Any] = None, expires_to: Optional[Any] = None,
holding_only: bool = False, holding_only: bool = False,
account_id: Optional[int] = None, account_id: Optional[int] = None,
stock_identities: Optional[List[Tuple[str, str]]] = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -133,9 +134,27 @@ class DecisionSignalService:
expires_from_dt = self._parse_datetime(expires_from) expires_from_dt = self._parse_datetime(expires_from)
expires_to_dt = self._parse_datetime(expires_to) expires_to_dt = self._parse_datetime(expires_to)
stock_codes = self._stock_filter_codes(stock_code, market=market_norm) stock_codes = self._stock_filter_codes(stock_code, market=market_norm)
stock_identities = None stock_identity_filters: Optional[List[Tuple[str, str]]] = None
if holding_only: if stock_identities is not None:
# Explicit identities come from a caller-owned snapshot; skip cached holdings entirely.
requested_codes = set(stock_codes or [])
normalized_identities: set[Tuple[str, str]] = set()
for identity_market, identity_code in stock_identities:
if not str(identity_code or "").strip():
continue
identity_market_norm = self._normalize_market(identity_market)
if market_norm and identity_market_norm != market_norm:
continue
identity_code_norm = self._normalize_stock_code(identity_code, market=identity_market_norm)
if requested_codes and identity_code_norm not in requested_codes:
continue
normalized_identities.add((identity_market_norm, identity_code_norm))
stock_identity_filters = sorted(normalized_identities)
stock_codes = None
if not stock_identity_filters:
return {"items": [], "total": 0, "page": safe_page, "page_size": safe_page_size}
elif holding_only:
held_identities = self._cached_holding_identities(account_id=account_id) held_identities = self._cached_holding_identities(account_id=account_id)
if market_norm: if market_norm:
held_identities = { held_identities = {
@@ -146,14 +165,14 @@ class DecisionSignalService:
held_identities = { held_identities = {
identity for identity in held_identities if identity[1] in requested_codes identity for identity in held_identities if identity[1] in requested_codes
} }
stock_identities = sorted(held_identities) stock_identity_filters = sorted(held_identities)
stock_codes = None stock_codes = None
if not stock_identities: if not stock_identity_filters:
return {"items": [], "total": 0, "page": safe_page, "page_size": safe_page_size} return {"items": [], "total": 0, "page": safe_page, "page_size": safe_page_size}
rows, total = self.repo.list( rows, total = self.repo.list(
stock_codes=stock_codes, stock_codes=stock_codes,
stock_identities=stock_identities, stock_identities=stock_identity_filters,
market=market_norm, market=market_norm,
action=action_norm, action=action_norm,
market_phase=market_phase_norm, market_phase=market_phase_norm,
@@ -183,12 +202,13 @@ class DecisionSignalService:
created_to=created_to_dt, created_to=created_to_dt,
expires_from=expires_from_dt, expires_from=expires_from_dt,
expires_to=expires_to_dt, expires_to=expires_to_dt,
stock_identities=stock_identity_filters,
holding_only=holding_only, holding_only=holding_only,
): ):
self._backfill_analysis_signal_from_history(source_report_id_norm) self._backfill_analysis_signal_from_history(source_report_id_norm)
rows, total = self.repo.list( rows, total = self.repo.list(
stock_codes=stock_codes, stock_codes=stock_codes,
stock_identities=stock_identities, stock_identities=stock_identity_filters,
market=market_norm, market=market_norm,
action=action_norm, action=action_norm,
market_phase=market_phase_norm, market_phase=market_phase_norm,
@@ -276,6 +296,7 @@ class DecisionSignalService:
created_to: Optional[datetime], created_to: Optional[datetime],
expires_from: Optional[datetime], expires_from: Optional[datetime],
expires_to: Optional[datetime], expires_to: Optional[datetime],
stock_identities: Optional[List[Tuple[str, str]]],
holding_only: bool, holding_only: bool,
) -> bool: ) -> bool:
"""Only lazy-backfill for the exact report section query used by Web.""" """Only lazy-backfill for the exact report section query used by Web."""
@@ -296,6 +317,7 @@ class DecisionSignalService:
created_to, created_to,
expires_from, expires_from,
expires_to, expires_to,
stock_identities,
holding_only, holding_only,
) )
) )

View File

@@ -82,7 +82,7 @@ class PortfolioRiskService:
lookback_days=thresholds["lookback_days"], lookback_days=thresholds["lookback_days"],
) )
stop_loss = self._build_stop_loss(snapshot, thresholds) stop_loss = self._build_stop_loss(snapshot, thresholds)
decision_signal_risk = self._build_decision_signal_risk(snapshot, account_id=account_id) decision_signal_risk = self._build_decision_signal_risk(snapshot)
return { return {
"as_of": as_of_date.isoformat(), "as_of": as_of_date.isoformat(),
@@ -100,21 +100,22 @@ class PortfolioRiskService:
def _build_decision_signal_risk( def _build_decision_signal_risk(
self, self,
snapshot: Dict[str, Any], snapshot: Dict[str, Any],
*,
account_id: Optional[int],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
try: try:
held_positions = self._held_position_identities(snapshot) held_positions = self._held_position_identities(snapshot)
if not held_positions: if not held_positions:
return self._empty_decision_signal_risk(available=True) return self._empty_decision_signal_risk(available=True)
stock_identities = sorted({
(position["market"], position["signal_stock_code"])
for position in held_positions
})
defensive_actions = set(DEFENSIVE_DECISION_SIGNAL_ACTIONS) defensive_actions = set(DEFENSIVE_DECISION_SIGNAL_ACTIONS)
latest_by_identity: Dict[Tuple[str, str], Dict[str, Any]] = {} latest_by_identity: Dict[Tuple[str, str], Dict[str, Any]] = {}
page = 1 page = 1
while True: while True:
response = self.decision_signal_service.list_signals( response = self.decision_signal_service.list_signals(
holding_only=True, stock_identities=stock_identities,
account_id=account_id,
status="active", status="active",
page=page, page=page,
page_size=100, page_size=100,

View File

@@ -10,6 +10,10 @@
{% for e in enriched %} {% 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 }} {{ 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 signal_excerpt = decision_signal_excerpt(e.result) %}
{% if signal_excerpt %}
{{ signal_excerpt }}
{% endif %}
{% endfor %} {% endfor %}
--- ---

View File

@@ -9,6 +9,10 @@
**📊 {{ labels.summary_heading }}** **📊 {{ labels.summary_heading }}**
{% for e in enriched %} {% 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 }} {{ 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 signal_excerpt = decision_signal_excerpt(e.result) %}
{% if signal_excerpt %}
{{ signal_excerpt }}
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for e in enriched %} {% for e in enriched %}
@@ -24,6 +28,10 @@
{% if one_sentence %} {% if one_sentence %}
📌 **{{ one_sentence[:80] }}** 📌 **{{ one_sentence[:80] }}**
{% endif %} {% endif %}
{% set signal_excerpt = decision_signal_excerpt(result) %}
{% if signal_excerpt %}
{{ signal_excerpt }}
{% endif %}
{% if intel.get('earnings_outlook') %} {% if intel.get('earnings_outlook') %}
📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }} 📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }}

View File

@@ -494,6 +494,84 @@ def test_list_signals_does_not_backfill_ambiguous_history_default_decision_type_
assert session.query(DecisionSignalRecord).count() == 0 assert session.query(DecisionSignalRecord).count() == 0
def test_list_signals_explicit_stock_identities_override_holding_only_and_intersect_filters(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db)
service.create_signal(
_payload(
source_report_id=171501,
trace_id="trace-explicit-identity-000001",
stock_code="000001",
stock_name="平安银行",
action="sell",
)
)
service.create_signal(
_payload(
source_report_id=171502,
trace_id="trace-explicit-identity-600519",
stock_code="600519",
action="reduce",
)
)
listed = service.list_signals(
stock_identities=[("cn", "000001")],
holding_only=True,
status="active",
)
assert listed["total"] == 1
assert listed["items"][0]["stock_code"] == "000001"
assert listed["items"][0]["action"] == "sell"
mismatched_stock_filter = service.list_signals(
stock_code="600519",
market="cn",
stock_identities=[("cn", "000001")],
status="active",
)
assert mismatched_stock_filter == {"items": [], "total": 0, "page": 1, "page_size": 20}
def test_list_signals_explicit_empty_stock_identities_returns_empty_without_widening(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db)
service.create_signal(
_payload(
source_report_id=171503,
trace_id="trace-empty-identity-600519",
stock_code="600519",
action="sell",
)
)
listed = service.list_signals(stock_identities=[], status="active")
assert listed == {"items": [], "total": 0, "page": 1, "page_size": 20}
def test_list_signals_explicit_stock_identities_do_not_trigger_history_backfill(isolated_db) -> None:
record_id = isolated_db.save_analysis_history(
result=_history_result(operation_advice="卖出", decision_type="sell", action="sell", action_label="卖出"),
query_id="query-explicit-identity-no-backfill",
report_type="simple",
news_content="新闻摘要",
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
save_snapshot=True,
)
service = DecisionSignalService(db_manager=isolated_db)
listed = service.list_signals(
source_type="analysis",
source_report_id=record_id,
stock_identities=[("cn", "600519")],
)
assert listed == {"items": [], "total": 0, "page": 1, "page_size": 20}
with isolated_db.get_session() as session:
assert session.query(DecisionSignalRecord).count() == 0
def test_service_plan_quality_slots_and_explicit_override(isolated_db) -> None: def test_service_plan_quality_slots_and_explicit_override(isolated_db) -> None:
service = DecisionSignalService(db_manager=isolated_db) service = DecisionSignalService(db_manager=isolated_db)

View File

@@ -50,6 +50,16 @@ def _make_response(status_code: int, json: Optional[dict] = None) -> requests.Re
return response return response
def _attach_decision_signal_summary(result: AnalysisResult) -> AnalysisResult:
result.decision_signal_summary = {
"action": "sell",
"action_label": "卖出",
"horizon": "1d",
"reason": "技术面走弱",
}
return result
def _make_feishu_message() -> BotMessage: def _make_feishu_message() -> BotMessage:
return BotMessage( return BotMessage(
platform="feishu", platform="feishu",
@@ -629,6 +639,118 @@ class TestNotificationServiceReportGeneration(unittest.TestCase):
self.assertIn("*分析模型gemini/gemini-2.5-flash*", out) self.assertIn("*分析模型gemini/gemini-2.5-flash*", out)
@mock.patch("src.notification.get_config")
def test_generate_dashboard_report_appends_decision_signal_excerpt_fallback(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = _attach_decision_signal_summary(AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
))
out = service.generate_dashboard_report([result], report_date="2026-02-01")
self.assertIn("AI 决策信号", out)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
@mock.patch("src.notification.get_config")
def test_generate_daily_report_appends_decision_signal_excerpt_fallback(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
result = _attach_decision_signal_summary(AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
))
for summary_only in (True, False):
service = NotificationService()
service._report_summary_only = summary_only
out = service.generate_daily_report([result], report_date="2026-02-01")
self.assertEqual(out.count("AI 决策信号"), 1)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
@mock.patch("src.notification.get_config")
def test_generate_wechat_dashboard_appends_decision_signal_excerpt_fallback(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
result = _attach_decision_signal_summary(AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
))
for summary_only in (True, False):
service = NotificationService()
service._report_summary_only = summary_only
out = service.generate_wechat_dashboard([result])
self.assertIn("AI 决策信号", out)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
@mock.patch("src.notification.get_config")
def test_generate_wechat_summary_appends_decision_signal_excerpt(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=False)
service = NotificationService()
result = _attach_decision_signal_summary(AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
))
out = service.generate_wechat_summary([result])
self.assertEqual(out.count("AI 决策信号"), 1)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
@mock.patch("src.notification.get_config")
def test_generate_dashboard_report_appends_decision_signal_excerpt_with_renderer(
self, mock_get_config: mock.MagicMock
):
mock_get_config.return_value = _make_config(report_renderer_enabled=True)
service = NotificationService()
result = _attach_decision_signal_summary(AnalysisResult(
code="600519",
name="贵州茅台",
sentiment_score=72,
trend_prediction="看多",
operation_advice="持有",
analysis_summary="稳健",
))
out = service.generate_dashboard_report([result], report_date="2026-02-01")
self.assertIn("AI 决策信号", out)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
@mock.patch("src.notification.get_config") @mock.patch("src.notification.get_config")
def test_aggregate_reports_show_compact_market_status_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) mock_get_config.return_value = _make_config(report_renderer_enabled=False)

View File

@@ -561,6 +561,73 @@ class PortfolioPr2TestCase(unittest.TestCase):
self.assertEqual(signal_actions["300750"], "reduce") self.assertEqual(signal_actions["300750"], "reduce")
self.assertEqual(signal_actions["000001"], "alert") self.assertEqual(signal_actions["000001"], "alert")
def test_risk_report_uses_requested_snapshot_for_decision_signal_filters(self) -> None:
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
aid = account["id"]
self.service.record_cash_ledger(
account_id=aid,
event_date=date(2026, 1, 1),
direction="in",
amount=100000.0,
currency="CNY",
)
self.service.record_trade(
account_id=aid,
symbol="600519",
trade_date=date(2026, 1, 1),
side="buy",
quantity=10,
price=100,
market="cn",
currency="CNY",
)
self._save_close("600519", date(2026, 1, 1), 100.0)
self._save_close("600519", date(2026, 1, 2), 100.0)
self.service.record_trade(
account_id=aid,
symbol="000001",
trade_date=date(2026, 1, 2),
side="buy",
quantity=10,
price=20,
market="cn",
currency="CNY",
)
self._save_close("000001", date(2026, 1, 2), 20.0)
self._create_signal("000001", "sell")
original = self.risk_service.decision_signal_service.list_signals
with patch.object(
self.risk_service.decision_signal_service,
"list_signals",
wraps=original,
) as spy:
report = self.risk_service.get_risk_report(
account_id=aid,
as_of=date(2026, 1, 2),
cost_method="fifo",
)
block = report["decision_signal_risk"]
self.assertTrue(block["available"])
self.assertEqual(block["total"], 1)
self.assertEqual(block["items"][0]["symbol"], "000001")
self.assertEqual(block["items"][0]["signal"]["action"], "sell")
for call in spy.mock_calls:
self.assertIsNot(call.kwargs.get("holding_only"), True)
identity_calls = [
call.kwargs.get("stock_identities")
for call in spy.mock_calls
if call.kwargs.get("stock_identities") is not None
]
observed_identities = {
identity
for ids in identity_calls
for identity in ids
}
self.assertIn(("cn", "000001"), observed_identities)
self.assertIn(("cn", "600519"), observed_identities)
def test_risk_report_decision_signal_fail_open(self) -> None: def test_risk_report_decision_signal_fail_open(self) -> None:
account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY") account = self.service.create_account(name="Main", broker="Demo", market="cn", base_currency="CNY")
aid = account["id"] aid = account["id"]

View File

@@ -59,6 +59,16 @@ def _make_renderer_config(show_llm_model: bool = True) -> MagicMock:
return config return config
def _with_decision_signal_summary(result: AnalysisResult) -> AnalysisResult:
result.decision_signal_summary = {
"action": "sell",
"action_label": "卖出",
"horizon": "1d",
"reason": "技术面走弱",
}
return result
class TestReportRenderer(unittest.TestCase): class TestReportRenderer(unittest.TestCase):
"""Report renderer tests.""" """Report renderer tests."""
@@ -80,6 +90,17 @@ class TestReportRenderer(unittest.TestCase):
self.assertIn("作战计划", out) self.assertIn("作战计划", out)
self.assertNotIn("盘中决策护栏", out) self.assertNotIn("盘中决策护栏", out)
def test_render_markdown_includes_decision_signal_excerpt(self) -> None:
"""Markdown summary and full templates include DecisionSignal excerpts."""
for summary_only in (True, False):
r = _with_decision_signal_summary(_make_result())
out = render("markdown", [r], summary_only=summary_only)
self.assertIsNotNone(out)
self.assertIn("AI 决策信号", out)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
def test_render_markdown_phase_decision_section(self) -> None: def test_render_markdown_phase_decision_section(self) -> None:
"""Markdown renders phase_decision when present.""" """Markdown renders phase_decision when present."""
r = _make_result( r = _make_result(
@@ -137,6 +158,17 @@ class TestReportRenderer(unittest.TestCase):
self.assertIsNotNone(out) self.assertIsNotNone(out)
self.assertIn("贵州茅台", out) self.assertIn("贵州茅台", out)
def test_render_wechat_includes_decision_signal_excerpt(self) -> None:
"""Wechat summary and full templates include DecisionSignal excerpts."""
for summary_only in (True, False):
r = _with_decision_signal_summary(_make_result())
out = render("wechat", [r], summary_only=summary_only)
self.assertIsNotNone(out)
self.assertIn("AI 决策信号", out)
self.assertIn("动作: 卖出", out)
self.assertIn("周期: 1d", out)
self.assertIn("理由: 技术面走弱", out)
def test_render_brief(self) -> None: def test_render_brief(self) -> None:
"""Brief platform renders 3-5 sentence summary.""" """Brief platform renders 3-5 sentence summary."""
r = _make_result() r = _make_result()