diff --git a/application/execution_service.py b/application/execution_service.py index beb5798..50cba00 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -139,6 +139,12 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 MIN_NOTIONAL_BUY_USD = 1.0 +_ACCEPTED_ORDER_STATUSES = frozenset( + {"accepted", "filled", "partiallyfilled", "previewed", "submitted"} +) +_BROKER_REJECTION_SKIP_REASONS = frozenset( + {"broker_rejected", "fractional_trading_disclosure_required"} +) SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"}) _SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT = 0.85 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = { @@ -561,6 +567,52 @@ def _submit_notional_buy_order( } +def _order_submission_accepted(order: dict[str, Any]) -> bool: + status = "".join( + ch for ch in str(order.get("status") or "").strip().lower() if ch.isalnum() + ) + return status in _ACCEPTED_ORDER_STATUSES + + +def _broker_rejection_reason(order: dict[str, Any]) -> str: + raw_payload = dict(order.get("raw_payload") or {}) + for key, value in raw_payload.items(): + normalized = "".join(ch for ch in str(key).lower() if ch.isalnum()) + if normalized == "refcode" and str(value or "").strip() == "1219": + return "fractional_trading_disclosure_required" + return "broker_rejected" + + +def _record_order_result( + order: dict[str, Any], + *, + submitted: list[dict[str, Any]], + skipped: list[dict[str, Any]], +) -> bool: + if _order_submission_accepted(order): + submitted.append(order) + return True + skipped.append({**order, "reason": _broker_rejection_reason(order)}) + return False + + +def _orders_for_allocation_drift( + submitted_orders: list[dict[str, Any]], + *, + prices: dict[str, float], +) -> list[dict[str, Any]]: + projected_orders = [] + for order in submitted_orders: + projected_order = dict(order) + notional_usd = float(projected_order.get("notional_usd") or 0.0) + symbol = str(projected_order.get("symbol") or "").strip().upper() + price = float(prices.get(symbol) or 0.0) + if notional_usd > 0.0 and price > 0.0: + projected_order["quantity"] = notional_usd / price + projected_orders.append(projected_order) + return projected_orders + + def execute_value_target_plan( *, plan: dict[str, Any], @@ -680,19 +732,22 @@ def execute_value_target_plan( ) continue sell_limit_price = price * float(limit_sell_discount) - submitted.append( - _submit_order( - execution_port, - symbol=symbol, - side="sell", - quantity=quantity, - limit_price=sell_limit_price, - max_notional_usd=max_order_notional_usd, - ) + order_result = _submit_order( + execution_port, + symbol=symbol, + side="sell", + quantity=quantity, + limit_price=sell_limit_price, + max_notional_usd=max_order_notional_usd, ) - submitted_sell_orders.append(submitted[-1]) pending_sell_release_symbols.append(symbol) - sell_submitted = True + if _record_order_result( + order_result, + submitted=submitted, + skipped=skipped, + ): + submitted_sell_orders.append(order_result) + sell_submitted = True continue confirmed_sell_release_value = compute_confirmed_sell_release_value( @@ -775,15 +830,18 @@ def execute_value_target_plan( } ) continue - submitted.append( - _submit_notional_buy_order( - execution_port, - symbol=symbol, - notional_usd=buy_budget, - max_notional_usd=max_order_notional_usd, - ) + order_result = _submit_notional_buy_order( + execution_port, + symbol=symbol, + notional_usd=buy_budget, + max_notional_usd=max_order_notional_usd, ) - investable_cash = max(0.0, investable_cash - buy_budget) + if _record_order_result( + order_result, + submitted=submitted, + skipped=skipped, + ): + investable_cash = max(0.0, investable_cash - buy_budget) continue limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) quantity = _planned_buy_order_quantity( @@ -820,27 +878,41 @@ def execute_value_target_plan( } ) continue - submitted.append( - _submit_order( - execution_port, - symbol=symbol, - side="buy", - quantity=quantity, - limit_price=limit_price, - max_notional_usd=max_order_notional_usd, - ) + order_result = _submit_order( + execution_port, + symbol=symbol, + side="buy", + quantity=quantity, + limit_price=limit_price, + max_notional_usd=max_order_notional_usd, ) - investable_cash = max(0.0, investable_cash - (quantity * limit_price)) + if _record_order_result( + order_result, + submitted=submitted, + skipped=skipped, + ): + investable_cash = max(0.0, investable_cash - (quantity * limit_price)) total_value = float(portfolio.get("total_equity") or portfolio.get("total_strategy_equity") or 0.0) - drift_notes = build_small_account_allocation_drift_notes( - target_values=small_account_reference_target_values, - current_values=market_values, - current_quantities=current_quantities, - prices=reference_prices, - submitted_orders=submitted, - total_value=total_value, - cash_value=float(portfolio.get("liquid_cash") or 0.0), + has_broker_rejection = any( + str(item.get("reason") or "") in _BROKER_REJECTION_SKIP_REASONS + for item in skipped + ) + drift_notes = ( + () + if has_broker_rejection + else build_small_account_allocation_drift_notes( + target_values=small_account_reference_target_values, + current_values=market_values, + current_quantities=current_quantities, + prices=reference_prices, + submitted_orders=_orders_for_allocation_drift( + submitted, + prices=reference_prices, + ), + total_value=total_value, + cash_value=float(portfolio.get("liquid_cash") or 0.0), + ) ) execution_notes = tuple(execution_notes) + tuple(drift_notes) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 90b41a3..4d61f2c 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -38,6 +38,7 @@ from decision_mapper import map_strategy_decision_to_plan from notifications.telegram import build_sender, build_translator, render_cycle_summary from quant_platform_kit.common.execution_outcomes import ( + DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS, filter_execution_blocking_skips, is_terminal_funding_block, resolve_strategy_run_stage, @@ -69,6 +70,13 @@ LIMIT_SELL_DISCOUNT = 0.995 LIMIT_BUY_PREMIUM = 1.005 DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {"SOXL": 1.015, "TQQQ": 1.010} +BROKER_EXECUTION_BLOCKING_SKIP_REASONS = frozenset( + { + *DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS, + "broker_rejected", + "fractional_trading_disclosure_required", + } +) def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]: @@ -623,7 +631,10 @@ def log_message(message: str) -> None: submitted_orders = list(execution_result.submitted_orders) skipped_orders = list(execution_result.skipped_orders) execution_notes = list(execution_result.execution_notes) - blocking_skips = filter_execution_blocking_skips(skipped_orders) + blocking_skips = filter_execution_blocking_skips( + skipped_orders, + blocking_reasons=BROKER_EXECUTION_BLOCKING_SKIP_REASONS, + ) execution_blocked = bool(blocking_skips) funding_blocked = is_terminal_funding_block(blocking_skips) terminal_funding_block = funding_blocked and not execution_result.action_done diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 4e3026b..cb5772d 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -77,6 +77,19 @@ def _extract_broker_order_id(payload) -> str | None: return None +def _extract_broker_status_code(payload) -> int | None: + for key, value in flatten_values(payload).items(): + leaf_key = str(key or "").rsplit(".", 1)[-1] + normalized = "".join(ch for ch in leaf_key.lower() if ch.isalnum()) + if normalized != "statuscode": + continue + try: + return int(value) + except (TypeError, ValueError): + return None + return None + + def _market_date(value: datetime) -> date: normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) return normalized.astimezone(_NEW_YORK_TZ).date() @@ -335,12 +348,22 @@ def submit(order_intent) -> ExecutionReport: dry_run=not self.live_orders, explicit_live_ack=self.live_order_ack, ) + broker_order_id = _extract_broker_order_id(raw) + broker_status_code = _extract_broker_status_code(raw) + live_order_accepted = ( + broker_order_id is not None + and (broker_status_code is None or 200 <= broker_status_code < 300) + ) return ExecutionReport( symbol=request.symbol, side=request.side, - quantity=float(request.quantity or request.notional_usd or 0), - status="previewed" if not self.live_orders else "submitted", - broker_order_id=_extract_broker_order_id(raw), + quantity=float(request.quantity or 0), + status=( + "previewed" + if not self.live_orders + else ("submitted" if live_order_accepted else "rejected") + ), + broker_order_id=broker_order_id, raw_payload=raw, ) diff --git a/notifications/telegram.py b/notifications/telegram.py index 5f14abd..8a7e6d9 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -234,6 +234,8 @@ def format_small_account_whole_share_bootstrap_notes( "order_logs_title": "🧾 执行明细", "dry_run_order": "🧪 模拟{order_type}{side} {symbol}: {quantity}{price}", "submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)", + "submitted_limit_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)", + "submitted_market_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(券商已受理,尚未确认成交)", "order_type_limit": "限价", "order_type_market": "市价", "side_buy": "买入", @@ -310,6 +312,8 @@ def format_small_account_whole_share_bootstrap_notes( "skip_reason_negative_cash": "账户现金为负,跳过买入以避免额外融资", "skip_reason_buy_quantity_zero": "整数股不足 1 股,无需下单", "skip_reason_insufficient_cash_for_whole_share": "现金不足以买入一整股", + "skip_reason_broker_rejected": "券商拒绝订单", + "skip_reason_fractional_trading_disclosure_required": "请先在 Firstrade 接受零碎股交易披露(券商拒单 1219)", "skip_reason_unknown": "未知原因", "deferred_orders_line": "ℹ️ [本轮跳过] {details}", "skip_symbols_reason": "{symbols}({reason})", @@ -403,6 +407,8 @@ def format_small_account_whole_share_bootstrap_notes( "order_logs_title": "🧾 Execution details", "dry_run_order": "🧪 Dry-run {order_type} {side} {symbol}: {quantity}{price}", "submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (fill not confirmed; a limit order may remain unfilled or be canceled)", + "submitted_limit_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (fill not confirmed; a limit order may remain unfilled or be canceled)", + "submitted_market_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (accepted by broker; fill not confirmed)", "order_type_limit": "limit", "order_type_market": "market", "side_buy": "buy", @@ -479,6 +485,8 @@ def format_small_account_whole_share_bootstrap_notes( "skip_reason_negative_cash": "account cash is negative; buy skipped to avoid additional margin", "skip_reason_buy_quantity_zero": "whole-share quantity rounds to 0; no order needed", "skip_reason_insufficient_cash_for_whole_share": "insufficient cash for one whole share", + "skip_reason_broker_rejected": "broker rejected the order", + "skip_reason_fractional_trading_disclosure_required": "accept Firstrade's Fractional Shares Trading Disclosure first (broker rejection 1219)", "skip_reason_unknown": "unknown reason", "deferred_orders_line": "ℹ️ [Skipped this cycle] {details}", "skip_symbols_reason": "{symbols} ({reason})", @@ -949,7 +957,12 @@ def _format_order_lines( price_suffix = translator("order_price_suffix", price=price) if price else "" side_key = "side_buy" if side == "buy" else "side_sell" order_type_key = "order_type_limit" if order_type == "limit" else "order_type_market" - quantity = _format_shares(order.get("quantity"), translator=translator) + notional_usd = _safe_float(order.get("notional_usd")) + quantity = ( + _format_money(notional_usd) + if notional_usd is not None and notional_usd > 0.0 + else _format_shares(order.get("quantity"), translator=translator) + ) if dry_run_only: lines.append( translator( @@ -966,7 +979,7 @@ def _format_order_lines( order_id_suffix = translator("order_id_suffix", order_id=order_id) if order_id else "" lines.append( translator( - "submitted_order", + "submitted_market_order" if order_type == "market" else "submitted_limit_order", icon="📈" if side == "buy" else "📉", order_type=translator(order_type_key), side=translator(side_key), diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 68c408c..a20ad27 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -579,6 +579,51 @@ def test_execute_value_target_plan_uses_notional_buy_when_enabled(): assert order.symbol == "QQQM" assert order.order_type == "market" assert order.metadata["notional_usd"] == 50.0 + assert result.execution_notes == () + + +def test_execute_value_target_plan_routes_rejected_notional_buy_to_skipped_orders(): + class RejectedExecutionPort(FakeExecutionPort): + def submit_order(self, order_intent) -> ExecutionReport: + self.orders.append(order_intent) + return ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="rejected", + raw_payload={ + "statusCode": 400, + "error": "Bad Request", + "message": ( + "Fractional Shares Trading Disclosure must be accepted before placing order." + ), + "refCode": 1219, + }, + ) + + execution_port = RejectedExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": {"targets": {"IBIT": 150.0}}, + "portfolio": { + "market_values": {"IBIT": 70.0}, + "quantities": {"IBIT": 2.0}, + "liquid_cash": 80.0, + "total_equity": 150.0, + }, + "execution": {"current_min_trade": 1.0, "investable_cash": 80.0}, + }, + market_data_port=FakeMarketDataPort({"IBIT": 35.0}), + execution_port=execution_port, + dry_run_only=False, + notional_buy_execution=True, + ) + + assert result.action_done is False + assert result.submitted_orders == () + assert result.skipped_orders[0]["reason"] == "fractional_trading_disclosure_required" + assert result.skipped_orders[0]["notional_usd"] == 80.0 + assert result.execution_notes == () def test_execute_value_target_plan_notional_buy_skips_below_minimum(): diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index bc1003a..f19b423 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -93,12 +93,20 @@ def get_order_status(self, _account, _order_id): def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): self.orders.append((request, dry_run, explicit_live_ack)) - return { + response = { "preview": dry_run, "symbol": request.symbol, "quantity": request.quantity, "price_type": request.price_type, } + if not dry_run: + response.update( + { + "statusCode": 200, + "result": {"order_id": f"OID-{len(self.orders)}"}, + } + ) + return response class FakeStrategyRuntime: @@ -254,6 +262,105 @@ def fake_client_factory(*args, **kwargs): assert "🧪 Dry-run limit buy AAA: 2 shares @ $10.05" in messages[0] +def test_run_strategy_cycle_persists_broker_rejection_as_retryable_block(monkeypatch): + class IbitRuntime(FakeStrategyRuntime): + profile = "ibit_smart_dca" + display_name = "IBIT Smart DCA" + + class RejectedOrderClient(FakeFirstradeClient): + def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): + self.orders.append((request, dry_run, explicit_live_ack)) + return { + "statusCode": 400, + "error": "Bad Request", + "message": ( + "Fractional Shares Trading Disclosure must be accepted before placing order." + ), + "refCode": 1219, + } + + store = FakeStateStore() + messages = [] + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: IbitRuntime(), + ) + monkeypatch.setattr( + "application.rebalance_service.notional_buy_execution_enabled", + lambda _profile: True, + ) + monkeypatch.setattr( + "application.rebalance_service._utcnow", + lambda: datetime(2026, 7, 27, 19, 45, tzinfo=timezone.utc), + ) + + result = run_strategy_cycle( + runtime_settings=_runtime_settings_with_persistence( + strategy_profile="ibit_smart_dca", + strategy_display_name="IBIT Smart DCA", + notify_lang="zh", + dry_run_only=False, + live_trading_enabled=True, + live_order_ack=True, + max_order_notional_usd=None, + persist_strategy_runs=True, + ), + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=RejectedOrderClient, + state_store=store, + notification_sender=messages.append, + env_reader=lambda _name, default=None: default, + ) + + latest_payload = store.writes[-2][1] + assert result["ok"] is False + assert result["action_done"] is False + assert result["strategy_run_stage"] == "EXECUTION_BLOCKED" + assert result["submitted_orders"] == [] + assert result["skipped_orders"][0]["reason"] == "fractional_trading_disclosure_required" + assert latest_payload["stage"] == "EXECUTION_BLOCKED" + assert "请先在 Firstrade 接受零碎股交易披露" in messages[0] + assert "已提交" not in messages[0] + + +def test_render_cycle_summary_formats_notional_market_buy_as_usd(): + message = render_cycle_summary( + { + "account": "****1234", + "strategy_profile": "ibit_smart_dca", + "strategy_display_name": "IBIT Smart DCA", + "dry_run_only": False, + "portfolio": { + "total_equity": 150.0, + "liquid_cash": 80.0, + "market_values": {"IBIT": 70.0}, + "quantities": {"IBIT": 2.0}, + }, + "allocation": {"targets": {"IBIT": 150.0}}, + "execution": {"reserved_cash": 0.0, "investable_cash": 80.0}, + "submitted_orders": [ + { + "symbol": "IBIT", + "side": "buy", + "quantity": 0.0, + "notional_usd": 80.0, + "order_type": "market", + "status": "submitted", + "broker_order_id": "OID-123", + "raw_payload": {}, + } + ], + "skipped_orders": [], + "execution_notes": [], + }, + lang="zh", + ) + + assert "📈 已提交市价买入 IBIT: $80.00" in message + assert "80股" not in message + assert "限价单可能未成交或取消" not in message + + def test_run_strategy_cycle_translates_weight_targets_when_balance_total_missing(monkeypatch): class CashOnlyClient(FakeFirstradeClient): def get_balances(self, _account): diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index 8102c24..e8aaba3 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -219,16 +219,54 @@ def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): assert request.quantity is None assert request.price_type == "market" assert request.max_notional_usd == 100.0 - assert report.quantity == 50.0 + assert report.quantity == 0.0 assert report.status == "previewed" +def test_execution_port_rejects_live_broker_error_response(): + class RejectedOrderClient(FakeClient): + def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): + del request, dry_run, explicit_live_ack + return { + "statusCode": 400, + "error": "Bad Request", + "message": ( + "Fractional Shares Trading Disclosure must be accepted before placing order." + ), + "refCode": 1219, + } + + from quant_platform_kit.common.models import OrderIntent + + adapters = build_runtime_broker_adapters( + client=RejectedOrderClient(), + account="12345678", + live_orders=True, + live_order_ack=True, + ) + report = adapters.build_execution_port().submit_order( + OrderIntent( + symbol="IBIT", + side="buy", + quantity=0.0, + order_type="market", + metadata={"notional_usd": 80.0}, + ) + ) + + assert report.quantity == 0.0 + assert report.status == "rejected" + assert report.broker_order_id is None + assert report.raw_payload["refCode"] == 1219 + + def test_execution_port_extracts_broker_order_id_from_raw_payload(): class LiveOrderClient(FakeClient): def place_stock_order(self, request, dry_run=True, explicit_live_ack=False): del explicit_live_ack return { - "order_id": "OID-123", + "statusCode": 200, + "result": {"order_id": "OID-123"}, "symbol": request.symbol, "dry_run": dry_run, "notional": request.notional_usd is not None,