Skip to content

Commit dda5269

Browse files
authored
fix: address P6 signal linkage gaps (#1724)
1 parent ec6c8fb commit dda5269

10 files changed

Lines changed: 366 additions & 12 deletions

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1010
## [Unreleased]
1111

1212
- [新功能] #1390 P6 将 DecisionSignal 复用到告警、通知和组合风险:告警触发关联 latest active 信号或创建最小 alert 信号,通知追加低敏信号摘要,持仓风险聚合 active sell/reduce/alert 信号并保持 fail-open。
13+
- [修复] #1722 修复 #1390 P6 DecisionSignal 在组合风险快照语义和默认聚合通知展示中的遗漏。
1314
- [新功能] #1707 资讯源新增 `newsnow` 类型、`NEWSNOW_BASE_URL` 配置和 `/api/v1/intelligence/sources/defaults` 默认源初始化接口,内置财联社热门、雪球热门股票、华尔街见闻快讯、金十数据和格隆汇事件等财经热点源,可直接拉取落库并进入既有分析证据链路;官方 NewsNow 部署指南见 https://github.com/qqhann/newsnow,生产环境建议自建实例而非使用公开示例。
1415

1516
- [修复] AlphaSift 热点题材刷新在 EastMoney 瞬断且无缓存时返回友好空态,并让桌面更新保留 AlphaSift 热点缓存。

src/notification.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,9 @@ def generate_daily_report(
850850
f"{labels['score_label']} {r.sentiment_score} | "
851851
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
852852
)
853+
signal_excerpt = self._decision_signal_excerpt(r, report_language)
854+
if signal_excerpt:
855+
report_lines.append(signal_excerpt)
853856
else:
854857
report_lines.extend([f"## 📈 {labels['report_title']}", ""])
855858
# 逐个股票的详细分析
@@ -866,6 +869,9 @@ def generate_daily_report(
866869
f"**Confidence:{confidence_stars}**",
867870
"",
868871
])
872+
signal_excerpt = self._decision_signal_excerpt(result, report_language)
873+
if signal_excerpt:
874+
report_lines.extend([signal_excerpt, ""])
869875
self._append_market_snapshot(report_lines, result)
870876

871877
# 核心看点
@@ -1099,6 +1105,9 @@ def generate_dashboard_report(
10991105
f"{labels['score_label']} {r.sentiment_score} | "
11001106
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
11011107
)
1108+
signal_excerpt = self._decision_signal_excerpt(r, report_language)
1109+
if signal_excerpt:
1110+
report_lines.append(signal_excerpt)
11021111
report_lines.extend([
11031112
"",
11041113
"---",
@@ -1401,6 +1410,9 @@ def generate_wechat_dashboard(self, results: List[AnalysisResult]) -> str:
14011410
f"{labels['score_label']} {r.sentiment_score} | "
14021411
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
14031412
)
1413+
signal_excerpt = self._decision_signal_excerpt(r, report_language)
1414+
if signal_excerpt:
1415+
lines.append(signal_excerpt)
14041416
else:
14051417
for result in sorted_results:
14061418
signal_text, signal_emoji, _ = self._get_signal_level(result)
@@ -1421,6 +1433,10 @@ def generate_wechat_dashboard(self, results: List[AnalysisResult]) -> str:
14211433
if one_sentence:
14221434
lines.append(f"📌 **{one_sentence[:80]}**")
14231435
lines.append("")
1436+
signal_excerpt = self._decision_signal_excerpt(result, report_language)
1437+
if signal_excerpt:
1438+
lines.append(signal_excerpt)
1439+
lines.append("")
14241440

14251441
# 重要信息区(舆情+基本面)
14261442
info_lines = []
@@ -1551,6 +1567,9 @@ def generate_wechat_summary(self, results: List[AnalysisResult]) -> str:
15511567
f"{labels['score_label']}:{result.sentiment_score} | "
15521568
f"{localize_trend_prediction(result.trend_prediction, report_language)}"
15531569
)
1570+
signal_excerpt = self._decision_signal_excerpt(result, report_language)
1571+
if signal_excerpt:
1572+
lines.append(signal_excerpt)
15541573

15551574
# 操作理由(截断)
15561575
if hasattr(result, 'buy_reason') and result.buy_reason:

src/services/decision_signal_service.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def list_signals(
115115
expires_to: Optional[Any] = None,
116116
holding_only: bool = False,
117117
account_id: Optional[int] = None,
118+
stock_identities: Optional[List[Tuple[str, str]]] = None,
118119
page: int = 1,
119120
page_size: int = 20,
120121
) -> Dict[str, Any]:
@@ -133,9 +134,27 @@ def list_signals(
133134
expires_from_dt = self._parse_datetime(expires_from)
134135
expires_to_dt = self._parse_datetime(expires_to)
135136
stock_codes = self._stock_filter_codes(stock_code, market=market_norm)
136-
stock_identities = None
137-
138-
if holding_only:
137+
stock_identity_filters: Optional[List[Tuple[str, str]]] = None
138+
139+
if stock_identities is not None:
140+
# Explicit identities come from a caller-owned snapshot; skip cached holdings entirely.
141+
requested_codes = set(stock_codes or [])
142+
normalized_identities: set[Tuple[str, str]] = set()
143+
for identity_market, identity_code in stock_identities:
144+
if not str(identity_code or "").strip():
145+
continue
146+
identity_market_norm = self._normalize_market(identity_market)
147+
if market_norm and identity_market_norm != market_norm:
148+
continue
149+
identity_code_norm = self._normalize_stock_code(identity_code, market=identity_market_norm)
150+
if requested_codes and identity_code_norm not in requested_codes:
151+
continue
152+
normalized_identities.add((identity_market_norm, identity_code_norm))
153+
stock_identity_filters = sorted(normalized_identities)
154+
stock_codes = None
155+
if not stock_identity_filters:
156+
return {"items": [], "total": 0, "page": safe_page, "page_size": safe_page_size}
157+
elif holding_only:
139158
held_identities = self._cached_holding_identities(account_id=account_id)
140159
if market_norm:
141160
held_identities = {
@@ -146,14 +165,14 @@ def list_signals(
146165
held_identities = {
147166
identity for identity in held_identities if identity[1] in requested_codes
148167
}
149-
stock_identities = sorted(held_identities)
168+
stock_identity_filters = sorted(held_identities)
150169
stock_codes = None
151-
if not stock_identities:
170+
if not stock_identity_filters:
152171
return {"items": [], "total": 0, "page": safe_page, "page_size": safe_page_size}
153172

154173
rows, total = self.repo.list(
155174
stock_codes=stock_codes,
156-
stock_identities=stock_identities,
175+
stock_identities=stock_identity_filters,
157176
market=market_norm,
158177
action=action_norm,
159178
market_phase=market_phase_norm,
@@ -183,12 +202,13 @@ def list_signals(
183202
created_to=created_to_dt,
184203
expires_from=expires_from_dt,
185204
expires_to=expires_to_dt,
205+
stock_identities=stock_identity_filters,
186206
holding_only=holding_only,
187207
):
188208
self._backfill_analysis_signal_from_history(source_report_id_norm)
189209
rows, total = self.repo.list(
190210
stock_codes=stock_codes,
191-
stock_identities=stock_identities,
211+
stock_identities=stock_identity_filters,
192212
market=market_norm,
193213
action=action_norm,
194214
market_phase=market_phase_norm,
@@ -276,6 +296,7 @@ def _should_backfill_history_bound_analysis_signal(
276296
created_to: Optional[datetime],
277297
expires_from: Optional[datetime],
278298
expires_to: Optional[datetime],
299+
stock_identities: Optional[List[Tuple[str, str]]],
279300
holding_only: bool,
280301
) -> bool:
281302
"""Only lazy-backfill for the exact report section query used by Web."""
@@ -296,6 +317,7 @@ def _should_backfill_history_bound_analysis_signal(
296317
created_to,
297318
expires_from,
298319
expires_to,
320+
stock_identities,
299321
holding_only,
300322
)
301323
)

src/services/portfolio_risk_service.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def get_risk_report(
8282
lookback_days=thresholds["lookback_days"],
8383
)
8484
stop_loss = self._build_stop_loss(snapshot, thresholds)
85-
decision_signal_risk = self._build_decision_signal_risk(snapshot, account_id=account_id)
85+
decision_signal_risk = self._build_decision_signal_risk(snapshot)
8686

8787
return {
8888
"as_of": as_of_date.isoformat(),
@@ -100,21 +100,22 @@ def get_risk_report(
100100
def _build_decision_signal_risk(
101101
self,
102102
snapshot: Dict[str, Any],
103-
*,
104-
account_id: Optional[int],
105103
) -> Dict[str, Any]:
106104
try:
107105
held_positions = self._held_position_identities(snapshot)
108106
if not held_positions:
109107
return self._empty_decision_signal_risk(available=True)
108+
stock_identities = sorted({
109+
(position["market"], position["signal_stock_code"])
110+
for position in held_positions
111+
})
110112

111113
defensive_actions = set(DEFENSIVE_DECISION_SIGNAL_ACTIONS)
112114
latest_by_identity: Dict[Tuple[str, str], Dict[str, Any]] = {}
113115
page = 1
114116
while True:
115117
response = self.decision_signal_service.list_signals(
116-
holding_only=True,
117-
account_id=account_id,
118+
stock_identities=stock_identities,
118119
status="active",
119120
page=page,
120121
page_size=100,

templates/report_markdown.j2

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
{% for e in enriched %}
1212
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
13+
{% set signal_excerpt = decision_signal_excerpt(e.result) %}
14+
{% if signal_excerpt %}
15+
{{ signal_excerpt }}
16+
{% endif %}
1317
{% endfor %}
1418

1519
---

templates/report_wechat.j2

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
**📊 {{ labels.summary_heading }}**
1010
{% for e in enriched %}
1111
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.localized_operation_advice }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
12+
{% set signal_excerpt = decision_signal_excerpt(e.result) %}
13+
{% if signal_excerpt %}
14+
{{ signal_excerpt }}
15+
{% endif %}
1216
{% endfor %}
1317
{% else %}
1418
{% for e in enriched %}
@@ -24,6 +28,10 @@
2428
{% if one_sentence %}
2529
📌 **{{ one_sentence[:80] }}**
2630
{% endif %}
31+
{% set signal_excerpt = decision_signal_excerpt(result) %}
32+
{% if signal_excerpt %}
33+
{{ signal_excerpt }}
34+
{% endif %}
2735

2836
{% if intel.get('earnings_outlook') %}
2937
📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }}

tests/test_decision_signal_service.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,84 @@ def test_list_signals_does_not_backfill_ambiguous_history_default_decision_type_
494494
assert session.query(DecisionSignalRecord).count() == 0
495495

496496

497+
def test_list_signals_explicit_stock_identities_override_holding_only_and_intersect_filters(isolated_db) -> None:
498+
service = DecisionSignalService(db_manager=isolated_db)
499+
service.create_signal(
500+
_payload(
501+
source_report_id=171501,
502+
trace_id="trace-explicit-identity-000001",
503+
stock_code="000001",
504+
stock_name="平安银行",
505+
action="sell",
506+
)
507+
)
508+
service.create_signal(
509+
_payload(
510+
source_report_id=171502,
511+
trace_id="trace-explicit-identity-600519",
512+
stock_code="600519",
513+
action="reduce",
514+
)
515+
)
516+
517+
listed = service.list_signals(
518+
stock_identities=[("cn", "000001")],
519+
holding_only=True,
520+
status="active",
521+
)
522+
523+
assert listed["total"] == 1
524+
assert listed["items"][0]["stock_code"] == "000001"
525+
assert listed["items"][0]["action"] == "sell"
526+
527+
mismatched_stock_filter = service.list_signals(
528+
stock_code="600519",
529+
market="cn",
530+
stock_identities=[("cn", "000001")],
531+
status="active",
532+
)
533+
534+
assert mismatched_stock_filter == {"items": [], "total": 0, "page": 1, "page_size": 20}
535+
536+
537+
def test_list_signals_explicit_empty_stock_identities_returns_empty_without_widening(isolated_db) -> None:
538+
service = DecisionSignalService(db_manager=isolated_db)
539+
service.create_signal(
540+
_payload(
541+
source_report_id=171503,
542+
trace_id="trace-empty-identity-600519",
543+
stock_code="600519",
544+
action="sell",
545+
)
546+
)
547+
548+
listed = service.list_signals(stock_identities=[], status="active")
549+
550+
assert listed == {"items": [], "total": 0, "page": 1, "page_size": 20}
551+
552+
553+
def test_list_signals_explicit_stock_identities_do_not_trigger_history_backfill(isolated_db) -> None:
554+
record_id = isolated_db.save_analysis_history(
555+
result=_history_result(operation_advice="卖出", decision_type="sell", action="sell", action_label="卖出"),
556+
query_id="query-explicit-identity-no-backfill",
557+
report_type="simple",
558+
news_content="新闻摘要",
559+
context_snapshot={"market_phase_summary": {"phase": "postmarket"}},
560+
save_snapshot=True,
561+
)
562+
service = DecisionSignalService(db_manager=isolated_db)
563+
564+
listed = service.list_signals(
565+
source_type="analysis",
566+
source_report_id=record_id,
567+
stock_identities=[("cn", "600519")],
568+
)
569+
570+
assert listed == {"items": [], "total": 0, "page": 1, "page_size": 20}
571+
with isolated_db.get_session() as session:
572+
assert session.query(DecisionSignalRecord).count() == 0
573+
574+
497575
def test_service_plan_quality_slots_and_explicit_override(isolated_db) -> None:
498576
service = DecisionSignalService(db_manager=isolated_db)
499577

0 commit comments

Comments
 (0)