Skip to content

Commit 10e3684

Browse files
Pigbibicodex
andcommitted
fix: fail closed on IBKR execution readiness
Co-Authored-By: Codex <noreply@openai.com>
1 parent 4705b7a commit 10e3684

14 files changed

Lines changed: 648 additions & 28 deletions

application/execution_service.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,14 @@ def _resolve_order_account_id(account_ids=None) -> str | None:
310310
return normalized[0] if normalized else None
311311

312312

313-
MARKET_CURRENCY_CASH_TAG_PRIORITY = ("CashBalance", "TotalCashBalance", "SettledCash")
313+
MARKET_CURRENCY_CASH_TAG_PRIORITY = (
314+
"$LEDGER-CashBalance",
315+
"$LEDGER-TotalCashBalance",
316+
"CashBalance",
317+
"TotalCashBalance",
318+
"SettledCash",
319+
"TotalCashValue",
320+
)
314321

315322

316323
def _coerce_float(value):
@@ -2251,20 +2258,31 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
22512258
buying_power=buying_power,
22522259
)
22532260

2254-
execution_summary["execution_status"] = (
2255-
"executed"
2256-
if (
2257-
execution_summary["orders_submitted"]
2258-
or execution_summary["orders_filled"]
2259-
or execution_summary["orders_partially_filled"]
2260-
or execution_summary["option_orders_submitted"]
2261-
or execution_summary["option_orders_filled"]
2262-
or execution_summary["option_orders_partially_filled"]
2263-
)
2264-
else "no_op"
2261+
has_accepted_order = bool(
2262+
execution_summary["orders_submitted"]
2263+
or execution_summary["orders_filled"]
2264+
or execution_summary["orders_partially_filled"]
2265+
or execution_summary["option_orders_submitted"]
2266+
or execution_summary["option_orders_filled"]
2267+
or execution_summary["option_orders_partially_filled"]
22652268
)
2266-
if execution_summary["execution_status"] == "executed":
2269+
submission_failure = next(
2270+
(
2271+
reason
2272+
for reason in execution_summary["skipped_reasons"]
2273+
if reason.startswith(("submit_failed:", "option_submit_failed:"))
2274+
),
2275+
None,
2276+
)
2277+
if has_accepted_order:
2278+
execution_summary["execution_status"] = "executed"
22672279
execution_summary["no_op_reason"] = None
2280+
elif submission_failure:
2281+
execution_summary["execution_status"] = "blocked"
2282+
execution_summary["no_op_reason"] = submission_failure
2283+
trade_logs.append(translator("failed", reason=submission_failure))
2284+
else:
2285+
execution_summary["execution_status"] = "no_op"
22682286
execution_summary["residual_cash_estimate"] = float(max(buying_power, 0.0))
22692287
append_small_account_allocation_drift_notes()
22702288
return _finalize_result(trade_logs, execution_summary, return_summary=return_summary)

application/ibkr_portfolio.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,18 @@
88

99
from quant_platform_kit.common.models import PortfolioSnapshot, Position
1010

11-
MARKET_CURRENCY_CASH_TAG_PRIORITY = ("CashBalance", "TotalCashBalance", "SettledCash")
11+
MARKET_CURRENCY_CASH_TAG_PRIORITY = (
12+
"$LEDGER-CashBalance",
13+
"$LEDGER-TotalCashBalance",
14+
"CashBalance",
15+
"TotalCashBalance",
16+
"SettledCash",
17+
"TotalCashValue",
18+
)
19+
20+
21+
class IBKRPortfolioSnapshotUnavailableError(RuntimeError):
22+
"""Raised when IBKR did not return enough account data for safe execution."""
1223

1324

1425
def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
@@ -126,11 +137,14 @@ def fetch_portfolio_snapshot(
126137

127138
total_equity = 0.0
128139
available_funds = None
140+
matched_account_value_count = 0
141+
matched_market_currency_value_count = 0
129142
values_by_account_currency: dict[tuple[str | None, str], dict[str, float]] = {}
130143
for account_value in ib.accountValues():
131144
account_id = str(getattr(account_value, "account", "") or "").strip() or None
132145
if not _matches_account(account_id, selected_account_ids):
133146
continue
147+
matched_account_value_count += 1
134148
value_currency = str(getattr(account_value, "currency", "") or "").strip().upper()
135149
if value_currency:
136150
tag_values = values_by_account_currency.setdefault((account_id, value_currency), {})
@@ -139,6 +153,7 @@ def fetch_portfolio_snapshot(
139153
tag_values[str(getattr(account_value, "tag", "") or "").strip()] = numeric_value
140154
if value_currency != market_currency:
141155
continue
156+
matched_market_currency_value_count += 1
142157
if account_value.tag == "NetLiquidation":
143158
total_equity += float(account_value.value)
144159
elif account_value.tag == "AvailableFunds":
@@ -149,6 +164,18 @@ def fetch_portfolio_snapshot(
149164
values_by_account_currency,
150165
currency=market_currency,
151166
)
167+
if selected_account_ids and matched_account_value_count == 0:
168+
raise IBKRPortfolioSnapshotUnavailableError(
169+
"IBKR returned no account values for the configured account selection."
170+
)
171+
if selected_account_ids and matched_market_currency_value_count == 0:
172+
raise IBKRPortfolioSnapshotUnavailableError(
173+
f"IBKR returned no {market_currency} account values for the configured account selection."
174+
)
175+
if cash_only_execution and not positions and market_currency_cash is None:
176+
raise IBKRPortfolioSnapshotUnavailableError(
177+
f"IBKR cash-only snapshot is missing the {market_currency} cash balance and positions."
178+
)
152179
if cash_only_execution:
153180
buying_power = float(market_currency_cash or 0.0) if market_currency_cash is not None else 0.0
154181
position_market_values = {

application/rebalance_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1102,8 +1102,11 @@ def run_strategy_core(
11021102
flush=True,
11031103
)
11041104
_record_platform_execution_telemetry(signal_metadata, dict(execution_summary or {}))
1105+
execution_status = str((execution_summary or {}).get("execution_status") or "").strip().lower()
1106+
execution_blocked = execution_status in {"blocked", "error", "failed", "failure"}
1107+
execution_reason = str((execution_summary or {}).get("no_op_reason") or "execution_blocked")
11051108
return StrategyCycleResult(
1106-
result="OK - executed",
1109+
result=f"Blocked - {execution_reason}" if execution_blocked else "OK - executed",
11071110
signal_metadata=dict(signal_metadata or {}),
11081111
target_weights=dict(resolved_target_weights or {}),
11091112
execution_summary=dict(execution_summary or {}),

application/runtime_broker_adapters.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ class IBKRGatewayUnavailableError(ConnectionError):
1414
"""Raised after retryable IBKR gateway connection attempts are exhausted."""
1515

1616

17+
class IBKRTradingPermissionError(RuntimeError):
18+
"""Raised when a live Gateway connection cannot verify order-read access."""
19+
20+
1721
@dataclass(frozen=True)
1822
class IBKRRuntimeBrokerAdapters:
1923
host_resolver: Any
@@ -85,6 +89,62 @@ def fetch_account_portfolio_snapshot(self, ib):
8589
return self.fetch_portfolio_snapshot_fn(ib, account_ids=self.account_ids)
8690
return self.fetch_portfolio_snapshot_fn(ib)
8791

92+
def validate_trading_permissions(self, ib):
93+
if self.dry_run_only or str(self.execution_mode or "").strip().lower() != "live":
94+
return
95+
request_open_orders = getattr(ib, "reqOpenOrders", None)
96+
if not callable(request_open_orders):
97+
raise IBKRTradingPermissionError(
98+
"IB Gateway live execution cannot verify order-read access."
99+
)
100+
101+
original_raise_request_errors = getattr(ib, "RaiseRequestErrors", False)
102+
original_request_timeout = getattr(ib, "RequestTimeout", 0)
103+
read_only_errors: list[tuple[Any, str]] = []
104+
error_event = getattr(ib, "errorEvent", None)
105+
error_handler_registered = False
106+
107+
def is_read_only_error(message: Any) -> bool:
108+
normalized = str(message).lower().replace("-", " ").replace("_", " ")
109+
return "read only" in " ".join(normalized.split())
110+
111+
def capture_api_error(_request_id, error_code, error_message, _contract):
112+
if is_read_only_error(error_message):
113+
read_only_errors.append((error_code, str(error_message)))
114+
115+
try:
116+
if error_event is not None:
117+
error_event += capture_api_error
118+
error_handler_registered = True
119+
# Read order state only; this never calls placeOrder or cancelOrder.
120+
ib.RaiseRequestErrors = True
121+
ib.RequestTimeout = self.connect_timeout_seconds
122+
request_open_orders()
123+
if read_only_errors:
124+
raise IBKRTradingPermissionError(
125+
"IB Gateway API is in Read-Only mode; live execution is disabled."
126+
)
127+
except TimeoutError as exc:
128+
if read_only_errors:
129+
raise IBKRTradingPermissionError(
130+
"IB Gateway API is in Read-Only mode; live execution is disabled."
131+
) from exc
132+
raise
133+
except Exception as exc:
134+
if is_read_only_error(exc):
135+
raise IBKRTradingPermissionError(
136+
"IB Gateway API is in Read-Only mode; live execution is disabled."
137+
) from exc
138+
raise IBKRTradingPermissionError(
139+
"IB Gateway live execution could not verify order-read access "
140+
f"(error_type={type(exc).__name__})."
141+
) from exc
142+
finally:
143+
if error_handler_registered:
144+
error_event -= capture_api_error
145+
ib.RaiseRequestErrors = original_raise_request_errors
146+
ib.RequestTimeout = original_request_timeout
147+
88148
def connect_ib(self):
89149
self.ensure_event_loop_fn()
90150
host = self.host_resolver()
@@ -108,6 +168,7 @@ def connect_ib(self):
108168
)
109169
try:
110170
self.validate_configured_accounts(ib)
171+
self.validate_trading_permissions(ib)
111172
except Exception:
112173
disconnect_fn = getattr(ib, "disconnect", None)
113174
if callable(disconnect_fn):

entrypoints/cloud_run.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ def is_market_open_now(
3535
schedule = calendar.schedule(start_date=now_ny.date(), end_date=now_ny.date())
3636
if len(getattr(schedule, "index", ())) == 0:
3737
return False
38-
return calendar.open_at_time(schedule, now_ny)
38+
try:
39+
return calendar.open_at_time(schedule, now_ny)
40+
except ValueError as exc:
41+
if "not covered by the schedule" not in str(exc):
42+
raise
43+
return False
3944

4045

4146
def is_market_open_today(

main.py

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from application.cycle_result import coerce_strategy_cycle_result
2222
from application.runtime_broker_adapters import (
2323
IBKRGatewayUnavailableError,
24+
IBKRTradingPermissionError,
2425
build_runtime_broker_adapters,
2526
)
2627
from application.runtime_composer import build_runtime_composer
@@ -74,7 +75,10 @@
7475
max_workers_from_env,
7576
timeout_seconds_from_env,
7677
)
77-
from application.ibkr_portfolio import fetch_portfolio_snapshot
78+
from application.ibkr_portfolio import (
79+
IBKRPortfolioSnapshotUnavailableError,
80+
fetch_portfolio_snapshot,
81+
)
7882
from application.execution_service import (
7983
check_order_submitted as application_check_order_submitted,
8084
execute_rebalance as application_execute_rebalance,
@@ -1292,28 +1296,53 @@ def _handle_request(
12921296
)
12931297
if notification_delivery_log:
12941298
report_summary["notification_delivery_log"] = notification_delivery_log
1299+
execution_status = str(report_summary.get("execution_status") or "").strip().lower()
1300+
execution_blocked = execution_status in {"blocked", "error", "failed", "failure"}
1301+
if execution_blocked:
1302+
append_runtime_report_error(
1303+
report,
1304+
stage="strategy_execution",
1305+
message=(
1306+
f"Strategy execution status is {execution_status}; "
1307+
f"reason={report_summary.get('no_op_reason') or 'unspecified'}"
1308+
),
1309+
error_type="StrategyExecutionBlocked",
1310+
failure_category="strategy_execution_blocked",
1311+
)
1312+
report_diagnostics = {
1313+
"result": cycle_result.result,
1314+
"price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"),
1315+
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols")
1316+
or reconciliation_record.get("snapshot_price_fallback_symbols")
1317+
or [],
1318+
**({"signal_snapshot": signal_snapshot} if has_signal_snapshot else {}),
1319+
}
1320+
if execution_blocked:
1321+
report_diagnostics["failure_category"] = "strategy_execution_blocked"
12951322
finalize_runtime_report(
12961323
report,
1297-
status="ok",
1324+
status="error" if execution_blocked else "ok",
12981325
summary=report_summary,
1299-
diagnostics={
1300-
"result": cycle_result.result,
1301-
"price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"),
1302-
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols")
1303-
or reconciliation_record.get("snapshot_price_fallback_symbols")
1304-
or [],
1305-
**({"signal_snapshot": signal_snapshot} if has_signal_snapshot else {}),
1306-
},
1326+
diagnostics=report_diagnostics,
13071327
artifacts={
13081328
"reconciliation_record_path": cycle_result.reconciliation_record_path,
13091329
},
13101330
)
13111331
log_runtime_event(
13121332
log_context,
1313-
"strategy_cycle_completed",
1314-
message="Strategy dry-run completed" if dry_run_only_override else "Strategy execution completed",
1333+
"strategy_cycle_blocked" if execution_blocked else "strategy_cycle_completed",
1334+
message=(
1335+
"Strategy execution blocked"
1336+
if execution_blocked
1337+
else "Strategy dry-run completed"
1338+
if dry_run_only_override
1339+
else "Strategy execution completed"
1340+
),
1341+
severity="ERROR" if execution_blocked else "INFO",
13151342
execution_window=execution_window,
13161343
result=cycle_result.result,
1344+
execution_status=report_summary.get("execution_status"),
1345+
no_op_reason=report_summary.get("no_op_reason"),
13171346
)
13181347
return (response_body if dry_run_only_override else cycle_result.result), 200
13191348
except RuntimeDeadlineExceeded as exc:
@@ -1362,6 +1391,40 @@ def _handle_request(
13621391
exc=exc,
13631392
)
13641393
return "Error", 503
1394+
except (IBKRTradingPermissionError, IBKRPortfolioSnapshotUnavailableError) as exc:
1395+
failure_category = (
1396+
"ibkr_trading_permission"
1397+
if isinstance(exc, IBKRTradingPermissionError)
1398+
else "ibkr_portfolio_snapshot"
1399+
)
1400+
append_runtime_report_error(
1401+
report,
1402+
stage=failure_category,
1403+
message=str(exc),
1404+
error_type=type(exc).__name__,
1405+
failure_category=failure_category,
1406+
)
1407+
finalize_runtime_report(
1408+
report,
1409+
status="error",
1410+
diagnostics={"failure_category": failure_category},
1411+
)
1412+
log_runtime_event(
1413+
log_context,
1414+
f"{failure_category}_failed",
1415+
message="IBKR execution readiness validation failed",
1416+
severity="ERROR",
1417+
error_type=type(exc).__name__,
1418+
error_message=str(exc),
1419+
failure_category=failure_category,
1420+
)
1421+
error_msg = f"🚨 【IBKR 执行条件异常】\n{str(exc)}"
1422+
_publish_runtime_failure_notification(
1423+
detailed_text=error_msg,
1424+
compact_text=error_msg,
1425+
exc=exc,
1426+
)
1427+
return "Error", 503
13651428
except Exception as exc:
13661429
append_runtime_report_error(
13671430
report,

scripts/execution_report_heartbeat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
DEFAULT_ACCEPT_STATUSES = {"ok", "skipped", "success", "completed", "no_action"}
1919
DEFAULT_REJECT_STATUSES = {"error", "failed", "failure", "cancelled", "canceled", "timed_out"}
20+
DEFAULT_REJECT_EXECUTION_STATUSES = {"blocked", "error", "failed", "failure"}
2021
DEFAULT_ACCEPT_STAGES = {
2122
"DRY_RUN_COMPLETED",
2223
"FUNDING_BLOCKED",
@@ -749,6 +750,12 @@ def _report_status(payload: dict[str, Any]) -> tuple[str, str]:
749750
return status, stage
750751

751752

753+
def _report_execution_status(payload: dict[str, Any]) -> str:
754+
summary = payload.get("summary")
755+
nested_status = summary.get("execution_status") if isinstance(summary, dict) else None
756+
return str(payload.get("execution_status") or nested_status or "").strip()
757+
758+
752759
def _payload_service_name(payload: dict[str, Any]) -> str:
753760
runtime_target = payload.get("runtime_target")
754761
service = payload.get("service_name")
@@ -806,11 +813,15 @@ def _is_accepted_report(payload: dict[str, Any]) -> tuple[bool, str]:
806813
status, stage = _report_status(payload)
807814
status_key = status.lower()
808815
stage_key = stage.upper()
816+
execution_status = _report_execution_status(payload)
817+
execution_status_key = execution_status.lower()
809818
errors = _report_errors(payload)
810819
if errors and not allow_errors:
811820
return False, f"errors={len(errors)} status={status or '-'} stage={stage or '-'}"
812821
if status_key in reject_statuses or stage_key in reject_stages:
813822
return False, f"rejected status={status or '-'} stage={stage or '-'}"
823+
if execution_status_key in DEFAULT_REJECT_EXECUTION_STATUSES:
824+
return False, f"rejected execution_status={execution_status}"
814825
if status_key and status_key in accepted_statuses:
815826
return True, f"status={status}"
816827
if stage_key and stage_key in accepted_stages:

0 commit comments

Comments
 (0)