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
120 changes: 83 additions & 37 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,47 @@ def check_order_submitted(report, *, translator):
return False, f"❌ {translator('failed', reason=status)}"


_FILLED_ORDER_STATUSES = frozenset({"Filled"})
_PARTIALLY_FILLED_ORDER_STATUSES = frozenset({"PartiallyFilled", "Partial"})
_PENDING_ORDER_STATUSES = frozenset(
{"PendingSubmit", "ApiPending", "ApiPendingSubmit", "Submitted", "PreSubmitted"}
)


def _record_order_outcome(
execution_summary: dict,
order_payload: dict,
*,
status: object,
option_order: bool = False,
) -> str:
"""Record the broker state without treating a non-terminal state as an execution.

IBKR can acknowledge an order and reject or cancel it later. A report captured
while the order is still pending is useful for reconciliation, but it must not
make the rebalance appear complete.
"""
normalized_status = str(status or "").strip()
prefix = "option_orders" if option_order else "orders"
if normalized_status in _FILLED_ORDER_STATUSES:
execution_summary[f"{prefix}_filled"].append(order_payload)
return "filled"
if normalized_status in _PARTIALLY_FILLED_ORDER_STATUSES:
execution_summary[f"{prefix}_partially_filled"].append(order_payload)
return "partially_filled"
if normalized_status in _PENDING_ORDER_STATUSES:
execution_summary[f"{prefix}_pending"].append(order_payload)
return "pending"
execution_summary[f"{prefix}_skipped"].append(
{**order_payload, "reason": normalized_status or "submit_failed"}
)
failure_prefix = "option_submit_failed" if option_order else "submit_failed"
execution_summary["skipped_reasons"].append(
f"{failure_prefix}:{order_payload.get('symbol')}:{normalized_status or 'unknown'}"
)
return "failed"


def _normalize_account_ids(account_ids=None) -> tuple[str, ...]:
if account_ids is None:
return ()
Expand Down Expand Up @@ -794,26 +835,23 @@ def _execute_option_order_intents(
account_id=order_account_id,
)
report = submit_order_intent(ib, order_intent)
ok, status_msg = check_order_submitted(report, translator=translator)
_, status_msg = check_order_submitted(report, translator=translator)
status = str(getattr(report, "status", "") or "")
order_payload = {
**payload,
"status": status,
"broker_order_id": getattr(report, "broker_order_id", None),
}
if status == "Filled":
execution_summary["option_orders_filled"].append(order_payload)
elif status in {"PartiallyFilled", "Partial"}:
execution_summary["option_orders_partially_filled"].append(order_payload)
elif ok:
execution_summary["option_orders_submitted"].append(order_payload)
else:
execution_summary["option_orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
execution_summary["skipped_reasons"].append(f"option_submit_failed:{symbol}:{status or 'unknown'}")
outcome = _record_order_outcome(
execution_summary,
order_payload,
status=status,
option_order=True,
)
trade_logs.append(f"option {action} {symbol} {format_quantity(quantity)} @{limit_price:.2f} {status_msg}")
if ok and action.startswith("buy"):
if outcome != "failed" and action.startswith("buy"):
buying_power -= estimated_notional
elif ok and intent_type == "multi_leg_option":
elif outcome != "failed" and intent_type == "multi_leg_option":
buying_power -= max_loss
return buying_power

Expand Down Expand Up @@ -1423,12 +1461,14 @@ def record_quote_snapshot(symbol, snapshot) -> None:
"target_safe_haven_weight": signal_metadata.get("safe_haven_weight"),
"realized_safe_haven_weight": signal_metadata.get("safe_haven_weight"),
"orders_submitted": [],
"orders_pending": [],
"orders_filled": [],
"orders_partially_filled": [],
"orders_skipped": [],
"option_order_intent_count": len(option_order_intents),
"option_order_underliers": list(option_underliers),
"option_orders_submitted": [],
"option_orders_pending": [],
"option_orders_filled": [],
"option_orders_partially_filled": [],
"option_orders_skipped": [],
Expand All @@ -1445,6 +1485,8 @@ def record_quote_snapshot(symbol, snapshot) -> None:
"small_account_safe_haven_cash_substituted_symbols": [],
"small_account_whole_share_cash_notes": [],
"small_account_allocation_drift_notes": [],
"small_account_buy_blocked": False,
"small_account_buy_block_reason": None,
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
"projected_sell_release_value": 0.0,
"current_stock_weight": 0.0,
Expand Down Expand Up @@ -1661,6 +1703,8 @@ def append_small_account_allocation_drift_notes():
if execution_summary.get("small_account_allocation_drift_notes"):
return
submitted_orders = tuple(execution_summary.get("orders_submitted") or ()) + tuple(
execution_summary.get("orders_pending") or ()
) + tuple(
execution_summary.get("orders_filled") or ()
) + tuple(execution_summary.get("orders_partially_filled") or ())
notes = build_small_account_allocation_drift_notes(
Expand Down Expand Up @@ -2039,7 +2083,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
account_id=order_account_id,
),
)
ok, status_msg = check_order_submitted(report, translator=translator)
_, status_msg = check_order_submitted(report, translator=translator)
status = str(getattr(report, "status", "") or "")
order_payload = {
"symbol": symbol,
Expand All @@ -2048,17 +2092,9 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
"status": status,
"broker_order_id": getattr(report, "broker_order_id", None),
}
if status == "Filled":
execution_summary["orders_filled"].append(order_payload)
elif status in {"PartiallyFilled", "Partial"}:
execution_summary["orders_partially_filled"].append(order_payload)
elif ok:
execution_summary["orders_submitted"].append(order_payload)
else:
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}")
if ok:
if outcome != "failed":
sell_executed = True
projected_sell_release_value += _projected_sell_release_value_for_report(
report,
Expand Down Expand Up @@ -2094,7 +2130,19 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
if current_mv.get(symbol, 0.0) < float(target or 0.0) - threshold
]
buys_blocked_reason = None
if cash_only_execution and pending_sell_release_symbols and buy_needed_symbols:
if bool(signal_metadata.get("small_account_warning")) and buy_needed_symbols:
buys_blocked_reason = "small_account_below_recommended_equity"
execution_summary["small_account_buy_blocked"] = True
execution_summary["small_account_buy_block_reason"] = buys_blocked_reason
execution_summary["skipped_reasons"].append(buys_blocked_reason)
trade_logs.append(
translator(
"buy_deferred_small_account",
portfolio_equity=f"{float(equity or 0.0):,.2f}",
min_recommended_equity=f"{float(signal_metadata.get('min_recommended_equity_usd') or 0.0):,.2f}",
)
)
if buys_blocked_reason is None and cash_only_execution and pending_sell_release_symbols and buy_needed_symbols:
if _rotation_guard_should_block_buys(
pending_sell_release_symbols=pending_sell_release_symbols,
buy_needed_symbols=buy_needed_symbols,
Expand Down Expand Up @@ -2219,7 +2267,7 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
account_id=order_account_id,
),
)
ok, status_msg = check_order_submitted(report, translator=translator)
_, status_msg = check_order_submitted(report, translator=translator)
status = str(getattr(report, "status", "") or "")
order_payload = {
"symbol": symbol,
Expand All @@ -2229,19 +2277,11 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
"status": status,
"broker_order_id": getattr(report, "broker_order_id", None),
}
if status == "Filled":
execution_summary["orders_filled"].append(order_payload)
elif status in {"PartiallyFilled", "Partial"}:
execution_summary["orders_partially_filled"].append(order_payload)
elif ok:
execution_summary["orders_submitted"].append(order_payload)
else:
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
outcome = _record_order_outcome(execution_summary, order_payload, status=status)
trade_logs.append(
translator("limit_buy", symbol=symbol, qty=format_quantity(qty), price=f"{limit_price:.2f}") + f" {status_msg}"
)
if ok:
if outcome != "failed":
investable_buying_power -= qty * limit_price

buying_power = _execute_option_order_intents(
Expand All @@ -2257,14 +2297,17 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
buying_power=buying_power,
)

has_accepted_order = bool(
has_terminal_order = bool(
execution_summary["orders_submitted"]
or execution_summary["orders_filled"]
or execution_summary["orders_partially_filled"]
or execution_summary["option_orders_submitted"]
or execution_summary["option_orders_filled"]
or execution_summary["option_orders_partially_filled"]
)
has_pending_order = bool(
execution_summary["orders_pending"] or execution_summary["option_orders_pending"]
)
submission_failure = next(
(
reason
Expand All @@ -2273,7 +2316,10 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
),
None,
)
if has_accepted_order:
if has_pending_order:
execution_summary["execution_status"] = "pending_reconciliation"
execution_summary["no_op_reason"] = "broker_order_pending_confirmation"
elif has_terminal_order:
execution_summary["execution_status"] = "executed"
execution_summary["no_op_reason"] = None
elif submission_failure:
Expand Down
6 changes: 6 additions & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def _record_platform_execution_telemetry(
"execution_status": summary.get("execution_status"),
"no_op_reason": summary.get("no_op_reason") or metadata.get("no_op_reason"),
"orders_submitted": list(summary.get("orders_submitted") or ()),
"orders_pending": list(summary.get("orders_pending") or ()),
"orders_filled": list(summary.get("orders_filled") or ()),
"orders_skipped": list(summary.get("orders_skipped") or ()),
"trade_date": summary.get("trade_date") or metadata.get("trade_date"),
Expand Down Expand Up @@ -142,9 +143,11 @@ def _execution_summary_has_order_activity(execution_summary: Mapping[str, object
bool(tuple(summary.get(key) or ()))
for key in (
"orders_submitted",
"orders_pending",
"orders_filled",
"orders_partially_filled",
"option_orders_submitted",
"option_orders_pending",
"option_orders_filled",
"option_orders_partially_filled",
)
Expand Down Expand Up @@ -708,9 +711,11 @@ def _should_record_execution_marker(*, trade_logs, execution_summary, config: IB
return False
accepted_order_keys = (
"orders_submitted",
"orders_pending",
"orders_filled",
"orders_partially_filled",
"option_orders_submitted",
"option_orders_pending",
"option_orders_filled",
"option_orders_partially_filled",
)
Expand Down Expand Up @@ -1110,6 +1115,7 @@ def run_strategy_core(
"path": str(record_path),
"status": record.get("execution_status"),
"orders_submitted": len(record.get("orders_submitted") or ()),
"orders_pending": len(record.get("orders_pending") or ()),
"orders_filled": len(record.get("orders_filled") or ()),
"orders_skipped": len(record.get("orders_skipped") or ()),
},
Expand Down
6 changes: 6 additions & 0 deletions application/reconciliation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,15 @@ def build_reconciliation_record(
],
"target_vs_current": execution_summary.get("target_vs_current") or [],
"orders_submitted": execution_summary.get("orders_submitted") or [],
"orders_pending": execution_summary.get("orders_pending") or [],
"orders_filled": execution_summary.get("orders_filled") or [],
"orders_partially_filled": execution_summary.get("orders_partially_filled") or [],
"orders_skipped": execution_summary.get("orders_skipped") or [],
"option_orders_submitted": execution_summary.get("option_orders_submitted") or [],
"option_orders_pending": execution_summary.get("option_orders_pending") or [],
"option_orders_filled": execution_summary.get("option_orders_filled") or [],
"option_orders_partially_filled": execution_summary.get("option_orders_partially_filled") or [],
"option_orders_skipped": execution_summary.get("option_orders_skipped") or [],
"skipped_reasons": execution_summary.get("skipped_reasons") or [],
"residual_cash_estimate": execution_summary.get("residual_cash_estimate"),
"cash_reserve_dollars": execution_summary.get("cash_reserve_dollars"),
Expand Down
3 changes: 3 additions & 0 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ def _build_order_batch_lines(execution_summary, *, translator) -> list[str]:
mode = str(execution_summary.get("mode") or "").strip().lower()
order_groups = [
("orders_submitted", "dry_run" if mode == "dry_run" else "submitted"),
("orders_pending", "pending"),
("orders_filled", "filled"),
("orders_partially_filled", "partial"),
]
Expand Down Expand Up @@ -650,10 +651,12 @@ def render_trade_notification(
execution_summary.get(field_name)
for field_name in (
"orders_submitted",
"orders_pending",
"orders_filled",
"orders_partially_filled",
"orders_skipped",
"option_orders_submitted",
"option_orders_pending",
"option_orders_filled",
"option_orders_partially_filled",
"option_orders_skipped",
Expand Down
6 changes: 6 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"no_order_plan_reason": "未下单: {reason}",
"buy_deferred": "ℹ️ [买入说明] {detail}",
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
"buy_deferred_small_account": "ℹ️ [买入保护] 账户净值 ${portfolio_equity} 低于策略建议 ${min_recommended_equity};本轮禁止新增或加仓,允许策略正常减仓",
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
"small_account_allocation_drift_detail": "{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})",
"buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单",
Expand All @@ -84,6 +85,8 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"dry_run_sell_batch": "🧪 模拟卖出 {count}个标的: {details}",
"submitted_buy_batch": "📈 已提交买单 {count}个标的: {details}",
"submitted_sell_batch": "📉 已提交卖单 {count}个标的: {details}",
"pending_buy_batch": "⏳ 买单待券商最终确认 {count}个标的: {details}",
"pending_sell_batch": "⏳ 卖单待券商最终确认 {count}个标的: {details}",
"filled_buy_batch": "✅ 买单成交 {count}个标的: {details}",
"filled_sell_batch": "✅ 卖单成交 {count}个标的: {details}",
"partial_buy_batch": "⚠️ 买单部分成交 {count}个标的: {details}",
Expand Down Expand Up @@ -284,6 +287,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"no_order_plan_reason": "No order submitted: {reason}",
"buy_deferred": "ℹ️ [Buy note] {detail}",
"buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this small account keeps cash this cycle and does not rebuy {cash_symbols}",
"buy_deferred_small_account": "ℹ️ [Buy guard] Portfolio equity ${portfolio_equity} is below the strategy recommendation ${min_recommended_equity}; new buys and top-ups are blocked while strategy-driven reductions remain allowed",
"small_account_allocation_drift": "📏 Integer-share drift: if this cycle's orders fully fill, {details}",
"small_account_allocation_drift_detail": "{symbol} projected {projected_weight} vs target {target_weight} ({drift_weight})",
"buy_lifted_small_account_whole_share": "ℹ️ [Buy note] {symbols} target is close to one share; small-account whole-share compatibility allows a 1-share order this cycle",
Expand All @@ -300,6 +304,8 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
"dry_run_sell_batch": "🧪 dry-run sells for {count} symbols: {details}",
"submitted_buy_batch": "📈 Submitted buy orders for {count} symbols: {details}",
"submitted_sell_batch": "📉 Submitted sell orders for {count} symbols: {details}",
"pending_buy_batch": "⏳ Buy orders pending broker confirmation for {count} symbols: {details}",
"pending_sell_batch": "⏳ Sell orders pending broker confirmation for {count} symbols: {details}",
"filled_buy_batch": "✅ Filled buy orders for {count} symbols: {details}",
"filled_sell_batch": "✅ Filled sell orders for {count} symbols: {details}",
"partial_buy_batch": "⚠️ Partial buy fills for {count} symbols: {details}",
Expand Down
Loading