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
43 changes: 30 additions & 13 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,13 @@ def _resolve_order_account_id(account_ids=None) -> str | None:
return normalized[0] if normalized else None


MARKET_CURRENCY_CASH_TAG_PRIORITY = ("CashBalance", "TotalCashBalance", "SettledCash")
MARKET_CURRENCY_CASH_TAG_PRIORITY = (
"$LEDGER-CashBalance",
"$LEDGER-TotalCashBalance",
"CashBalance",
"TotalCashBalance",
"SettledCash",
)


def _coerce_float(value):
Expand Down Expand Up @@ -2251,20 +2257,31 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
buying_power=buying_power,
)

execution_summary["execution_status"] = (
"executed"
if (
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"]
)
else "no_op"
has_accepted_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"]
)
if execution_summary["execution_status"] == "executed":
submission_failure = next(
(
reason
for reason in execution_summary["skipped_reasons"]
if reason.startswith(("submit_failed:", "option_submit_failed:"))
),
None,
)
if has_accepted_order:
execution_summary["execution_status"] = "executed"
execution_summary["no_op_reason"] = None
elif submission_failure:
execution_summary["execution_status"] = "blocked"
execution_summary["no_op_reason"] = submission_failure
trade_logs.append(translator("failed", reason=submission_failure))
else:
execution_summary["execution_status"] = "no_op"
execution_summary["residual_cash_estimate"] = float(max(buying_power, 0.0))
append_small_account_allocation_drift_notes()
return _finalize_result(trade_logs, execution_summary, return_summary=return_summary)
36 changes: 36 additions & 0 deletions application/ibkr_order_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,42 @@ def factory(side: str, quantity: float) -> Any:
return factory


def probe_order_write_access(
ib: Any,
*,
symbol: str,
account_id: str,
stock_factory: Callable[..., Any] | None = None,
market_order_factory: Callable[..., Any] | None = None,
stock_exchange: str = "SMART",
stock_currency: str = "USD",
) -> Any:
"""Verify order-write access with an IBKR what-if order that cannot execute."""

what_if_order = getattr(ib, "whatIfOrder", None)
if not callable(what_if_order):
raise RuntimeError("IBKR connection does not support what-if orders")

normalized_symbol = str(symbol or "").strip().upper()
normalized_account_id = str(account_id or "").strip()
if not normalized_symbol or not normalized_account_id:
raise ValueError("IBKR what-if probe requires a symbol and account_id")

contract = _stock_factory_for_market(
stock_factory,
exchange=str(stock_exchange or "SMART").upper(),
currency=str(stock_currency or "USD").upper(),
)(normalized_symbol)
order = _market_order_factory_with_time_in_force(
market_order_factory,
time_in_force=DEFAULT_TIME_IN_FORCE,
)("BUY", 1)
order.account = normalized_account_id
order.whatIf = True
order.transmit = True
return what_if_order(contract, order)


def submit_order_intent(
ib: Any,
order_intent: OrderIntent,
Expand Down
28 changes: 27 additions & 1 deletion application/ibkr_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@

from quant_platform_kit.common.models import PortfolioSnapshot, Position

MARKET_CURRENCY_CASH_TAG_PRIORITY = ("CashBalance", "TotalCashBalance", "SettledCash")
MARKET_CURRENCY_CASH_TAG_PRIORITY = (
"$LEDGER-CashBalance",
"$LEDGER-TotalCashBalance",
"CashBalance",
"TotalCashBalance",
"SettledCash",
)


class IBKRPortfolioSnapshotUnavailableError(RuntimeError):
"""Raised when IBKR did not return enough account data for safe execution."""


def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
Expand Down Expand Up @@ -126,11 +136,14 @@ def fetch_portfolio_snapshot(

total_equity = 0.0
available_funds = None
matched_account_value_count = 0
matched_market_currency_value_count = 0
values_by_account_currency: dict[tuple[str | None, str], dict[str, float]] = {}
for account_value in ib.accountValues():
account_id = str(getattr(account_value, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
matched_account_value_count += 1
value_currency = str(getattr(account_value, "currency", "") or "").strip().upper()
if value_currency:
tag_values = values_by_account_currency.setdefault((account_id, value_currency), {})
Expand All @@ -139,6 +152,7 @@ def fetch_portfolio_snapshot(
tag_values[str(getattr(account_value, "tag", "") or "").strip()] = numeric_value
if value_currency != market_currency:
continue
matched_market_currency_value_count += 1
if account_value.tag == "NetLiquidation":
total_equity += float(account_value.value)
elif account_value.tag == "AvailableFunds":
Expand All @@ -149,6 +163,18 @@ def fetch_portfolio_snapshot(
values_by_account_currency,
currency=market_currency,
)
if selected_account_ids and matched_account_value_count == 0:
raise IBKRPortfolioSnapshotUnavailableError(
"IBKR returned no account values for the configured account selection."
)
if selected_account_ids and matched_market_currency_value_count == 0:
raise IBKRPortfolioSnapshotUnavailableError(
f"IBKR returned no {market_currency} account values for the configured account selection."
)
if cash_only_execution and market_currency_cash is None:
raise IBKRPortfolioSnapshotUnavailableError(
f"IBKR cash-only snapshot is missing the {market_currency} cash balance."
)
if cash_only_execution:
buying_power = float(market_currency_cash or 0.0) if market_currency_cash is not None else 0.0
position_market_values = {
Expand Down
5 changes: 4 additions & 1 deletion application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,8 +1102,11 @@ def run_strategy_core(
flush=True,
)
_record_platform_execution_telemetry(signal_metadata, dict(execution_summary or {}))
execution_status = str((execution_summary or {}).get("execution_status") or "").strip().lower()
execution_blocked = execution_status in {"blocked", "error", "failed", "failure"}
execution_reason = str((execution_summary or {}).get("no_op_reason") or "execution_blocked")
return StrategyCycleResult(
result="OK - executed",
result=f"Blocked - {execution_reason}" if execution_blocked else "OK - executed",
signal_metadata=dict(signal_metadata or {}),
target_weights=dict(resolved_target_weights or {}),
execution_summary=dict(execution_summary or {}),
Expand Down
71 changes: 71 additions & 0 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class IBKRGatewayUnavailableError(ConnectionError):
"""Raised after retryable IBKR gateway connection attempts are exhausted."""


class IBKRTradingPermissionError(RuntimeError):
"""Raised when a live Gateway connection cannot verify order-write access."""


@dataclass(frozen=True)
class IBKRRuntimeBrokerAdapters:
host_resolver: Any
Expand Down Expand Up @@ -56,6 +60,7 @@ class IBKRRuntimeBrokerAdapters:
limit_buy_premium_by_symbol: dict[str, float] | None = None
printer: Any = print
refresh_host_fn: Any = None
trading_permission_probe_fn: Any = None

def validate_configured_accounts(self, ib):
if not self.account_ids:
Expand Down Expand Up @@ -85,6 +90,69 @@ def fetch_account_portfolio_snapshot(self, ib):
return self.fetch_portfolio_snapshot_fn(ib, account_ids=self.account_ids)
return self.fetch_portfolio_snapshot_fn(ib)

def validate_trading_permissions(self, ib):
if self.dry_run_only or str(self.execution_mode or "").strip().lower() != "live":
return
permission_probe = self.trading_permission_probe_fn
if not callable(permission_probe):
raise IBKRTradingPermissionError(
"IB Gateway live execution cannot verify non-transmitting order-write access."
)

original_raise_request_errors = getattr(ib, "RaiseRequestErrors", False)
original_request_timeout = getattr(ib, "RequestTimeout", 0)
read_only_errors: list[tuple[Any, str]] = []
error_event = getattr(ib, "errorEvent", None)
error_handler_registered = False

def is_read_only_error(message: Any) -> bool:
normalized = str(message).lower().replace("-", " ").replace("_", " ")
return "read only" in " ".join(normalized.split())

def capture_api_error(_request_id, error_code, error_message, _contract):
if is_read_only_error(error_message):
read_only_errors.append((error_code, str(error_message)))

try:
if error_event is not None:
error_event += capture_api_error
error_handler_registered = True
# IBKR what-if validation exercises order-write access without creating a live order.
ib.RaiseRequestErrors = True
ib.RequestTimeout = self.connect_timeout_seconds
probe_result = permission_probe(ib)
probe_warning = getattr(probe_result, "warningText", "")
if read_only_errors or is_read_only_error(probe_warning):
raise IBKRTradingPermissionError(
"IB Gateway API is in Read-Only mode; live execution is disabled."
)
except TimeoutError as exc:
if read_only_errors:
raise IBKRTradingPermissionError(
"IB Gateway API is in Read-Only mode; live execution is disabled."
) from exc
raise
except (ConnectionError, OSError) as exc:
if read_only_errors or is_read_only_error(exc):
raise IBKRTradingPermissionError(
"IB Gateway API is in Read-Only mode; live execution is disabled."
) from exc
raise
except Exception as exc:
if is_read_only_error(exc):
raise IBKRTradingPermissionError(
"IB Gateway API is in Read-Only mode; live execution is disabled."
) from exc
raise IBKRTradingPermissionError(
"IB Gateway live execution could not verify non-transmitting order-write access "
f"(error_type={type(exc).__name__})."
) from exc
Comment thread
Pigbibi marked this conversation as resolved.
finally:
if error_handler_registered:
error_event -= capture_api_error
ib.RaiseRequestErrors = original_raise_request_errors
ib.RequestTimeout = original_request_timeout

def connect_ib(self):
self.ensure_event_loop_fn()
host = self.host_resolver()
Expand All @@ -108,6 +176,7 @@ def connect_ib(self):
)
try:
self.validate_configured_accounts(ib)
self.validate_trading_permissions(ib)
except Exception:
disconnect_fn = getattr(ib, "disconnect", None)
if callable(disconnect_fn):
Expand Down Expand Up @@ -320,6 +389,7 @@ def build_runtime_broker_adapters(
limit_buy_premium_by_symbol: dict[str, float] | None = None,
printer=print,
refresh_host_fn=None,
trading_permission_probe_fn=None,
) -> IBKRRuntimeBrokerAdapters:
return IBKRRuntimeBrokerAdapters(
host_resolver=host_resolver,
Expand Down Expand Up @@ -362,4 +432,5 @@ def build_runtime_broker_adapters(
execution_mode=str(execution_mode or "paper").strip().lower().replace("-", "_"),
printer=printer,
refresh_host_fn=refresh_host_fn,
trading_permission_probe_fn=trading_permission_probe_fn,
)
7 changes: 6 additions & 1 deletion entrypoints/cloud_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ def is_market_open_now(
schedule = calendar.schedule(start_date=now_ny.date(), end_date=now_ny.date())
if len(getattr(schedule, "index", ())) == 0:
return False
return calendar.open_at_time(schedule, now_ny)
try:
return calendar.open_at_time(schedule, now_ny)
except ValueError as exc:
if "not covered by the schedule" not in str(exc):
raise
return False


def is_market_open_today(
Expand Down
Loading
Loading