Skip to content

Commit 95dafb0

Browse files
committed
Simplify IBKR rebalance notifications
1 parent 6fdc28f commit 95dafb0

3 files changed

Lines changed: 265 additions & 61 deletions

File tree

application/rebalance_service.py

Lines changed: 143 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,141 @@ def _format_text(value, *, fallback: str) -> str:
1515
return text or fallback
1616

1717

18+
def _format_symbol_preview(symbols, *, limit: int = 3) -> str:
19+
normalized = [str(symbol).strip().upper() for symbol in symbols if str(symbol).strip()]
20+
if not normalized:
21+
return ""
22+
shown = normalized[:limit]
23+
remaining = len(normalized) - len(shown)
24+
if remaining > 0:
25+
shown.append(f"+{remaining}")
26+
return ",".join(shown)
27+
28+
29+
def _summarize_target_changes(target_vs_current, *, limit: int = 5) -> str | None:
30+
rows = []
31+
for row in target_vs_current or ():
32+
symbol = str(row.get("symbol") or "").strip().upper()
33+
if not symbol:
34+
continue
35+
delta = float(row.get("delta_weight") or 0.0)
36+
if abs(delta) < 0.001:
37+
continue
38+
rows.append((abs(delta), symbol, delta))
39+
if not rows:
40+
return None
41+
rows.sort(key=lambda item: (-item[0], item[1]))
42+
preview = [f"{symbol} {delta:+.1%}" for _abs_delta, symbol, delta in rows[:limit]]
43+
remaining = len(rows) - len(preview)
44+
if remaining > 0:
45+
preview.append(f"+{remaining}")
46+
return ", ".join(preview)
47+
48+
49+
def _summarize_orders(orders, *, limit: int = 3) -> str:
50+
preview = []
51+
for order in orders[:limit]:
52+
symbol = str(order.get("symbol") or "").strip().upper()
53+
quantity = int(order.get("quantity") or 0)
54+
if symbol and quantity > 0:
55+
preview.append(f"{symbol} {quantity}")
56+
elif symbol:
57+
preview.append(symbol)
58+
remaining = len(orders) - len(preview)
59+
if remaining > 0:
60+
preview.append(f"+{remaining}")
61+
return ", ".join(preview)
62+
63+
64+
def _build_order_batch_lines(execution_summary, *, translator) -> list[str]:
65+
mode = str(execution_summary.get("mode") or "").strip().lower()
66+
order_groups = [
67+
("orders_submitted", "dry_run" if mode == "dry_run" else "submitted"),
68+
("orders_filled", "filled"),
69+
("orders_partially_filled", "partial"),
70+
]
71+
lines: list[str] = []
72+
for field_name, prefix in order_groups:
73+
orders = list(execution_summary.get(field_name) or [])
74+
if not orders:
75+
continue
76+
buy_orders = [order for order in orders if str(order.get("side") or "").strip().lower() == "buy"]
77+
sell_orders = [order for order in orders if str(order.get("side") or "").strip().lower() == "sell"]
78+
if buy_orders:
79+
lines.append(
80+
translator(
81+
f"{prefix}_buy_batch",
82+
count=len(buy_orders),
83+
details=_summarize_orders(buy_orders),
84+
)
85+
)
86+
if sell_orders:
87+
lines.append(
88+
translator(
89+
f"{prefix}_sell_batch",
90+
count=len(sell_orders),
91+
details=_summarize_orders(sell_orders),
92+
)
93+
)
94+
return lines
95+
96+
97+
def _build_notification_trade_lines(
98+
trade_logs,
99+
*,
100+
execution_summary,
101+
translator,
102+
) -> list[str]:
103+
lines: list[str] = []
104+
execution_summary = dict(execution_summary or {})
105+
106+
no_op_reason = str(execution_summary.get("no_op_reason") or "").strip()
107+
if no_op_reason.startswith("same_day_execution_locked:"):
108+
lines.append(
109+
translator(
110+
"same_day_execution_locked_notice",
111+
mode=_format_text(execution_summary.get("mode"), fallback="<none>"),
112+
trade_date=_format_text(execution_summary.get("trade_date"), fallback="<none>"),
113+
snapshot_date=_format_text(execution_summary.get("snapshot_as_of"), fallback="<none>"),
114+
)
115+
)
116+
117+
fallback_symbols = tuple(execution_summary.get("snapshot_price_fallback_symbols") or ())
118+
if execution_summary.get("snapshot_price_fallback_used") and fallback_symbols:
119+
lines.append(
120+
translator(
121+
"dry_run_snapshot_prices",
122+
count=len(fallback_symbols),
123+
symbols=_format_symbol_preview(fallback_symbols),
124+
)
125+
)
126+
127+
target_change_summary = _summarize_target_changes(execution_summary.get("target_vs_current"))
128+
if target_change_summary:
129+
lines.append(translator("target_diff_summary", details=target_change_summary))
130+
131+
lines.extend(_build_order_batch_lines(execution_summary, translator=translator))
132+
133+
for raw_line in trade_logs or ():
134+
text = str(raw_line).strip()
135+
if not text:
136+
continue
137+
if text.startswith(("目标差异 ", "target_diff ", "DRY_RUN buy ", "DRY_RUN sell ")):
138+
continue
139+
if text.startswith(("🧪 dry-run估价:", "🧪 dry-run pricing:")):
140+
continue
141+
if "execution_lock_acquired" in text or "已获取执行锁" in text:
142+
continue
143+
if text.startswith(("profile=", "strategy_profile=", "策略=")):
144+
continue
145+
if "same_day_execution_locked" in text or "当日执行锁已存在" in text:
146+
continue
147+
if text not in lines:
148+
lines.append(text)
149+
150+
return lines
151+
152+
18153
def build_dashboard(
19154
positions,
20155
account_values,
@@ -48,12 +183,7 @@ def build_dashboard(
48183
target_stock_weight = signal_metadata.get("target_stock_weight")
49184
realized_stock_weight = signal_metadata.get("realized_stock_weight")
50185
safe_haven_weight = signal_metadata.get("safe_haven_weight")
51-
config_source = signal_metadata.get("strategy_config_source")
52186
snapshot_as_of = signal_metadata.get("snapshot_as_of")
53-
snapshot_path = signal_metadata.get("feature_snapshot_path") or signal_metadata.get("snapshot_path")
54-
snapshot_age_days = signal_metadata.get("snapshot_age_days")
55-
snapshot_file_timestamp = signal_metadata.get("snapshot_file_timestamp")
56-
snapshot_decision = signal_metadata.get("snapshot_guard_decision")
57187
diagnostics = [
58188
translator("strategy_profile_detail", profile=_format_text(strategy_profile, fallback="<unknown>")),
59189
translator("regime_detail", value=_format_text(regime, fallback="<none>")) if regime is not None else None,
@@ -63,22 +193,13 @@ def build_dashboard(
63193
else None,
64194
translator("realized_stock_detail", value=f"{realized_stock_weight:.1%}")
65195
if isinstance(realized_stock_weight, (int, float))
196+
and isinstance(target_stock_weight, (int, float))
197+
and abs(float(realized_stock_weight) - float(target_stock_weight)) >= 0.01
66198
else None,
67199
translator("safe_haven_target_detail", value=f"{safe_haven_weight:.1%}")
68200
if isinstance(safe_haven_weight, (int, float))
69201
else None,
70-
translator("snapshot_decision_detail", value=_format_text(snapshot_decision, fallback="<none>"))
71-
if snapshot_decision
72-
else None,
73202
translator("snapshot_as_of_detail", value=_format_text(snapshot_as_of, fallback="<none>")) if snapshot_as_of else None,
74-
translator("snapshot_age_days_detail", value=_format_text(snapshot_age_days, fallback="<none>"))
75-
if isinstance(snapshot_age_days, (int, float))
76-
else None,
77-
translator("snapshot_file_ts_detail", value=_format_text(snapshot_file_timestamp, fallback="<none>"))
78-
if snapshot_file_timestamp
79-
else None,
80-
translator("snapshot_path_detail", value=_format_text(snapshot_path, fallback="<none>")) if snapshot_path else None,
81-
translator("config_source_detail", value=_format_text(config_source, fallback="<none>")) if config_source else None,
82203
]
83204
diagnostics_text = " | ".join(part for part in diagnostics if part)
84205
return (
@@ -213,7 +334,12 @@ def run_strategy_core(
213334
flush=True,
214335
)
215336
if trade_logs:
216-
trade_lines = "\n".join(trade_logs)
337+
notification_trade_lines = _build_notification_trade_lines(
338+
trade_logs,
339+
execution_summary=execution_summary,
340+
translator=translator,
341+
)
342+
trade_lines = "\n".join(notification_trade_lines)
217343
message = (
218344
f"{translator('rebalance_title')}\n"
219345
f"{dashboard}\n"

notifications/telegram.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,22 @@
2828
"snapshot_path_detail": "快照路径={value}",
2929
"config_source_detail": "配置来源={value}",
3030
"dry_run_snapshot_prices": "🧪 dry-run估价: 使用快照收盘价 {count}个标的 ({symbols})",
31+
"target_diff_summary": "调仓变化: {details}",
3132
"trade_date_detail": "交易日={value}",
3233
"target_diff": "目标差异 {symbol}: 当前={current} 目标={target} 变化={delta}",
3334
"pending_orders_detected": "检测到未完成订单: profile={profile} symbols={symbols}",
3435
"same_day_fills_detected": "检测到当日成交: profile={profile} mode={mode} symbols={symbols} trade_date={trade_date}",
3536
"same_day_execution_locked": "当日执行锁已存在: profile={profile} mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} target_hash={target_hash} lock_path={lock_path}",
3637
"execution_lock_acquired": "已获取执行锁: mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} lock_path={lock_path}",
38+
"same_day_execution_locked_notice": "当日已执行过: mode={mode} | 交易日={trade_date} | 快照日期={snapshot_date}",
39+
"dry_run_buy_batch": "🧪 dry-run买入 {count}个标的: {details}",
40+
"dry_run_sell_batch": "🧪 dry-run卖出 {count}个标的: {details}",
41+
"submitted_buy_batch": "📈 已提交买单 {count}个标的: {details}",
42+
"submitted_sell_batch": "📉 已提交卖单 {count}个标的: {details}",
43+
"filled_buy_batch": "✅ 买单成交 {count}个标的: {details}",
44+
"filled_sell_batch": "✅ 卖单成交 {count}个标的: {details}",
45+
"partial_buy_batch": "⚠️ 买单部分成交 {count}个标的: {details}",
46+
"partial_sell_batch": "⚠️ 卖单部分成交 {count}个标的: {details}",
3747
"no_equity": "❌ 无净值",
3848
"signal_label": "信号",
3949
"no_trades": "✅ 无需调仓",
@@ -73,12 +83,22 @@
7383
"snapshot_path_detail": "snapshot_path={value}",
7484
"config_source_detail": "config_source={value}",
7585
"dry_run_snapshot_prices": "🧪 dry-run pricing: snapshot close for {count} symbols ({symbols})",
86+
"target_diff_summary": "Target changes: {details}",
7687
"trade_date_detail": "trade_date={value}",
7788
"target_diff": "target_diff {symbol}: current={current} target={target} delta={delta}",
7889
"pending_orders_detected": "pending_orders_detected profile={profile} symbols={symbols}",
7990
"same_day_fills_detected": "same_day_fills_detected profile={profile} mode={mode} symbols={symbols} trade_date={trade_date}",
8091
"same_day_execution_locked": "same_day_execution_locked profile={profile} mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} target_hash={target_hash} lock_path={lock_path}",
8192
"execution_lock_acquired": "execution_lock_acquired mode={mode} trade_date={trade_date} snapshot_date={snapshot_date} lock_path={lock_path}",
93+
"same_day_execution_locked_notice": "same-day execution already exists: mode={mode} trade_date={trade_date} snapshot_date={snapshot_date}",
94+
"dry_run_buy_batch": "🧪 dry-run buys for {count} symbols: {details}",
95+
"dry_run_sell_batch": "🧪 dry-run sells for {count} symbols: {details}",
96+
"submitted_buy_batch": "📈 Submitted buy orders for {count} symbols: {details}",
97+
"submitted_sell_batch": "📉 Submitted sell orders for {count} symbols: {details}",
98+
"filled_buy_batch": "✅ Filled buy orders for {count} symbols: {details}",
99+
"filled_sell_batch": "✅ Filled sell orders for {count} symbols: {details}",
100+
"partial_buy_batch": "⚠️ Partial buy fills for {count} symbols: {details}",
101+
"partial_sell_batch": "⚠️ Partial sell fills for {count} symbols: {details}",
82102
"no_equity": "❌ No equity",
83103
"signal_label": "Signal",
84104
"no_trades": "✅ No rebalance needed",

0 commit comments

Comments
 (0)