Skip to content

Commit 0fb5e18

Browse files
authored
Unify Firstrade Telegram notifications (#10)
1 parent e53e2d1 commit 0fb5e18

3 files changed

Lines changed: 222 additions & 15 deletions

File tree

application/rebalance_service.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
)
2626
from application.runtime_broker_adapters import build_runtime_broker_adapters
2727
from decision_mapper import map_strategy_decision_to_plan
28-
from notifications.telegram import build_sender, render_cycle_summary
28+
from notifications.telegram import build_sender, build_translator, render_cycle_summary
2929
from quant_platform_kit.common.runtime_inputs import (
3030
build_semiconductor_rotation_indicators_from_history,
3131
required_semiconductor_rotation_history_lookback,
@@ -42,13 +42,6 @@ def get_project_id() -> str | None:
4242
return os.getenv("GOOGLE_CLOUD_PROJECT")
4343

4444

45-
def _identity_translator(key: str, **kwargs) -> str:
46-
if not kwargs:
47-
return key
48-
details = ", ".join(f"{name}={value}" for name, value in sorted(kwargs.items()))
49-
return f"{key}: {details}"
50-
51-
5245
def _series_from_price_history(market_data_port, symbol: str) -> pd.Series:
5346
series = market_data_port.get_price_series(symbol)
5447
index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points])
@@ -186,7 +179,7 @@ def run_strategy_cycle(
186179
available_inputs=available_inputs,
187180
market_inputs=market_inputs,
188181
portfolio_snapshot=snapshot,
189-
translator=_identity_translator,
182+
translator=build_translator(settings.notify_lang),
190183
)
191184
evaluation = strategy_runtime.evaluate(**evaluation_inputs)
192185
plan = map_strategy_decision_to_plan(

notifications/telegram.py

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22

33
from __future__ import annotations
44

5+
import re
56
from collections.abc import Callable, Mapping
67
from 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

914
SEPARATOR = "━━━━━━━━━━━━━━━━━━"
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

1220
I18N = {
1321
"zh": {
@@ -16,14 +24,18 @@
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}个交易日后执行",
@@ -37,12 +49,41 @@
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",
@@ -55,14 +96,18 @@
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",
@@ -76,12 +121,41 @@
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+
148286
def _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

242404
def _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:

tests/test_rebalance_service.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,53 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_chinese_template
184184
assert "targets:" not in message
185185

186186

187+
def test_render_cycle_summary_localizes_strategy_signal_codes():
188+
message = render_cycle_summary(
189+
{
190+
"account": "****1234",
191+
"strategy_profile": "soxl_soxx_trend_income",
192+
"strategy_display_name": "SOXL/SOXX 半导体趋势收益",
193+
"dry_run_only": True,
194+
"portfolio": {
195+
"total_equity": 0.0,
196+
"liquid_cash": 0.0,
197+
"portfolio_rows": (("SOXL", "SOXX", "BOXX"), ("QQQI", "SPYI")),
198+
"market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0},
199+
"quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 0, "QQQI": 0, "SPYI": 0},
200+
},
201+
"allocation": {"targets": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0}},
202+
"execution": {
203+
"reserved_cash": 0.0,
204+
"investable_cash": 0.0,
205+
"dashboard_text": "\n".join(
206+
(
207+
"📌 策略账户概览",
208+
"💼 策略持仓",
209+
" - SOXL: $0.00 / 0股",
210+
"🎯 信号: signal_blend_gate_risk_on: soxl_ratio=70.0%, soxx_ratio=20.0%, trend_symbol=SOXX, window=140",
211+
)
212+
),
213+
"status_display": "market_status_blend_gate_risk_on: asset=SOXX+SOXL",
214+
"signal_display": "signal_blend_gate_risk_on: soxl_ratio=70.0%, soxx_ratio=20.0%, trend_symbol=SOXX, window=140 | small_account_warning_note: min_recommended_equity=$1,000, portfolio_equity=$0, reason=integer-share minimum position sizing may prevent backtest replication",
215+
"signal_date": "2026-05-23",
216+
"effective_date": "2026-05-25",
217+
"execution_timing_contract": "next_trading_day",
218+
},
219+
"submitted_orders": [],
220+
"skipped_orders": [],
221+
},
222+
lang="zh",
223+
)
224+
225+
assert "📊 市场状态: 🚀 风险开启(SOXX+SOXL)" in message
226+
assert "🎯 信号: SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%" in message
227+
assert " - 小账户提示:净值 $0 低于建议 $1,000;整数股和最小仓位限制可能导致实盘无法完全复现回测" in message
228+
assert message.count("🎯 信号:") == 1
229+
assert "signal_blend_gate_risk_on" not in message
230+
assert "soxl_ratio" not in message
231+
assert "small_account_warning_note" not in message
232+
233+
187234
def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template():
188235
message = render_cycle_summary(
189236
{

0 commit comments

Comments
 (0)