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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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` → 启动直接报错
Expand Down
28 changes: 27 additions & 1 deletion application/runtime_broker_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
77 changes: 77 additions & 0 deletions tests/test_runtime_broker_adapters.py
Original file line number Diff line number Diff line change
@@ -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