From 6ec3ab010eabed9da6b35acf8df48ea5dc3dd841 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:17:33 +0800 Subject: [PATCH] Show verified broker account balances in idle heartbeat Co-Authored-By: Codex --- application/rebalance_service.py | 5 ++++ application/runtime_broker_adapters.py | 2 ++ notifications/telegram.py | 26 +++++++++++++++++++ tests/test_notifications_telegram.py | 35 ++++++++++++++++++++++++++ tests/test_runtime_broker_adapters.py | 2 ++ 5 files changed, 70 insertions(+) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index d6cf3d1..7287ea4 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -773,6 +773,11 @@ def acquire_submission_claim() -> bool: "strategy_run_stage": strategy_run_stage, "strategy_run_persisted": strategy_run_persisted, "portfolio": plan.get("portfolio", {}), + "heartbeat_account_snapshot": { + "available_cash": snapshot.cash_balance, + "net_assets": (getattr(snapshot, "metadata", {}) or {}).get("broker_account_equity"), + "observed_at": snapshot.as_of.isoformat(), + }, "allocation": plan.get("allocation", {}), "execution": plan.get("execution", {}), "signal_snapshot": signal_snapshot, diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 8e903e1..d29da54 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -310,6 +310,7 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: prefer_cash_plus_positions=bool(managed), cash_only_execution=self.cash_only_execution, ) + broker_account_equity = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS) return PortfolioSnapshot( as_of=self.clock(), total_equity=float(total_equity), @@ -321,6 +322,7 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot: "account_hash": self.account_hash or mask_account_id(self.account), "api_kind": "unofficial-reverse-engineered", "total_equity_source": total_equity_source, + "broker_account_equity": broker_account_equity, "cash_only_execution": self.cash_only_execution, "market_currency_cash": cash_balance, "available_funds": reported_buying_power, diff --git a/notifications/telegram.py b/notifications/telegram.py index 0ae07d5..df76c0c 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -4,6 +4,7 @@ from quant_platform_kit.common.operational_notification_localization import resolve_operational_notification_locale +import math import re from collections.abc import Callable, Mapping from typing import Any @@ -158,6 +159,10 @@ def format_small_account_whole_share_bootstrap_notes( "runtime_failure_log": "策略运行失败(runtime_setup_failed)", "rebalance_title": "🔔 【调仓指令】", "heartbeat_title": "💓 【心跳检测】", + "heartbeat_available_cash": "💵 可用现金: {value}", + "heartbeat_account_equity": "💰 账户总权益: {value}", + "heartbeat_observed_at": "🕒 账户观察时间: {value}", + "heartbeat_unverified": "未核实", "strategy_label": "🧭 策略: {name}", "account_label": "🆔 账户: {account}", "dry_run_banner": "🧪 模拟运行,本轮不提交真实订单", @@ -339,6 +344,10 @@ def format_small_account_whole_share_bootstrap_notes( "runtime_failure_log": "Strategy run failed (runtime_setup_failed)", "rebalance_title": "🔔 【Rebalance Instruction】", "heartbeat_title": "💓 【Heartbeat】", + "heartbeat_available_cash": "💵 Available cash: {value}", + "heartbeat_account_equity": "💰 Total account equity: {value}", + "heartbeat_observed_at": "🕒 Account observed: {value}", + "heartbeat_unverified": "Unverified", "strategy_label": "🧭 Strategy: {name}", "account_label": "🆔 Account: {account}", "dry_run_banner": "🧪 Dry run only; no live orders submitted", @@ -1081,6 +1090,23 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str: lines.append(translator("strategy_label", name=strategy_name)) if account: lines.append(translator("account_label", account=account)) + if not submitted: + account_snapshot = result.get("heartbeat_account_snapshot") + account_snapshot = account_snapshot if isinstance(account_snapshot, Mapping) else {} + observed_at = str(account_snapshot.get("observed_at") or "").strip() + verified = False + for field, label in (("available_cash", "heartbeat_available_cash"), ("net_assets", "heartbeat_account_equity")): + amount = account_snapshot.get(field) + valid = ( + isinstance(amount, (int, float)) and not isinstance(amount, bool) + and math.isfinite(amount) and bool(observed_at) + and (field != "net_assets" or amount > 0) + ) + verified = verified or valid + value = f"USD {amount:,.2f}" if valid else translator("heartbeat_unverified") + lines.append(translator(label, value=value)) + if verified: + lines.append(translator("heartbeat_observed_at", value=observed_at)) if dry_run_only: lines.append(translator("dry_run_banner")) if bool(result.get("execution_blocked")): diff --git a/tests/test_notifications_telegram.py b/tests/test_notifications_telegram.py index 9489452..0b4f89f 100644 --- a/tests/test_notifications_telegram.py +++ b/tests/test_notifications_telegram.py @@ -3,6 +3,41 @@ from notifications.telegram import render_cycle_summary +def test_no_trade_heartbeat_shows_broker_account_values_in_both_locales(): + result = { + "account": "****1234", + "strategy_profile": "soxl_soxx_trend_income", + "portfolio": {"total_equity": 25.0, "liquid_cash": 10.0}, + "execution": {}, + "submitted_orders": [], + "skipped_orders": [], + "heartbeat_account_snapshot": { + "available_cash": 100.0, + "net_assets": 1234.56, + "observed_at": "2026-09-25T19:45:00+00:00", + }, + } + zh = render_cycle_summary(result, lang="zh") + en = render_cycle_summary(result, lang="en") + assert "可用现金: USD 100.00" in zh + assert "账户总权益: USD 1,234.56" in zh + assert "Available cash: USD 100.00" in en + assert "Total account equity: USD 1,234.56" in en + + +def test_no_trade_heartbeat_marks_missing_account_values_unverified(): + message = render_cycle_summary({ + "account": "****1234", + "portfolio": {}, + "execution": {}, + "submitted_orders": [], + "skipped_orders": [], + "heartbeat_account_snapshot": {"available_cash": 0.0, "net_assets": None}, + }, lang="en") + assert "Available cash: Unverified" in message + assert "Total account equity: Unverified" in message + + def test_render_cycle_summary_dashboard_text_does_not_hide_account_overview(): message = render_cycle_summary( { diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index 46450a3..194ae91 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -55,6 +55,7 @@ def test_runtime_adapters_build_quote_and_portfolio_ports(): assert portfolio.cash_balance == 20.0 assert portfolio.positions[0].symbol == "SPY" assert portfolio.metadata["total_equity_source"] == "cash_plus_positions" + assert portfolio.metadata["broker_account_equity"] == 120.0 def test_portfolio_snapshot_uses_account_value_balance_key(): @@ -72,6 +73,7 @@ def get_balances(self, _account): assert portfolio.total_equity == 221.0 assert portfolio.cash_balance == 200.0 assert portfolio.metadata["total_equity_source"] == "cash_plus_positions" + assert portfolio.metadata["broker_account_equity"] == 1234.56 def test_managed_portfolio_snapshot_ignores_full_account_value_balance_key():