From 23a3b858ed97deac396c49547025349a684787c5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:52:50 +0800 Subject: [PATCH 1/4] Explain LongBridge non-USD cash in strategy notifications --- application/rebalance_service.py | 155 ++++++++++++++++++++++++++----- decision_mapper.py | 23 ++++- notifications/telegram.py | 12 +++ requirements.txt | 2 +- tests/test_decision_mapper.py | 2 + tests/test_notifications.py | 9 ++ tests/test_rebalance_service.py | 58 ++++++++++-- 7 files changed, 228 insertions(+), 33 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 4a516d1..af20e89 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -5,6 +5,7 @@ import os import re import traceback +from collections.abc import Mapping from datetime import datetime _ZH_REASON_REPLACEMENTS = ( @@ -167,12 +168,78 @@ def _build_benchmark_lines(execution, *, translator): ] -def _format_holdings_lines(portfolio_rows, market_values, *, translator) -> list[str]: - lines = [translator("holdings_title")] +def _normalize_cash_by_currency(raw_cash) -> dict[str, float]: + if not isinstance(raw_cash, Mapping): + return {} + cash_by_currency: dict[str, float] = {} + for currency, amount in raw_cash.items(): + normalized_currency = str(currency or "").strip().upper() + if not normalized_currency: + continue + cash_by_currency[normalized_currency] = float(amount) + return cash_by_currency + + +def _format_cash_by_currency(cash_by_currency: Mapping[str, float]) -> str: + parts = [] + for currency in sorted(cash_by_currency, key=lambda value: (value != "USD", value)): + amount = float(cash_by_currency[currency]) + if amount == 0.0: + continue + parts.append(f"{currency} {amount:,.2f}") + return ", ".join(parts) + + +def _has_positive_non_usd_cash(cash_by_currency: Mapping[str, float]) -> bool: + return any( + currency != "USD" and float(amount) > 0.0 + for currency, amount in cash_by_currency.items() + ) + + +def _append_portfolio_summary_lines( + lines, + *, + total_strategy_equity, + available_cash, + investable_cash, + cash_by_currency, + portfolio_rows, + market_values, + quantities, + translator, +) -> None: + lines.append(translator("portfolio_summary_title")) + lines.append( + " - " + + translator( + "portfolio_total_assets", + value=f"{total_strategy_equity:,.2f}", + ) + ) + lines.append( + " - " + + translator( + "portfolio_buying_power", + available=f"{available_cash:.2f}", + investable=f"{investable_cash:.2f}", + ) + ) + formatted_cash = _format_cash_by_currency(cash_by_currency) + if formatted_cash: + lines.append(translator("cash_by_currency", currencies=formatted_cash)) + lines.append(" - " + translator("holdings_title")) for row in portfolio_rows: for symbol in row: - lines.append(f" - {symbol}: ${market_values[symbol]:,.2f}") - return lines + lines.append( + " - " + + translator( + "holding_line", + symbol=symbol, + value=f"{market_values[symbol]:,.2f}", + qty=quantities.get(symbol, 0), + ) + ) def _append_status_lines(lines, *, execution, translator, signal_key): @@ -319,6 +386,7 @@ def fetch_replanned_state(): target_values = dict(allocation["targets"]) total_strategy_equity = float(portfolio["total_equity"]) available_cash = float(portfolio["liquid_cash"]) + cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) portfolio_rows = tuple(portfolio["portfolio_rows"]) @@ -441,9 +509,25 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): target_values = dict(allocation["targets"]) total_strategy_equity = float(portfolio["total_equity"]) available_cash = float(portfolio["liquid_cash"]) + cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) portfolio_rows = tuple(portfolio["portfolio_rows"]) + + if ( + available_cash <= 0.0 + and investable_cash <= 0.0 + and _has_positive_non_usd_cash(cash_by_currency) + ): + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_non_usd_cash", + available=f"{available_cash:.2f}", + investable=f"{investable_cash:.2f}", + currencies=_format_cash_by_currency(cash_by_currency), + ) buy_candidates = [ symbol for symbol in strategy_assets @@ -552,11 +636,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if action_done: - cash_summary = translator( - "cash_summary", - available=f"{available_cash:.2f}", - investable=f"{investable_cash:.2f}", - ) formatted_logs = "\n".join(f" - {log}" for log in [*logs, *skip_logs, *note_logs]) tg_lines = [translator("rebalance_title")] _append_strategy_line( @@ -566,7 +645,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if dry_run_only: tg_lines.append(translator("dry_run_banner")) - tg_lines.append(cash_summary) + _append_portfolio_summary_lines( + tg_lines, + total_strategy_equity=total_strategy_equity, + available_cash=available_cash, + investable_cash=investable_cash, + cash_by_currency=cash_by_currency, + portfolio_rows=portfolio_rows, + market_values=market_values, + quantities=quantities, + translator=translator, + ) _append_status_lines( tg_lines, execution=execution, @@ -583,6 +672,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if dry_run_only: compact_lines.append(translator("dry_run_banner")) + _append_portfolio_summary_lines( + compact_lines, + total_strategy_equity=total_strategy_equity, + available_cash=available_cash, + investable_cash=investable_cash, + cash_by_currency=cash_by_currency, + portfolio_rows=portfolio_rows, + market_values=market_values, + quantities=quantities, + translator=translator, + ) _append_compact_status_lines( compact_lines, execution=execution, @@ -595,12 +695,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): send_tg_message(compact_tg_message) else: equity_text = f"{total_strategy_equity:,.2f}" - cash_summary = translator( - "cash_summary", - available=f"{available_cash:.2f}", - investable=f"{investable_cash:.2f}", - ) - holdings_lines = _format_holdings_lines(portfolio_rows, market_values, translator=translator) no_trade_lines = [ translator("heartbeat_title"), ] @@ -612,14 +706,18 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): no_trade_lines.append(translator("equity", value=equity_text)) if dry_run_only: no_trade_lines.append(translator("dry_run_banner")) - no_trade_lines.extend( - [ - cash_summary, - separator, - *holdings_lines, - separator, - ] + _append_portfolio_summary_lines( + no_trade_lines, + total_strategy_equity=total_strategy_equity, + available_cash=available_cash, + investable_cash=investable_cash, + cash_by_currency=cash_by_currency, + portfolio_rows=portfolio_rows, + market_values=market_values, + quantities=quantities, + translator=translator, ) + no_trade_lines.append(separator) _append_status_lines( no_trade_lines, execution=execution, @@ -656,6 +754,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): compact_no_trade_lines.append(translator("equity", value=equity_text)) if dry_run_only: compact_no_trade_lines.append(translator("dry_run_banner")) + _append_portfolio_summary_lines( + compact_no_trade_lines, + total_strategy_equity=total_strategy_equity, + available_cash=available_cash, + investable_cash=investable_cash, + cash_by_currency=cash_by_currency, + portfolio_rows=portfolio_rows, + market_values=market_values, + quantities=quantities, + translator=translator, + ) _append_compact_status_lines( compact_no_trade_lines, execution=execution, diff --git a/decision_mapper.py b/decision_mapper.py index a6c47e7..1ba346f 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -39,6 +39,23 @@ def _build_portfolio_inputs( raise ValueError("LongBridge plan mapping requires account_state or snapshot") +def _cash_by_currency_from_account_state( + account_state: Mapping[str, Any] | None, +) -> dict[str, float]: + if account_state is None: + return {} + raw_cash = account_state.get("cash_by_currency") + if not isinstance(raw_cash, Mapping): + return {} + cash_by_currency: dict[str, float] = {} + for currency, amount in raw_cash.items(): + normalized_currency = str(currency or "").strip().upper() + if not normalized_currency: + continue + cash_by_currency[normalized_currency] = float(amount) + return cash_by_currency + + def _symbol_role(symbol: str) -> str | None: normalized = str(symbol or "").strip().upper() if normalized in _SAFE_HAVEN_SYMBOLS: @@ -283,7 +300,7 @@ def map_strategy_decision_to_plan( strategy_symbols_order, portfolio_rows_layout, execution_fields, execution_defaults = _resolve_layout( canonical_profile ) - return build_value_target_runtime_plan( + plan = build_value_target_runtime_plan( normalized_decision, strategy_profile=canonical_profile, portfolio_inputs=portfolio_inputs, @@ -294,3 +311,7 @@ def map_strategy_decision_to_plan( execution_fields=execution_fields, execution_defaults=execution_defaults, ) + cash_by_currency = _cash_by_currency_from_account_state(account_state) + if cash_by_currency: + plan["portfolio"]["cash_by_currency"] = cash_by_currency + return plan diff --git a/notifications/telegram.py b/notifications/telegram.py index 6a43b9f..6ad3cd4 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -27,7 +27,12 @@ "heartbeat_title": "💓 【心跳检测】", "equity": "💰 净值: ${value}", "cash_summary": "💵 资金\n - 账户现金: ${available}\n - 可投资现金: ${investable}", + "cash_by_currency": " - 各币种现金: {currencies}", "cash_label": "现金", + "portfolio_summary_title": "📌 账户概览", + "portfolio_total_assets": "总资产(策略标的+现金): ${value}", + "portfolio_buying_power": "购买力: ${available} | 可投资现金: ${investable}", + "holding_line": "{symbol}: ${value} / {qty}股", "holdings_title": "💼 持仓", "order_logs_title": "🧾 执行明细", "benchmark_title": "📈 {symbol} 基准", @@ -50,6 +55,7 @@ "sell_skipped": "⚪️ [卖出跳过] {detail}", "buy_deferred": "ℹ️ [买入说明] {detail}", "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", + "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}", @@ -100,7 +106,12 @@ "heartbeat_title": "💓 【Heartbeat】", "equity": "💰 Equity: ${value}", "cash_summary": "💵 Cash\n - Account cash: ${available}\n - Investable cash: ${investable}", + "cash_by_currency": " - Cash by currency: {currencies}", "cash_label": "Cash", + "portfolio_summary_title": "📌 Portfolio snapshot", + "portfolio_total_assets": "Total assets (strategy symbols + cash): ${value}", + "portfolio_buying_power": "Buying power: ${available} | Investable cash: ${investable}", + "holding_line": "{symbol}: ${value} / {qty} shares", "holdings_title": "💼 Holdings", "order_logs_title": "🧾 Execution details", "benchmark_title": "📈 {symbol} Benchmark", @@ -123,6 +134,7 @@ "sell_skipped": "⚪️ [Sell skipped] {detail}", "buy_deferred": "ℹ️ [Buy note] {detail}", "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", + "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}", "buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}", diff --git a/requirements.txt b/requirements.txt index f23fee1..e8c07a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@fix-ibkr-longbridge-cash-diagnostics us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.28 pandas requests diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index 6bc2de2..109dd53 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -36,6 +36,7 @@ def test_maps_semiconductor_strategy_decision_to_execution_plan(self): "quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, "sellable_quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, "total_strategy_equity": 50000.0, + "cash_by_currency": {"USD": 10000.0, "SGD": 350.0}, } plan = map_strategy_decision_to_plan( @@ -48,6 +49,7 @@ def test_maps_semiconductor_strategy_decision_to_execution_plan(self): self.assertEqual(plan["allocation"]["strategy_symbols"], ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) self.assertEqual(plan["allocation"]["targets"]["BOXX"], 15000.0) self.assertEqual(plan["portfolio"]["portfolio_rows"], (("SOXL", "SOXX"), ("QQQI", "SPYI"), ("BOXX",))) + self.assertEqual(plan["portfolio"]["cash_by_currency"], {"USD": 10000.0, "SGD": 350.0}) self.assertEqual(plan["portfolio"]["sellable_quantities"]["BOXX"], 50) self.assertEqual(plan["execution"]["trade_threshold_value"], 500.0) self.assertEqual(plan["execution"]["investable_cash"], 9000.0) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 83694df..3541c98 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -35,6 +35,15 @@ def test_build_translator_supports_chinese(self): "💵 资金\n - 账户现金: $1.00\n - 可投资现金: $2.00", ) self.assertEqual(translate("holdings_title"), "💼 持仓") + self.assertEqual(translate("portfolio_summary_title"), "📌 账户概览") + self.assertEqual( + translate("portfolio_buying_power", available="1.00", investable="2.00"), + "购买力: $1.00 | 可投资现金: $2.00", + ) + self.assertEqual( + translate("holding_line", symbol="SOXL", value="123.45", qty=3), + "SOXL: $123.45 / 3股", + ) self.assertEqual(translate("order_logs_title"), "🧾 执行明细") self.assertEqual(translate("benchmark_title", symbol="QQQ"), "📈 QQQ 基准") self.assertEqual(translate("market_status_blend_gate_risk_on", asset="SOXX+SOXL"), "🚀 风险开启(SOXX+SOXL)") diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 75b4f87..4d01c08 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -52,6 +52,7 @@ def _build_plan( benchmark_price=0.0, long_trend_value=0.0, exit_line=0.0, + cash_by_currency=None, ): return { "strategy_profile": strategy_profile, @@ -71,6 +72,7 @@ def _build_plan( "sellable_quantities": dict(sellable_quantities), "total_equity": float(total_strategy_equity), "liquid_cash": float(available_cash), + "cash_by_currency": dict(cash_by_currency or {}), }, "execution": { "trade_threshold_value": float(trade_threshold_value), @@ -321,7 +323,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertIn("可投资现金", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) - def test_zero_investable_cash_is_silently_skipped(self): + def test_zero_investable_cash_reports_buying_power_without_trade_note(self): plan = _build_plan( strategy_symbols=("BOXX",), safe_haven_symbols=("BOXX",), @@ -349,7 +351,8 @@ def test_zero_investable_cash_is_silently_skipped(self): self.assertEqual(len(sent_messages), 1) self.assertNotIn("账户现金", sent_messages[0]) - self.assertNotIn("可投资现金: $0.00", sent_messages[0]) + self.assertIn("购买力: $3065.61 | 可投资现金: $0.00", sent_messages[0]) + self.assertIn("BOXX: $24,880.00 / 214股", sent_messages[0]) self.assertIn("✅ 无需调仓", sent_messages[0]) self.assertNotIn("本轮没有可执行订单", sent_messages[0]) self.assertNotIn("说明", sent_messages[0]) @@ -386,6 +389,42 @@ def test_cash_limit_zero_mentions_possible_order_hold(self): self.assertIn("券商估算可买数量为 0", sent_messages[0]) self.assertIn("可能有未完成挂单", sent_messages[0]) + def test_non_usd_cash_is_reported_when_usd_cash_is_zero(self): + plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + risk_symbols=("SOXL", "SOXX"), + income_symbols=("QQQI", "SPYI"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0, "QQQI": 0, "SPYI": 0}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0, "QQQI": 0, "SPYI": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=0.0, + market_status="🚀 RISK-ON (SOXX+SOXL)", + deploy_ratio_text="90.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%", + available_cash=0.0, + total_strategy_equity=0.0, + portfolio_rows=(("SOXL", "SOXX"), ("QQQI", "SPYI"), ("BOXX",)), + cash_by_currency={"USD": 0.0, "SGD": 350.0}, + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={}, + dry_run_only=True, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("各币种现金: SGD 350.00", sent_messages[0]) + self.assertIn("检测到非 USD 现金", sent_messages[0]) + self.assertIn("本轮没有可执行订单", sent_messages[0]) + self.assertNotIn("✅ 无需调仓", sent_messages[0]) + def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): initial_plan = _build_plan( strategy_symbols=("SOXL", "SOXX"), @@ -618,8 +657,10 @@ def test_heartbeat_accepts_normalized_portfolio_and_execution_sections(self): self.assertIn("💓 【心跳检测】", sent_messages[0]) self.assertIn("可投资现金", sent_messages[0]) self.assertNotIn("💵 资金\n - 账户现金:", sent_messages[0]) - self.assertNotIn("💼 持仓", sent_messages[0]) - self.assertNotIn(" - SOXX:", sent_messages[0]) + self.assertIn("📌 账户概览", sent_messages[0]) + self.assertIn("总资产(策略标的+现金): $60,000.00", sent_messages[0]) + self.assertIn("购买力: $101.95 | 可投资现金: $101.95", sent_messages[0]) + self.assertIn("SOXX: $0.00 / 0股", sent_messages[0]) def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_line(self): plan = _build_plan( @@ -660,10 +701,11 @@ def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_l self.assertIn("💓 【心跳检测】", sent_messages[0]) self.assertIn("🧭 策略: TQQQ 增长收益", sent_messages[0]) self.assertIn("🧪 模拟运行模式", sent_messages[0]) - self.assertNotIn("💼 持仓", sent_messages[0]) - self.assertNotIn(" - TQQQ: $0.00", sent_messages[0]) - self.assertNotIn(" - BOXX: $0.00", sent_messages[0]) - self.assertNotIn(" - QQQI: $0.00", sent_messages[0]) + self.assertIn("📌 账户概览", sent_messages[0]) + self.assertIn("TQQQ: $0.00 / 0股", sent_messages[0]) + self.assertIn("BOXX: $0.00 / 0股", sent_messages[0]) + self.assertIn("QQQI: $0.00 / 0股", sent_messages[0]) + self.assertIn("SPYI: $0.00 / 0股", sent_messages[0]) self.assertNotIn("📈 QQQ 基准\n - QQQ: 588.50\n - MA200: 595.25\n - 退出线: 573.00", sent_messages[0]) self.assertIn("🎯 信号: 💤 等待信号", sent_messages[0]) self.assertNotIn("账户现金: $0.00 | 可投资现金", sent_messages[0]) From 65cddf4b4e81d97d87e1616b039655c6a9156473 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 21 Apr 2026 04:55:45 +0800 Subject: [PATCH 2/4] Keep released QPK pin for CI compatibility --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e8c07a6..f23fee1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@fix-ibkr-longbridge-cash-diagnostics +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.28 pandas requests From b3ae70548c484190dffa679a2c4c2c5fd7bd5ede Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:13:59 +0800 Subject: [PATCH 3/4] Render strategy portfolio dashboard annotations --- application/rebalance_service.py | 108 +++++-------------------------- decision_mapper.py | 28 ++++++++ notifications/telegram.py | 12 ---- tests/test_decision_mapper.py | 33 ++++++++++ tests/test_notifications.py | 10 --- tests/test_rebalance_service.py | 30 ++++++++- 6 files changed, 103 insertions(+), 118 deletions(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index af20e89..eae9674 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -197,49 +197,18 @@ def _has_positive_non_usd_cash(cash_by_currency: Mapping[str, float]) -> bool: ) -def _append_portfolio_summary_lines( - lines, - *, - total_strategy_equity, - available_cash, - investable_cash, - cash_by_currency, - portfolio_rows, - market_values, - quantities, - translator, -) -> None: - lines.append(translator("portfolio_summary_title")) - lines.append( - " - " - + translator( - "portfolio_total_assets", - value=f"{total_strategy_equity:,.2f}", - ) - ) - lines.append( - " - " - + translator( - "portfolio_buying_power", - available=f"{available_cash:.2f}", - investable=f"{investable_cash:.2f}", - ) +def _format_dashboard_text(text) -> str: + return "\n".join( + line.rstrip() + for line in str(text or "").splitlines() + if line.strip() ) - formatted_cash = _format_cash_by_currency(cash_by_currency) - if formatted_cash: - lines.append(translator("cash_by_currency", currencies=formatted_cash)) - lines.append(" - " + translator("holdings_title")) - for row in portfolio_rows: - for symbol in row: - lines.append( - " - " - + translator( - "holding_line", - symbol=symbol, - value=f"{market_values[symbol]:,.2f}", - qty=quantities.get(symbol, 0), - ) - ) + + +def _append_dashboard_lines(lines, *, execution) -> None: + dashboard_text = _format_dashboard_text(execution.get("dashboard_text")) + if dashboard_text: + lines.extend(dashboard_text.splitlines()) def _append_status_lines(lines, *, execution, translator, signal_key): @@ -384,12 +353,10 @@ def fetch_replanned_state(): quantities = dict(portfolio["quantities"]) sellable_quantities = dict(portfolio["sellable_quantities"]) target_values = dict(allocation["targets"]) - total_strategy_equity = float(portfolio["total_equity"]) available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) - portfolio_rows = tuple(portfolio["portfolio_rows"]) def record_dry_run(symbol, side, quantity, price, *, order_type): price_text = f"${price:.2f}" if price is not None else translator("order_type_market") side_key = "side_buy" if str(side).lower() == "buy" else "side_sell" @@ -507,12 +474,10 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): quantities = dict(portfolio["quantities"]) sellable_quantities = dict(portfolio["sellable_quantities"]) target_values = dict(allocation["targets"]) - total_strategy_equity = float(portfolio["total_equity"]) available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) - portfolio_rows = tuple(portfolio["portfolio_rows"]) if ( available_cash <= 0.0 @@ -645,17 +610,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if dry_run_only: tg_lines.append(translator("dry_run_banner")) - _append_portfolio_summary_lines( - tg_lines, - total_strategy_equity=total_strategy_equity, - available_cash=available_cash, - investable_cash=investable_cash, - cash_by_currency=cash_by_currency, - portfolio_rows=portfolio_rows, - market_values=market_values, - quantities=quantities, - translator=translator, - ) + _append_dashboard_lines(tg_lines, execution=execution) _append_status_lines( tg_lines, execution=execution, @@ -672,17 +627,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if dry_run_only: compact_lines.append(translator("dry_run_banner")) - _append_portfolio_summary_lines( - compact_lines, - total_strategy_equity=total_strategy_equity, - available_cash=available_cash, - investable_cash=investable_cash, - cash_by_currency=cash_by_currency, - portfolio_rows=portfolio_rows, - market_values=market_values, - quantities=quantities, - translator=translator, - ) + _append_dashboard_lines(compact_lines, execution=execution) _append_compact_status_lines( compact_lines, execution=execution, @@ -694,7 +639,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): print(with_prefix(detailed_tg_message), flush=True) send_tg_message(compact_tg_message) else: - equity_text = f"{total_strategy_equity:,.2f}" no_trade_lines = [ translator("heartbeat_title"), ] @@ -703,20 +647,9 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): strategy_display_name=strategy_display_name, translator=translator, ) - no_trade_lines.append(translator("equity", value=equity_text)) if dry_run_only: no_trade_lines.append(translator("dry_run_banner")) - _append_portfolio_summary_lines( - no_trade_lines, - total_strategy_equity=total_strategy_equity, - available_cash=available_cash, - investable_cash=investable_cash, - cash_by_currency=cash_by_currency, - portfolio_rows=portfolio_rows, - market_values=market_values, - quantities=quantities, - translator=translator, - ) + _append_dashboard_lines(no_trade_lines, execution=execution) no_trade_lines.append(separator) _append_status_lines( no_trade_lines, @@ -751,20 +684,9 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): strategy_display_name=strategy_display_name, translator=translator, ) - compact_no_trade_lines.append(translator("equity", value=equity_text)) if dry_run_only: compact_no_trade_lines.append(translator("dry_run_banner")) - _append_portfolio_summary_lines( - compact_no_trade_lines, - total_strategy_equity=total_strategy_equity, - available_cash=available_cash, - investable_cash=investable_cash, - cash_by_currency=cash_by_currency, - portfolio_rows=portfolio_rows, - market_values=market_values, - quantities=quantities, - translator=translator, - ) + _append_dashboard_lines(compact_no_trade_lines, execution=execution) _append_compact_status_lines( compact_no_trade_lines, execution=execution, diff --git a/decision_mapper.py b/decision_mapper.py index 1ba346f..d51798c 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -56,6 +56,22 @@ def _cash_by_currency_from_account_state( return cash_by_currency +def _cash_by_currency_from_snapshot(snapshot: Any | None) -> dict[str, float]: + metadata = getattr(snapshot, "metadata", {}) or {} + if not isinstance(metadata, Mapping): + return {} + raw_cash = metadata.get("cash_by_currency") + if not isinstance(raw_cash, Mapping): + return {} + cash_by_currency: dict[str, float] = {} + for currency, amount in raw_cash.items(): + normalized_currency = str(currency or "").strip().upper() + if not normalized_currency: + continue + cash_by_currency[normalized_currency] = float(amount) + return cash_by_currency + + def _symbol_role(symbol: str) -> str | None: normalized = str(symbol or "").strip().upper() if normalized in _SAFE_HAVEN_SYMBOLS: @@ -76,6 +92,8 @@ def _build_weight_translation_annotations( liquid_cash: float, ) -> ValueTargetExecutionAnnotations: diagnostics = dict(decision.diagnostics) + raw_annotations = diagnostics.get("execution_annotations") + execution_annotations = dict(raw_annotations) if isinstance(raw_annotations, Mapping) else {} threshold_value = _default_threshold_value(total_equity) signal_display = str( diagnostics.get("signal_description") @@ -89,12 +107,18 @@ def _build_weight_translation_annotations( or diagnostics.get("canary_status") or "" ).strip() or None + dashboard_text = str( + execution_annotations.get("dashboard_text") + or diagnostics.get("dashboard") + or "" + ).strip() or None benchmark_symbol = str(diagnostics.get("benchmark_symbol") or "").strip().upper() or None return ValueTargetExecutionAnnotations( trade_threshold_value=threshold_value, reserved_cash=0.0, signal_display=signal_display, status_display=status_display, + dashboard_text=dashboard_text, benchmark_symbol=benchmark_symbol, benchmark_price=( float(diagnostics["benchmark_price"]) @@ -202,6 +226,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "reserved_cash", "signal_display", "status_display", + "dashboard_text", "benchmark_symbol", "benchmark_price", "long_trend_value", @@ -213,6 +238,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "reserved_cash": 0.0, "signal_display": "", "status_display": "", + "dashboard_text": "", "benchmark_symbol": "QQQ", "benchmark_price": 0.0, "long_trend_value": 0.0, @@ -312,6 +338,8 @@ def map_strategy_decision_to_plan( execution_defaults=execution_defaults, ) cash_by_currency = _cash_by_currency_from_account_state(account_state) + if not cash_by_currency: + cash_by_currency = _cash_by_currency_from_snapshot(snapshot) if cash_by_currency: plan["portfolio"]["cash_by_currency"] = cash_by_currency return plan diff --git a/notifications/telegram.py b/notifications/telegram.py index 6ad3cd4..5eb71be 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -27,13 +27,7 @@ "heartbeat_title": "💓 【心跳检测】", "equity": "💰 净值: ${value}", "cash_summary": "💵 资金\n - 账户现金: ${available}\n - 可投资现金: ${investable}", - "cash_by_currency": " - 各币种现金: {currencies}", "cash_label": "现金", - "portfolio_summary_title": "📌 账户概览", - "portfolio_total_assets": "总资产(策略标的+现金): ${value}", - "portfolio_buying_power": "购买力: ${available} | 可投资现金: ${investable}", - "holding_line": "{symbol}: ${value} / {qty}股", - "holdings_title": "💼 持仓", "order_logs_title": "🧾 执行明细", "benchmark_title": "📈 {symbol} 基准", "benchmark_price": "{symbol}: {value}", @@ -106,13 +100,7 @@ "heartbeat_title": "💓 【Heartbeat】", "equity": "💰 Equity: ${value}", "cash_summary": "💵 Cash\n - Account cash: ${available}\n - Investable cash: ${investable}", - "cash_by_currency": " - Cash by currency: {currencies}", "cash_label": "Cash", - "portfolio_summary_title": "📌 Portfolio snapshot", - "portfolio_total_assets": "Total assets (strategy symbols + cash): ${value}", - "portfolio_buying_power": "Buying power: ${available} | Investable cash: ${investable}", - "holding_line": "{symbol}: ${value} / {qty} shares", - "holdings_title": "💼 Holdings", "order_logs_title": "🧾 Execution details", "benchmark_title": "📈 {symbol} Benchmark", "benchmark_price": "{symbol}: {value}", diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index 109dd53..ff56706 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -65,6 +65,7 @@ def test_prefers_normalized_execution_annotations_when_present(self): "trade_threshold_value": 250.0, "signal_display": "signal", "status_display": "risk-on", + "dashboard_text": "strategy dashboard", "deploy_ratio_text": "60.0%", "income_ratio_text": "10.0%", "income_locked_ratio_text": "10.0%", @@ -91,6 +92,7 @@ def test_prefers_normalized_execution_annotations_when_present(self): self.assertEqual(plan["execution"]["trade_threshold_value"], 250.0) self.assertEqual(plan["execution"]["status_display"], "risk-on") self.assertEqual(plan["execution"]["signal_display"], "signal") + self.assertEqual(plan["execution"]["dashboard_text"], "strategy dashboard") self.assertEqual(plan["execution"]["investable_cash"], 9000.0) def test_maps_hybrid_decision_from_snapshot_source(self): @@ -149,6 +151,7 @@ def test_translates_weight_decision_for_tech_strategy(self): diagnostics={ "signal_description": "risk on", "status_description": "regime=soft_defense | breadth=55.0%", + "dashboard": "tech dashboard", "benchmark_symbol": "QQQ", }, ) @@ -174,9 +177,39 @@ def test_translates_weight_decision_for_tech_strategy(self): self.assertEqual(plan["allocation"]["targets"]["BOXX"], 6000.0) self.assertEqual(plan["execution"]["signal_display"], "risk on") self.assertEqual(plan["execution"]["status_display"], "regime=soft_defense | breadth=55.0%") + self.assertEqual(plan["execution"]["dashboard_text"], "tech dashboard") self.assertEqual(plan["execution"]["benchmark_symbol"], "QQQ") self.assertEqual(plan["portfolio"]["portfolio_rows"], (("AAPL", "MSFT", "BOXX"),)) + def test_keeps_cash_by_currency_from_snapshot_metadata(self): + decision = StrategyDecision( + positions=(PositionTarget(symbol="SOXL", target_value=0.0),), + diagnostics={ + "execution_annotations": { + "trade_threshold_value": 100.0, + "investable_cash": 0.0, + } + }, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=0.0, + buying_power=0.0, + positions=(), + metadata={ + "account_hash": "longbridge-sg", + "cash_by_currency": {"USD": 0.0, "SGD": 350.0}, + }, + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="soxl_soxx_trend_income", + ) + + self.assertEqual(plan["portfolio"]["cash_by_currency"], {"USD": 0.0, "SGD": 350.0}) + def test_translates_weight_decision_for_global_etf_rotation(self): decision = StrategyDecision( positions=( diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 3541c98..298f95a 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -34,16 +34,6 @@ def test_build_translator_supports_chinese(self): translate("cash_summary", available="1.00", investable="2.00"), "💵 资金\n - 账户现金: $1.00\n - 可投资现金: $2.00", ) - self.assertEqual(translate("holdings_title"), "💼 持仓") - self.assertEqual(translate("portfolio_summary_title"), "📌 账户概览") - self.assertEqual( - translate("portfolio_buying_power", available="1.00", investable="2.00"), - "购买力: $1.00 | 可投资现金: $2.00", - ) - self.assertEqual( - translate("holding_line", symbol="SOXL", value="123.45", qty=3), - "SOXL: $123.45 / 3股", - ) self.assertEqual(translate("order_logs_title"), "🧾 执行明细") self.assertEqual(translate("benchmark_title", symbol="QQQ"), "📈 QQQ 基准") self.assertEqual(translate("market_status_blend_gate_risk_on", asset="SOXX+SOXL"), "🚀 风险开启(SOXX+SOXL)") diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 4d01c08..e85a108 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -47,13 +47,37 @@ def _build_plan( available_cash, total_strategy_equity, portfolio_rows, - dashboard_text="", + dashboard_text=None, benchmark_symbol="", benchmark_price=0.0, long_trend_value=0.0, exit_line=0.0, cash_by_currency=None, ): + if dashboard_text is None: + dashboard_lines = [ + "📌 策略账户概览", + f" - 总资产(策略标的+现金): ${float(total_strategy_equity):,.2f}", + f" - 购买力: ${float(available_cash):.2f} | 可投资现金: ${float(investable_cash):.2f}", + ] + nonzero_cash = { + currency: amount + for currency, amount in dict(cash_by_currency or {}).items() + if float(amount) != 0.0 + } + if nonzero_cash and (len(nonzero_cash) > 1 or "USD" not in nonzero_cash): + formatted_cash = ", ".join( + f"{currency} {float(nonzero_cash[currency]):,.2f}" + for currency in sorted(nonzero_cash, key=lambda value: (value != "USD", value)) + ) + dashboard_lines.append(f" - 各币种现金: {formatted_cash}") + dashboard_lines.append("💼 策略持仓") + for row in portfolio_rows: + for symbol in row: + dashboard_lines.append( + f" - {symbol}: ${float(market_values[symbol]):,.2f} / {quantities.get(symbol, 0)}股" + ) + dashboard_text = "\n".join(dashboard_lines) return { "strategy_profile": strategy_profile, "allocation": { @@ -657,7 +681,7 @@ def test_heartbeat_accepts_normalized_portfolio_and_execution_sections(self): self.assertIn("💓 【心跳检测】", sent_messages[0]) self.assertIn("可投资现金", sent_messages[0]) self.assertNotIn("💵 资金\n - 账户现金:", sent_messages[0]) - self.assertIn("📌 账户概览", sent_messages[0]) + self.assertIn("📌 策略账户概览", sent_messages[0]) self.assertIn("总资产(策略标的+现金): $60,000.00", sent_messages[0]) self.assertIn("购买力: $101.95 | 可投资现金: $101.95", sent_messages[0]) self.assertIn("SOXX: $0.00 / 0股", sent_messages[0]) @@ -701,7 +725,7 @@ def test_hybrid_heartbeat_hides_empty_semiconductor_fields_and_shows_benchmark_l self.assertIn("💓 【心跳检测】", sent_messages[0]) self.assertIn("🧭 策略: TQQQ 增长收益", sent_messages[0]) self.assertIn("🧪 模拟运行模式", sent_messages[0]) - self.assertIn("📌 账户概览", sent_messages[0]) + self.assertIn("📌 策略账户概览", sent_messages[0]) self.assertIn("TQQQ: $0.00 / 0股", sent_messages[0]) self.assertIn("BOXX: $0.00 / 0股", sent_messages[0]) self.assertIn("QQQI: $0.00 / 0股", sent_messages[0]) From cdbff3ada4f4d404a60c3c55bc6126864bd8d4fd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 21 Apr 2026 05:18:50 +0800 Subject: [PATCH 4/4] Pin released strategy dashboard dependencies --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index f23fee1..210cff1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.18 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.28 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.19 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.30 pandas requests pytz