22
33from __future__ import annotations
44
5- from collections .abc import Mapping
5+ from collections .abc import Callable , Mapping
66from 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+
9105def 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+
25318def 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