Skip to content

Commit e5ea694

Browse files
committed
Improve Telegram rebalance notifications
1 parent 81ce082 commit e5ea694

3 files changed

Lines changed: 162 additions & 20 deletions

File tree

notifications/renderers.py

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
("pending_orders_detected", "检测到未完成订单"),
1717
("same_day_execution_locked", "当日执行锁已存在"),
1818
("same_day_fills_detected", "检测到当日成交"),
19+
("target_diff_below_threshold", "调仓差异低于阈值"),
20+
("min_notional", "低于最小订单金额"),
21+
("quantity_zero", "整数股数量为0"),
1922
("fail_reason=", "失败原因="),
2023
("decision=", "决策="),
2124
)
@@ -186,6 +189,13 @@ def _build_notification_trade_lines(
186189
snapshot_date=_format_text(execution_summary.get("snapshot_as_of"), fallback="<none>"),
187190
)
188191
)
192+
elif no_op_reason:
193+
lines.append(
194+
translator(
195+
"no_order_plan_reason",
196+
reason=_localize_notification_text(f"reason={no_op_reason}", translator=translator),
197+
)
198+
)
189199

190200
fallback_symbols = tuple(execution_summary.get("snapshot_price_fallback_symbols") or ())
191201
if execution_summary.get("snapshot_price_fallback_used") and fallback_symbols:
@@ -215,7 +225,7 @@ def _build_notification_trade_lines(
215225
continue
216226
if "execution_lock_acquired" in text or "已获取执行锁" in text:
217227
continue
218-
if text.startswith(("profile=", "strategy_profile=", "策略=")):
228+
if text.startswith(("profile=", "strategy_profile=", "execution_profile=", "策略=", "执行配置=")):
219229
continue
220230
if "same_day_execution_locked" in text or "当日执行锁已存在" in text:
221231
continue
@@ -225,6 +235,16 @@ def _build_notification_trade_lines(
225235
return lines
226236

227237

238+
def _build_detailed_trade_lines(trade_logs, *, translator) -> list[str]:
239+
lines: list[str] = []
240+
for raw_line in trade_logs or ():
241+
text = _localize_notification_text(str(raw_line).strip(), translator=translator)
242+
if not text:
243+
continue
244+
lines.extend(_split_labeled_text(text))
245+
return lines
246+
247+
228248
def _resolve_weight_allocation(signal_metadata, *, required: bool) -> dict:
229249
metadata = dict(signal_metadata or {})
230250
allocation = dict(metadata.get("allocation") or {})
@@ -384,10 +404,27 @@ def build_dashboard(
384404
)
385405

386406

387-
def _first_prefixed_line(prefix: str, text: str, *, translator) -> str | None:
407+
def _first_summary_text(text: str, *, translator) -> str:
388408
localized = _localize_notification_text(text, translator=translator)
389-
lines = _format_prefixed_text(prefix, localized)
390-
return lines[0] if lines else None
409+
lines = _split_labeled_text(localized)
410+
return lines[0] if lines else ""
411+
412+
413+
def _localize_signal_state(text: str, *, translator) -> str:
414+
value = str(text or "").strip()
415+
if value.lower() not in {"hold", "entry", "reduce", "exit", "idle"}:
416+
return value
417+
key = f"signal_state_{value.lower()}"
418+
translated = translator(key)
419+
return value if translated == key else str(translated)
420+
421+
422+
def _extract_timing_lines(text: str) -> list[str]:
423+
return [
424+
line.strip()
425+
for line in _format_dashboard_text(text).splitlines()
426+
if line.strip().startswith("⏱")
427+
]
391428

392429

393430
def _build_compact_message(
@@ -402,19 +439,26 @@ def _build_compact_message(
402439
body_lines,
403440
dashboard_text: str = "",
404441
extra_notification_lines=(),
442+
include_dashboard: bool = False,
405443
) -> str:
406444
lines = [title]
407445
strategy_name = _format_text(strategy_display_name, fallback="<unknown>")
408446
lines.append(translator("strategy_label", name=strategy_name))
409447
lines.extend(_extra_notification_lines(extra_notification_lines))
410448
dashboard = _format_dashboard_text(dashboard_text)
411-
if dashboard:
449+
if include_dashboard and dashboard:
412450
lines.append(separator)
413451
lines.extend(dashboard.splitlines())
414-
status_line = _first_prefixed_line(status_icon, status_desc, translator=translator)
415-
if status_line:
452+
elif dashboard:
453+
lines.extend(_extract_timing_lines(dashboard))
454+
status_summary = _first_summary_text(status_desc, translator=translator)
455+
signal_summary = _first_summary_text(signal_desc, translator=translator)
456+
status_summary = _localize_signal_state(status_summary, translator=translator)
457+
signal_summary = _localize_signal_state(signal_summary, translator=translator)
458+
status_line = f"{status_icon} {status_summary}".strip() if status_summary else None
459+
if status_line and status_summary != signal_summary:
416460
lines.append(status_line)
417-
signal_line = _first_prefixed_line("🎯", signal_desc, translator=translator)
461+
signal_line = f"🎯 {signal_summary}".strip() if signal_summary else None
418462
if signal_line:
419463
lines.append(signal_line)
420464
compact_body = [str(line).strip() for line in body_lines or () if str(line).strip()]
@@ -451,6 +495,7 @@ def render_heartbeat_notification(
451495
body_lines=[no_op_text],
452496
dashboard_text=strategy_dashboard,
453497
extra_notification_lines=extra_lines,
498+
include_dashboard=True,
454499
)
455500
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)
456501

@@ -476,12 +521,16 @@ def render_trade_notification(
476521
execution_summary=execution_summary,
477522
translator=translator,
478523
)
524+
detailed_trade_lines = _build_detailed_trade_lines(
525+
trade_logs,
526+
translator=translator,
527+
)
479528
detailed_text = (
480529
f"{translator('rebalance_title')}\n"
481530
f"{chr(10).join(extra_lines) + chr(10) if extra_lines else ''}"
482531
f"{dashboard}\n"
483532
f"{separator}\n"
484-
f"{chr(10).join(notification_trade_lines)}"
533+
f"{chr(10).join(detailed_trade_lines)}"
485534
)
486535
compact_text = _build_compact_message(
487536
title=translator("rebalance_title"),
@@ -494,6 +543,7 @@ def render_trade_notification(
494543
body_lines=notification_trade_lines,
495544
dashboard_text=strategy_dashboard,
496545
extra_notification_lines=extra_lines,
546+
include_dashboard=True,
497547
)
498548
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)
499549

@@ -510,5 +560,6 @@ def render_trade_notification(
510560
body_lines=[translator("no_trades")],
511561
dashboard_text=strategy_dashboard,
512562
extra_notification_lines=extra_lines,
563+
include_dashboard=True,
513564
)
514565
return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text)

notifications/telegram.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"dry_run_snapshot_prices": "🧪 模拟运行估价: 使用快照收盘价 {count}个标的 ({symbols})",
4040
"price_fallback_prices": "📌 执行估价: 使用最近收盘价 {count}个标的 ({symbols})",
4141
"target_diff_summary": "调仓变化: {details}",
42+
"no_order_plan_reason": "未下单: {reason}",
4243
"trade_date_detail": "交易日={value}",
4344
"target_diff": "目标差异 {symbol}: 当前={current} 目标={target} 变化={delta}",
4445
"pending_orders_detected": "检测到未完成订单: 策略={profile} 标的={symbols}",
@@ -102,6 +103,11 @@
102103
"paper_liquidation_only": "IBKR 模拟账户清仓模式",
103104
"paper_liquidation_status": "模式={mode} 状态={status}",
104105
"paper_liquidation_positions_seen": "识别持仓={count}",
106+
"signal_state_hold": "趋势持有",
107+
"signal_state_entry": "入场信号",
108+
"signal_state_reduce": "减仓信号",
109+
"signal_state_exit": "离场信号",
110+
"signal_state_idle": "等待信号",
105111
"small_account_warning_note": "小账户提示:净值 {portfolio_equity} 低于建议 {min_recommended_equity};{reason}",
106112
"small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "整数股和最小仓位限制可能导致实盘无法完全复现回测",
107113
"strategy_name_global_etf_rotation": "全球 ETF 轮动",
@@ -148,6 +154,7 @@
148154
"dry_run_snapshot_prices": "🧪 dry-run pricing: snapshot close for {count} symbols ({symbols})",
149155
"price_fallback_prices": "📌 execution pricing: recent close for {count} symbols ({symbols})",
150156
"target_diff_summary": "Target changes: {details}",
157+
"no_order_plan_reason": "No order submitted: {reason}",
151158
"trade_date_detail": "trade_date={value}",
152159
"target_diff": "target_diff {symbol}: current={current} target={target} delta={delta}",
153160
"pending_orders_detected": "pending_orders_detected profile={profile} symbols={symbols}",
@@ -211,6 +218,11 @@
211218
"paper_liquidation_only": "IBKR paper liquidation only",
212219
"paper_liquidation_status": "mode={mode} status={status}",
213220
"paper_liquidation_positions_seen": "positions_seen={count}",
221+
"signal_state_hold": "Trend Hold",
222+
"signal_state_entry": "Entry Signal",
223+
"signal_state_reduce": "Reduce Signal",
224+
"signal_state_exit": "Exit Signal",
225+
"signal_state_idle": "Idle",
214226
"small_account_warning_note": "small account warning: portfolio equity {portfolio_equity} is below recommended {min_recommended_equity}; {reason}",
215227
"small_account_warning_reason_integer_shares_min_position_value_may_prevent_backtest_replication": "integer-share minimum position sizing may prevent backtest replication",
216228
"strategy_name_global_etf_rotation": "Global ETF Rotation",

tests/test_rebalance_service.py

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import json
22

33
from application.rebalance_service import run_strategy_core
4-
from notifications.renderers import _build_notification_trade_lines, build_dashboard
4+
from notifications.renderers import (
5+
_build_notification_trade_lines,
6+
build_dashboard,
7+
render_trade_notification,
8+
)
59
from notifications.telegram import build_translator
610

711

@@ -46,6 +50,7 @@ def _build_test_translator():
4650
"same_day_execution_locked_notice": "same_day_execution_locked_notice {mode} {trade_date} {snapshot_date}",
4751
"dry_run_snapshot_prices": "dry_run_snapshot_prices count={count} symbols={symbols}",
4852
"price_fallback_prices": "price_fallback_prices count={count} symbols={symbols}",
53+
"no_order_plan_reason": "no_order_plan_reason {reason}",
4954
"dry_run_buy_batch": "dry_run_buy_batch count={count} details={details}",
5055
"dry_run_sell_batch": "dry_run_sell_batch count={count} details={details}",
5156
"submitted_buy_batch": "submitted_buy_batch count={count} details={details}",
@@ -146,7 +151,7 @@ def test_build_dashboard_localizes_qqq_tech_diagnostics_for_zh():
146151
assert "入选标的数=8 前排标的=CIEN(0.92)" in dashboard
147152

148153

149-
def test_notification_trade_lines_localize_runtime_diagnostic_tail_for_zh():
154+
def test_notification_trade_lines_suppress_runtime_diagnostic_tail_for_zh():
150155
lines = _build_notification_trade_lines(
151156
[
152157
(
@@ -158,15 +163,54 @@ def test_notification_trade_lines_localize_runtime_diagnostic_tail_for_zh():
158163
translator=build_translator("zh"),
159164
)
160165

161-
assert lines == [
162-
"执行配置=SOXL/SOXX 半导体趋势收益",
163-
"市场阶段=无",
164-
"宽度=0.0%",
165-
"目标股票仓位=0.0%",
166-
"实际股票仓位=0.0%",
167-
"快照日期=无",
168-
"交易日=无",
169-
]
166+
assert lines == []
167+
168+
169+
def test_notification_trade_lines_include_no_order_reason_for_small_account_zh():
170+
lines = _build_notification_trade_lines(
171+
[],
172+
execution_summary={"no_op_reason": "min_notional:BOXX,QQQ,TQQQ"},
173+
translator=build_translator("zh"),
174+
)
175+
176+
assert lines == ["未下单: 原因=低于最小订单金额:BOXX,QQQ,TQQQ"]
177+
178+
179+
def test_notification_trade_lines_include_no_order_reason_for_small_account_en():
180+
lines = _build_notification_trade_lines(
181+
[],
182+
execution_summary={"no_op_reason": "min_notional:BOXX,QQQ,TQQQ"},
183+
translator=build_translator("en"),
184+
)
185+
186+
assert lines == ["No order submitted: reason=min_notional:BOXX,QQQ,TQQQ"]
187+
188+
189+
def test_trade_notification_localizes_compact_signal_state_for_zh_and_en():
190+
common_kwargs = dict(
191+
dashboard="",
192+
strategy_dashboard="",
193+
trade_logs=[],
194+
execution_summary={},
195+
signal_desc="entry",
196+
status_desc="entry",
197+
status_icon="🐤",
198+
separator="---",
199+
strategy_display_name="TQQQ Growth Income",
200+
extra_notification_lines=(),
201+
)
202+
203+
zh_notification = render_trade_notification(
204+
**common_kwargs,
205+
translator=build_translator("zh"),
206+
)
207+
en_notification = render_trade_notification(
208+
**common_kwargs,
209+
translator=build_translator("en"),
210+
)
211+
212+
assert "🎯 入场信号" in zh_notification.compact_text
213+
assert "🎯 Entry Signal" in en_notification.compact_text
170214

171215

172216
def test_run_strategy_core_passes_signal_metadata_to_execution():
@@ -325,6 +369,41 @@ def disconnect(self):
325369
assert "目标差异" not in observed["messages"][0]
326370

327371

372+
def test_trade_notification_keeps_detailed_logs_out_of_compact_message():
373+
notification = render_trade_notification(
374+
dashboard="📌 Strategy portfolio\n - Total assets: $1,000.00",
375+
strategy_dashboard=(
376+
"📌 Strategy portfolio\n"
377+
" - Total assets: $1,000.00\n"
378+
"⏱ Timing: 2026-04-01 -> 2026-04-02 (next trading day)"
379+
),
380+
trade_logs=[
381+
(
382+
"execution_profile=tqqq_growth_income | regime=<none> | breadth=0.0% | "
383+
"target_stock=0.0% | realized_stock=0.0% | snapshot_as_of=<none> | trade_date=2026-04-02"
384+
)
385+
],
386+
execution_summary={"no_op_reason": "min_notional:QQQ,TQQQ"},
387+
signal_desc="entry | small account warning",
388+
status_desc="entry",
389+
status_icon="🐤",
390+
translator=_build_test_translator(),
391+
separator="---",
392+
strategy_display_name="TQQQ Growth Income",
393+
extra_notification_lines=(
394+
"🆔 Account: U18308207",
395+
"🧩 Plugin: Crisis Watch Notice | status: no crisis detected | notice: no action",
396+
),
397+
)
398+
399+
assert "execution_profile=tqqq_growth_income" in notification.detailed_text
400+
assert "execution_profile=tqqq_growth_income" not in notification.compact_text
401+
assert "📌 Strategy portfolio" in notification.compact_text
402+
assert "⏱ Timing: 2026-04-01 -> 2026-04-02 (next trading day)" in notification.compact_text
403+
assert "no_order_plan_reason reason=min_notional:QQQ,TQQQ" in notification.compact_text
404+
assert notification.compact_text.index("🆔 Account: U18308207") < notification.compact_text.index("🧩 Plugin:")
405+
406+
328407
def test_run_strategy_core_writes_reconciliation_record_under_strategy_dir(tmp_path):
329408
class FakeIB:
330409
def isConnected(self):

0 commit comments

Comments
 (0)