Skip to content

Commit 80d3392

Browse files
authored
Validate configured IBKR managed accounts (#95)
1 parent bc0155d commit 80d3392

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ Recommended account-group config payload:
207207

208208
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.
209209

210+
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.
211+
210212
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.
211213

212214
Current behavior is fail-fast:
@@ -491,6 +493,8 @@ IB_GATEWAY_IP_MODE=internal
491493

492494
实盘多账户建议一个 UID 对应一个 Cloud Run 服务和一个账号组。每个实盘账号组只放一个 `account_ids` 值;运行时会用它过滤持仓、pending/fill 检查,并把同一个 UID 写进 IBKR 订单的 `order.account`
493495

496+
如果 IB Gateway 登录用户名本身能访问多个 linked IBKR 账户,仍然建议把这种更宽的登录权限留在 Gateway 层,每个 Cloud Run 服务只通过自己选中的账号组 `account_ids` 限定一个交易账户。服务连接成功后会校验该账号是否出现在 IBKR `managedAccounts` 里;如果不可见,会在读取组合或提交订单之前失败。开源仓库只保留通用示例,私有实盘映射放在 Secret Manager 等运行配置里。
497+
494498
当前行为改成了 fail-fast:
495499

496500
- 没有 `STRATEGY_PROFILE` → 启动直接报错

application/runtime_broker_adapters.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,24 @@ class IBKRRuntimeBrokerAdapters:
4848
sleep_fn: Any
4949
printer: Any = print
5050

51+
def validate_configured_accounts(self, ib):
52+
if not self.account_ids:
53+
return
54+
managed_accounts_fn = getattr(ib, "managedAccounts", None)
55+
if not callable(managed_accounts_fn):
56+
return
57+
managed_accounts = {str(account_id).strip() for account_id in (managed_accounts_fn() or ())}
58+
missing_accounts = tuple(
59+
account_id for account_id in self.account_ids if str(account_id).strip() not in managed_accounts
60+
)
61+
if not missing_accounts:
62+
return
63+
raise RuntimeError(
64+
"Configured IBKR account_ids are not available to the current Gateway username "
65+
f"for account_group={self.account_group!r}; "
66+
f"missing_count={len(missing_accounts)}; managed_count={len(managed_accounts)}."
67+
)
68+
5169
def fetch_account_portfolio_snapshot(self, ib):
5270
if self.account_ids:
5371
return self.fetch_portfolio_snapshot_fn(ib, account_ids=self.account_ids)
@@ -68,12 +86,20 @@ def connect_ib(self):
6886
flush=True,
6987
)
7088
try:
71-
return self.connect_ib_fn(
89+
ib = self.connect_ib_fn(
7290
host,
7391
self.ib_port,
7492
client_id,
7593
timeout=self.connect_timeout_seconds,
7694
)
95+
try:
96+
self.validate_configured_accounts(ib)
97+
except Exception:
98+
disconnect_fn = getattr(ib, "disconnect", None)
99+
if callable(disconnect_fn):
100+
disconnect_fn()
101+
raise
102+
return ib
77103
except (ConnectionError, TimeoutError, OSError) as exc:
78104
last_error = exc
79105
self.printer(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from types import SimpleNamespace
2+
3+
import pytest
4+
5+
from application.runtime_broker_adapters import build_runtime_broker_adapters
6+
7+
8+
def _build_adapters(*, account_ids=("U1234567",)):
9+
return build_runtime_broker_adapters(
10+
host_resolver=lambda: "127.0.0.1",
11+
ib_port=4001,
12+
ib_client_id=11,
13+
connect_timeout_seconds=60,
14+
connect_attempts=1,
15+
connect_retry_delay_seconds=0,
16+
client_id_retry_offset=100,
17+
ensure_event_loop_fn=lambda: None,
18+
connect_ib_fn=lambda *_args, **_kwargs: SimpleNamespace(managedAccounts=lambda: ["U1234567"]),
19+
fetch_portfolio_snapshot_fn=lambda *_args, **_kwargs: None,
20+
fetch_quote_snapshots_fn=lambda *_args, **_kwargs: None,
21+
submit_order_intent_fn=lambda *_args, **_kwargs: None,
22+
application_get_market_prices_fn=lambda *_args, **_kwargs: None,
23+
application_check_order_submitted_fn=lambda *_args, **_kwargs: None,
24+
application_execute_rebalance_fn=lambda *_args, **_kwargs: None,
25+
execute_paper_liquidation_fn=lambda *_args, **_kwargs: None,
26+
translator=lambda key, **_kwargs: key,
27+
strategy_profile="global_etf_rotation",
28+
account_group="live-slot-a",
29+
service_name="interactive-brokers-live-slot-a-service",
30+
account_ids=account_ids,
31+
dry_run_only=False,
32+
cash_reserve_ratio=0.0,
33+
cash_reserve_floor_usd=0.0,
34+
rebalance_threshold_ratio=0.02,
35+
limit_buy_premium=1.005,
36+
quantity_step=1.0,
37+
min_order_notional=50.0,
38+
safe_haven_cash_substitute_threshold_usd=750.0,
39+
sell_settle_delay_sec=0.0,
40+
separator="---",
41+
strategy_display_name="Test Strategy",
42+
sleep_fn=lambda _seconds: None,
43+
printer=lambda *_args, **_kwargs: None,
44+
)
45+
46+
47+
def test_connect_ib_accepts_configured_managed_account():
48+
adapters = _build_adapters(account_ids=("U1234567",))
49+
50+
ib = adapters.connect_ib()
51+
52+
assert ib.managedAccounts() == ["U1234567"]
53+
54+
55+
def test_connect_ib_rejects_configured_account_not_visible_to_gateway_username():
56+
observed = {"disconnects": 0}
57+
58+
class FakeIB:
59+
def managedAccounts(self):
60+
return ["U7654321"]
61+
62+
def disconnect(self):
63+
observed["disconnects"] += 1
64+
65+
adapters = _build_adapters(account_ids=("U1234567",))
66+
adapters = adapters.__class__(
67+
**{
68+
**adapters.__dict__,
69+
"connect_ib_fn": lambda *_args, **_kwargs: FakeIB(),
70+
}
71+
)
72+
73+
with pytest.raises(RuntimeError, match="Configured IBKR account_ids are not available"):
74+
adapters.connect_ib()
75+
76+
assert observed["disconnects"] == 1
77+

0 commit comments

Comments
 (0)