Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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)
Expand Down
61 changes: 56 additions & 5 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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)


Expand Down
6 changes: 6 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "🚨 【策略插件告警】",
Expand Down Expand Up @@ -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】",
Expand Down
37 changes: 37 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
57 changes: 57 additions & 0 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down