22
33from __future__ import annotations
44
5+ import re
56from collections .abc import Callable , Mapping
67from typing import Any
78
9+ from quant_platform_kit .common .notification_localization import (
10+ localize_notification_text as _base_localize_notification_text ,
11+ )
12+
813
914SEPARATOR = "━━━━━━━━━━━━━━━━━━"
1015
16+ _DETAIL_FIELD_SPLIT_RE = re .compile (r",\s*(?=[A-Za-z_][\w-]*\s*=)" )
17+ _STRUCTURED_PAREN_RE = re .compile (r"^(?P<key>[A-Za-z_][\w-]*)\((?P<details>.*)\)$" )
18+
1119
1220I18N = {
1321 "zh" : {
1624 "strategy_label" : "🧭 策略: {name}" ,
1725 "account_label" : "🆔 账户: {account}" ,
1826 "dry_run_banner" : "🧪 模拟运行,本轮不提交真实订单" ,
27+ "dashboard_label" : "📊 资产看板" ,
1928 "account_overview_title" : "📌 策略账户概览" ,
29+ "equity" : "净值" ,
2030 "total_assets" : "总资产(策略标的+现金)" ,
2131 "buying_power" : "购买力" ,
2232 "reserved_cash" : "预留现金" ,
2333 "investable_cash" : "可投资现金" ,
2434 "holdings_title" : "💼 策略持仓" ,
2535 "holding_line" : "{symbol}: {market_value} / {quantity}" ,
2636 "quantity_shares" : "{quantity}股" ,
37+ "signal_label" : "信号" ,
38+ "separator" : SEPARATOR ,
2739 "same_trading_day" : "当日执行" ,
2840 "next_trading_day" : "次一交易日执行" ,
2941 "next_n_trading_days" : "{count}个交易日后执行" ,
3749 "submitted_sell_order" : "已提交卖单: {symbol} {quantity}" ,
3850 "no_order_submitted" : "未下单: 原因={reason}" ,
3951 "no_rebalance_needed" : "✅ 无需调仓" ,
52+ "no_trades" : "✅ 无需调仓" ,
4053 "no_executable_orders" : "无可执行订单" ,
4154 "signal_state_hold" : "趋势持有" ,
4255 "signal_state_entry" : "入场信号" ,
4356 "signal_state_reduce" : "减仓信号" ,
4457 "signal_state_exit" : "离场信号" ,
4558 "signal_state_idle" : "等待信号" ,
59+ "signal_hold" : "趋势持有" ,
60+ "signal_entry" : "入场信号" ,
61+ "signal_reduce" : "减仓信号" ,
62+ "signal_exit" : "离场信号" ,
63+ "signal_idle" : "等待信号" ,
64+ "market_status_blend_gate_risk_on" : "🚀 风险开启({asset})" ,
65+ "market_status_blend_gate_defensive" : "🛡️ 降杠杆({asset})" ,
66+ "market_status_blend_gate_overlay_capped" : "🧯 过热降档({asset})" ,
67+ "signal_blend_gate_risk_on" : "{trend_symbol} 站上 {window} 日门槛线,持有 SOXL {soxl_ratio} + SOXX {soxx_ratio}" ,
68+ "signal_blend_gate_defensive" : "{trend_symbol} 跌破门槛线,防守持有 SOXX {soxx_ratio}" ,
69+ "signal_blend_gate_overlay_capped" : "{trend_symbol} 仍在 {window} 日门槛线上方,但触发过热降档({reasons}),目标仓位 {allocation_text}" ,
70+ "market_status_risk_on" : "🚀 风险开启({asset})" ,
71+ "market_status_delever" : "🛡️ 降杠杆({asset})" ,
72+ "signal_risk_on" : "SOXL 站上 {window} 日均线,持有 SOXL,交易层风险仓位 {ratio}" ,
73+ "signal_delever" : "SOXL 跌破 {window} 日均线,切换至 SOXX,交易层风险仓位 {ratio}" ,
74+ "blend_gate_reason_rsi_cap" : "RSI 超阈值" ,
75+ "blend_gate_reason_bollinger_cap" : "突破布林上轨" ,
76+ "blend_gate_reason_volatility_delever" : "{symbol} {window} 日年化波动率 {volatility} 高于 {threshold},SOXL 转向 {redirect_symbol}" ,
77+ "small_account_warning_note" : "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}" ,
78+ "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication" : "整数股和最小仓位限制可能导致实盘无法完全复现回测" ,
79+ "strategy_name_tqqq_growth_income" : "TQQQ 增长收益" ,
80+ "strategy_name_soxl_soxx_trend_income" : "SOXL/SOXX 半导体趋势收益" ,
81+ "strategy_name_global_etf_rotation" : "全球 ETF 轮动" ,
82+ "strategy_name_global_etf_confidence_vol_gate" : "全球 ETF 置信波动门控" ,
83+ "strategy_name_russell_1000_multi_factor_defensive" : "罗素1000多因子" ,
84+ "strategy_name_tech_communication_pullback_enhancement" : "科技通信回调增强" ,
85+ "strategy_name_qqq_tech_enhancement" : "科技通信回调增强" ,
86+ "strategy_name_mega_cap_leader_rotation_top50_balanced" : "Mega Cap Top50 平衡龙头轮动" ,
4687 "skip_reason_below_trade_threshold" : "低于调仓阈值" ,
4788 "skip_reason_quote_unavailable" : "无法获取报价" ,
4889 "skip_reason_sell_quantity_zero" : "卖出股数为0" ,
5596 "strategy_label" : "🧭 Strategy: {name}" ,
5697 "account_label" : "🆔 Account: {account}" ,
5798 "dry_run_banner" : "🧪 Dry run only; no live orders submitted" ,
99+ "dashboard_label" : "📊 Dashboard" ,
58100 "account_overview_title" : "📌 Strategy Account" ,
101+ "equity" : "Equity" ,
59102 "total_assets" : "Total assets" ,
60103 "buying_power" : "Buying power" ,
61104 "reserved_cash" : "Reserved cash" ,
62105 "investable_cash" : "Investable cash" ,
63106 "holdings_title" : "💼 Strategy Holdings" ,
64107 "holding_line" : "{symbol}: {market_value} / {quantity}" ,
65108 "quantity_shares" : "{quantity} shares" ,
109+ "signal_label" : "Signal" ,
110+ "separator" : SEPARATOR ,
66111 "same_trading_day" : "same trading day" ,
67112 "next_trading_day" : "next trading day" ,
68113 "next_n_trading_days" : "next {count} trading days" ,
76121 "submitted_sell_order" : "Submitted sell: {symbol} {quantity}" ,
77122 "no_order_submitted" : "No order submitted: reason={reason}" ,
78123 "no_rebalance_needed" : "✅ No rebalance needed" ,
124+ "no_trades" : "✅ No rebalance needed" ,
79125 "no_executable_orders" : "no executable orders" ,
80126 "signal_state_hold" : "Trend Hold" ,
81127 "signal_state_entry" : "Entry Signal" ,
82128 "signal_state_reduce" : "Reduce Signal" ,
83129 "signal_state_exit" : "Exit Signal" ,
84130 "signal_state_idle" : "Idle" ,
131+ "signal_hold" : "Trend Hold" ,
132+ "signal_entry" : "Entry Signal" ,
133+ "signal_reduce" : "Reduce Signal" ,
134+ "signal_exit" : "Exit Signal" ,
135+ "signal_idle" : "Idle" ,
136+ "market_status_blend_gate_risk_on" : "Risk on ({asset})" ,
137+ "market_status_blend_gate_defensive" : "Defensive ({asset})" ,
138+ "market_status_blend_gate_overlay_capped" : "Overheat capped ({asset})" ,
139+ "signal_blend_gate_risk_on" : "{trend_symbol} is above the {window}-day gate; hold SOXL {soxl_ratio} + SOXX {soxx_ratio}" ,
140+ "signal_blend_gate_defensive" : "{trend_symbol} is below the gate; hold SOXX {soxx_ratio}" ,
141+ "signal_blend_gate_overlay_capped" : "{trend_symbol} remains above the {window}-day gate, but overheat cap is active ({reasons}); target {allocation_text}" ,
142+ "market_status_risk_on" : "Risk on ({asset})" ,
143+ "market_status_delever" : "Delever ({asset})" ,
144+ "signal_risk_on" : "SOXL is above the {window}-day average; hold SOXL at risk sleeve {ratio}" ,
145+ "signal_delever" : "SOXL is below the {window}-day average; switch to SOXX at risk sleeve {ratio}" ,
146+ "blend_gate_reason_rsi_cap" : "RSI over threshold" ,
147+ "blend_gate_reason_bollinger_cap" : "price above upper band" ,
148+ "blend_gate_reason_volatility_delever" : "{symbol} {window}d annualized volatility {volatility} is above {threshold}; redirect SOXL to {redirect_symbol}" ,
149+ "small_account_warning_note" : "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}" ,
150+ "small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication" : "integer-share minimum position sizing may prevent backtest replication" ,
151+ "strategy_name_tqqq_growth_income" : "TQQQ Growth Income" ,
152+ "strategy_name_soxl_soxx_trend_income" : "SOXL/SOXX Semiconductor Trend Income" ,
153+ "strategy_name_global_etf_rotation" : "Global ETF Rotation" ,
154+ "strategy_name_global_etf_confidence_vol_gate" : "Global ETF Confidence Vol Gate" ,
155+ "strategy_name_russell_1000_multi_factor_defensive" : "Russell 1000 Multi-Factor Defensive" ,
156+ "strategy_name_tech_communication_pullback_enhancement" : "Tech Communication Pullback Enhancement" ,
157+ "strategy_name_qqq_tech_enhancement" : "Tech Communication Pullback Enhancement" ,
158+ "strategy_name_mega_cap_leader_rotation_top50_balanced" : "Mega Cap Top50 Balanced Leader Rotation" ,
85159 "skip_reason_below_trade_threshold" : "below trade threshold" ,
86160 "skip_reason_quote_unavailable" : "quote unavailable" ,
87161 "skip_reason_sell_quantity_zero" : "sell quantity rounds to 0" ,
@@ -97,7 +171,12 @@ def build_translator(lang: str | None) -> Callable[..., str]:
97171
98172 def translate (key : str , ** kwargs ) -> str :
99173 template = I18N [active_lang ].get (key , I18N ["en" ].get (key , key ))
100- return template .format (** kwargs ) if kwargs else template
174+ if not kwargs :
175+ return template
176+ try :
177+ return template .format (** kwargs )
178+ except (IndexError , KeyError , ValueError ):
179+ return template
101180
102181 return translate
103182
@@ -145,6 +224,65 @@ def _format_shares(value: Any, *, translator: Callable[..., str]) -> str:
145224 return translator ("quantity_shares" , quantity = _format_quantity (value ))
146225
147226
227+ def _parse_detail_kwargs (text : str ) -> dict [str , str ]:
228+ values : dict [str , str ] = {}
229+ for part in _DETAIL_FIELD_SPLIT_RE .split (str (text or "" )):
230+ key , sep , value = part .partition ("=" )
231+ if not sep :
232+ continue
233+ normalized_key = key .strip ()
234+ if not normalized_key :
235+ continue
236+ values [normalized_key ] = value .strip ()
237+ return values
238+
239+
240+ def _structured_key_and_kwargs (text : str ) -> tuple [str , dict [str , str ]]:
241+ key , sep , details = text .partition (":" )
242+ if sep :
243+ return key .strip (), _parse_detail_kwargs (details )
244+ match = _STRUCTURED_PAREN_RE .fullmatch (text .strip ())
245+ if not match :
246+ return "" , {}
247+ return match .group ("key" ).strip (), _parse_detail_kwargs (match .group ("details" ))
248+
249+
250+ def _localize_structured_text (text : Any , * , translator : Callable [..., str ]) -> str :
251+ value = str (text or "" ).strip ()
252+ if not value :
253+ return ""
254+ translation_key , kwargs = _structured_key_and_kwargs (value )
255+ if translation_key :
256+ for nested_key in ("reason" , "reasons" ):
257+ if nested_key in kwargs :
258+ kwargs [nested_key ] = _base_localize_notification_text (
259+ kwargs [nested_key ],
260+ translator = translator ,
261+ )
262+ translated = translator (translation_key , ** kwargs ) if kwargs else translator (translation_key )
263+ if translated != translation_key :
264+ return translated
265+ translated = translator (value )
266+ if translated != value :
267+ return translated
268+ return _base_localize_notification_text (value , translator = translator )
269+
270+
271+ def _is_dashboard_signal_line (line : str ) -> bool :
272+ text = str (line or "" ).strip ()
273+ text = text .removeprefix ("-" ).strip ()
274+ if not text :
275+ return False
276+ lowered = text .lower ()
277+ return (
278+ text .startswith ("🎯" )
279+ or lowered .startswith (("signal:" , "signal:" ))
280+ or text .startswith (("信号:" , "信号:" ))
281+ or "small account warning" in lowered
282+ or "小账户提示" in text
283+ )
284+
285+
148286def _format_dashboard_lines (
149287 portfolio : Mapping [str , Any ],
150288 execution : Mapping [str , Any ],
@@ -153,7 +291,15 @@ def _format_dashboard_lines(
153291) -> list [str ]:
154292 dashboard_text = str (execution .get ("dashboard_text" ) or "" ).strip ()
155293 if dashboard_text :
156- return [line .rstrip () for line in dashboard_text .splitlines () if line .strip ()]
294+ has_signal_display = bool (str (execution .get ("signal_display" ) or "" ).strip ())
295+ lines = []
296+ for line in dashboard_text .splitlines ():
297+ if not line .strip ():
298+ continue
299+ if has_signal_display and _is_dashboard_signal_line (line ):
300+ continue
301+ lines .append (_base_localize_notification_text (line .rstrip (), translator = translator ))
302+ return lines
157303
158304 lines = [translator ("account_overview_title" )]
159305 total_equity = _safe_float (portfolio .get ("total_equity" ))
@@ -234,9 +380,25 @@ def _first_summary(value: Any, *, translator: Callable[..., str]) -> str:
234380 if not text :
235381 return ""
236382 summary = text .split (" | " , 1 )[0 ].strip ()
237- key = f"signal_state_{ summary .lower ()} "
238- translated = translator (key )
239- return translated if translated != key else summary
383+ structured = _localize_structured_text (summary , translator = translator )
384+ if structured and structured != summary :
385+ return structured
386+ normalized = summary .lower ()
387+ for key in (f"signal_state_{ normalized } " , f"signal_{ normalized } " ):
388+ translated = translator (key )
389+ if translated != key :
390+ return translated
391+ return structured or _base_localize_notification_text (summary , translator = translator )
392+
393+
394+ def _detail_lines (value : Any , * , translator : Callable [..., str ]) -> list [str ]:
395+ segments = [segment .strip () for segment in str (value or "" ).split (" | " ) if segment .strip ()]
396+ details = []
397+ for segment in segments [1 :]:
398+ localized = _localize_structured_text (segment , translator = translator )
399+ if localized :
400+ details .append (localized )
401+ return details
240402
241403
242404def _format_signal_lines (execution : Mapping [str , Any ], * , translator : Callable [..., str ]) -> list [str ]:
@@ -247,6 +409,7 @@ def _format_signal_lines(execution: Mapping[str, Any], *, translator: Callable[.
247409 lines .append (translator ("market_status_line" , status = status ))
248410 if signal :
249411 lines .append (translator ("signal_line" , signal = signal ))
412+ lines .extend (f" - { line } " for line in _detail_lines (execution .get ("signal_display" ), translator = translator ))
250413 return lines
251414
252415
@@ -329,7 +492,11 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
329492 for item in skipped
330493 )
331494 has_rebalance_attempt = bool (submitted or target_diff_lines or has_meaningful_skip )
332- strategy_name = str (result .get ("strategy_display_name" ) or result .get ("strategy_profile" ) or "" ).strip ()
495+ strategy_profile = str (result .get ("strategy_profile" ) or "" ).strip ()
496+ strategy_name = str (result .get ("strategy_display_name" ) or strategy_profile ).strip ()
497+ translated_strategy_name = translator (f"strategy_name_{ strategy_profile } " ) if strategy_profile else ""
498+ if translated_strategy_name and translated_strategy_name != f"strategy_name_{ strategy_profile } " :
499+ strategy_name = translated_strategy_name
333500 account = str (result .get ("account" ) or "" ).strip ()
334501 lines = [translator ("rebalance_title" if has_rebalance_attempt else "heartbeat_title" )]
335502 if strategy_name :
0 commit comments