Skip to content

Commit 45628a5

Browse files
committed
Polish LongBridge hybrid notifications
1 parent 65893cb commit 45628a5

8 files changed

Lines changed: 198 additions & 51 deletions

File tree

application/rebalance_service.py

Lines changed: 92 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,60 @@ def _plan_allocation(plan):
1919
return dict(plan.get("allocation") or {})
2020

2121

22+
def _has_text(value):
23+
return bool(str(value or "").strip())
24+
25+
26+
def _has_benchmark_context(execution):
27+
return any(
28+
float(execution.get(key) or 0.0) > 0.0
29+
for key in ("benchmark_price", "long_trend_value", "exit_line")
30+
)
31+
32+
33+
def _build_benchmark_line(execution):
34+
if not _has_benchmark_context(execution):
35+
return None
36+
benchmark_symbol = str(execution.get("benchmark_symbol") or "QQQ")
37+
benchmark_price = float(execution.get("benchmark_price") or 0.0)
38+
long_trend_value = float(execution.get("long_trend_value") or 0.0)
39+
exit_line = float(execution.get("exit_line") or 0.0)
40+
return (
41+
f"{benchmark_symbol}: {benchmark_price:.2f} | "
42+
f"MA200: {long_trend_value:.2f} | Exit: {exit_line:.2f}"
43+
)
44+
45+
46+
def _append_status_lines(lines, *, execution, translator, signal_key):
47+
status_display = str(execution.get("status_display") or "").strip()
48+
if status_display:
49+
lines.append(translator("market_status", status=status_display))
50+
51+
deploy_ratio_text = str(execution.get("deploy_ratio_text") or "").strip()
52+
if deploy_ratio_text:
53+
lines.append(translator("risk_position", ratio=deploy_ratio_text))
54+
55+
income_ratio_text = str(execution.get("income_ratio_text") or "").strip()
56+
if income_ratio_text:
57+
lines.append(translator("income_target", ratio=income_ratio_text))
58+
59+
income_locked_ratio_text = str(execution.get("income_locked_ratio_text") or "").strip()
60+
if income_locked_ratio_text:
61+
lines.append(translator("income_locked", ratio=income_locked_ratio_text))
62+
63+
signal_display = str(execution.get("signal_display") or "").strip()
64+
if signal_display:
65+
lines.append(translator(signal_key, msg=signal_display))
66+
67+
benchmark_line = _build_benchmark_line(execution)
68+
if benchmark_line:
69+
lines.append(benchmark_line)
70+
71+
dashboard_text = str(execution.get("dashboard_text") or "").strip()
72+
if dashboard_text:
73+
lines.append(dashboard_text)
74+
75+
2276
def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail):
2377
message = translator(kind, detail=detail)
2478
skip_logs.append(message)
@@ -104,12 +158,6 @@ def run_strategy(
104158
investable_cash = float(execution["investable_cash"])
105159
current_min_trade = float(execution["current_min_trade"])
106160
portfolio_rows = tuple(portfolio["portfolio_rows"])
107-
market_status = str(execution["status_display"])
108-
signal_message = str(execution["signal_display"])
109-
deploy_ratio_text = str(execution["deploy_ratio_text"])
110-
income_ratio_text = str(execution["income_ratio_text"])
111-
income_locked_ratio_text = str(execution["income_locked_ratio_text"])
112-
113161
def record_dry_run(symbol, side, quantity, price, *, order_type):
114162
price_text = f"${price:.2f}" if price is not None else "market"
115163
suffix = " LIMIT" if order_type == "limit" else ""
@@ -216,12 +264,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
216264
investable_cash = float(execution["investable_cash"])
217265
current_min_trade = float(execution["current_min_trade"])
218266
portfolio_rows = tuple(portfolio["portfolio_rows"])
219-
market_status = str(execution["status_display"])
220-
signal_message = str(execution["signal_display"])
221-
deploy_ratio_text = str(execution["deploy_ratio_text"])
222-
income_ratio_text = str(execution["income_ratio_text"])
223-
income_locked_ratio_text = str(execution["income_locked_ratio_text"])
224-
225267
buy_candidates = [
226268
symbol
227269
for symbol in strategy_assets
@@ -339,22 +381,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
339381
tg_lines = [translator("rebalance_title")]
340382
if dry_run_only:
341383
tg_lines.append(translator("dry_run_banner"))
342-
tg_lines.extend(
343-
[
344-
translator("market_status", status=market_status),
345-
cash_summary,
346-
translator("risk_position", ratio=deploy_ratio_text),
347-
translator("income_target", ratio=income_ratio_text),
348-
translator("income_locked", ratio=income_locked_ratio_text),
349-
translator("signal", msg=signal_message),
350-
separator,
351-
formatted_logs,
352-
]
384+
tg_lines.append(cash_summary)
385+
_append_status_lines(
386+
tg_lines,
387+
execution=execution,
388+
translator=translator,
389+
signal_key="signal",
353390
)
391+
tg_lines.extend([separator, formatted_logs])
354392
tg_message = "\n".join(tg_lines)
355393
send_tg_message(tg_message)
356394
else:
357-
cash_label = translator("cash_label")
358395
equity_text = f"{total_strategy_equity:,.2f}"
359396
cash_summary = translator(
360397
"cash_summary",
@@ -363,33 +400,39 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
363400
)
364401
holdings_lines = []
365402
for row in portfolio_rows:
366-
if len(row) == 1:
367-
symbol = row[0]
368-
holdings_lines.append(
369-
f"{symbol}: ${market_values[symbol]:,.2f} {cash_label}: ${available_cash:,.2f}"
370-
)
371-
else:
372-
holdings_lines.append(
373-
" ".join(
374-
f"{symbol}: ${market_values[symbol]:,.2f}"
375-
for symbol in row
376-
)
403+
holdings_lines.append(
404+
" ".join(
405+
f"{symbol}: ${market_values[symbol]:,.2f}"
406+
for symbol in row
377407
)
378-
no_trade_message = (
379-
f"{translator('heartbeat_title')}\n"
380-
f"{translator('market_status', status=market_status)}\n"
381-
f"{translator('equity', value=equity_text)}\n"
382-
f"{cash_summary}\n"
383-
f"{separator}\n"
384-
+ "\n".join(holdings_lines) + "\n"
385-
f"{separator}\n"
386-
f"{translator('risk_position', ratio=deploy_ratio_text)}\n"
387-
f"{translator('income_target', ratio=income_ratio_text)}\n"
388-
f"{translator('income_locked', ratio=income_locked_ratio_text)}\n"
389-
f"{translator('heartbeat_signal', msg=signal_message)}\n"
390-
f"{separator}\n"
391-
f"{translator('no_executable_orders') if (skip_logs or note_logs) else translator('no_trades')}"
408+
)
409+
no_trade_lines = [
410+
translator("heartbeat_title"),
411+
translator("equity", value=equity_text),
412+
]
413+
if dry_run_only:
414+
no_trade_lines.append(translator("dry_run_banner"))
415+
no_trade_lines.extend(
416+
[
417+
cash_summary,
418+
separator,
419+
*holdings_lines,
420+
separator,
421+
]
422+
)
423+
_append_status_lines(
424+
no_trade_lines,
425+
execution=execution,
426+
translator=translator,
427+
signal_key="heartbeat_signal",
428+
)
429+
no_trade_lines.extend(
430+
[
431+
separator,
432+
translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades"),
433+
]
392434
)
435+
no_trade_message = "\n".join(no_trade_lines)
393436
if skip_logs:
394437
no_trade_message += (
395438
f"\n{separator}\n"

decision_mapper.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ def map_strategy_decision_to_plan(
7171
"trade_threshold_value",
7272
"signal_display",
7373
"status_display",
74+
"dashboard_text",
75+
"benchmark_symbol",
76+
"benchmark_price",
77+
"long_trend_value",
78+
"exit_line",
7479
"deploy_ratio_text",
7580
"income_ratio_text",
7681
"income_locked_ratio_text",
@@ -81,6 +86,7 @@ def map_strategy_decision_to_plan(
8186
execution_defaults={
8287
"signal_display": "",
8388
"status_display": "",
89+
"dashboard_text": "",
8490
"deploy_ratio_text": "",
8591
"income_ratio_text": "",
8692
"income_locked_ratio_text": "",

main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
build_issue_notifier,
2828
build_prefixer,
2929
build_sender,
30+
build_signal_text,
3031
build_translator,
3132
)
3233
from quant_platform_kit.common.runtime_reports import (
@@ -104,6 +105,9 @@ def get_project_id():
104105
def t(key, **kwargs):
105106
return build_translator(NOTIFY_LANG)(key, **kwargs)
106107

108+
109+
signal_text = build_signal_text(t)
110+
107111
def with_prefix(message: str) -> str:
108112
return build_prefixer(ACCOUNT_PREFIX, SERVICE_NAME)(message)
109113

@@ -317,7 +321,10 @@ def resolve_rebalance_plan(*, indicators, account_state):
317321
snapshot = build_portfolio_snapshot_from_account_state(account_state)
318322
evaluation_inputs["snapshot"] = snapshot
319323

320-
evaluation = STRATEGY_RUNTIME.evaluate(**evaluation_inputs)
324+
evaluation = STRATEGY_RUNTIME.evaluate(
325+
signal_text_fn=signal_text,
326+
**evaluation_inputs,
327+
)
321328
return map_strategy_decision_to_plan(
322329
evaluation.decision,
323330
account_state=account_state if "account_state" in AVAILABLE_INPUTS else None,

notifications/telegram.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
import requests
66

77

8+
SIGNAL_ICONS = {
9+
"hold": "💎",
10+
"entry": "🚀",
11+
"reduce": "⚠️",
12+
"exit": "🔴",
13+
"idle": "💤",
14+
}
15+
16+
817
I18N = {
918
"zh": {
1019
"rebalance_title": "🔔 【调仓指令】",
@@ -44,6 +53,11 @@
4453
"status_expired": "过期",
4554
"signal_risk_on": "SOXL 站上 {window} 日均线,持有 SOXL,交易层风险仓位 {ratio}",
4655
"signal_delever": "SOXL 跌破 {window} 日均线,切换至 SOXX,交易层风险仓位 {ratio}",
56+
"signal_hold": "趋势持有",
57+
"signal_entry": "入场信号",
58+
"signal_reduce": "减仓信号",
59+
"signal_exit": "离场信号",
60+
"signal_idle": "等待信号",
4761
},
4862
"en": {
4963
"rebalance_title": "🔔 【Trade Execution Report】",
@@ -83,6 +97,11 @@
8397
"status_expired": "Expired",
8498
"signal_risk_on": "SOXL above {window}d MA, hold SOXL, risk {ratio}",
8599
"signal_delever": "SOXL below {window}d MA, switch to SOXX, risk {ratio}",
100+
"signal_hold": "Trend Hold",
101+
"signal_entry": "Entry Signal",
102+
"signal_reduce": "Reduce Signal",
103+
"signal_exit": "Exit Signal",
104+
"signal_idle": "Idle",
86105
},
87106
}
88107

@@ -96,6 +115,15 @@ def translate(key, **kwargs):
96115
return translate
97116

98117

118+
def build_signal_text(translate_fn):
119+
def signal_text(icon_key):
120+
emoji = SIGNAL_ICONS.get(icon_key, "❓")
121+
name = translate_fn(f"signal_{icon_key}")
122+
return f"{emoji} {name}"
123+
124+
return signal_text
125+
126+
99127
def build_prefixer(account_prefix: str, service_name: str):
100128
def with_prefix(message: str) -> str:
101129
return f"[{account_prefix}/{service_name}] {message}"

strategy_runtime.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@ def evaluate(
4343
self,
4444
*,
4545
translator: Callable[[str], str],
46+
signal_text_fn: Callable[[str], str] | None = None,
4647
**available_inputs,
4748
) -> StrategyEvaluationResult:
4849
runtime_config = dict(self.runtime_overrides)
4950
runtime_config.setdefault("translator", translator)
51+
if signal_text_fn is not None:
52+
runtime_config.setdefault("signal_text_fn", signal_text_fn)
5053
ctx = build_strategy_context_from_available_inputs(
5154
entrypoint=self.entrypoint,
5255
runtime_adapter=self.runtime_adapter,

tests/test_decision_mapper.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ def test_maps_hybrid_decision_from_snapshot_source(self):
131131
self.assertEqual(plan["allocation"]["strategy_symbols"], ("TQQQ", "BOXX", "QQQI", "SPYI"))
132132
self.assertEqual(plan["execution"]["trade_threshold_value"], 250.0)
133133
self.assertEqual(plan["execution"]["investable_cash"], 5000.0)
134+
self.assertEqual(plan["execution"]["benchmark_symbol"], "QQQ")
135+
self.assertEqual(plan["execution"]["benchmark_price"], 500.0)
136+
self.assertEqual(plan["execution"]["long_trend_value"], 480.0)
137+
self.assertEqual(plan["execution"]["exit_line"], 470.0)
134138
self.assertEqual(plan["portfolio"]["market_values"]["TQQQ"], 5000.0)
135139

136140

tests/test_rebalance_service.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
def _build_plan(
2929
*,
30+
strategy_profile="semiconductor_rotation_income",
3031
strategy_symbols,
3132
risk_symbols=(),
3233
income_symbols=(),
@@ -46,9 +47,14 @@ def _build_plan(
4647
available_cash,
4748
total_strategy_equity,
4849
portfolio_rows,
50+
dashboard_text="",
51+
benchmark_symbol="",
52+
benchmark_price=0.0,
53+
long_trend_value=0.0,
54+
exit_line=0.0,
4955
):
5056
return {
51-
"strategy_profile": "semiconductor_rotation_income",
57+
"strategy_profile": strategy_profile,
5258
"allocation": {
5359
"target_mode": "value",
5460
"strategy_symbols": tuple(strategy_symbols),
@@ -75,6 +81,11 @@ def _build_plan(
7581
"income_locked_ratio_text": income_locked_ratio_text,
7682
"investable_cash": float(investable_cash),
7783
"current_min_trade": float(current_min_trade),
84+
"dashboard_text": dashboard_text,
85+
"benchmark_symbol": benchmark_symbol,
86+
"benchmark_price": float(benchmark_price),
87+
"long_trend_value": float(long_trend_value),
88+
"exit_line": float(exit_line),
7889
},
7990
}
8091

@@ -425,6 +436,49 @@ def test_heartbeat_accepts_normalized_portfolio_and_execution_sections(self):
425436
self.assertIn("可投资现金", sent_messages[0])
426437
self.assertIn("SOXX", sent_messages[0])
427438

439+
def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_line(self):
440+
plan = _build_plan(
441+
strategy_profile="hybrid_growth_income",
442+
strategy_symbols=("TQQQ", "BOXX", "QQQI", "SPYI"),
443+
risk_symbols=("TQQQ",),
444+
income_symbols=("QQQI", "SPYI"),
445+
safe_haven_symbols=("BOXX",),
446+
targets={"TQQQ": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0},
447+
market_values={"TQQQ": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0},
448+
sellable_quantities={"TQQQ": 0, "BOXX": 0, "QQQI": 0, "SPYI": 0},
449+
quantities={"TQQQ": 0, "BOXX": 0, "QQQI": 0, "SPYI": 0},
450+
current_min_trade=250.0,
451+
trade_threshold_value=250.0,
452+
investable_cash=0.0,
453+
market_status="",
454+
deploy_ratio_text="",
455+
income_ratio_text="",
456+
income_locked_ratio_text="",
457+
signal_message="💤 等待信号",
458+
available_cash=0.0,
459+
total_strategy_equity=0.0,
460+
portfolio_rows=(("TQQQ", "BOXX"), ("QQQI", "SPYI")),
461+
benchmark_symbol="QQQ",
462+
benchmark_price=588.50,
463+
long_trend_value=595.25,
464+
exit_line=573.00,
465+
)
466+
467+
sent_messages, _, _ = self._run_strategy(
468+
plan,
469+
prices={"TQQQ.US": 50.0, "BOXX.US": 100.0, "QQQI.US": 40.0, "SPYI.US": 45.0},
470+
dry_run_only=True,
471+
)
472+
473+
self.assertEqual(len(sent_messages), 1)
474+
self.assertIn("💓 【心跳检测】", sent_messages[0])
475+
self.assertIn("🧪 dry-run 模式", sent_messages[0])
476+
self.assertIn("QQQ: 588.50 | MA200: 595.25 | Exit: 573.00", sent_messages[0])
477+
self.assertIn("🎯 信号: 💤 等待信号", sent_messages[0])
478+
self.assertNotIn("📊 市场状态: ", sent_messages[0])
479+
self.assertNotIn("💼 交易层风险仓位: ", sent_messages[0])
480+
self.assertNotIn("🏦 收入层锁定占比: ", sent_messages[0])
481+
428482

429483
if __name__ == "__main__":
430484
unittest.main()

0 commit comments

Comments
 (0)