diff --git a/README.md b/README.md index 784fd03..5f6dde4 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ Recommended account-group config payload: For live multi-account rollout, keep one Cloud Run service per live account group. Each live group should carry exactly one `account_ids` value so portfolio reads, pending/fill guards, and submitted IBKR orders are all routed to that account. +If the IB Gateway username can access multiple linked IBKR accounts, keep those broader login credentials in the Gateway layer and restrict each Cloud Run service with its selected account-group `account_ids` value. At connect time the service validates that the configured account is visible in IBKR `managedAccounts`; if it is not visible, the cycle fails before portfolio reads or order submission. This keeps open-source configuration examples generic while allowing private deployments to map separate services to separate live accounts. + See [`docs/examples/ibkr-account-groups.paper.json`](docs/examples/ibkr-account-groups.paper.json) for a ready-to-edit starter example, and [`docs/ibkr_runtime_rollout.md`](docs/ibkr_runtime_rollout.md) for the exact rollout steps to get `ACCOUNT_GROUP=paper` running. Current behavior is fail-fast: @@ -491,6 +493,8 @@ IB_GATEWAY_IP_MODE=internal 实盘多账户建议一个 UID 对应一个 Cloud Run 服务和一个账号组。每个实盘账号组只放一个 `account_ids` 值;运行时会用它过滤持仓、pending/fill 检查,并把同一个 UID 写进 IBKR 订单的 `order.account`。 +如果 IB Gateway 登录用户名本身能访问多个 linked IBKR 账户,仍然建议把这种更宽的登录权限留在 Gateway 层,每个 Cloud Run 服务只通过自己选中的账号组 `account_ids` 限定一个交易账户。服务连接成功后会校验该账号是否出现在 IBKR `managedAccounts` 里;如果不可见,会在读取组合或提交订单之前失败。开源仓库只保留通用示例,私有实盘映射放在 Secret Manager 等运行配置里。 + 当前行为改成了 fail-fast: - 没有 `STRATEGY_PROFILE` → 启动直接报错 diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index b52b9e2..f3686d4 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -48,6 +48,24 @@ class IBKRRuntimeBrokerAdapters: sleep_fn: Any printer: Any = print + def validate_configured_accounts(self, ib): + if not self.account_ids: + return + managed_accounts_fn = getattr(ib, "managedAccounts", None) + if not callable(managed_accounts_fn): + return + managed_accounts = {str(account_id).strip() for account_id in (managed_accounts_fn() or ())} + missing_accounts = tuple( + account_id for account_id in self.account_ids if str(account_id).strip() not in managed_accounts + ) + if not missing_accounts: + return + raise RuntimeError( + "Configured IBKR account_ids are not available to the current Gateway username " + f"for account_group={self.account_group!r}; " + f"missing_count={len(missing_accounts)}; managed_count={len(managed_accounts)}." + ) + def fetch_account_portfolio_snapshot(self, ib): if self.account_ids: return self.fetch_portfolio_snapshot_fn(ib, account_ids=self.account_ids) @@ -68,12 +86,20 @@ def connect_ib(self): flush=True, ) try: - return self.connect_ib_fn( + ib = self.connect_ib_fn( host, self.ib_port, client_id, timeout=self.connect_timeout_seconds, ) + try: + self.validate_configured_accounts(ib) + except Exception: + disconnect_fn = getattr(ib, "disconnect", None) + if callable(disconnect_fn): + disconnect_fn() + raise + return ib except (ConnectionError, TimeoutError, OSError) as exc: last_error = exc self.printer( diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py new file mode 100644 index 0000000..dc6ac74 --- /dev/null +++ b/tests/test_runtime_broker_adapters.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace + +import pytest + +from application.runtime_broker_adapters import build_runtime_broker_adapters + + +def _build_adapters(*, account_ids=("U1234567",)): + return build_runtime_broker_adapters( + host_resolver=lambda: "127.0.0.1", + ib_port=4001, + ib_client_id=11, + connect_timeout_seconds=60, + connect_attempts=1, + connect_retry_delay_seconds=0, + client_id_retry_offset=100, + ensure_event_loop_fn=lambda: None, + connect_ib_fn=lambda *_args, **_kwargs: SimpleNamespace(managedAccounts=lambda: ["U1234567"]), + fetch_portfolio_snapshot_fn=lambda *_args, **_kwargs: None, + fetch_quote_snapshots_fn=lambda *_args, **_kwargs: None, + submit_order_intent_fn=lambda *_args, **_kwargs: None, + application_get_market_prices_fn=lambda *_args, **_kwargs: None, + application_check_order_submitted_fn=lambda *_args, **_kwargs: None, + application_execute_rebalance_fn=lambda *_args, **_kwargs: None, + execute_paper_liquidation_fn=lambda *_args, **_kwargs: None, + translator=lambda key, **_kwargs: key, + strategy_profile="global_etf_rotation", + account_group="live-slot-a", + service_name="interactive-brokers-live-slot-a-service", + account_ids=account_ids, + dry_run_only=False, + cash_reserve_ratio=0.0, + cash_reserve_floor_usd=0.0, + rebalance_threshold_ratio=0.02, + limit_buy_premium=1.005, + quantity_step=1.0, + min_order_notional=50.0, + safe_haven_cash_substitute_threshold_usd=750.0, + sell_settle_delay_sec=0.0, + separator="---", + strategy_display_name="Test Strategy", + sleep_fn=lambda _seconds: None, + printer=lambda *_args, **_kwargs: None, + ) + + +def test_connect_ib_accepts_configured_managed_account(): + adapters = _build_adapters(account_ids=("U1234567",)) + + ib = adapters.connect_ib() + + assert ib.managedAccounts() == ["U1234567"] + + +def test_connect_ib_rejects_configured_account_not_visible_to_gateway_username(): + observed = {"disconnects": 0} + + class FakeIB: + def managedAccounts(self): + return ["U7654321"] + + def disconnect(self): + observed["disconnects"] += 1 + + adapters = _build_adapters(account_ids=("U1234567",)) + adapters = adapters.__class__( + **{ + **adapters.__dict__, + "connect_ib_fn": lambda *_args, **_kwargs: FakeIB(), + } + ) + + with pytest.raises(RuntimeError, match="Configured IBKR account_ids are not available"): + adapters.connect_ib() + + assert observed["disconnects"] == 1 +