From 1eefe64d25208f4d1e065819cb4e507a24db7d9f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:41:32 +0800 Subject: [PATCH] Fix LongBridge notification localization and note dedupe --- application/execution_service.py | 35 ++++++++++++++---- notifications/renderers.py | 61 +++++++++++++++++++++++++++++--- notifications/telegram.py | 6 ++++ tests/test_notifications.py | 37 +++++++++++++++++++ tests/test_rebalance_service.py | 57 +++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 11 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index db10beb..2e46eaa 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -369,6 +369,23 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): print(with_prefix(message), flush=True) +def _small_account_cash_substitution_note_key(note) -> str: + if not isinstance(note, Mapping): + return "" + symbol = str(note.get("symbol") or "").strip().upper() + if not symbol: + return "" + cash_symbols = tuple( + dict.fromkeys( + str(cash_symbol or "").strip().upper() + for cash_symbol in tuple(note.get("cash_symbols") or ()) + if str(cash_symbol or "").strip() + ) + ) + cash_symbols_key = ",".join(cash_symbols) if cash_symbols else "__cash__" + return f"small_account_cash_substitution:{symbol}:{cash_symbols_key}" + + def record_small_account_cash_substitution_notes( note_logs, *, @@ -378,13 +395,19 @@ def record_small_account_cash_substitution_notes( seen_keys, symbol_suffix=".US", ): - for message in format_small_account_cash_substitution_notes( - allocation.get("small_account_whole_share_cash_notes") or (), - translator=translator, - symbol_suffix=symbol_suffix, - ): - if message in seen_keys: + for raw_note in tuple(allocation.get("small_account_whole_share_cash_notes") or ()): + messages = format_small_account_cash_substitution_notes( + (raw_note,), + translator=translator, + symbol_suffix=symbol_suffix, + ) + if not messages: + continue + message = messages[0] + note_key = _small_account_cash_substitution_note_key(raw_note) or message + if note_key in seen_keys or message in seen_keys: continue + seen_keys.add(note_key) seen_keys.add(message) note_logs.append(message) print(with_prefix(message), flush=True) diff --git a/notifications/renderers.py b/notifications/renderers.py index eb8635d..8028c27 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -29,6 +29,32 @@ "market_data": ("市场数据", "market data"), } +_LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS = ( + ("regime=hard_defense", "市场阶段=强防御"), + ("regime=soft_defense", "市场阶段=软防御"), + ("regime=risk_on", "市场阶段=进攻"), + ("benchmark_trend=down", "基准趋势=向下"), + ("benchmark_trend=up", "基准趋势=向上"), + ("benchmark=down", "基准趋势=向下"), + ("benchmark=up", "基准趋势=向上"), + ("breadth=", "市场宽度="), + ("target_stock=", "目标股票仓位="), + ("realized_stock=", "实际股票仓位="), + ("stock_exposure=", "股票目标仓位="), + ("safe_haven=", "避险仓位="), + ("selected=", "入选标的数="), + ("top=", "前排标的="), + ("partial_history_refresh", "部分行情刷新"), + ("full_history_refresh", "完整行情刷新"), + ("universe_fallback", "股票池复用"), +) + +_SOURCE_INPUT_STATUS_LABELS = { + "partial_history_refresh": ("部分行情刷新", "partial history refresh"), + "full_history_refresh": ("完整行情刷新", "full history refresh"), + "universe_fallback": ("股票池复用", "universe fallback"), +} + try: from quant_platform_kit.common.notification_localization import ( localize_price_source_label as _shared_localize_price_source_label, @@ -57,7 +83,31 @@ def _translator_uses_zh(translator) -> bool: def _localize_notification_text(text, *, translator): - return _base_localize_notification_text(text, translator=translator) + try: + return _base_localize_notification_text( + text, + translator=translator, + extra_replacements=_LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS, + ) + except TypeError: # pragma: no cover - compatibility with older shared wheels + localized = _base_localize_notification_text(text, translator=translator) + if not _translator_uses_zh(translator): + return localized + for source, target in _LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS: + localized = localized.replace(source, target) + return localized + + +def _localize_source_input_status(status, *, translator) -> str: + value = str(status or "").strip() + if not value: + return "" + label = _SOURCE_INPUT_STATUS_LABELS.get(value) + if label is not None: + return label[0] if _translator_uses_zh(translator) else label[1] + if _translator_uses_zh(translator): + return _localize_notification_text(value, translator=translator) + return value.replace("_", " ") def _localize_timing_contract(contract: str, *, translator) -> str: @@ -205,6 +255,7 @@ def _format_source_input_line(snapshot, *, translator) -> str: 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() + localized_status = _localize_source_input_status(status, translator=translator) 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: @@ -220,8 +271,8 @@ def _format_source_input_line(snapshot, *, translator) -> str: if fallback_streak not in (None, "", 0, "0"): fallback_text += f" 连续{fallback_streak}次" parts.append(fallback_text) - elif status: - parts.append(f"状态 {status}") + elif localized_status: + parts.append(f"状态 {localized_status}") return "🧩 输入状态: " + " | ".join(parts) parts = [] if price_as_of: @@ -233,8 +284,8 @@ def _format_source_input_line(snapshot, *, translator) -> str: 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}") + elif localized_status: + parts.append(f"status {localized_status}") return "🧩 Inputs: " + " | ".join(parts) diff --git a/notifications/telegram.py b/notifications/telegram.py index 2229728..b5eab5a 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -102,8 +102,11 @@ "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_global_etf_tactical_rotation": "港股全球 ETF 战术轮动", "strategy_name_hk_high_dividend_low_vol_trend": "港股高股息低波趋势", + "strategy_name_hk_dividend_gold_defensive_rotation": "港股股息黄金防守轮动", "strategy_name_hk_low_vol_dividend_quality": "港股低波股息质量", + "strategy_name_hk_low_vol_dividend_quality_snapshot": "港股低波股息质量快照", "strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}", "strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}", "strategy_plugin_alert_title": "🚨 【策略插件告警】", @@ -222,8 +225,11 @@ "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_global_etf_tactical_rotation": "HK Global ETF Tactical Rotation", "strategy_name_hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend", + "strategy_name_hk_dividend_gold_defensive_rotation": "HK Dividend-Gold Defensive Rotation", "strategy_name_hk_low_vol_dividend_quality": "HK Low-Volatility Dividend Quality", + "strategy_name_hk_low_vol_dividend_quality_snapshot": "HK Low-Vol Dividend Quality Snapshot", "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/tests/test_notifications.py b/tests/test_notifications.py index eb224f8..5ef63a3 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -147,6 +147,43 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self): self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text) self.assertNotIn("longbridge_candlesticks", rendered.compact_text) + def test_heartbeat_localizes_strategy_diagnostics_and_source_input_status(self): + rendered = render_heartbeat_notification( + execution={ + "signal_snapshot": { + "market_date": "", + "latest_price_source": "longbridge_candlesticks", + "price_as_of": "2026-06-01", + "universe_as_of": "2026-05-14", + "source_input_status": "partial_history_refresh", + }, + "status_display": "regime=risk_on", + "signal_display": ( + "regime=risk_on breadth=68.0% benchmark_trend=up " + "target_stock=100.0% realized_stock=100.0% selected=4 " + "top=MU(4.07), INTC(2.23), AMD(1.96)" + ), + }, + skip_logs=(), + note_logs=(), + translator=build_translator("zh"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="Mega Cap Top50 平衡龙头轮动", + dry_run_only=False, + ) + + self.assertIn("🧩 输入状态: 价格 2026-06-01 | 股票池 2026-05-14 | 状态 部分行情刷新", rendered.compact_text) + self.assertIn("📊 市场状态: 市场阶段=进攻", rendered.compact_text) + self.assertIn( + "🎯 信号: 市场阶段=进攻 市场宽度=68.0% 基准趋势=向上 " + "目标股票仓位=100.0% 实际股票仓位=100.0% 入选标的数=4 " + "前排标的=MU(4.07), INTC(2.23), AMD(1.96)", + rendered.compact_text, + ) + self.assertNotIn("regime=risk_on", rendered.compact_text) + self.assertNotIn("target_stock=", rendered.compact_text) + self.assertNotIn("partial_history_refresh", rendered.compact_text) + def test_build_prefixer_prefers_account_prefix_only(self): with_prefix = build_prefixer("HK", "longbridge-quant-semiconductor-rotation-income-hk") self.assertEqual(with_prefix("hello"), "[HK] hello") diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index e28cdc3..bb3031b 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -814,6 +814,63 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): self.assertNotIn("BOXX.US 目标差额 $524.92", sent_messages[0]) self.assertNotIn("限价买入] SOXX", sent_messages[0]) + def test_small_account_cash_substitution_note_is_not_duplicated_after_sell_refresh(self): + plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 0.0, "SOXX": 220.19, "BOXX": 1200.0}, + market_values={"SOXL": 1046.20, "SOXX": 0.0, "BOXX": 0.0}, + sellable_quantities={"SOXL": 4, "SOXX": 0, "BOXX": 0}, + quantities={"SOXL": 4, "SOXX": 0, "BOXX": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=1420.53, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 目标仓位 15.0%", + available_cash=1464.46, + total_strategy_equity=1464.46, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + refreshed_plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 0.0, "SOXX": 219.67, "BOXX": 1200.0}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=1420.01, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 目标仓位 15.0%", + available_cash=1463.94, + total_strategy_equity=1463.94, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + + sent_messages, _, _ = self._run_strategy( + plan, + refreshed_plan=refreshed_plan, + prices={"SOXL.US": 261.55, "SOXX.US": 601.80, "BOXX.US": 116.59}, + estimate_max_purchase_quantity_value=10, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("限价卖出] SOXL: 4股", sent_messages[0]) + self.assertEqual(sent_messages[0].count("[买入说明] SOXX.US"), 1) + self.assertEqual(sent_messages[0].count("小账户本轮保留现金"), 1) + self.assertIn("SOXX.US 目标金额 $220.19 低于 1 股价格 $601.80", sent_messages[0]) + self.assertNotIn("SOXX.US 目标金额 $219.67 低于 1 股价格 $601.80", sent_messages[0]) + def test_target_gap_below_one_share_does_not_report_cash_shortage(self): plan = _build_plan( strategy_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"),