diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 466287d..6b05d59 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -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))) @@ -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__})." diff --git a/main.py b/main.py index a5a1e42..219dec5 100644 --- a/main.py +++ b/main.py @@ -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, @@ -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( diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 68879b1..e9f2d56 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -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 ): @@ -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) diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index 525d66e..a759a76 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -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,