Skip to content

Commit ba5f4a4

Browse files
authored
Fix LongBridge notification localization and note dedupe (#133)
1 parent eece314 commit ba5f4a4

5 files changed

Lines changed: 185 additions & 11 deletions

File tree

‎application/execution_service.py‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,23 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs):
369369
print(with_prefix(message), flush=True)
370370

371371

372+
def _small_account_cash_substitution_note_key(note) -> str:
373+
if not isinstance(note, Mapping):
374+
return ""
375+
symbol = str(note.get("symbol") or "").strip().upper()
376+
if not symbol:
377+
return ""
378+
cash_symbols = tuple(
379+
dict.fromkeys(
380+
str(cash_symbol or "").strip().upper()
381+
for cash_symbol in tuple(note.get("cash_symbols") or ())
382+
if str(cash_symbol or "").strip()
383+
)
384+
)
385+
cash_symbols_key = ",".join(cash_symbols) if cash_symbols else "__cash__"
386+
return f"small_account_cash_substitution:{symbol}:{cash_symbols_key}"
387+
388+
372389
def record_small_account_cash_substitution_notes(
373390
note_logs,
374391
*,
@@ -378,13 +395,19 @@ def record_small_account_cash_substitution_notes(
378395
seen_keys,
379396
symbol_suffix=".US",
380397
):
381-
for message in format_small_account_cash_substitution_notes(
382-
allocation.get("small_account_whole_share_cash_notes") or (),
383-
translator=translator,
384-
symbol_suffix=symbol_suffix,
385-
):
386-
if message in seen_keys:
398+
for raw_note in tuple(allocation.get("small_account_whole_share_cash_notes") or ()):
399+
messages = format_small_account_cash_substitution_notes(
400+
(raw_note,),
401+
translator=translator,
402+
symbol_suffix=symbol_suffix,
403+
)
404+
if not messages:
405+
continue
406+
message = messages[0]
407+
note_key = _small_account_cash_substitution_note_key(raw_note) or message
408+
if note_key in seen_keys or message in seen_keys:
387409
continue
410+
seen_keys.add(note_key)
388411
seen_keys.add(message)
389412
note_logs.append(message)
390413
print(with_prefix(message), flush=True)

‎notifications/renderers.py‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,32 @@
2929
"market_data": ("市场数据", "market data"),
3030
}
3131

32+
_LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS = (
33+
("regime=hard_defense", "市场阶段=强防御"),
34+
("regime=soft_defense", "市场阶段=软防御"),
35+
("regime=risk_on", "市场阶段=进攻"),
36+
("benchmark_trend=down", "基准趋势=向下"),
37+
("benchmark_trend=up", "基准趋势=向上"),
38+
("benchmark=down", "基准趋势=向下"),
39+
("benchmark=up", "基准趋势=向上"),
40+
("breadth=", "市场宽度="),
41+
("target_stock=", "目标股票仓位="),
42+
("realized_stock=", "实际股票仓位="),
43+
("stock_exposure=", "股票目标仓位="),
44+
("safe_haven=", "避险仓位="),
45+
("selected=", "入选标的数="),
46+
("top=", "前排标的="),
47+
("partial_history_refresh", "部分行情刷新"),
48+
("full_history_refresh", "完整行情刷新"),
49+
("universe_fallback", "股票池复用"),
50+
)
51+
52+
_SOURCE_INPUT_STATUS_LABELS = {
53+
"partial_history_refresh": ("部分行情刷新", "partial history refresh"),
54+
"full_history_refresh": ("完整行情刷新", "full history refresh"),
55+
"universe_fallback": ("股票池复用", "universe fallback"),
56+
}
57+
3258
try:
3359
from quant_platform_kit.common.notification_localization import (
3460
localize_price_source_label as _shared_localize_price_source_label,
@@ -57,7 +83,31 @@ def _translator_uses_zh(translator) -> bool:
5783

5884

5985
def _localize_notification_text(text, *, translator):
60-
return _base_localize_notification_text(text, translator=translator)
86+
try:
87+
return _base_localize_notification_text(
88+
text,
89+
translator=translator,
90+
extra_replacements=_LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS,
91+
)
92+
except TypeError: # pragma: no cover - compatibility with older shared wheels
93+
localized = _base_localize_notification_text(text, translator=translator)
94+
if not _translator_uses_zh(translator):
95+
return localized
96+
for source, target in _LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS:
97+
localized = localized.replace(source, target)
98+
return localized
99+
100+
101+
def _localize_source_input_status(status, *, translator) -> str:
102+
value = str(status or "").strip()
103+
if not value:
104+
return ""
105+
label = _SOURCE_INPUT_STATUS_LABELS.get(value)
106+
if label is not None:
107+
return label[0] if _translator_uses_zh(translator) else label[1]
108+
if _translator_uses_zh(translator):
109+
return _localize_notification_text(value, translator=translator)
110+
return value.replace("_", " ")
61111

62112

63113
def _localize_timing_contract(contract: str, *, translator) -> str:
@@ -205,6 +255,7 @@ def _format_source_input_line(snapshot, *, translator) -> str:
205255
price_as_of = str(snapshot.get("price_as_of") or "").strip()
206256
universe_as_of = str(snapshot.get("universe_as_of") or "").strip()
207257
status = str(snapshot.get("source_input_status") or "").strip()
258+
localized_status = _localize_source_input_status(status, translator=translator)
208259
fallback_used = _is_truthy(snapshot.get("source_input_fallback_used"))
209260
fallback_streak = snapshot.get("source_input_fallback_streak")
210261
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:
220271
if fallback_streak not in (None, "", 0, "0"):
221272
fallback_text += f" 连续{fallback_streak}次"
222273
parts.append(fallback_text)
223-
elif status:
224-
parts.append(f"状态 {status}")
274+
elif localized_status:
275+
parts.append(f"状态 {localized_status}")
225276
return "🧩 输入状态: " + " | ".join(parts)
226277
parts = []
227278
if price_as_of:
@@ -233,8 +284,8 @@ def _format_source_input_line(snapshot, *, translator) -> str:
233284
if fallback_streak not in (None, "", 0, "0"):
234285
fallback_text += f" streak={fallback_streak}"
235286
parts.append(fallback_text)
236-
elif status:
237-
parts.append(f"status {status}")
287+
elif localized_status:
288+
parts.append(f"status {localized_status}")
238289
return "🧩 Inputs: " + " | ".join(parts)
239290

240291

‎notifications/telegram.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,11 @@
102102
"strategy_name_qqq_tech_enhancement": "科技通信回调增强",
103103
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动",
104104
"strategy_name_hk_listed_global_etf_rotation": "港股上市全球 ETF 轮动",
105+
"strategy_name_hk_global_etf_tactical_rotation": "港股全球 ETF 战术轮动",
105106
"strategy_name_hk_high_dividend_low_vol_trend": "港股高股息低波趋势",
107+
"strategy_name_hk_dividend_gold_defensive_rotation": "港股股息黄金防守轮动",
106108
"strategy_name_hk_low_vol_dividend_quality": "港股低波股息质量",
109+
"strategy_name_hk_low_vol_dividend_quality_snapshot": "港股低波股息质量快照",
107110
"strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}",
108111
"strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}",
109112
"strategy_plugin_alert_title": "🚨 【策略插件告警】",
@@ -222,8 +225,11 @@
222225
"strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement",
223226
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced",
224227
"strategy_name_hk_listed_global_etf_rotation": "HK-listed Global ETF Rotation",
228+
"strategy_name_hk_global_etf_tactical_rotation": "HK Global ETF Tactical Rotation",
225229
"strategy_name_hk_high_dividend_low_vol_trend": "HK High Dividend Low-Volatility Trend",
230+
"strategy_name_hk_dividend_gold_defensive_rotation": "HK Dividend-Gold Defensive Rotation",
226231
"strategy_name_hk_low_vol_dividend_quality": "HK Low-Volatility Dividend Quality",
232+
"strategy_name_hk_low_vol_dividend_quality_snapshot": "HK Low-Vol Dividend Quality Snapshot",
227233
"strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}",
228234
"strategy_plugin_alert_subject": "🚨 Strategy plugin alert: {plugin} | {route}",
229235
"strategy_plugin_alert_title": "🚨 【Strategy Plugin Alert】",

‎tests/test_notifications.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,43 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self):
147147
self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text)
148148
self.assertNotIn("longbridge_candlesticks", rendered.compact_text)
149149

150+
def test_heartbeat_localizes_strategy_diagnostics_and_source_input_status(self):
151+
rendered = render_heartbeat_notification(
152+
execution={
153+
"signal_snapshot": {
154+
"market_date": "",
155+
"latest_price_source": "longbridge_candlesticks",
156+
"price_as_of": "2026-06-01",
157+
"universe_as_of": "2026-05-14",
158+
"source_input_status": "partial_history_refresh",
159+
},
160+
"status_display": "regime=risk_on",
161+
"signal_display": (
162+
"regime=risk_on breadth=68.0% benchmark_trend=up "
163+
"target_stock=100.0% realized_stock=100.0% selected=4 "
164+
"top=MU(4.07), INTC(2.23), AMD(1.96)"
165+
),
166+
},
167+
skip_logs=(),
168+
note_logs=(),
169+
translator=build_translator("zh"),
170+
separator="━━━━━━━━━━━━━━━━━━",
171+
strategy_display_name="Mega Cap Top50 平衡龙头轮动",
172+
dry_run_only=False,
173+
)
174+
175+
self.assertIn("🧩 输入状态: 价格 2026-06-01 | 股票池 2026-05-14 | 状态 部分行情刷新", rendered.compact_text)
176+
self.assertIn("📊 市场状态: 市场阶段=进攻", rendered.compact_text)
177+
self.assertIn(
178+
"🎯 信号: 市场阶段=进攻 市场宽度=68.0% 基准趋势=向上 "
179+
"目标股票仓位=100.0% 实际股票仓位=100.0% 入选标的数=4 "
180+
"前排标的=MU(4.07), INTC(2.23), AMD(1.96)",
181+
rendered.compact_text,
182+
)
183+
self.assertNotIn("regime=risk_on", rendered.compact_text)
184+
self.assertNotIn("target_stock=", rendered.compact_text)
185+
self.assertNotIn("partial_history_refresh", rendered.compact_text)
186+
150187
def test_build_prefixer_prefers_account_prefix_only(self):
151188
with_prefix = build_prefixer("HK", "longbridge-quant-semiconductor-rotation-income-hk")
152189
self.assertEqual(with_prefix("hello"), "[HK] hello")

‎tests/test_rebalance_service.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,63 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self):
814814
self.assertNotIn("BOXX.US 目标差额 $524.92", sent_messages[0])
815815
self.assertNotIn("限价买入] SOXX", sent_messages[0])
816816

817+
def test_small_account_cash_substitution_note_is_not_duplicated_after_sell_refresh(self):
818+
plan = _build_plan(
819+
strategy_symbols=("SOXL", "SOXX", "BOXX"),
820+
risk_symbols=("SOXL", "SOXX"),
821+
safe_haven_symbols=("BOXX",),
822+
targets={"SOXL": 0.0, "SOXX": 220.19, "BOXX": 1200.0},
823+
market_values={"SOXL": 1046.20, "SOXX": 0.0, "BOXX": 0.0},
824+
sellable_quantities={"SOXL": 4, "SOXX": 0, "BOXX": 0},
825+
quantities={"SOXL": 4, "SOXX": 0, "BOXX": 0},
826+
current_min_trade=10.0,
827+
trade_threshold_value=10.0,
828+
investable_cash=1420.53,
829+
market_status="🧯 过热降档(SOXX)",
830+
deploy_ratio_text="15.0%",
831+
income_ratio_text="0.0%",
832+
income_locked_ratio_text="0.0%",
833+
signal_message="SOXX 目标仓位 15.0%",
834+
available_cash=1464.46,
835+
total_strategy_equity=1464.46,
836+
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
837+
)
838+
refreshed_plan = _build_plan(
839+
strategy_symbols=("SOXL", "SOXX", "BOXX"),
840+
risk_symbols=("SOXL", "SOXX"),
841+
safe_haven_symbols=("BOXX",),
842+
targets={"SOXL": 0.0, "SOXX": 219.67, "BOXX": 1200.0},
843+
market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
844+
sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
845+
quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
846+
current_min_trade=10.0,
847+
trade_threshold_value=10.0,
848+
investable_cash=1420.01,
849+
market_status="🧯 过热降档(SOXX)",
850+
deploy_ratio_text="15.0%",
851+
income_ratio_text="0.0%",
852+
income_locked_ratio_text="0.0%",
853+
signal_message="SOXX 目标仓位 15.0%",
854+
available_cash=1463.94,
855+
total_strategy_equity=1463.94,
856+
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
857+
)
858+
859+
sent_messages, _, _ = self._run_strategy(
860+
plan,
861+
refreshed_plan=refreshed_plan,
862+
prices={"SOXL.US": 261.55, "SOXX.US": 601.80, "BOXX.US": 116.59},
863+
estimate_max_purchase_quantity_value=10,
864+
)
865+
866+
self.assertEqual(len(sent_messages), 1)
867+
self.assertIn("🔔 【调仓指令】", sent_messages[0])
868+
self.assertIn("限价卖出] SOXL: 4股", sent_messages[0])
869+
self.assertEqual(sent_messages[0].count("[买入说明] SOXX.US"), 1)
870+
self.assertEqual(sent_messages[0].count("小账户本轮保留现金"), 1)
871+
self.assertIn("SOXX.US 目标金额 $220.19 低于 1 股价格 $601.80", sent_messages[0])
872+
self.assertNotIn("SOXX.US 目标金额 $219.67 低于 1 股价格 $601.80", sent_messages[0])
873+
817874
def test_target_gap_below_one_share_does_not_report_cash_shortage(self):
818875
plan = _build_plan(
819876
strategy_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"),

0 commit comments

Comments
 (0)