From f61111a7641ab1020f034228edc1f9186e43514c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:25:10 +0800 Subject: [PATCH] Polish snapshot heartbeat i18n --- application/rebalance_service.py | 63 ++++++++++++++++++++++++++++---- notifications/telegram.py | 12 ++++++ requirements.txt | 2 +- tests/test_rebalance_service.py | 51 ++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index ff7c4dd..b289778 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -131,6 +131,31 @@ def _localize_notification_text(text: str, *, translator) -> str: return localized +def _render_notification_context_text( + notification_context: Mapping[str, object] | None, + *, + translator, + fallback: str = "", +) -> str: + if not isinstance(notification_context, Mapping): + return fallback + key = str(notification_context.get("code") or "").strip() + if not key: + return fallback + params = dict(notification_context.get("params") or {}) + rendered = translator(key, **params) + return fallback if rendered == key else str(rendered) + + +def _translate_snapshot_guard_decision(decision: object, *, translator) -> str: + value = str(decision or "").strip() + if not value: + return "" + key = f"snapshot_guard_decision_{value}" + translated = translator(key) + return value if translated == key else str(translated) + + def _split_detail_segment(text: str) -> list[str]: value = str(text or "").strip() if not value: @@ -585,13 +610,37 @@ def run_strategy_core( decision = signal_metadata.get("snapshot_guard_decision") no_op_reason = signal_metadata.get("no_op_reason") fail_reason = signal_metadata.get("fail_reason") - no_op_text = config.translator("no_trades") - if decision: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'decision={decision}', translator=config.translator)}" - if no_op_reason: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'reason={no_op_reason}', translator=config.translator)}" - if fail_reason: - no_op_text = f"{no_op_text} | {_localize_notification_text(f'fail_reason={fail_reason}', translator=config.translator)}" + notification_context = signal_metadata.get("notification_context") + status_context = ( + notification_context.get("status") + if isinstance(notification_context, Mapping) + else None + ) + rendered_status = _render_notification_context_text( + status_context, + translator=config.translator, + fallback="", + ) + no_op_segments = [config.translator("no_trades")] + if rendered_status: + no_op_segments.append(rendered_status) + else: + if decision: + no_op_segments.append( + config.translator( + "snapshot_decision_detail", + value=_translate_snapshot_guard_decision(decision, translator=config.translator), + ) + ) + if no_op_reason: + no_op_segments.append( + _localize_notification_text(f"reason={no_op_reason}", translator=config.translator) + ) + if fail_reason: + no_op_segments.append( + _localize_notification_text(f"fail_reason={fail_reason}", translator=config.translator) + ) + no_op_text = " | ".join(segment for segment in no_op_segments if str(segment).strip()) no_op_text = "\n".join(_split_labeled_text(no_op_text)) record = build_reconciliation_record( strategy_profile=signal_metadata.get("strategy_profile"), diff --git a/notifications/telegram.py b/notifications/telegram.py index 3c33cef..4565aa4 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -50,6 +50,12 @@ "partial_sell_batch": "⚠️ 卖单部分成交 {count}个标的: {details}", "no_equity": "❌ 无净值", "signal_label": "信号", + "signal_monthly_snapshot_waiting": "月度快照节奏 | 等待进入执行窗口", + "status_monthly_snapshot_waiting_window": "不执行 | 原因=当前不在月度执行窗口 | 快照日期={snapshot_as_of} | 允许日期={allowed_dates}", + "status_no_execution_window_after_snapshot": "不执行 | 原因=快照后没有可用执行窗口 | 快照日期={snapshot_as_of}", + "snapshot_guard_decision_proceed": "继续", + "snapshot_guard_decision_no_op": "不执行", + "snapshot_guard_decision_fail_closed": "关闭执行", "market_status_risk_on": "🚀 风险开启({asset})", "market_status_delever": "🛡️ 降杠杆({asset})", "signal_risk_on": "SOXL 站上 {window} 日均线,持有 SOXL,交易层风险仓位 {ratio}", @@ -132,6 +138,12 @@ "partial_sell_batch": "⚠️ Partial sell fills for {count} symbols: {details}", "no_equity": "❌ No equity", "signal_label": "Signal", + "signal_monthly_snapshot_waiting": "monthly snapshot cadence | waiting inside execution window", + "status_monthly_snapshot_waiting_window": "no-op | reason=outside monthly execution window | snapshot_as_of={snapshot_as_of} | allowed={allowed_dates}", + "status_no_execution_window_after_snapshot": "no-op | reason=no execution window after snapshot | snapshot_as_of={snapshot_as_of}", + "snapshot_guard_decision_proceed": "proceed", + "snapshot_guard_decision_no_op": "no_op", + "snapshot_guard_decision_fail_closed": "fail_closed", "market_status_risk_on": "🚀 RISK-ON ({asset})", "market_status_delever": "🛡️ DE-LEVER ({asset})", "signal_risk_on": "SOXL above {window}d MA, hold SOXL, risk {ratio}", diff --git a/requirements.txt b/requirements.txt index e0c8850..ae849a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ac550fee3ca33bbf28c8a886848dede4e4e09a1b -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0f4373f69c9d9f800eda476ff213b7013e51aba4 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@9f55c58b6854ae700c47a684262eec10e763306a pandas numpy requests diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index be65454..f568e04 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -368,3 +368,54 @@ def disconnect(self): assert payload_path is not None payload = json.loads(payload_path.read_text(encoding="utf-8")) assert payload["no_op_reason"] == "outside_execution_window" + + +def test_run_strategy_core_prefers_structured_noop_status_in_zh(): + observed = {"messages": []} + + class FakeIB: + def isConnected(self): + return True + + def disconnect(self): + return None + + result = run_strategy_core( + connect_ib=lambda: FakeIB(), + get_current_portfolio=lambda _ib: ({}, {"equity": 1000.0, "buying_power": 500.0}), + compute_signals=lambda _ib, _holdings: ( + None, + "monthly snapshot cadence | waiting inside execution window", + False, + "no-op | reason=outside_monthly_execution_window", + { + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "trade_date": "2026-04-22", + "snapshot_as_of": "2026-04-16", + "snapshot_guard_decision": "proceed", + "no_op_reason": "outside_monthly_execution_window snapshot=2026-04-16 allowed=2026-04-17,2026-04-20,2026-04-21", + "managed_symbols": ("AAPL", "MSFT", "BOXX"), + "dry_run_only": True, + "notification_context": { + "signal": {"code": "signal_monthly_snapshot_waiting", "params": {}}, + "status": { + "code": "status_monthly_snapshot_waiting_window", + "params": { + "snapshot_as_of": "2026-04-16", + "allowed_dates": "2026-04-17, 2026-04-20, 2026-04-21", + }, + }, + }, + }, + ), + execute_rebalance=lambda *_args, **_kwargs: [], + send_tg_message=lambda message: observed["messages"].append(message), + translator=build_translator("zh"), + separator="---", + strategy_display_name="Mega Cap Top50 平衡龙头轮动", + ) + + assert result.result == "OK - heartbeat" + assert observed["messages"] + assert "当前不在月度执行窗口" in observed["messages"][0] + assert "决策=proceed" not in observed["messages"][0]