diff --git a/main.py b/main.py index dd16d7f..1b69674 100644 --- a/main.py +++ b/main.py @@ -882,9 +882,20 @@ def require_gateway_execution_backend(): ) -def connect_ib(*, validate_trading_permissions: bool = True): +def connect_ib( + *, + read_only: bool = False, + validate_trading_permissions: bool = True, +): + """Connect to the Gateway with an optional read-only session boundary.""" require_gateway_execution_backend() - return build_broker_adapters().connect_ib( + # A probe must not merely skip the local what-if permission check. Its + # Gateway session itself must be read-only, so a future probe change cannot + # accidentally acquire broker write authority. + adapter_dry_run_override = True if read_only else None + return build_broker_adapters( + dry_run_only_override=adapter_dry_run_override, + ).connect_ib( validate_trading_permissions=validate_trading_permissions, ) @@ -1735,7 +1746,10 @@ def _handle_probe(*, response_body: str = "Probe OK"): http_method=request.method, execution_window="probe", ) - ib = connect_ib(validate_trading_permissions=False) + ib = connect_ib( + read_only=True, + validate_trading_permissions=False, + ) snapshot = build_portfolio_snapshot(ib) positions = tuple(getattr(snapshot, "positions", ()) or ()) buying_power = float(getattr(snapshot, "buying_power", 0.0) or 0.0) diff --git a/tests/test_event_loop.py b/tests/test_event_loop.py index 34d9073..984bc15 100644 --- a/tests/test_event_loop.py +++ b/tests/test_event_loop.py @@ -51,6 +51,22 @@ def fake_ibkr_connect(host, port, client_id, **kwargs): assert observed["args"] == ("127.0.0.1", 4001, 1, {"timeout": 60, "readonly": False}) +def test_live_runtime_can_force_a_read_only_gateway_session(strategy_module_factory, monkeypatch): + module = strategy_module_factory(IBKR_DRY_RUN_ONLY="false") + observed = {} + + def fake_ibkr_connect(host, port, client_id, **kwargs): + observed["args"] = (host, port, client_id, kwargs) + return object() + + monkeypatch.setattr(module, "ibkr_connect_ib", fake_ibkr_connect) + + adapters = module.build_broker_adapters(dry_run_only_override=True) + adapters.connect_ib_fn("127.0.0.1", 4001, 1, timeout=60) + + assert observed["args"] == ("127.0.0.1", 4001, 1, {"timeout": 60, "readonly": True}) + + def test_connect_ib_retries_with_offset_client_ids(strategy_module_factory, monkeypatch): module = strategy_module_factory( IBKR_CONNECT_ATTEMPTS="3", diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index e8e2c1b..cbf74fb 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -562,8 +562,13 @@ def disconnect(self): "attach_strategy_plugin_report", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("health probe should not attach strategy plugin reports")), ) - def fake_connect_ib(*, validate_trading_permissions=True): - observed["connection_options"].append(validate_trading_permissions) + def fake_connect_ib(*, read_only=False, validate_trading_permissions=True): + observed["connection_options"].append( + { + "read_only": read_only, + "validate_trading_permissions": validate_trading_permissions, + } + ) return FakeIB() monkeypatch.setattr(strategy_module, "connect_ib", fake_connect_ib) @@ -589,7 +594,9 @@ def fake_connect_ib(*, validate_trading_permissions=True): assert observed["report"]["summary"]["positions_count"] == 1 assert observed["disconnects"] == 1 assert observed["notifications"] == [] - assert observed["connection_options"] == [False] + assert observed["connection_options"] == [ + {"read_only": True, "validate_trading_permissions": False} + ] def test_handle_probe_connect_timeout_sends_concise_connection_notification(strategy_module, monkeypatch):