From 9022bbbfc987cacb39a26e96412e8ddc15b64327 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:40:21 +0800 Subject: [PATCH] Show snapshot input diagnostics --- application/signal_snapshot.py | 26 +++++++++++ notifications/renderers.py | 55 ++++++++++++++++++++++++ notifications/telegram.py | 2 + requirements.txt | 6 +-- tests/test_notifications.py | 8 ++++ tests/test_runtime_config_support.py | 64 +++++++++++++++++++--------- tests/test_signal_snapshot.py | 21 +++++++++ 7 files changed, 159 insertions(+), 23 deletions(-) diff --git a/application/signal_snapshot.py b/application/signal_snapshot.py index 19f5c68..771d709 100644 --- a/application/signal_snapshot.py +++ b/application/signal_snapshot.py @@ -168,6 +168,32 @@ def build_signal_snapshot( source.get("signal_source"), ), "quote_overlay_used": source.get("quote_overlay_used"), + "price_as_of": _json_safe( + _first_value(source.get("price_as_of"), source.get("snapshot_manifest_price_as_of")) + ), + "universe_as_of": _json_safe( + _first_value(source.get("universe_as_of"), source.get("snapshot_manifest_universe_as_of")) + ), + "source_input_status": _first_value( + source.get("source_input_status"), + source.get("snapshot_manifest_source_input_status"), + ), + "source_input_fallback_used": _first_value( + source.get("source_input_fallback_used"), + source.get("snapshot_manifest_source_input_fallback_used"), + ), + "source_input_fallback_reason": _first_value( + source.get("source_input_fallback_reason"), + source.get("snapshot_manifest_source_input_fallback_reason"), + ), + "source_input_fallback_streak": _first_value( + source.get("source_input_fallback_streak"), + source.get("snapshot_manifest_source_input_fallback_streak"), + ), + "source_refresh_run_id": _first_value( + source.get("source_refresh_run_id"), + source.get("snapshot_manifest_source_refresh_run_id"), + ), "data_freshness_warning": _first_value( source.get("data_freshness_warning"), source.get("snapshot_price_fallback_used"), diff --git a/notifications/renderers.py b/notifications/renderers.py index 103520f..eb8635d 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -193,6 +193,57 @@ def _append_signal_snapshot_line(lines, *, execution, translator) -> None: lines.append(line) +def _is_truthy(value) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "y"} + + +def _format_source_input_line(snapshot, *, translator) -> str: + if not isinstance(snapshot, Mapping): + return "" + price_as_of = str(snapshot.get("price_as_of") or "").strip() + universe_as_of = str(snapshot.get("universe_as_of") or "").strip() + status = str(snapshot.get("source_input_status") or "").strip() + fallback_used = _is_truthy(snapshot.get("source_input_fallback_used")) + fallback_streak = snapshot.get("source_input_fallback_streak") + if not price_as_of and not universe_as_of and not status and not fallback_used: + return "" + if _translator_uses_zh(translator): + parts = [] + if price_as_of: + parts.append(f"价格 {price_as_of}") + if universe_as_of: + parts.append(f"股票池 {universe_as_of}") + if fallback_used: + fallback_text = "股票池复用" + if fallback_streak not in (None, "", 0, "0"): + fallback_text += f" 连续{fallback_streak}次" + parts.append(fallback_text) + elif status: + parts.append(f"状态 {status}") + return "🧩 输入状态: " + " | ".join(parts) + parts = [] + if price_as_of: + parts.append(f"price {price_as_of}") + if universe_as_of: + parts.append(f"universe {universe_as_of}") + if fallback_used: + fallback_text = "universe fallback" + if fallback_streak not in (None, "", 0, "0"): + fallback_text += f" streak={fallback_streak}" + parts.append(fallback_text) + elif status: + parts.append(f"status {status}") + return "🧩 Inputs: " + " | ".join(parts) + + +def _append_source_input_line(lines, *, execution, translator) -> None: + line = _format_source_input_line(execution.get("signal_snapshot"), translator=translator) + if line: + lines.append(line) + + def _append_status_lines(lines, *, execution, translator, signal_key): status_display = _localize_notification_text(execution.get("status_display"), translator=translator) if status_display: @@ -270,6 +321,7 @@ def render_rebalance_notification( _append_dashboard_block(detailed_lines, execution=execution, separator=separator) _append_timing_lines(detailed_lines, execution=execution, translator=translator) _append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator) + _append_source_input_line(detailed_lines, execution=execution, translator=translator) _append_status_lines( detailed_lines, execution=execution, @@ -286,6 +338,7 @@ def render_rebalance_notification( _append_dashboard_block(compact_lines, execution=execution, separator=separator) _append_timing_lines(compact_lines, execution=execution, translator=translator) _append_signal_snapshot_line(compact_lines, execution=execution, translator=translator) + _append_source_input_line(compact_lines, execution=execution, translator=translator) _append_compact_status_lines( compact_lines, execution=execution, @@ -318,6 +371,7 @@ def render_heartbeat_notification( _append_dashboard_block(detailed_lines, execution=execution, separator=separator) _append_timing_lines(detailed_lines, execution=execution, translator=translator) _append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator) + _append_source_input_line(detailed_lines, execution=execution, translator=translator) detailed_lines.append(separator) _append_status_lines( detailed_lines, @@ -353,6 +407,7 @@ def render_heartbeat_notification( _append_dashboard_block(compact_lines, execution=execution, separator=separator) _append_timing_lines(compact_lines, execution=execution, translator=translator) _append_signal_snapshot_line(compact_lines, execution=execution, translator=translator) + _append_source_input_line(compact_lines, execution=execution, translator=translator) _append_compact_status_lines( compact_lines, execution=execution, diff --git a/notifications/telegram.py b/notifications/telegram.py index ae59483..96808d8 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -102,6 +102,7 @@ "strategy_name_qqq_tech_enhancement": "科技通信回调增强", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动", "strategy_name_hk_listed_global_etf_rotation": "港股上市全球 ETF 轮动", + "strategy_name_hk_high_dividend_low_vol_trend": "港股高股息低波趋势", "strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}", "strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}", "strategy_plugin_alert_title": "🚨 【策略插件告警】", @@ -220,6 +221,7 @@ "strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement", "strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced", "strategy_name_hk_listed_global_etf_rotation": "HK-listed Global ETF Rotation", + "strategy_name_hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend", "strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}", "strategy_plugin_alert_subject": "🚨 Strategy plugin alert: {plugin} | {route}", "strategy_plugin_alert_title": "🚨 【Strategy Plugin Alert】", diff --git a/requirements.txt b/requirements.txt index 8173619..8d0e20b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@71141ce9e8a343cec8e2140994071eea66422bc6 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.36 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.50 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.4.2 pandas requests pytz diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 5dd03dc..eb224f8 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -110,6 +110,8 @@ def test_supported_strategy_profiles_have_translated_names(self): self.assertEqual(en_name("global_etf_confidence_vol_gate"), "Global ETF Confidence Vol Gate") self.assertEqual(zh_name("hk_listed_global_etf_rotation"), "港股上市全球 ETF 轮动") self.assertEqual(en_name("hk_listed_global_etf_rotation"), "HK-listed Global ETF Rotation") + self.assertEqual(zh_name("hk_high_dividend_low_vol_trend"), "港股高股息低波趋势") + self.assertEqual(en_name("hk_high_dividend_low_vol_trend"), "HK High Dividend Low-Volatility Trend") for profile in SUPPORTED_STRATEGY_PROFILES: self.assertNotEqual(zh_name(profile), profile) @@ -122,6 +124,11 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self): "market_date": "2026-05-28", "latest_price_source": "longbridge_candlesticks", "quote_overlay_used": None, + "price_as_of": "2026-05-28", + "universe_as_of": "2026-04-30", + "source_input_status": "universe_fallback", + "source_input_fallback_used": True, + "source_input_fallback_streak": 1, }, "status_display": "🚀 风险开启(SOXX+SOXL)", "signal_display": "SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%", @@ -135,6 +142,7 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self): ) self.assertIn("数据源 LongBridge 日线K线", rendered.compact_text) + self.assertIn("🧩 输入状态: 价格 2026-05-28 | 股票池 2026-04-30 | 股票池复用 连续1次", rendered.compact_text) self.assertNotIn("报价覆盖", rendered.compact_text) self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text) self.assertNotIn("longbridge_candlesticks", rendered.compact_text) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 295655e..4bfda71 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -62,7 +62,12 @@ } ) OPTIONAL_LONGBRIDGE_PROFILES = frozenset({"global_etf_confidence_vol_gate"}) -HK_RUNTIME_ENABLED_PROFILES = frozenset({"hk_listed_global_etf_rotation"}) +HK_RUNTIME_ENABLED_PROFILES = frozenset( + { + "hk_listed_global_etf_rotation", + "hk_high_dividend_low_vol_trend", + } +) HK_DISABLED_PROFILES = frozenset( { "hk_blue_chip_leader_rotation", @@ -614,6 +619,17 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) "platform": "longbridge", }, ) + self.assertEqual( + by_profile["hk_high_dividend_low_vol_trend"], + { + "canonical_profile": "hk_high_dividend_low_vol_trend", + "display_name": "HK High Dividend Low-Volatility Trend", + "domain": "hk_equity", + "eligible": True, + "enabled": True, + "platform": "longbridge", + }, + ) for profile in HK_DISABLED_PROFILES: self.assertNotIn(profile, by_profile) @@ -654,25 +670,31 @@ def test_rejects_disabled_hk_profiles(self): with self.assertRaisesRegex(ValueError, "Unsupported STRATEGY_PROFILE"): load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - def test_accepts_runtime_enabled_hk_global_etf_rotation(self): - with patch.dict( - os.environ, - { - "RUNTIME_TARGET_JSON": runtime_target_json("hk_listed_global_etf_rotation"), - "ACCOUNT_REGION": "HK", - }, - clear=True, - ): - settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + def test_accepts_runtime_enabled_hk_profiles(self): + expected_names = { + "hk_listed_global_etf_rotation": "HK-listed Global ETF Rotation", + "hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend", + } + for profile, display_name in expected_names.items(): + with self.subTest(profile=profile): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(profile), + "ACCOUNT_REGION": "HK", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - self.assertEqual(settings.strategy_profile, "hk_listed_global_etf_rotation") - self.assertEqual(settings.strategy_display_name, "HK-listed Global ETF Rotation") - self.assertEqual(settings.strategy_domain, "hk_equity") - self.assertEqual(settings.market, HK_MARKET) - self.assertEqual(settings.market_calendar, HK_MARKET_CALENDAR) - self.assertEqual(settings.market_timezone, HK_MARKET_TIMEZONE) - self.assertEqual(settings.symbol_suffix, HK_SYMBOL_SUFFIX) - self.assertEqual(settings.trading_currency, HK_TRADING_CURRENCY) + self.assertEqual(settings.strategy_profile, profile) + self.assertEqual(settings.strategy_display_name, display_name) + self.assertEqual(settings.strategy_domain, "hk_equity") + self.assertEqual(settings.market, HK_MARKET) + self.assertEqual(settings.market_calendar, HK_MARKET_CALENDAR) + self.assertEqual(settings.market_timezone, HK_MARKET_TIMEZONE) + self.assertEqual(settings.symbol_suffix, HK_SYMBOL_SUFFIX) + self.assertEqual(settings.trading_currency, HK_TRADING_CURRENCY) def test_derives_feature_snapshot_paths_from_artifact_root(self): with TemporaryDirectory() as tmp_dir: @@ -739,7 +761,7 @@ def test_print_strategy_profile_status_json_matches_registry(self): self.assertFalse(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"]) for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation", "hk_blue_chip_leader_rotation"): self.assertNotIn(profile, by_profile) - for profile in ("hk_listed_global_etf_rotation",): + for profile in HK_RUNTIME_ENABLED_PROFILES: self.assertEqual(by_profile[profile]["profile_group"], "direct_runtime_inputs") self.assertEqual(by_profile[profile]["input_mode"], "market_history") self.assertFalse(by_profile[profile]["requires_snapshot_artifacts"]) @@ -765,9 +787,11 @@ def test_print_strategy_profile_status_table_contains_expected_headers(self): self.assertIn("soxl_soxx_trend_income", result.stdout) self.assertIn("global_etf_rotation", result.stdout) self.assertIn("hk_listed_global_etf_rotation", result.stdout) + self.assertIn("hk_high_dividend_low_vol_trend", result.stdout) self.assertIn("russell_1000_multi_factor_defensive", result.stdout) self.assertIn("Global ETF Rotation", result.stdout) self.assertIn("HK-listed Global ETF Rotation", result.stdout) + self.assertIn("HK High Dividend Low-Volatility Trend", result.stdout) self.assertIn("Russell 1000 Multi-Factor", result.stdout) self.assertIn("Tech/Communication Pullback Enhancement", result.stdout) self.assertNotIn("hk_blue_chip_leader_rotation", result.stdout) diff --git a/tests/test_signal_snapshot.py b/tests/test_signal_snapshot.py index 7a35af1..ca2962a 100644 --- a/tests/test_signal_snapshot.py +++ b/tests/test_signal_snapshot.py @@ -49,6 +49,27 @@ def test_prefers_structured_market_date_over_status_text(self): self.assertEqual(snapshot["market_date"], "2026-06-02") self.assertEqual(snapshot["signal_as_of"], "2026-06-01") + def test_includes_snapshot_manifest_input_diagnostics(self): + snapshot = build_signal_snapshot( + platform="longbridge", + metadata={ + "snapshot_manifest_price_as_of": "2026-06-01", + "snapshot_manifest_universe_as_of": "2026-05-31", + "snapshot_manifest_source_input_status": "universe_fallback", + "snapshot_manifest_source_input_fallback_used": True, + "snapshot_manifest_source_input_fallback_reason": "RuntimeError: upstream returned HTML", + "snapshot_manifest_source_input_fallback_streak": 1, + "snapshot_manifest_source_refresh_run_id": "12345", + }, + ) + + self.assertEqual(snapshot["price_as_of"], "2026-06-01") + self.assertEqual(snapshot["universe_as_of"], "2026-05-31") + self.assertEqual(snapshot["source_input_status"], "universe_fallback") + self.assertIs(snapshot["source_input_fallback_used"], True) + self.assertEqual(snapshot["source_input_fallback_streak"], 1) + self.assertEqual(snapshot["source_refresh_run_id"], "12345") + if __name__ == "__main__": unittest.main()