Skip to content

Commit 182e838

Browse files
committed
Unify Telegram cycle summaries
1 parent 1f93cd6 commit 182e838

2 files changed

Lines changed: 429 additions & 38 deletions

File tree

notifications/telegram.py

Lines changed: 326 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,106 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Mapping
5+
from collections.abc import Callable, Mapping
66
from typing import Any
77

88

9+
SEPARATOR = "━━━━━━━━━━━━━━━━━━"
10+
11+
12+
I18N = {
13+
"zh": {
14+
"rebalance_title": "🔔 【调仓指令】",
15+
"heartbeat_title": "💓 【心跳检测】",
16+
"strategy_label": "🧭 策略: {name}",
17+
"account_label": "🆔 账户: {account}",
18+
"dry_run_banner": "🧪 模拟运行,本轮不提交真实订单",
19+
"account_overview_title": "📌 策略账户概览",
20+
"total_assets": "总资产(策略标的+现金)",
21+
"buying_power": "购买力",
22+
"reserved_cash": "预留现金",
23+
"investable_cash": "可投资现金",
24+
"holdings_title": "💼 策略持仓",
25+
"holding_line": "{symbol}: {market_value} / {quantity}",
26+
"quantity_shares": "{quantity}股",
27+
"same_trading_day": "当日执行",
28+
"next_trading_day": "次一交易日执行",
29+
"next_n_trading_days": "{count}个交易日后执行",
30+
"timing_line": "⏱ 执行时点: {value}",
31+
"market_status_line": "📊 市场状态: {status}",
32+
"signal_line": "🎯 信号: {signal}",
33+
"target_diff_summary": "调仓变化: {details}",
34+
"dry_run_buy_order": "🧪 模拟买单: {symbol} {quantity}",
35+
"dry_run_sell_order": "🧪 模拟卖单: {symbol} {quantity}",
36+
"submitted_buy_order": "已提交买单: {symbol} {quantity}",
37+
"submitted_sell_order": "已提交卖单: {symbol} {quantity}",
38+
"no_order_submitted": "未下单: 原因={reason}",
39+
"no_rebalance_needed": "✅ 无需调仓",
40+
"no_executable_orders": "无可执行订单",
41+
"signal_state_hold": "趋势持有",
42+
"signal_state_entry": "入场信号",
43+
"signal_state_reduce": "减仓信号",
44+
"signal_state_exit": "离场信号",
45+
"signal_state_idle": "等待信号",
46+
"skip_reason_below_trade_threshold": "低于调仓阈值",
47+
"skip_reason_quote_unavailable": "无法获取报价",
48+
"skip_reason_sell_quantity_zero": "卖出股数为0",
49+
"skip_reason_buy_quantity_zero": "买入股数为0",
50+
"skip_reason_unknown": "未知原因",
51+
},
52+
"en": {
53+
"rebalance_title": "🔔 【Rebalance Instruction】",
54+
"heartbeat_title": "💓 【Heartbeat】",
55+
"strategy_label": "🧭 Strategy: {name}",
56+
"account_label": "🆔 Account: {account}",
57+
"dry_run_banner": "🧪 Dry run only; no live orders submitted",
58+
"account_overview_title": "📌 Strategy Account",
59+
"total_assets": "Total assets",
60+
"buying_power": "Buying power",
61+
"reserved_cash": "Reserved cash",
62+
"investable_cash": "Investable cash",
63+
"holdings_title": "💼 Strategy Holdings",
64+
"holding_line": "{symbol}: {market_value} / {quantity}",
65+
"quantity_shares": "{quantity} shares",
66+
"same_trading_day": "same trading day",
67+
"next_trading_day": "next trading day",
68+
"next_n_trading_days": "next {count} trading days",
69+
"timing_line": "⏱ Timing: {value}",
70+
"market_status_line": "📊 Market: {status}",
71+
"signal_line": "🎯 Signal: {signal}",
72+
"target_diff_summary": "Target changes: {details}",
73+
"dry_run_buy_order": "🧪 Dry-run buy: {symbol} {quantity}",
74+
"dry_run_sell_order": "🧪 Dry-run sell: {symbol} {quantity}",
75+
"submitted_buy_order": "Submitted buy: {symbol} {quantity}",
76+
"submitted_sell_order": "Submitted sell: {symbol} {quantity}",
77+
"no_order_submitted": "No order submitted: reason={reason}",
78+
"no_rebalance_needed": "✅ No rebalance needed",
79+
"no_executable_orders": "no executable orders",
80+
"signal_state_hold": "Trend Hold",
81+
"signal_state_entry": "Entry Signal",
82+
"signal_state_reduce": "Reduce Signal",
83+
"signal_state_exit": "Exit Signal",
84+
"signal_state_idle": "Idle",
85+
"skip_reason_below_trade_threshold": "below trade threshold",
86+
"skip_reason_quote_unavailable": "quote unavailable",
87+
"skip_reason_sell_quantity_zero": "sell quantity rounds to 0",
88+
"skip_reason_buy_quantity_zero": "buy quantity rounds to 0",
89+
"skip_reason_unknown": "unknown reason",
90+
},
91+
}
92+
93+
94+
def build_translator(lang: str | None) -> Callable[..., str]:
95+
normalized = str(lang or "").lower()
96+
active_lang = "zh" if normalized.startswith("zh") else "en"
97+
98+
def translate(key: str, **kwargs) -> str:
99+
template = I18N[active_lang].get(key, I18N["en"].get(key, key))
100+
return template.format(**kwargs) if kwargs else template
101+
102+
return translate
103+
104+
9105
def build_sender(token: str | None, chat_id: str | None, *, requests_module=None):
10106
if requests_module is None:
11107
import requests as requests_module
@@ -22,46 +118,240 @@ def send_tg_message(message: str) -> None:
22118
return send_tg_message
23119

24120

121+
def _safe_float(value: Any) -> float | None:
122+
if value in (None, ""):
123+
return None
124+
try:
125+
return float(str(value).replace(",", ""))
126+
except (TypeError, ValueError):
127+
return None
128+
129+
130+
def _format_money(value: Any) -> str:
131+
number = _safe_float(value)
132+
return "$0.00" if number is None else f"${number:,.2f}"
133+
134+
135+
def _format_quantity(value: Any) -> str:
136+
number = _safe_float(value)
137+
if number is None:
138+
return "0"
139+
if float(number).is_integer():
140+
return str(int(number))
141+
return f"{number:g}"
142+
143+
144+
def _format_shares(value: Any, *, translator: Callable[..., str]) -> str:
145+
return translator("quantity_shares", quantity=_format_quantity(value))
146+
147+
148+
def _format_dashboard_lines(
149+
portfolio: Mapping[str, Any],
150+
execution: Mapping[str, Any],
151+
*,
152+
translator: Callable[..., str],
153+
) -> list[str]:
154+
dashboard_text = str(execution.get("dashboard_text") or "").strip()
155+
if dashboard_text:
156+
return [line.rstrip() for line in dashboard_text.splitlines() if line.strip()]
157+
158+
lines = [translator("account_overview_title")]
159+
total_equity = _safe_float(portfolio.get("total_equity"))
160+
if total_equity is not None:
161+
lines.append(f" - {translator('total_assets')}: {_format_money(total_equity)}")
162+
buying_power = _safe_float(portfolio.get("liquid_cash"))
163+
if buying_power is not None:
164+
lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}")
165+
reserved_cash = _safe_float(execution.get("reserved_cash"))
166+
if reserved_cash is not None:
167+
lines.append(f" - {translator('reserved_cash')}: {_format_money(reserved_cash)}")
168+
investable_cash = _safe_float(execution.get("investable_cash"))
169+
if investable_cash is not None:
170+
lines.append(f" - {translator('investable_cash')}: {_format_money(investable_cash)}")
171+
172+
market_values = {
173+
str(symbol).upper(): float(value or 0.0)
174+
for symbol, value in dict(portfolio.get("market_values") or {}).items()
175+
}
176+
quantities = {
177+
str(symbol).upper(): value
178+
for symbol, value in dict(portfolio.get("quantities") or {}).items()
179+
}
180+
portfolio_rows = tuple(portfolio.get("portfolio_rows") or ())
181+
symbols: list[str] = []
182+
for row in portfolio_rows:
183+
if isinstance(row, (list, tuple)):
184+
symbols.extend(str(symbol).upper() for symbol in row)
185+
elif row:
186+
symbols.append(str(row).upper())
187+
if not symbols:
188+
symbols = sorted(market_values)
189+
if symbols:
190+
lines.append(translator("holdings_title"))
191+
for symbol in symbols:
192+
lines.append(
193+
" - "
194+
+ translator(
195+
"holding_line",
196+
symbol=symbol,
197+
market_value=_format_money(market_values.get(symbol, 0.0)),
198+
quantity=_format_shares(quantities.get(symbol, 0), translator=translator),
199+
)
200+
)
201+
return lines
202+
203+
204+
def _localize_timing_contract(contract: Any, *, translator: Callable[..., str]) -> str:
205+
value = str(contract or "").strip()
206+
if value == "same_trading_day":
207+
return translator("same_trading_day")
208+
if value == "next_trading_day":
209+
return translator("next_trading_day")
210+
if value.startswith("next_") and value.endswith("_trading_days"):
211+
count_text = value.removeprefix("next_").removesuffix("_trading_days")
212+
if count_text.isdigit():
213+
return translator("next_n_trading_days", count=int(count_text))
214+
return value
215+
216+
217+
def _format_timing_lines(execution: Mapping[str, Any], *, translator: Callable[..., str]) -> list[str]:
218+
signal_date = str(execution.get("signal_date") or "").strip()
219+
effective_date = str(execution.get("effective_date") or "").strip()
220+
contract = _localize_timing_contract(execution.get("execution_timing_contract"), translator=translator)
221+
if not signal_date and not effective_date and not contract:
222+
return []
223+
if signal_date and effective_date:
224+
value = f"{signal_date} -> {effective_date}"
225+
else:
226+
value = signal_date or effective_date or contract
227+
if contract and contract not in value:
228+
value = f"{value} ({contract})"
229+
return [translator("timing_line", value=value)]
230+
231+
232+
def _first_summary(value: Any, *, translator: Callable[..., str]) -> str:
233+
text = str(value or "").strip()
234+
if not text:
235+
return ""
236+
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
240+
241+
242+
def _format_signal_lines(execution: Mapping[str, Any], *, translator: Callable[..., str]) -> list[str]:
243+
status = _first_summary(execution.get("status_display"), translator=translator)
244+
signal = _first_summary(execution.get("signal_display"), translator=translator)
245+
lines = []
246+
if status and status != signal:
247+
lines.append(translator("market_status_line", status=status))
248+
if signal:
249+
lines.append(translator("signal_line", signal=signal))
250+
return lines
251+
252+
253+
def _format_target_diff_lines(
254+
allocation: Mapping[str, Any],
255+
portfolio: Mapping[str, Any],
256+
*,
257+
translator: Callable[..., str],
258+
) -> list[str]:
259+
targets = {
260+
str(symbol).upper(): float(value or 0.0)
261+
for symbol, value in dict(allocation.get("targets") or {}).items()
262+
}
263+
market_values = {
264+
str(symbol).upper(): float(value or 0.0)
265+
for symbol, value in dict(portfolio.get("market_values") or {}).items()
266+
}
267+
details = []
268+
for symbol in sorted(set(targets) | set(market_values)):
269+
delta = targets.get(symbol, 0.0) - market_values.get(symbol, 0.0)
270+
if abs(delta) < 0.005:
271+
continue
272+
details.append(f"{symbol} {delta:+,.2f} USD")
273+
if not details:
274+
return []
275+
return [translator("target_diff_summary", details=", ".join(details))]
276+
277+
278+
def _format_order_lines(
279+
submitted: list[Mapping[str, Any]],
280+
*,
281+
dry_run_only: bool,
282+
translator: Callable[..., str],
283+
) -> list[str]:
284+
lines = []
285+
for order in submitted:
286+
side = str(order.get("side") or "").lower()
287+
symbol = str(order.get("symbol") or "").upper()
288+
side_key = "buy" if side == "buy" else "sell"
289+
mode_key = "dry_run" if dry_run_only else "submitted"
290+
lines.append(
291+
translator(
292+
f"{mode_key}_{side_key}_order",
293+
symbol=symbol,
294+
quantity=_format_shares(order.get("quantity"), translator=translator),
295+
)
296+
)
297+
return lines
298+
299+
300+
def _format_skipped_reason(skipped: list[Mapping[str, Any]], *, translator: Callable[..., str]) -> str:
301+
grouped: dict[str, list[str]] = {}
302+
for item in skipped:
303+
raw_reason = str(item.get("reason") or "unknown")
304+
key = f"skip_reason_{raw_reason}"
305+
reason = translator(key)
306+
if reason == key:
307+
reason = raw_reason or translator("skip_reason_unknown")
308+
symbol = str(item.get("symbol") or "").upper()
309+
grouped.setdefault(reason, [])
310+
if symbol:
311+
grouped[reason].append(symbol)
312+
parts = []
313+
for reason, symbols in grouped.items():
314+
parts.append(f"{reason}:{','.join(symbols)}" if symbols else reason)
315+
return ", ".join(parts) if parts else translator("no_executable_orders")
316+
317+
25318
def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
26-
use_zh = str(lang or "").lower().startswith("zh")
319+
translator = build_translator(lang)
27320
submitted = list(result.get("submitted_orders") or ())
28321
skipped = list(result.get("skipped_orders") or ())
29322
execution = dict(result.get("execution") or {})
30323
allocation = dict(result.get("allocation") or {})
31-
header = "Firstrade 策略运行" if use_zh else "Firstrade Strategy Cycle"
32-
dry_run_label = "模拟" if use_zh else "dry-run"
33-
live_label = "实盘" if use_zh else "live"
34-
no_trade = "无需调仓" if use_zh else "no rebalance needed"
35-
submitted_label = "订单" if use_zh else "orders"
36-
skipped_label = "跳过" if use_zh else "skipped"
37-
mode = dry_run_label if result.get("dry_run_only") else live_label
38-
lines = [
39-
header,
40-
f"mode: {mode}",
41-
f"profile: {result.get('strategy_profile')}",
42-
f"account: {result.get('account')}",
43-
]
44-
signal = execution.get("signal_display")
45-
status = execution.get("status_display")
46-
if status:
47-
lines.append(f"status: {status}")
48-
if signal:
49-
lines.append(f"signal: {signal}")
50-
targets = dict(allocation.get("targets") or {})
51-
if targets:
52-
target_text = ", ".join(
53-
f"{symbol}=${float(value):,.2f}"
54-
for symbol, value in sorted(targets.items())
55-
)
56-
lines.append(f"targets: {target_text}")
324+
portfolio = dict(result.get("portfolio") or {})
325+
dry_run_only = bool(result.get("dry_run_only"))
326+
target_diff_lines = _format_target_diff_lines(allocation, portfolio, translator=translator)
327+
has_meaningful_skip = any(
328+
str(item.get("reason") or "") != "below_trade_threshold"
329+
for item in skipped
330+
)
331+
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()
333+
account = str(result.get("account") or "").strip()
334+
lines = [translator("rebalance_title" if has_rebalance_attempt else "heartbeat_title")]
335+
if strategy_name:
336+
lines.append(translator("strategy_label", name=strategy_name))
337+
if account:
338+
lines.append(translator("account_label", account=account))
339+
if dry_run_only:
340+
lines.append(translator("dry_run_banner"))
341+
342+
dashboard_lines = _format_dashboard_lines(portfolio, execution, translator=translator)
343+
if dashboard_lines:
344+
lines.append(SEPARATOR)
345+
lines.extend(dashboard_lines)
346+
lines.extend(_format_timing_lines(execution, translator=translator))
347+
lines.extend(_format_signal_lines(execution, translator=translator))
348+
lines.append(SEPARATOR)
349+
lines.extend(target_diff_lines)
57350
if submitted:
58-
order_text = ", ".join(
59-
f"{order.get('side')} {order.get('symbol')} x{order.get('quantity')}"
60-
for order in submitted
61-
)
62-
lines.append(f"{submitted_label}: {order_text}")
351+
lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator))
352+
elif skipped and has_rebalance_attempt:
353+
reason = _format_skipped_reason(skipped, translator=translator)
354+
lines.append(translator("no_order_submitted", reason=reason))
63355
else:
64-
lines.append(no_trade)
65-
if skipped:
66-
lines.append(f"{skipped_label}: {len(skipped)}")
356+
lines.append(translator("no_rebalance_needed"))
67357
return "\n".join(str(line) for line in lines if str(line).strip())

0 commit comments

Comments
 (0)