mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: address P6 signal linkage gaps (#1724)
This commit is contained in:
@@ -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 热点缓存。
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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] }}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user