Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 109 additions & 37 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down
13 changes: 12 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
)

Expand Down
17 changes: 15 additions & 2 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "买入",
Expand Down Expand Up @@ -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})",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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})",
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down
45 changes: 45 additions & 0 deletions tests/test_execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading