Skip to content

Commit 2ad6039

Browse files
Pigbibicodex
andauthored
Fix Firstrade notional order rejection handling (#234)
Co-authored-by: Codex <noreply@openai.com>
1 parent b0cb287 commit 2ad6039

7 files changed

Lines changed: 355 additions & 46 deletions

File tree

application/execution_service.py

Lines changed: 109 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ class ExecutionCycleResult:
139139
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
140140
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
141141
MIN_NOTIONAL_BUY_USD = 1.0
142+
_ACCEPTED_ORDER_STATUSES = frozenset(
143+
{"accepted", "filled", "partiallyfilled", "previewed", "submitted"}
144+
)
145+
_BROKER_REJECTION_SKIP_REASONS = frozenset(
146+
{"broker_rejected", "fractional_trading_disclosure_required"}
147+
)
142148
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"})
143149
_SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT = 0.85
144150
SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = {
@@ -561,6 +567,52 @@ def _submit_notional_buy_order(
561567
}
562568

563569

570+
def _order_submission_accepted(order: dict[str, Any]) -> bool:
571+
status = "".join(
572+
ch for ch in str(order.get("status") or "").strip().lower() if ch.isalnum()
573+
)
574+
return status in _ACCEPTED_ORDER_STATUSES
575+
576+
577+
def _broker_rejection_reason(order: dict[str, Any]) -> str:
578+
raw_payload = dict(order.get("raw_payload") or {})
579+
for key, value in raw_payload.items():
580+
normalized = "".join(ch for ch in str(key).lower() if ch.isalnum())
581+
if normalized == "refcode" and str(value or "").strip() == "1219":
582+
return "fractional_trading_disclosure_required"
583+
return "broker_rejected"
584+
585+
586+
def _record_order_result(
587+
order: dict[str, Any],
588+
*,
589+
submitted: list[dict[str, Any]],
590+
skipped: list[dict[str, Any]],
591+
) -> bool:
592+
if _order_submission_accepted(order):
593+
submitted.append(order)
594+
return True
595+
skipped.append({**order, "reason": _broker_rejection_reason(order)})
596+
return False
597+
598+
599+
def _orders_for_allocation_drift(
600+
submitted_orders: list[dict[str, Any]],
601+
*,
602+
prices: dict[str, float],
603+
) -> list[dict[str, Any]]:
604+
projected_orders = []
605+
for order in submitted_orders:
606+
projected_order = dict(order)
607+
notional_usd = float(projected_order.get("notional_usd") or 0.0)
608+
symbol = str(projected_order.get("symbol") or "").strip().upper()
609+
price = float(prices.get(symbol) or 0.0)
610+
if notional_usd > 0.0 and price > 0.0:
611+
projected_order["quantity"] = notional_usd / price
612+
projected_orders.append(projected_order)
613+
return projected_orders
614+
615+
564616
def execute_value_target_plan(
565617
*,
566618
plan: dict[str, Any],
@@ -680,19 +732,22 @@ def execute_value_target_plan(
680732
)
681733
continue
682734
sell_limit_price = price * float(limit_sell_discount)
683-
submitted.append(
684-
_submit_order(
685-
execution_port,
686-
symbol=symbol,
687-
side="sell",
688-
quantity=quantity,
689-
limit_price=sell_limit_price,
690-
max_notional_usd=max_order_notional_usd,
691-
)
735+
order_result = _submit_order(
736+
execution_port,
737+
symbol=symbol,
738+
side="sell",
739+
quantity=quantity,
740+
limit_price=sell_limit_price,
741+
max_notional_usd=max_order_notional_usd,
692742
)
693-
submitted_sell_orders.append(submitted[-1])
694743
pending_sell_release_symbols.append(symbol)
695-
sell_submitted = True
744+
if _record_order_result(
745+
order_result,
746+
submitted=submitted,
747+
skipped=skipped,
748+
):
749+
submitted_sell_orders.append(order_result)
750+
sell_submitted = True
696751
continue
697752

698753
confirmed_sell_release_value = compute_confirmed_sell_release_value(
@@ -775,15 +830,18 @@ def execute_value_target_plan(
775830
}
776831
)
777832
continue
778-
submitted.append(
779-
_submit_notional_buy_order(
780-
execution_port,
781-
symbol=symbol,
782-
notional_usd=buy_budget,
783-
max_notional_usd=max_order_notional_usd,
784-
)
833+
order_result = _submit_notional_buy_order(
834+
execution_port,
835+
symbol=symbol,
836+
notional_usd=buy_budget,
837+
max_notional_usd=max_order_notional_usd,
785838
)
786-
investable_cash = max(0.0, investable_cash - buy_budget)
839+
if _record_order_result(
840+
order_result,
841+
submitted=submitted,
842+
skipped=skipped,
843+
):
844+
investable_cash = max(0.0, investable_cash - buy_budget)
787845
continue
788846
limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol)
789847
quantity = _planned_buy_order_quantity(
@@ -820,27 +878,41 @@ def execute_value_target_plan(
820878
}
821879
)
822880
continue
823-
submitted.append(
824-
_submit_order(
825-
execution_port,
826-
symbol=symbol,
827-
side="buy",
828-
quantity=quantity,
829-
limit_price=limit_price,
830-
max_notional_usd=max_order_notional_usd,
831-
)
881+
order_result = _submit_order(
882+
execution_port,
883+
symbol=symbol,
884+
side="buy",
885+
quantity=quantity,
886+
limit_price=limit_price,
887+
max_notional_usd=max_order_notional_usd,
832888
)
833-
investable_cash = max(0.0, investable_cash - (quantity * limit_price))
889+
if _record_order_result(
890+
order_result,
891+
submitted=submitted,
892+
skipped=skipped,
893+
):
894+
investable_cash = max(0.0, investable_cash - (quantity * limit_price))
834895

835896
total_value = float(portfolio.get("total_equity") or portfolio.get("total_strategy_equity") or 0.0)
836-
drift_notes = build_small_account_allocation_drift_notes(
837-
target_values=small_account_reference_target_values,
838-
current_values=market_values,
839-
current_quantities=current_quantities,
840-
prices=reference_prices,
841-
submitted_orders=submitted,
842-
total_value=total_value,
843-
cash_value=float(portfolio.get("liquid_cash") or 0.0),
897+
has_broker_rejection = any(
898+
str(item.get("reason") or "") in _BROKER_REJECTION_SKIP_REASONS
899+
for item in skipped
900+
)
901+
drift_notes = (
902+
()
903+
if has_broker_rejection
904+
else build_small_account_allocation_drift_notes(
905+
target_values=small_account_reference_target_values,
906+
current_values=market_values,
907+
current_quantities=current_quantities,
908+
prices=reference_prices,
909+
submitted_orders=_orders_for_allocation_drift(
910+
submitted,
911+
prices=reference_prices,
912+
),
913+
total_value=total_value,
914+
cash_value=float(portfolio.get("liquid_cash") or 0.0),
915+
)
844916
)
845917
execution_notes = tuple(execution_notes) + tuple(drift_notes)
846918

application/rebalance_service.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from decision_mapper import map_strategy_decision_to_plan
3939
from notifications.telegram import build_sender, build_translator, render_cycle_summary
4040
from quant_platform_kit.common.execution_outcomes import (
41+
DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS,
4142
filter_execution_blocking_skips,
4243
is_terminal_funding_block,
4344
resolve_strategy_run_stage,
@@ -69,6 +70,13 @@
6970
LIMIT_SELL_DISCOUNT = 0.995
7071
LIMIT_BUY_PREMIUM = 1.005
7172
DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {"SOXL": 1.015, "TQQQ": 1.010}
73+
BROKER_EXECUTION_BLOCKING_SKIP_REASONS = frozenset(
74+
{
75+
*DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS,
76+
"broker_rejected",
77+
"fractional_trading_disclosure_required",
78+
}
79+
)
7280

7381

7482
def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]:
@@ -623,7 +631,10 @@ def log_message(message: str) -> None:
623631
submitted_orders = list(execution_result.submitted_orders)
624632
skipped_orders = list(execution_result.skipped_orders)
625633
execution_notes = list(execution_result.execution_notes)
626-
blocking_skips = filter_execution_blocking_skips(skipped_orders)
634+
blocking_skips = filter_execution_blocking_skips(
635+
skipped_orders,
636+
blocking_reasons=BROKER_EXECUTION_BLOCKING_SKIP_REASONS,
637+
)
627638
execution_blocked = bool(blocking_skips)
628639
funding_blocked = is_terminal_funding_block(blocking_skips)
629640
terminal_funding_block = funding_blocked and not execution_result.action_done

application/runtime_broker_adapters.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ def _extract_broker_order_id(payload) -> str | None:
7777
return None
7878

7979

80+
def _extract_broker_status_code(payload) -> int | None:
81+
for key, value in flatten_values(payload).items():
82+
leaf_key = str(key or "").rsplit(".", 1)[-1]
83+
normalized = "".join(ch for ch in leaf_key.lower() if ch.isalnum())
84+
if normalized != "statuscode":
85+
continue
86+
try:
87+
return int(value)
88+
except (TypeError, ValueError):
89+
return None
90+
return None
91+
92+
8093
def _market_date(value: datetime) -> date:
8194
normalized = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
8295
return normalized.astimezone(_NEW_YORK_TZ).date()
@@ -335,12 +348,22 @@ def submit(order_intent) -> ExecutionReport:
335348
dry_run=not self.live_orders,
336349
explicit_live_ack=self.live_order_ack,
337350
)
351+
broker_order_id = _extract_broker_order_id(raw)
352+
broker_status_code = _extract_broker_status_code(raw)
353+
live_order_accepted = (
354+
broker_order_id is not None
355+
and (broker_status_code is None or 200 <= broker_status_code < 300)
356+
)
338357
return ExecutionReport(
339358
symbol=request.symbol,
340359
side=request.side,
341-
quantity=float(request.quantity or request.notional_usd or 0),
342-
status="previewed" if not self.live_orders else "submitted",
343-
broker_order_id=_extract_broker_order_id(raw),
360+
quantity=float(request.quantity or 0),
361+
status=(
362+
"previewed"
363+
if not self.live_orders
364+
else ("submitted" if live_order_accepted else "rejected")
365+
),
366+
broker_order_id=broker_order_id,
344367
raw_payload=raw,
345368
)
346369

notifications/telegram.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ def format_small_account_whole_share_bootstrap_notes(
234234
"order_logs_title": "🧾 执行明细",
235235
"dry_run_order": "🧪 模拟{order_type}{side} {symbol}: {quantity}{price}",
236236
"submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)",
237+
"submitted_limit_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)",
238+
"submitted_market_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(券商已受理,尚未确认成交)",
237239
"order_type_limit": "限价",
238240
"order_type_market": "市价",
239241
"side_buy": "买入",
@@ -310,6 +312,8 @@ def format_small_account_whole_share_bootstrap_notes(
310312
"skip_reason_negative_cash": "账户现金为负,跳过买入以避免额外融资",
311313
"skip_reason_buy_quantity_zero": "整数股不足 1 股,无需下单",
312314
"skip_reason_insufficient_cash_for_whole_share": "现金不足以买入一整股",
315+
"skip_reason_broker_rejected": "券商拒绝订单",
316+
"skip_reason_fractional_trading_disclosure_required": "请先在 Firstrade 接受零碎股交易披露(券商拒单 1219)",
313317
"skip_reason_unknown": "未知原因",
314318
"deferred_orders_line": "ℹ️ [本轮跳过] {details}",
315319
"skip_symbols_reason": "{symbols}({reason})",
@@ -403,6 +407,8 @@ def format_small_account_whole_share_bootstrap_notes(
403407
"order_logs_title": "🧾 Execution details",
404408
"dry_run_order": "🧪 Dry-run {order_type} {side} {symbol}: {quantity}{price}",
405409
"submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (fill not confirmed; a limit order may remain unfilled or be canceled)",
410+
"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)",
411+
"submitted_market_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (accepted by broker; fill not confirmed)",
406412
"order_type_limit": "limit",
407413
"order_type_market": "market",
408414
"side_buy": "buy",
@@ -479,6 +485,8 @@ def format_small_account_whole_share_bootstrap_notes(
479485
"skip_reason_negative_cash": "account cash is negative; buy skipped to avoid additional margin",
480486
"skip_reason_buy_quantity_zero": "whole-share quantity rounds to 0; no order needed",
481487
"skip_reason_insufficient_cash_for_whole_share": "insufficient cash for one whole share",
488+
"skip_reason_broker_rejected": "broker rejected the order",
489+
"skip_reason_fractional_trading_disclosure_required": "accept Firstrade's Fractional Shares Trading Disclosure first (broker rejection 1219)",
482490
"skip_reason_unknown": "unknown reason",
483491
"deferred_orders_line": "ℹ️ [Skipped this cycle] {details}",
484492
"skip_symbols_reason": "{symbols} ({reason})",
@@ -949,7 +957,12 @@ def _format_order_lines(
949957
price_suffix = translator("order_price_suffix", price=price) if price else ""
950958
side_key = "side_buy" if side == "buy" else "side_sell"
951959
order_type_key = "order_type_limit" if order_type == "limit" else "order_type_market"
952-
quantity = _format_shares(order.get("quantity"), translator=translator)
960+
notional_usd = _safe_float(order.get("notional_usd"))
961+
quantity = (
962+
_format_money(notional_usd)
963+
if notional_usd is not None and notional_usd > 0.0
964+
else _format_shares(order.get("quantity"), translator=translator)
965+
)
953966
if dry_run_only:
954967
lines.append(
955968
translator(
@@ -966,7 +979,7 @@ def _format_order_lines(
966979
order_id_suffix = translator("order_id_suffix", order_id=order_id) if order_id else ""
967980
lines.append(
968981
translator(
969-
"submitted_order",
982+
"submitted_market_order" if order_type == "market" else "submitted_limit_order",
970983
icon="📈" if side == "buy" else "📉",
971984
order_type=translator(order_type_key),
972985
side=translator(side_key),

tests/test_execution_service.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,51 @@ def test_execute_value_target_plan_uses_notional_buy_when_enabled():
579579
assert order.symbol == "QQQM"
580580
assert order.order_type == "market"
581581
assert order.metadata["notional_usd"] == 50.0
582+
assert result.execution_notes == ()
583+
584+
585+
def test_execute_value_target_plan_routes_rejected_notional_buy_to_skipped_orders():
586+
class RejectedExecutionPort(FakeExecutionPort):
587+
def submit_order(self, order_intent) -> ExecutionReport:
588+
self.orders.append(order_intent)
589+
return ExecutionReport(
590+
symbol=order_intent.symbol,
591+
side=order_intent.side,
592+
quantity=order_intent.quantity,
593+
status="rejected",
594+
raw_payload={
595+
"statusCode": 400,
596+
"error": "Bad Request",
597+
"message": (
598+
"Fractional Shares Trading Disclosure must be accepted before placing order."
599+
),
600+
"refCode": 1219,
601+
},
602+
)
603+
604+
execution_port = RejectedExecutionPort()
605+
result = execute_value_target_plan(
606+
plan={
607+
"allocation": {"targets": {"IBIT": 150.0}},
608+
"portfolio": {
609+
"market_values": {"IBIT": 70.0},
610+
"quantities": {"IBIT": 2.0},
611+
"liquid_cash": 80.0,
612+
"total_equity": 150.0,
613+
},
614+
"execution": {"current_min_trade": 1.0, "investable_cash": 80.0},
615+
},
616+
market_data_port=FakeMarketDataPort({"IBIT": 35.0}),
617+
execution_port=execution_port,
618+
dry_run_only=False,
619+
notional_buy_execution=True,
620+
)
621+
622+
assert result.action_done is False
623+
assert result.submitted_orders == ()
624+
assert result.skipped_orders[0]["reason"] == "fractional_trading_disclosure_required"
625+
assert result.skipped_orders[0]["notional_usd"] == 80.0
626+
assert result.execution_notes == ()
582627

583628

584629
def test_execute_value_target_plan_notional_buy_skips_below_minimum():

0 commit comments

Comments
 (0)