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
8 changes: 8 additions & 0 deletions application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def is_read_only_error(message: Any) -> bool:
normalized = str(message).lower().replace("-", " ").replace("_", " ")
return "read only" in " ".join(normalized.split())

def is_margin_probe_rejection(message: Any) -> bool:
"""Margin rejection still proves the API accepted a non-transmitting order."""
normalized = str(message).upper()
return "INITIAL MARGIN" in normalized or "EQUITY WITH LOAN VALUE" in normalized

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)))
Expand Down Expand Up @@ -146,6 +151,9 @@ def capture_api_error(_request_id, error_code, error_message, _contract):
raise IBKRTradingPermissionError(
"IB Gateway API is in Read-Only mode; live execution is disabled."
) from exc
if is_margin_probe_rejection(exc):
# Write path reached the broker; small accounts may reject the probe size.
return
raise IBKRTradingPermissionError(
"IB Gateway live execution could not verify non-transmitting order-write access "
f"(error_type={type(exc).__name__})."
Expand Down
10 changes: 9 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,14 @@ def compute_signals_fn(ib, current_holdings):
)
return compute_signals(ib, current_holdings)

def connect_ib_for_cycle():
# Dry-run/shadow must use a read-only Gateway session and must not run the
# live what-if write probe (that path can emit Error 201 on small accounts).
return connect_ib(
read_only=bool(effective_dry_run_only),
validate_trading_permissions=not bool(effective_dry_run_only),
)

return build_runtime_composer(
service_name=SERVICE_NAME or os.getenv("K_SERVICE", "interactive-brokers-platform"),
strategy_profile=STRATEGY_PROFILE,
Expand Down Expand Up @@ -728,7 +736,7 @@ def compute_signals_fn(ib, current_holdings):
separator=SEPARATOR,
send_message=send_tg_message,
notification_channel=_NOTIFICATION_CHANNEL,
connect_ib_fn=connect_ib,
connect_ib_fn=connect_ib_for_cycle,
build_portfolio_snapshot_fn=build_portfolio_snapshot,
compute_signals_fn=compute_signals_fn,
execute_rebalance_fn=lambda ib, target_weights, positions, account_values, **kwargs: execute_rebalance(
Expand Down
26 changes: 25 additions & 1 deletion tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,24 @@ def fake_build_broker_adapters(*, dry_run_only_override=None):
assert observed["dry_run_only_override"] is True


def test_dry_run_composer_connects_read_only_without_permission_probe(
strategy_module, monkeypatch
):
observed = {}

def fake_connect_ib(*, read_only=False, validate_trading_permissions=True):
observed["read_only"] = read_only
observed["validate_trading_permissions"] = validate_trading_permissions
return object()

monkeypatch.setattr(strategy_module, "connect_ib", fake_connect_ib)

runtime = strategy_module.build_composer(dry_run_only_override=True).build_rebalance_runtime()
runtime.connect_ib()

assert observed == {"read_only": True, "validate_trading_permissions": False}


def test_probe_market_order_write_access_uses_safe_haven_not_growth_symbol(
strategy_module, monkeypatch
):
Expand Down Expand Up @@ -1268,8 +1286,14 @@ def isConnected(self):
def disconnect(self):
observed["disconnect_calls"] += 1

def fake_connect_ib():
def fake_connect_ib(*, read_only=False, validate_trading_permissions=True):
observed["connect_calls"] += 1
observed.setdefault("connection_options", []).append(
{
"read_only": read_only,
"validate_trading_permissions": validate_trading_permissions,
}
)
return FakeIB()

monkeypatch.setattr(strategy_module, "connect_ib", fake_connect_ib)
Expand Down
36 changes: 36 additions & 0 deletions tests/test_runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,42 @@ def disconnect(self):
}


def test_connect_ib_accepts_margin_rejection_as_write_access_proof():
class FakeEvent:
def __iadd__(self, handler):
return self

def __isub__(self, handler):
return self

class FakeIB:
RaiseRequestErrors = False
RequestTimeout = 0

def __init__(self):
self.errorEvent = FakeEvent()

def managedAccounts(self):
return ["U1234567"]

def whatIfOrder(self, _contract, _order):
raise RuntimeError(
"Error 201, reqId 21: Order rejected - reason:YOUR ORDER IS NOT ACCEPTED. "
"IN ORDER TO OBTAIN THE DESIRED POSITION YOUR EQUITY WITH LOAN VALUE "
"[390.06 USD] MUST EXCEED THE INITIAL MARGIN [434.70 USD]"
)

adapters = _build_adapters(account_ids=("U1234567",), execution_mode="live")
adapters = adapters.__class__(
**{
**adapters.__dict__,
"connect_ib_fn": lambda *_args, **_kwargs: FakeIB(),
}
)

assert adapters.connect_ib().managedAccounts() == ["U1234567"]


def test_connect_ib_retries_when_trading_permission_probe_loses_connection():
observed = {
"connects": 0,
Expand Down