From b62a2c98d0408659209af670a6ebe8ee0280dac6 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 29 Sep 2026 06:44:02 +0800 Subject: [PATCH] fix(notifications): distinguish blocked execution and native holding currencies Co-Authored-By: Codex --- application/longbridge_portfolio.py | 14 ++++- application/rebalance_service.py | 59 ++++++++++++++----- application/runtime_broker_adapters.py | 1 + notifications/compact_adapter.py | 37 ++++++++++++ notifications/renderers.py | 47 +++++++++++++-- notifications/telegram.py | 12 ++++ tests/test_longbridge_local_helpers.py | 40 ++++++++++++- tests/test_notifications.py | 81 ++++++++++++++++++++++++++ tests/test_rebalance_service.py | 19 ++++-- tests/test_runtime_broker_adapters.py | 2 + 10 files changed, 284 insertions(+), 28 deletions(-) diff --git a/application/longbridge_portfolio.py b/application/longbridge_portfolio.py index df29186d..34ee3152 100644 --- a/application/longbridge_portfolio.py +++ b/application/longbridge_portfolio.py @@ -145,6 +145,7 @@ def warn(message: str) -> None: filter_enabled = bool(assets) position_rows: list[tuple[str, str, Any, Any]] = [] + position_currency_by_symbol: dict[str, str | None] = {} try: positions_response = t_ctx.stock_positions() except Exception as exc: @@ -176,6 +177,13 @@ def warn(message: str) -> None: raise RuntimeError("LongBridge position quantity missing") if raw_available_quantity is None: raw_available_quantity = raw_quantity + raw_currency = str(getattr(position, "currency", "") or "").strip().upper() + position_currency = raw_currency if len(raw_currency) == 3 and raw_currency.isalpha() else None + if root_symbol in position_currency_by_symbol: + if position_currency_by_symbol[root_symbol] != position_currency: + position_currency_by_symbol[root_symbol] = None + else: + position_currency_by_symbol[root_symbol] = position_currency if position_log_fn is not None: position_log_fn( "[position_snapshot] raw " @@ -185,7 +193,10 @@ def warn(message: str) -> None: position_rows.append((root_symbol, full_symbol, raw_quantity, raw_available_quantity)) - prices = _fetch_last_prices(q_ctx, [full_symbol for _root_symbol, full_symbol, _quantity, _available in position_rows]) + prices = _fetch_last_prices( + q_ctx, + [full_symbol for _root_symbol, full_symbol, _quantity, _available in position_rows], + ) for root_symbol, full_symbol, raw_quantity, raw_available_quantity in position_rows: try: quantity = float(raw_quantity) @@ -215,6 +226,7 @@ def warn(message: str) -> None: return { "broker_capital": broker_capital, "heartbeat_account_snapshot": _heartbeat_account_snapshot(account_balance, trading_currency, observed_at), + "position_currency_by_symbol": position_currency_by_symbol, "available_cash": available_cash, "cash_by_currency": cash_by_currency, "market_values": market_values, diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 2e8d4536..7084cc48 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -657,20 +657,37 @@ def fetch_replanned_state(): except ValueError as exc: if str(exc) not in _LIVE_COMMAND_BINDING_ERRORS: raise - message = "Durable live execution command binding is invalid; broker orders blocked" - runtime.notify_issue("Durable live execution blocked", message) + message = config.translator("issue_durable_execution_binding_invalid") + runtime.notify_issue( + config.translator("issue_durable_execution_blocked_title"), message + ) + blocked_execution = { + "execution_status": "blocked", + "blocked_reason": "durable_live_execution_command_binding_invalid", + "heartbeat_execution_state": "blocked", + "durable_live_execution_command": { + "command_id": command.command_id, + "status": "BLOCKED_INVALID_BINDING", + "effective_date": command.effective_date, + }, + } + notification_publisher.publish( + notification_renderers.render_heartbeat_notification( + execution=blocked_execution, + skip_logs=(), + note_logs=(), + translator=config.translator, + separator=config.separator, + strategy_display_name=config.strategy_display_name, + dry_run_only=config.dry_run_only, + extra_notification_lines=config.extra_notification_lines, + title_key=config.notification_title_key or "heartbeat_title", + ) + ) return ExecutionCycleResult( plan={}, portfolio={}, - execution={ - "execution_status": "blocked", - "blocked_reason": "durable_live_execution_command_binding_invalid", - "durable_live_execution_command": { - "command_id": command.command_id, - "status": "BLOCKED_INVALID_BINDING", - "effective_date": command.effective_date, - }, - }, + execution=blocked_execution, allocation={}, logs=(), skip_logs=(), @@ -870,6 +887,15 @@ def fetch_replanned_state(): if direct_live_routing_blocked: execution["direct_live_routing_blocked"] = True execution["direct_live_routing_block_reason"] = "durable_execution_command_required" + if ( + account_identity_blocked + or (direct_live_routing_blocked and not live_command_waiting) + or (live_command_blocked and not live_command_waiting) + or str(execution.get("execution_status") or "").strip().lower() == "blocked" + ): + execution["heartbeat_execution_state"] = "blocked" + elif live_command_waiting: + execution["heartbeat_execution_state"] = "waiting_window" execution_already_recorded = ( direct_live_routing_blocked or account_identity_blocked or live_command_blocked ) @@ -934,8 +960,10 @@ def fetch_replanned_state(): elif live_command_waiting: message = "Durable live execution command queued; waiting for its effective trading session" elif live_command_blocked: - message = "Durable live execution command is unresolved; broker orders blocked" - runtime.notify_issue("Durable live execution blocked", message) + message = config.translator("issue_durable_execution_unresolved") + runtime.notify_issue( + config.translator("issue_durable_execution_blocked_title"), message + ) elif direct_live_routing_blocked: message = _durable_command_required_message(execution=execution) runtime.notify_issue("Next-session execution blocked", message) @@ -1107,6 +1135,11 @@ def submit_claimed_order(order_intent): execution.pop("heartbeat_account_snapshot", None) if isinstance(account_snapshot, dict): execution["heartbeat_account_snapshot"] = dict(account_snapshot) + position_currency_by_symbol = (getattr(initial_snapshot, "metadata", {}) or {}).get( + "position_currency_by_symbol" + ) + if isinstance(position_currency_by_symbol, dict): + execution["position_currency_by_symbol"] = dict(position_currency_by_symbol) if pending_orders: try: diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 135dbc8e..ccb25da1 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -186,6 +186,7 @@ def build_portfolio_snapshot_from_account_state(self, account_state): "account_hash": self.account_hash, "broker_capital": account_state.get("broker_capital"), "heartbeat_account_snapshot": account_state.get("heartbeat_account_snapshot"), + "position_currency_by_symbol": account_state.get("position_currency_by_symbol"), }, ) diff --git a/notifications/compact_adapter.py b/notifications/compact_adapter.py index 0e978a03..73c4de92 100644 --- a/notifications/compact_adapter.py +++ b/notifications/compact_adapter.py @@ -15,6 +15,8 @@ "💼 Strategy holdings", } _NUMBER_RE = re.compile(r"[-+]?\d[\d,]*(?:\.\d+)?") +_ISO_CURRENCY_RE = re.compile(r"^[A-Z]{3}$") +_DOLLAR_AMOUNT_RE = re.compile(r"\$\s*(?=[-+]?\d)") def _contains_nonzero_number(text: str) -> bool: @@ -42,6 +44,41 @@ def replace_share(match: re.Match[str]) -> str: return re.sub(r"([-+]?\d[\d,]*(?:\.\d+)?)\s*股", replace_share, detail) +def label_holding_currencies( + dashboard_text: str, + *, + position_currency_by_symbol: object, + unknown_currency_label: str, +) -> str: + """Label dollar-denominated holding amounts only from same-cycle position metadata.""" + currencies = position_currency_by_symbol if isinstance(position_currency_by_symbol, dict) else {} + normalized_currencies = { + str(symbol).strip().upper(): str(currency).strip().upper() + for symbol, currency in currencies.items() + if isinstance(currency, str) and _ISO_CURRENCY_RE.fullmatch(currency.strip().upper()) + } + output: list[str] = [] + in_holdings = False + for raw_line in str(dashboard_text or "").splitlines(): + line = raw_line + stripped = line.strip() + if stripped in _HOLDINGS_HEADERS: + in_holdings = True + output.append(line) + continue + if in_holdings and (not stripped or stripped.startswith(("━", "📌", "💵", "📊", "🎯", "🧾", "⏱", "🧩"))): + in_holdings = False + if in_holdings and (":" in line or ":" in line): + separator = ":" if ":" in line else ":" + prefix, remainder = line.split(separator, 1) + symbol = prefix.strip().lstrip("-• ").strip().upper() + currency = normalized_currencies.get(symbol, unknown_currency_label) + remainder = _DOLLAR_AMOUNT_RE.sub(f"{currency} ", remainder) + line = f"{prefix}{separator}{remainder}" + output.append(line) + return "\n".join(output) + + def adapt_compact_sections( dashboard_text: str, *, diff --git a/notifications/renderers.py b/notifications/renderers.py index 4b969eab..2a6f1c5b 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import math -from notifications.compact_adapter import adapt_compact_sections +from notifications.compact_adapter import adapt_compact_sections, label_holding_currencies from notifications.events import RenderedNotification from quant_platform_kit.common.notification_localization import ( localize_notification_text as _base_localize_notification_text, @@ -150,7 +150,13 @@ def _build_risk_control_lines(execution, *, translator): ) -def _format_dashboard_text(text, *, translator=None, cash_only_execution: bool = True) -> str: +def _format_dashboard_text( + text, + *, + translator=None, + cash_only_execution: bool = True, + position_currency_by_symbol=None, +) -> str: lines = [] for raw_line in str(text or "").splitlines(): line = raw_line.rstrip() @@ -160,6 +166,12 @@ def _format_dashboard_text(text, *, translator=None, cash_only_execution: bool = line = _localize_notification_text(line, translator=translator) lines.append(line) result = "\n".join(lines) + if translator is not None: + result = label_holding_currencies( + result, + position_currency_by_symbol=position_currency_by_symbol, + unknown_currency_label=translator("holding_currency_unverified"), + ) if translator is not None: result = _relabel_dashboard_cash_labels_shared( result, @@ -175,6 +187,7 @@ def _append_dashboard_block(lines, *, execution, separator, translator, compact: execution.get("dashboard_text"), translator=translator, cash_only_execution=cash_only_execution, + position_currency_by_symbol=execution.get("position_currency_by_symbol"), ) dashboard_lines = [ line for line in dashboard_text.splitlines() @@ -316,6 +329,7 @@ def _compact_total_assets_line(execution, *, translator) -> str: execution.get("dashboard_text"), translator=translator, cash_only_execution=bool(execution.get("cash_only_execution", True)), + position_currency_by_symbol=execution.get("position_currency_by_symbol"), ) labels = ( "总资产", @@ -397,6 +411,7 @@ def render_rebalance_notification( execution.get("dashboard_text"), translator=translator, cash_only_execution=bool(execution.get("cash_only_execution", True)), + position_currency_by_symbol=execution.get("position_currency_by_symbol"), ) compact_lines.extend( adapt_compact_sections( @@ -467,10 +482,11 @@ def render_heartbeat_notification( translator=translator, signal_key="heartbeat_signal", ) + outcome_key = _heartbeat_outcome_key(execution, skip_logs=skip_logs, note_logs=note_logs) detailed_lines.extend( [ separator, - translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades"), + translator(outcome_key), ] ) detailed_text = "\n".join(detailed_lines) @@ -500,6 +516,7 @@ def render_heartbeat_notification( execution.get("dashboard_text"), translator=translator, cash_only_execution=bool(execution.get("cash_only_execution", True)), + position_currency_by_symbol=execution.get("position_currency_by_symbol"), ) compact_lines.extend( adapt_compact_sections( @@ -508,11 +525,29 @@ def render_heartbeat_notification( supplemental_lines=execution.get("compact_supplemental_lines", ()), ) ) - compact_lines.append( - translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades") - ) + compact_lines.append(translator(outcome_key)) return RenderedNotification( detailed_text=detailed_text, compact_text="\n".join(compact_lines), ) + + +def _heartbeat_outcome_key(execution, *, skip_logs, note_logs) -> str: + state = str(execution.get("heartbeat_execution_state") or "").strip().lower() + durable = execution.get("durable_live_execution_command") + durable = durable if isinstance(durable, Mapping) else {} + is_hard_blocked = ( + state == "blocked" + or str(execution.get("execution_status") or "").strip().lower() == "blocked" + or bool(execution.get("account_identity_blocked")) + or bool(execution.get("live_command_blocked")) + or str(durable.get("status") or "").strip().upper() == "BLOCKED_INVALID_BINDING" + ) + if is_hard_blocked: + return "heartbeat_execution_blocked" + if state == "waiting_window": + return "heartbeat_waiting_window" + if bool(execution.get("direct_live_routing_blocked")): + return "heartbeat_execution_blocked" + return "no_executable_orders" if (skip_logs or note_logs) else "no_trades" diff --git a/notifications/telegram.py b/notifications/telegram.py index 0c590475..4e77940d 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -71,6 +71,12 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "heartbeat_account_equity": "💰 账户总权益: {value}", "heartbeat_account_observed": "资金快照(本轮调仓前): {value}", "heartbeat_unverified": "未核实", + "heartbeat_execution_blocked": "⚠️ 执行受阻,本轮未提交新订单", + "heartbeat_waiting_window": "⏳ 等待执行时段", + "holding_currency_unverified": "币种未核实", + "issue_durable_execution_blocked_title": "🛑 持久化实盘指令被阻断", + "issue_durable_execution_binding_invalid": "持久化实盘指令绑定无效;已阻止券商订单", + "issue_durable_execution_unresolved": "持久化实盘指令仍未解决;已阻止券商订单", "precheck_title": "🧪 【策略演练】", "dry_run_title": "🧪 【策略演练】", "health_probe_title": "🔎 【连接探针】", @@ -282,6 +288,12 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "heartbeat_account_equity": "💰 Total account equity: {value}", "heartbeat_account_observed": "Account snapshot (before this cycle's rebalance): {value}", "heartbeat_unverified": "Unverified", + "heartbeat_execution_blocked": "⚠️ Execution blocked; no new orders submitted this cycle", + "heartbeat_waiting_window": "⏳ Waiting for the execution window", + "holding_currency_unverified": "Currency unverified", + "issue_durable_execution_blocked_title": "Durable live execution blocked", + "issue_durable_execution_binding_invalid": "Durable live execution command binding is invalid; broker orders blocked", + "issue_durable_execution_unresolved": "Durable live execution command is unresolved; broker orders blocked", "precheck_title": "🧪 【Strategy Dry Run】", "dry_run_title": "🧪 【Strategy Dry Run】", "health_probe_title": "🔎 【Health Probe】", diff --git a/tests/test_longbridge_local_helpers.py b/tests/test_longbridge_local_helpers.py index bc3f9715..ee518bc2 100644 --- a/tests/test_longbridge_local_helpers.py +++ b/tests/test_longbridge_local_helpers.py @@ -31,10 +31,12 @@ def quote(self, symbols): class FakePosition: - def __init__(self, symbol, quantity, available_quantity=None): + def __init__(self, symbol, quantity, available_quantity=None, currency=None): self.symbol = symbol self.quantity = quantity self.available_quantity = available_quantity if available_quantity is not None else quantity + if currency is not None: + self.currency = currency class FakeChannel: @@ -43,8 +45,8 @@ def __init__(self, positions): class FakePositionsResponse: - def __init__(self): - self.channels = [FakeChannel([FakePosition("SOXL.US", 3), FakePosition("QQQI.US", 2, 1)])] + def __init__(self, positions=None): + self.channels = [FakeChannel(positions or [FakePosition("SOXL.US", 3), FakePosition("QQQI.US", 2, 1)])] class LongBridgeLocalHelpersTests(unittest.TestCase): @@ -110,6 +112,38 @@ def test_heartbeat_labels_cash_and_account_equity_in_their_own_currencies(self): self.assertEqual(snapshot["equity_currency"], "SGD") self.assertIn("observed_at", snapshot) + def test_position_currency_projection_uses_only_consistent_native_position_currency(self): + balance = types.SimpleNamespace(currency="SGD", net_assets="2500.50", cash_infos=[]) + positions = FakePositionsResponse([ + FakePosition("SOXL.US", 1, currency="USD"), + FakePosition("00700.HK", 1, currency="HKD"), + FakePosition("QQQI.US", 1), + ]) + trade = types.SimpleNamespace( + account_balance=lambda: [balance], stock_positions=lambda: positions, + ) + state = fetch_strategy_account_state( + FakeQuoteContext(), trade, ["SOXL", "00700", "QQQI"], + ) + self.assertEqual( + state["position_currency_by_symbol"], + {"SOXL": "USD", "00700": "HKD", "QQQI": None}, + ) + self.assertEqual(state["market_values"], {"SOXL": 50.0, "00700": 320.0, "QQQI": 20.0}) + + def test_conflicting_position_currencies_withhold_symbol_currency(self): + balance = types.SimpleNamespace(currency="SGD", net_assets="2500.50", cash_infos=[]) + positions = FakePositionsResponse([ + FakePosition("SOXL.US", 1, currency="USD"), + FakePosition("SOXL.US", 1, currency="SGD"), + ]) + trade = types.SimpleNamespace( + account_balance=lambda: [balance], stock_positions=lambda: positions, + ) + state = fetch_strategy_account_state(FakeQuoteContext(), trade, ["SOXL"]) + self.assertEqual(state["position_currency_by_symbol"], {"SOXL": None}) + self.assertEqual(state["market_values"]["SOXL"], 100.0) + def test_fetch_strategy_account_state_rejects_account_balance_failure(self): class BalanceFailingTradeContext: def account_balance(self): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index be39903f..73dcbbe6 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -238,6 +238,87 @@ def test_heartbeat_compact_copy_keeps_only_account_equity_currency(self): self.assertNotIn(cash, rendered.compact_text) self.assertIn(equity, rendered.compact_text) + def test_heartbeat_distinguishes_blocked_queued_and_normal_cycles_in_both_locales(self): + cases = ( + ( + { + "execution_status": "blocked", + "blocked_reason": "durable_binding_invalid", + "heartbeat_execution_state": "waiting_window", + }, + "⚠️ 执行受阻,本轮未提交新订单", + "⚠️ Execution blocked; no new orders submitted this cycle", + (), + ), + ( + { + "heartbeat_execution_state": "waiting_window", + "direct_live_routing_blocked": True, + }, + "⏳ 等待执行时段", + "⏳ Waiting for the execution window", + (), + ), + ( + {}, + "✅ 无交易,无需调仓", + "✅ No trade; no rebalance needed", + (), + ), + ( + {}, + "⚠️ 本轮没有可执行订单", + "⚠️ No executable orders this cycle", + ("Durable live execution command blocked",), + ), + ) + for execution, zh_text, en_text, note_logs in cases: + with self.subTest(execution=execution): + for language, expected in (("zh", zh_text), ("en", en_text)): + rendered = render_heartbeat_notification( + execution=execution, + skip_logs=(), + note_logs=note_logs, + translator=build_translator(language), + separator="---", + strategy_display_name="Example", + dry_run_only=False, + ) + self.assertIn(expected, rendered.detailed_text) + self.assertIn(expected, rendered.compact_text) + + def test_heartbeat_labels_holding_amounts_only_with_position_currency_source(self): + execution = { + "heartbeat_account_snapshot": { + "net_assets": 2500.5, + "equity_currency": "SGD", + "observed_at": "2026-09-25T14:09:00+00:00", + }, + "dashboard_text": ( + "💼 Strategy holdings\n" + " - SOXL: $100.00 / 1 share\n" + " - 00700: $320.00 / 1 share" + ), + "position_currency_by_symbol": {"SOXL": "USD", "00700": None}, + } + zh_rendered = render_heartbeat_notification( + execution=execution, skip_logs=(), note_logs=(), + translator=build_translator("zh"), separator="---", + strategy_display_name="Example", dry_run_only=False, + ) + en_rendered = render_heartbeat_notification( + execution=execution, skip_logs=(), note_logs=(), + translator=build_translator("en"), separator="---", + strategy_display_name="Example", dry_run_only=False, + ) + self.assertIn("SOXL: USD 100.00", zh_rendered.detailed_text) + self.assertIn("00700: 币种未核实 320.00", zh_rendered.detailed_text) + self.assertIn("账户总权益: SGD 2,500.50", zh_rendered.detailed_text) + self.assertNotIn("$100.00", zh_rendered.detailed_text) + self.assertIn("SOXL: USD 100.00", en_rendered.compact_text) + self.assertIn("00700: Currency unverified 320.00", en_rendered.compact_text) + self.assertNotIn("$320.00", en_rendered.compact_text) + def test_compact_heartbeat_keeps_nonzero_holdings_and_omits_noise(self): rendered = render_heartbeat_notification( execution={ diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index aaef0f48..ed04c570 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -1605,7 +1605,7 @@ def test_live_commands_continue_daily_and_block_old_unknown_orders(self): clock = {"day": "2026-07-17", "status": "New"} next_days = {"2026-07-17": "2026-07-20", "2026-07-20": "2026-07-21", "2026-07-21": "2026-07-22", "2026-07-22": "2026-07-23"} targets = {"2026-07-17": 400.0, "2026-07-20": 300.0, "2026-07-21": 200.0, "2026-07-22": 200.0} - resolved_new_signals, orders, alerts, frozen_targets = [], [], [], [] + resolved_new_signals, orders, alerts, frozen_targets, messages = [], [], [], [], [] def new_plan(**_kwargs): resolved_new_signals.append(clock["day"]) @@ -1622,7 +1622,7 @@ def frozen_plan(*, allocation, execution, snapshot): market_data_port_factory=lambda _context: CallableMarketDataPort( quote_loader=lambda symbol: QuoteSnapshot(symbol=symbol, as_of=clock["day"], last_price=100.0)), estimate_max_purchase_quantity=lambda *_args, **_kwargs: 5, - notifications=CallableNotificationPort(lambda _message: None), + notifications=CallableNotificationPort(messages.append), notify_issue=lambda title, detail: alerts.append((title, detail)), portfolio_port_factory=lambda *_contexts: CallablePortfolioPort( lambda: replace(_build_snapshot(plan), as_of=clock["day"])), @@ -1636,13 +1636,17 @@ def frozen_plan(*, allocation, execution, snapshot): limit_sell_discount=0.995, limit_buy_premium=1.005, separator="-", translator=build_translator("en"), with_prefix=lambda message: message, strategy_profile="russell_top50_leader_rotation", execution_state_account_scope="SG", - physical_account_id="lb-sg-001", dry_run_only=False, notify_no_trade_cycles=False, + physical_account_id="lb-sg-001", dry_run_only=False, notify_no_trade_cycles=True, execution_dedup_enabled=True, execution_state_store=marker_store, durable_execution_command_live_enabled=True, execution_command_store=command_store, durable_live_execution_session_authorized=True, durable_execution_runtime_identity_digest="a" * 64, ) first = rebalance_service.run_strategy(runtime=runtime, config=config) + self.assertEqual(first.execution["heartbeat_execution_state"], "waiting_window") + self.assertIn("Waiting for the execution window", messages[0]) + self.assertEqual(orders, []) + self.assertIs(command_store.current_state(command_store.list_due("2026-07-20")[0]), ExecutionCommandState.QUEUED) rebalance_service.run_strategy(runtime=runtime, config=config) self.assertFalse(first.action_done) self.assertEqual(first.execution["durable_live_execution_command"]["status"], "QUEUED") @@ -1658,6 +1662,7 @@ def frozen_plan(*, allocation, execution, snapshot): clock["day"] = "2026-07-21" blocked = rebalance_service.run_strategy(runtime=runtime, config=config) self.assertFalse(blocked.action_done) + self.assertEqual(blocked.execution["heartbeat_execution_state"], "blocked") self.assertEqual(len(orders), 1) # Yesterday's unresolved order blocks today's due command. clock["status"] = "Filled" resumed = rebalance_service.run_strategy(runtime=runtime, config=config) @@ -1836,13 +1841,14 @@ def test_queued_stale_live_command_blocks_without_store_or_broker_writes(self): self.assertTrue(command_store.enqueue(valid)) self.assertTrue(command_store.enqueue(stale)) issues = [] + messages = [] runtime = LongBridgeRebalanceRuntime( bootstrap=lambda: ("quote", "trade", {"trend": "ok"}), resolve_rebalance_plan=lambda **_kwargs: (_ for _ in ()).throw(AssertionError("must not evaluate")), market_data_port_factory=lambda _context: CallableMarketDataPort( quote_loader=lambda _symbol: (_ for _ in ()).throw(AssertionError("must not load quote"))), estimate_max_purchase_quantity=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not estimate")), - notifications=CallableNotificationPort(lambda _message: None), + notifications=CallableNotificationPort(messages.append), notify_issue=lambda title, detail: issues.append((title, detail)), portfolio_port_factory=lambda *_contexts: CallablePortfolioPort( lambda: (_ for _ in ()).throw(AssertionError("must not load snapshot"))), @@ -1869,6 +1875,8 @@ def test_queued_stale_live_command_blocks_without_store_or_broker_writes(self): self.assertEqual(result.execution["blocked_reason"], "durable_live_execution_command_binding_invalid") self.assertEqual(result.execution["durable_live_execution_command"]["status"], "BLOCKED_INVALID_BINDING") self.assertEqual(issues, [("Durable live execution blocked", "Durable live execution command binding is invalid; broker orders blocked")]) + self.assertEqual(len(messages), 1) + self.assertIn("Execution blocked; no new orders submitted this cycle", messages[0]) reconcile.assert_not_called() self.assertIs(command_store.current_state(valid), ExecutionCommandState.QUEUED) self.assertIs(command_store.current_state(stale), ExecutionCommandState.QUEUED) @@ -3107,7 +3115,8 @@ def test_zero_investable_cash_reports_buying_power_without_trade_note(self): self.assertEqual(len(sent_messages), 1) self.assertNotIn("账户现金", sent_messages[0]) self.assertNotIn("可用现金: $3065.61 | 可投资现金: $0.00", sent_messages[0]) - self.assertIn("BOXX: $24,880.00 / 214股", sent_messages[0]) + self.assertIn("BOXX: 币种未核实 24,880.00 / 214股", sent_messages[0]) + self.assertNotIn("BOXX: $24,880.00", sent_messages[0]) self.assertIn("✅ 无交易,无需调仓", sent_messages[0]) self.assertNotIn("本轮没有可执行订单", sent_messages[0]) self.assertNotIn("说明", sent_messages[0]) diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index 54c97f1d..7cef2911 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -142,6 +142,7 @@ def test_build_portfolio_and_execution_ports_adapt_runtime_calls(): "available_cash": 500.0, "total_strategy_equity": 2500.0, "cash_by_currency": {"USD": 500.0}, + "position_currency_by_symbol": {"SOXL": "USD", "BOXX": None}, "sellable_quantities": {"SOXL": 10, "BOXX": 7}, }, )[-1], @@ -175,6 +176,7 @@ def test_build_portfolio_and_execution_ports_adapt_runtime_calls(): assert snapshot.buying_power == 500.0 assert snapshot.metadata["account_hash"] == "HK-001" assert snapshot.metadata["cash_by_currency"] == {"USD": 500.0} + assert snapshot.metadata["position_currency_by_symbol"] == {"SOXL": "USD", "BOXX": None} assert observed["orders"] == [ ( "trade-context",