From db6f67541f4b08bfe2a4ba3d2422fefb0e79af13 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:24:25 +0800 Subject: [PATCH] fix: reconcile Longbridge order outcomes Co-Authored-By: Codex --- application/execution_service.py | 43 +++++++++- application/rebalance_service.py | 24 +++++- decision_mapper.py | 4 + main.py | 15 +++- notifications/telegram.py | 6 ++ tests/test_decision_mapper.py | 7 ++ tests/test_rebalance_service.py | 142 +++++++++++++++++++++++++++++-- tests/test_request_handling.py | 30 +++++++ 8 files changed, 256 insertions(+), 15 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index d67cf24..996f377 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -256,6 +256,7 @@ class ExecutionCycleResult: note_logs: tuple[str, ...] action_done: bool dry_run_orders: tuple[dict, ...] = () + pending_orders: tuple[dict, ...] = () quote_snapshots: tuple[dict, ...] = () @@ -277,6 +278,19 @@ class ExecutionCycleResult: } +def _is_truthy(value) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _format_optional_equity(value) -> str: + try: + return f"${float(value):,.0f}" + except (TypeError, ValueError): + return "unknown" + + def _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol=None) -> float: normalized_symbol = str(symbol or "").strip().upper() try: @@ -984,6 +998,7 @@ def execute_rebalance_cycle( note_logs: list[str] = [] submitted_orders: list[dict] = [] dry_run_orders: list[dict] = [] + pending_orders: list[dict] = [] submitted_sell_orders: list[dict[str, Any]] = [] quote_snapshots_by_symbol: dict[str, dict] = {} small_account_cash_note_keys: set[str] = set() @@ -1087,6 +1102,9 @@ def _buy_step_for(symbol: str) -> float: symbol_suffix=symbol_suffix, ) target_values = dict(allocation["targets"]) + small_account_buy_blocked = _is_truthy(execution.get("small_account_warning")) + small_account_portfolio_equity = execution.get("portfolio_total_equity") + small_account_min_equity = execution.get("min_recommended_equity_usd") available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) @@ -1160,14 +1178,16 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su return False log_with_order_id = append_order_id_suffix(log_message, report.broker_order_id) - print(with_prefix(f"OK {log_with_order_id}"), flush=True) - logs.append(log_with_order_id) + pending_log = translator("order_pending_confirmation", detail=log_with_order_id) + print(with_prefix(pending_log), flush=True) + logs.append(pending_log) order_payload = { "symbol": str(symbol or "").strip().upper(), "side": str(side or "").strip().lower(), "quantity": float(order_intent.quantity or 0.0), "order_type": str(order_type or "").strip().lower(), - "status": report.status, + "status": "pending_reconciliation", + "submission_status": report.status, } if submitted_price is not None: order_payload["price"] = round(float(submitted_price), 4) @@ -1176,6 +1196,7 @@ def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, su if report.broker_order_id: order_payload["broker_order_id"] = report.broker_order_id submitted_orders.append(order_payload) + pending_orders.append(order_payload) if str(side or "").strip().lower() == "sell": submitted_sell_orders.append(order_payload) if post_submit_order is not None: @@ -1323,6 +1344,8 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): for symbol in buy_candidates if symbol != cash_sweep_symbol ] + if small_account_buy_blocked: + funding_buy_candidates = [] if ( not sell_submitted and funding_buy_candidates @@ -1508,7 +1531,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): and abs(target_values[symbol] - market_values[symbol]) > current_min_trade ] buys_blocked_reason: str | None = None - if cash_only_execution and buy_candidates and pending_sell_release_symbols: + if small_account_buy_blocked and buy_candidates: + buys_blocked_reason = "small_account_below_recommended_equity" + message = translator( + "buy_deferred_small_account", + portfolio_equity=_format_optional_equity(small_account_portfolio_equity), + min_recommended_equity=_format_optional_equity(small_account_min_equity), + ) + note_logs.append(message) + print(with_prefix(message), flush=True) + buy_candidates = [] + elif cash_only_execution and buy_candidates and pending_sell_release_symbols: estimated_buy_cost = 0.0 for symbol in buy_candidates: diff = target_values[symbol] - market_values[symbol] @@ -1716,6 +1749,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): not cash_sweep_sold_this_cycle and cash_sweep_symbol and cash_sweep_symbol in strategy_assets + and not small_account_buy_blocked and ( float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0 or not cash_sweep_substituted_to_cash @@ -1836,5 +1870,6 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): note_logs=tuple(note_logs), action_done=action_done, dry_run_orders=tuple(dry_run_orders), + pending_orders=tuple(pending_orders), quote_snapshots=tuple(quote_snapshots_by_symbol.values()), ) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index b90e79c..6272a18 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -31,11 +31,14 @@ def _record_platform_execution_telemetry( return execution = dict(execution_result.execution or {}) portfolio = dict(execution_result.portfolio or {}) + pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ()) try_record_platform_execution( profile, { "platform": "longbridge", - "action_done": bool(execution_result.action_done), + "action_done": bool(execution_result.action_done) and not pending_orders, + "broker_submission_done": bool(execution_result.action_done), + "orders_pending_count": len(pending_orders), "effective_date": execution.get("effective_date"), "signal_date": execution.get("signal_date"), "dry_run_only": bool(getattr(config, "dry_run_only", False)), @@ -239,6 +242,7 @@ def _record_execution_marker( "dry_run_only": bool(getattr(config, "dry_run_only", False)), "action_done": bool(getattr(result, "action_done", False)), "dry_run_orders_count": len(tuple(getattr(result, "dry_run_orders", ()) or ())), + "pending_orders_count": len(tuple(getattr(result, "pending_orders", ()) or ())), "signal_date": str(dict(getattr(result, "execution", {}) or {}).get("signal_date") or ""), "effective_date": str(dict(getattr(result, "execution", {}) or {}).get("effective_date") or ""), }, @@ -423,8 +427,24 @@ def fetch_replanned_state(): skip_logs = list(execution_result.skip_logs) note_logs = list(execution_result.note_logs) action_done = execution_result.action_done + pending_orders = tuple(getattr(execution_result, "pending_orders", ()) or ()) - if action_done: + if pending_orders: + notification_publisher.publish( + notification_renderers.render_rebalance_notification( + execution=execution, + logs=logs, + skip_logs=skip_logs, + note_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="pending_order_title", + ) + ) + elif action_done: notification_publisher.publish( notification_renderers.render_rebalance_notification( execution=execution, diff --git a/decision_mapper.py b/decision_mapper.py index fa3ade8..2cc7ed8 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -32,6 +32,10 @@ "snapshot_manifest_source_input_manifest_path", "snapshot_manifest_source_refresh_run_id", "snapshot_manifest_source_refresh_generated_at", + "small_account_warning", + "small_account_warning_reason", + "portfolio_total_equity", + "min_recommended_equity_usd", ) _TQQQ_RISK_CONTROL_EXECUTION_FIELDS = ( "dual_drive_volatility_delever_enabled", diff --git a/main.py b/main.py index 8c39b46..d00c838 100644 --- a/main.py +++ b/main.py @@ -278,12 +278,21 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict: skip_logs = tuple(getattr(cycle_result, "skip_logs", ()) or ()) note_logs = tuple(getattr(cycle_result, "note_logs", ()) or ()) dry_run_orders = tuple(getattr(cycle_result, "dry_run_orders", ()) or ()) + pending_orders = tuple(getattr(cycle_result, "pending_orders", ()) or ()) quote_snapshots = tuple(getattr(cycle_result, "quote_snapshots", ()) or ()) - order_events_count = len(logs) + order_events_count = 0 if pending_orders else len(logs) orders_previewed_count = len(dry_run_orders) if dry_run_orders else (order_events_count if dry_run else 0) + broker_submission_done = bool(getattr(cycle_result, "action_done", False)) summary = { - "action_done": bool(getattr(cycle_result, "action_done", False)), + "action_done": broker_submission_done and not pending_orders, + "broker_submission_done": broker_submission_done, + "execution_status": ( + "pending_reconciliation" + if pending_orders + else ("previewed" if dry_run and broker_submission_done else "no_action") + ), "order_events_count": order_events_count, + "orders_pending_count": len(pending_orders), "orders_previewed_count": orders_previewed_count, "orders_skipped_count": len(skip_logs), "notes_count": len(note_logs), @@ -291,6 +300,8 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict: } if dry_run_orders: summary["orders_previewed"] = [dict(order) for order in dry_run_orders] + if pending_orders: + summary["orders_pending"] = [dict(order) for order in pending_orders] if quote_snapshots: summary["quote_snapshot"] = { "quotes": [dict(snapshot) for snapshot in quote_snapshots], diff --git a/notifications/telegram.py b/notifications/telegram.py index fc57b9d..37c17e3 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -42,6 +42,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: I18N = { "zh": { "rebalance_title": "🔔 【调仓指令】", + "pending_order_title": "⏳ 【订单待券商最终确认】", "dry_run_banner": "🧪 模拟运行模式,本次不会真实下单", "strategy_label": "🧭 策略: {name}", "market_scope_detail": "🌏 市场: {market} | 交易币种: {currency} | 标的后缀: {symbol_suffix}", @@ -93,6 +94,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "buy_skip_whole_share_detail": "{symbol} 需增 ${diff},整数股不足 1 股,无需下单", "sell_skip_no_sellable_detail": "{symbol} 调仓差额 ${diff},持仓 {held} 股但无可卖数量(可卖 {sellable})", "buy_deferred": "ℹ️ [买入说明] {detail}", + "buy_deferred_small_account": "⛔ [买入保护] 净值 {portfolio_equity} 低于策略建议 {min_recommended_equity};本轮禁止新增买入或加仓,允许策略减仓卖出", "buy_deferred_pending_sell_release": "ℹ️ [买入跳过] 需先卖出 {symbols} 但整数股不足 1 股未成交;为避免融资本轮跳过对应买入", "buy_deferred_negative_cash": "ℹ️ [买入跳过] 账户现金已为负(${cash}),为避免额外融资本轮跳过买入", "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", @@ -120,6 +122,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "buy_deferred_cash_sweep_cash_limit": "{symbol} 剩余可投资现金 ${investable},预算可回补 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "dca_notional_to_whole_share_compat": "平台不支持碎股(API quantity ≥1),DCA 定投金额已转换为最小整股/整手订单", "execution_already_recorded": "已跳过重复执行:信号日 {signal_date} / 执行日 {effective_date} 已记录,本轮不再生成订单", + "order_pending_confirmation": "⏳ 已提交给券商,等待最终成交/拒绝确认:{detail}", "cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}", "limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}(已提交,等待成交确认)", "market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}", @@ -231,6 +234,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: }, "en": { "rebalance_title": "🔔 【Trade Execution Report】", + "pending_order_title": "⏳ 【Broker Order Pending Confirmation】", "dry_run_banner": "🧪 Dry run mode, no real orders will be submitted", "strategy_label": "🧭 Strategy: {name}", "market_scope_detail": "🌏 Market: {market} | trading currency: {currency} | symbol suffix: {symbol_suffix}", @@ -282,6 +286,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "buy_skip_whole_share_detail": "{symbol} needs ${diff} added; whole-share quantity rounds to 0; no order needed", "sell_skip_no_sellable_detail": "{symbol} rebalance gap ${diff}; held {held} shares but no sellable quantity (sellable {sellable})", "buy_deferred": "ℹ️ [Buy note] {detail}", + "buy_deferred_small_account": "⛔ [Buy guard] equity {portfolio_equity} is below the recommended {min_recommended_equity}; new buys and top-ups are blocked while strategy-driven sells remain allowed", "buy_deferred_pending_sell_release": "ℹ️ [Buy skipped] {symbols} still needs trimming but whole-share sell rounded to 0; skipping paired buys this cycle to avoid margin", "buy_deferred_negative_cash": "ℹ️ [Buy skipped] account cash is already negative (${cash}); skipping buys this cycle to avoid additional margin", "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", @@ -309,6 +314,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str: "buy_deferred_cash_sweep_cash_limit": "{symbol} residual investable cash ${investable}, budget supports {budget_qty} tail-rebuy shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "dca_notional_to_whole_share_compat": "Platform does not support fractional shares (API quantity >=1); DCA notional amounts converted to minimum whole-share / whole-lot orders", "execution_already_recorded": "Duplicate execution skipped: signal date {signal_date} / effective date {effective_date} is already recorded; no orders will be generated this cycle", + "order_pending_confirmation": "⏳ Submitted to the broker; waiting for final fill or rejection confirmation: {detail}", "cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}", "limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price} (submitted; awaiting fill confirmation)", "market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}", diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index 62f9bf0..78fafde 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -363,6 +363,10 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self): "snapshot_manifest_source_input_fallback_used": True, "snapshot_manifest_source_input_fallback_streak": 1, "snapshot_manifest_source_refresh_run_id": "26785047433", + "small_account_warning": True, + "small_account_warning_reason": "integer_share_rounding", + "portfolio_total_equity": 500.0, + "min_recommended_equity_usd": 1000.0, }, ) @@ -372,6 +376,9 @@ def test_carries_snapshot_manifest_diagnostics_to_execution(self): self.assertIs(plan["execution"]["snapshot_manifest_source_input_fallback_used"], True) self.assertEqual(plan["execution"]["snapshot_manifest_source_input_fallback_streak"], 1) self.assertEqual(plan["execution"]["snapshot_manifest_source_refresh_run_id"], "26785047433") + self.assertIs(plan["execution"]["small_account_warning"], True) + self.assertEqual(plan["execution"]["portfolio_total_equity"], 500.0) + self.assertEqual(plan["execution"]["min_recommended_equity_usd"], 1000.0) def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(self): decision = StrategyDecision( diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index ff3c4f4..45d3714 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -156,6 +156,134 @@ def _build_snapshot(plan, *, phase=""): class RebalanceServiceNotificationTests(unittest.TestCase): + def test_submitted_broker_order_is_recorded_as_pending_reconciliation(self): + submitted_orders = [] + plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 400.0}, + market_values={"SOXL": 0.0}, + sellable_quantities={"SOXL": 0}, + quantities={"SOXL": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=500.0, + market_status="Risk on", + deploy_ratio_text="70.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXL target", + available_cash=500.0, + total_strategy_equity=500.0, + portfolio_rows=(("SOXL",),), + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + plan, + plan["portfolio"], + plan["execution"], + plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-08-24", + last_price=100.0, + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 5, + execution_port=CallableExecutionPort( + lambda order_intent: ( + submitted_orders.append(order_intent), + ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="submitted", + broker_order_id="lb-order-pending", + ), + )[-1] + ), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=0.995, + limit_buy_premium=1.0, + ) + + self.assertTrue(result.action_done) + self.assertEqual(len(submitted_orders), 1) + self.assertEqual(len(result.pending_orders), 1) + self.assertEqual(result.pending_orders[0]["status"], "pending_reconciliation") + self.assertIn("等待最终成交", result.logs[0]) + + def test_small_account_warning_blocks_new_buys_but_not_sells(self): + submitted_orders = [] + plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 400.0}, + market_values={"SOXL": 0.0}, + sellable_quantities={"SOXL": 0}, + quantities={"SOXL": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=500.0, + market_status="Risk on", + deploy_ratio_text="70.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXL target", + available_cash=500.0, + total_strategy_equity=500.0, + portfolio_rows=(("SOXL",),), + ) + plan["execution"].update( + { + "small_account_warning": True, + "portfolio_total_equity": 500.0, + "min_recommended_equity_usd": 1000.0, + } + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + plan, + plan["portfolio"], + plan["execution"], + plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-08-24", + last_price=100.0, + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 5, + execution_port=CallableExecutionPort(submitted_orders.append), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=0.995, + limit_buy_premium=1.0, + ) + + self.assertFalse(result.action_done) + self.assertEqual(submitted_orders, []) + self.assertTrue(any("禁止新增买入或加仓" in note for note in result.note_logs)) + def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self): submitted_orders = [] plan = _build_plan( @@ -1033,7 +1161,7 @@ def test_run_strategy_supports_execution_port_runtime_path(self): self.assertEqual(observed_orders[0].order_type, "limit") self.assertEqual(observed_post_submit, [("trade-context", "SOXX.US", "lb-order-1")]) self.assertEqual(len(sent_messages), 1) - self.assertIn("【调仓", sent_messages[0]) + self.assertIn("【订单待券商最终确认】", sent_messages[0]) def test_run_strategy_skips_when_execution_marker_already_exists(self): sent_messages = [] @@ -1452,7 +1580,7 @@ def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("🧭 策略: SOXL/SOXX 半导体趋势收益", sent_messages[0]) self.assertNotIn("⏱ 执行时点:", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) @@ -1520,7 +1648,7 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0]) self.assertIn("本轮保留现金", sent_messages[0]) self.assertIn("现金替代:BOXX.US", sent_messages[0]) @@ -1581,7 +1709,7 @@ def test_small_account_cash_substitution_note_is_not_duplicated_after_sell_refre ) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("限价卖出] SOXL: 4股", sent_messages[0]) self.assertEqual(sent_messages[0].count("[买入说明] SOXX.US"), 1) self.assertEqual(sent_messages[0].count("本轮保留现金"), 1) @@ -1702,7 +1830,7 @@ def test_tqqq_delevered_qqqm_target_is_executable_for_small_account(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("限价买入] QQQM: 1股", sent_messages[0]) self.assertNotIn("QQQM.US 目标金额 $507.87 低于 1 股价格", sent_messages[0]) @@ -1769,7 +1897,7 @@ def test_existing_tqqq_retention_below_one_share_keeps_min_whole_share(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("限价卖出] TQQQ: 6股 @ $76.94", sent_messages[0]) self.assertNotIn("限价卖出] TQQQ: 7股", sent_messages[0]) self.assertIn("限价买入] QQQM: 1股 @ $298.68", sent_messages[0]) @@ -2171,7 +2299,7 @@ def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): self.assertEqual(observed_snapshots, [before_sell_snapshot, after_sell_snapshot]) self.assertEqual(len(sent_messages), 1) - self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("⏳ 【订单待券商最终确认】", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) self.assertIn("限价买入", sent_messages[0]) self.assertNotIn("买入跳过", sent_messages[0]) diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index d8e66e9..7e13407 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -881,6 +881,36 @@ def test_cycle_result_summary_counts_dry_run_order_previews(self): self.assertEqual(summary["orders_previewed"][0]["symbol"], "02800.HK") self.assertEqual(summary["quote_snapshot"]["quotes"][0]["symbol"], "02800.HK") + def test_cycle_result_summary_keeps_broker_submission_pending_until_reconciled(self): + module = load_module() + cycle_result = types.SimpleNamespace( + logs=("pending broker order",), + skip_logs=(), + note_logs=(), + action_done=True, + execution={}, + dry_run_orders=(), + pending_orders=( + { + "symbol": "SOXL.US", + "side": "sell", + "quantity": 1, + "status": "pending_reconciliation", + "broker_order_id": "lb-order-pending", + }, + ), + quote_snapshots=(), + ) + + summary = module._summarize_cycle_result_for_report(cycle_result, dry_run=False) + + self.assertFalse(summary["action_done"]) + self.assertTrue(summary["broker_submission_done"]) + self.assertEqual(summary["execution_status"], "pending_reconciliation") + self.assertEqual(summary["order_events_count"], 0) + self.assertEqual(summary["orders_pending_count"], 1) + self.assertEqual(summary["orders_pending"][0]["broker_order_id"], "lb-order-pending") + def test_notification_delivery_log_summary_records_sent_dry_run_without_raw_text(self): module = load_module()