From a83846ab070a9106c4059c2995620e55a705c60c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 26 May 2026 15:15:19 +0800 Subject: [PATCH] Support per-account IBKR gateway ports --- README.md | 8 ++++ docs/examples/ibkr-account-groups.paper.json | 1 + docs/ibkr_runtime_rollout.md | 3 ++ main.py | 12 +++--- runtime_config_support.py | 40 ++++++++++++++++---- tests/test_account_group_examples.py | 1 + tests/test_event_loop.py | 11 ++++++ tests/test_request_handling.py | 3 +- tests/test_runtime_config_support.py | 8 +++- tests/test_strategy_runtime.py | 1 + 10 files changed, 71 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5f6dde4..8673a3e 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ The selected account-group entry must provide at least: For the recommended Cloud Run deployment, also include: - `ib_gateway_zone` +- `ib_gateway_port` when a VM hosts more than one Gateway; omit it to use `4001` for live and `4002` for paper. - `ib_gateway_ip_mode` (or let it default to `internal`) If you use instance-name resolution with `ib_gateway_zone`, the Cloud Run runtime service account needs `roles/compute.viewer`. If you load the payload from Secret Manager, the same runtime service account also needs `roles/secretmanager.secretAccessor` on `ibkr-account-groups`. @@ -196,6 +197,7 @@ Recommended account-group config payload: "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", + "ib_gateway_port": 4002, "ib_gateway_ip_mode": "internal", "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", @@ -209,6 +211,8 @@ For live multi-account rollout, keep one Cloud Run service per live account grou 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. +If each IBKR username can only access one linked account, run one Gateway session per username and give each account group its own `ib_gateway_port` and `ib_client_id`. The `ib_gateway_instance_name` can still point to the same VM when the Gateway containers expose different host ports. + 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: @@ -449,6 +453,7 @@ IBKR 账户 按当前推荐的 Cloud Run 部署方式,最好再一起放上: - `ib_gateway_zone` +- `ib_gateway_port`(同一台 VM 上有多个 Gateway 时填写;不填则 live 默认 `4001`,paper 默认 `4002`) - `ib_gateway_ip_mode`(或者直接走默认 `internal`) 如果你配置了 `ib_gateway_zone` 让程序通过实例名解析内网 IP,Cloud Run runtime service account 需要 `roles/compute.viewer`。如果账号组配置来源是 Secret Manager,同一个 runtime service account 还需要对 `ibkr-account-groups` 具备 `roles/secretmanager.secretAccessor`。 @@ -480,6 +485,7 @@ IB_GATEWAY_IP_MODE=internal "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", + "ib_gateway_port": 4002, "ib_gateway_ip_mode": "internal", "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", @@ -495,6 +501,8 @@ IB_GATEWAY_IP_MODE=internal 如果 IB Gateway 登录用户名本身能访问多个 linked IBKR 账户,仍然建议把这种更宽的登录权限留在 Gateway 层,每个 Cloud Run 服务只通过自己选中的账号组 `account_ids` 限定一个交易账户。服务连接成功后会校验该账号是否出现在 IBKR `managedAccounts` 里;如果不可见,会在读取组合或提交订单之前失败。开源仓库只保留通用示例,私有实盘映射放在 Secret Manager 等运行配置里。 +如果 IBKR 的每个副用户名只能看到一个 linked 账户,就按“一个用户名一个 Gateway session”部署;每个账号组配置自己的 `ib_gateway_port` 和 `ib_client_id`。多个 Gateway 可以在同一台 VM 上运行,只要 Gateway 容器暴露到不同 host port。 + 当前行为改成了 fail-fast: - 没有 `STRATEGY_PROFILE` → 启动直接报错 diff --git a/docs/examples/ibkr-account-groups.paper.json b/docs/examples/ibkr-account-groups.paper.json index ebb4a4e..6ca66f1 100644 --- a/docs/examples/ibkr-account-groups.paper.json +++ b/docs/examples/ibkr-account-groups.paper.json @@ -4,6 +4,7 @@ "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", + "ib_gateway_port": 4002, "ib_gateway_ip_mode": "internal", "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", diff --git a/docs/ibkr_runtime_rollout.md b/docs/ibkr_runtime_rollout.md index 74b4396..c3026c8 100644 --- a/docs/ibkr_runtime_rollout.md +++ b/docs/ibkr_runtime_rollout.md @@ -43,6 +43,7 @@ 建议一起放进去: - `ib_gateway_zone` +- `ib_gateway_port`:同一台 VM 上跑多个 Gateway 时填写;不填则 live 默认 `4001`,paper 默认 `4002`。 - `ib_gateway_ip_mode` 当前代码里,`ib_gateway_instance_name`、`ib_gateway_mode`、`ib_client_id` 已经不再从 Cloud Run env 读了,所以不要继续把它们放在 GitHub Repository Variables 里当主配置源。 @@ -69,6 +70,7 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", + "ib_gateway_port": 4002, "ib_gateway_ip_mode": "internal", "ib_client_id": 1, "service_name": "interactive-brokers-quant-service", @@ -84,6 +86,7 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json - `ib_gateway_instance_name`:GCE 上 IB Gateway 实例名。 - `ib_gateway_zone`:建议现在就配上。当前推荐是按实例名解析内网 IP,这样 Cloud Run 不用手填固定私网 IP。 - `ib_gateway_mode`:`paper` 或 `live`。 +- `ib_gateway_port`:Cloud Run 连接 Gateway VM 的 host port。多个 IBKR 用户名分别跑 Gateway 时,每个 Gateway 要用不同 port。 - `ib_gateway_ip_mode`:推荐 `internal`。 - `ib_client_id`:这个账号组对应的 client id。 - `service_name`:当前只是预留元数据,建议先填成现有 Cloud Run 服务名,后面多账号拆服务时更顺。 diff --git a/main.py b/main.py index ad02338..8c9be44 100644 --- a/main.py +++ b/main.py @@ -134,7 +134,7 @@ def get_ib_gateway_mode(): def get_ib_port(): - return 4002 if get_ib_gateway_mode() == "paper" else 4001 + return RUNTIME_SETTINGS.ib_gateway_port def get_ib_connect_timeout_seconds(): @@ -515,11 +515,8 @@ def build_account_notification_lines() -> tuple[str, ...]: return (t("account_ids_detail", account_ids=", ".join(account_ids)),) -def build_extra_notification_lines(strategy_plugin_signals=()) -> tuple[str, ...]: - return ( - *build_account_notification_lines(), - *build_strategy_plugin_notification_lines(strategy_plugin_signals), - ) +def build_extra_notification_lines(_strategy_plugin_signals=()) -> tuple[str, ...]: + return build_account_notification_lines() def get_current_portfolio(ib): @@ -575,6 +572,7 @@ def run_paper_liquidation_cycle(): def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool | None = None): + del strategy_plugin_signals if PAPER_LIQUIDATE_ONLY and dry_run_only_override is None: return run_paper_liquidation_cycle() composer = build_composer(dry_run_only_override=dry_run_only_override) @@ -582,7 +580,7 @@ def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool runtime=composer.build_rebalance_runtime( silent_cycle_notifications=bool(dry_run_only_override), ), - config=composer.build_rebalance_config(extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals)), + config=composer.build_rebalance_config(extra_notification_lines=build_extra_notification_lines()), ) diff --git a/runtime_config_support.py b/runtime_config_support.py index ca42eb0..9e8d37b 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -34,6 +34,7 @@ class AccountGroupConfig: ib_gateway_instance_name: str | None = None ib_gateway_zone: str | None = None ib_gateway_mode: str | None = None + ib_gateway_port: int | None = None ib_gateway_ip_mode: str | None = None ib_client_id: int | None = None service_name: str | None = None @@ -46,6 +47,7 @@ class PlatformRuntimeSettings: ib_gateway_instance_name: str ib_gateway_zone: str ib_gateway_mode: str + ib_gateway_port: int ib_gateway_ip_mode: str ib_client_id: int strategy_profile: str @@ -145,6 +147,22 @@ def load_platform_runtime_settings( account_group=account_group, ) + ib_gateway_mode = resolve_ib_gateway_mode( + require_group_string( + group_config.ib_gateway_mode, + field_name="ib_gateway_mode", + account_group=account_group, + ) + ) + ib_gateway_port = resolve_ib_gateway_port( + ( + group_config.ib_gateway_port + if group_config.ib_gateway_port is not None + else parse_optional_int(os.getenv("IB_GATEWAY_PORT")) + ), + gateway_mode=ib_gateway_mode, + ) + return PlatformRuntimeSettings( project_id=project_id, ib_gateway_instance_name=instance_name, @@ -153,13 +171,8 @@ def load_platform_runtime_settings( os.getenv("IB_GATEWAY_ZONE", "").strip(), ) or "", - ib_gateway_mode=resolve_ib_gateway_mode( - require_group_string( - group_config.ib_gateway_mode, - field_name="ib_gateway_mode", - account_group=account_group, - ) - ), + ib_gateway_mode=ib_gateway_mode, + ib_gateway_port=ib_gateway_port, ib_gateway_ip_mode=resolve_ib_gateway_ip_mode( first_non_empty(group_config.ib_gateway_ip_mode, os.getenv("IB_GATEWAY_IP_MODE")), logger=logger, @@ -399,6 +412,7 @@ def parse_account_group_configs(payload: str) -> dict[str, AccountGroupConfig]: ib_gateway_instance_name=normalize_optional_string(group_payload.get("ib_gateway_instance_name")), ib_gateway_zone=normalize_optional_string(group_payload.get("ib_gateway_zone")), ib_gateway_mode=normalize_optional_string(group_payload.get("ib_gateway_mode")), + ib_gateway_port=parse_optional_int(group_payload.get("ib_gateway_port")), ib_gateway_ip_mode=normalize_optional_string(group_payload.get("ib_gateway_ip_mode")), ib_client_id=parse_optional_int(group_payload.get("ib_client_id")), service_name=normalize_optional_string(group_payload.get("service_name")), @@ -490,6 +504,18 @@ def resolve_ib_gateway_mode(raw_value: str | None) -> str: raise EnvironmentError("IB_GATEWAY_MODE must be either 'live' or 'paper'") +def resolve_ib_gateway_port(raw_value: Any, *, gateway_mode: str) -> int: + if raw_value is None or raw_value == "": + return 4002 if gateway_mode == "paper" else 4001 + try: + port = int(raw_value) + except (TypeError, ValueError) as exc: + raise EnvironmentError("ib_gateway_port must be an integer between 1 and 65535") from exc + if port < 1 or port > 65535: + raise EnvironmentError("ib_gateway_port must be an integer between 1 and 65535") + return port + + def resolve_ib_gateway_ip_mode( raw_value: str | None, *, diff --git a/tests/test_account_group_examples.py b/tests/test_account_group_examples.py index b786259..156940a 100644 --- a/tests/test_account_group_examples.py +++ b/tests/test_account_group_examples.py @@ -17,6 +17,7 @@ def test_default_account_group_example_is_valid(): assert default_group.ib_gateway_instance_name == "interactive-brokers-quant-instance" assert default_group.ib_gateway_zone == "us-central1-c" assert default_group.ib_gateway_mode == "paper" + assert default_group.ib_gateway_port == 4002 assert default_group.ib_gateway_ip_mode == "internal" assert default_group.ib_client_id == 1 assert default_group.service_name == "interactive-brokers-quant-service" diff --git a/tests/test_event_loop.py b/tests/test_event_loop.py index 2873405..6077750 100644 --- a/tests/test_event_loop.py +++ b/tests/test_event_loop.py @@ -130,6 +130,17 @@ def test_ib_gateway_mode_derives_paper_port(strategy_module_factory): assert module.IB_PORT == 4002 +def test_account_group_can_override_ib_gateway_port(strategy_module_factory): + module = strategy_module_factory( + IB_ACCOUNT_GROUP_CONFIG_JSON=( + '{"groups":{"default":{"ib_gateway_instance_name":"127.0.0.1",' + '"ib_gateway_mode":"live","ib_gateway_port":4011,"ib_client_id":1}}}' + ) + ) + + assert module.IB_PORT == 4011 + + def test_ib_gateway_mode_is_required(strategy_module_factory): with pytest.raises(EnvironmentError, match="requires ib_gateway_mode"): strategy_module_factory( diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 79813c0..2c52cf5 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -229,8 +229,9 @@ def test_handle_probe_failure_sends_notification(strategy_module, monkeypatch): def test_build_extra_notification_lines_includes_account_id(strategy_module): - lines = strategy_module.build_extra_notification_lines(()) + lines = strategy_module.build_extra_notification_lines(("plugin-line",)) assert any("U18308207" in line for line in lines) + assert all("plugin-line" not in line for line in lines) def test_handle_request_skips_overlapping_post(strategy_module, monkeypatch): diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 2b55c0e..6e337e7 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -203,7 +203,7 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo "IB_ACCOUNT_GROUP_CONFIG_JSON", '{"groups":{"taxable_main":{"ib_gateway_instance_name":"ib-gateway-main",' '"ib_gateway_zone":"us-central1-a","ib_gateway_mode":"live",' - '"ib_gateway_ip_mode":"external","ib_client_id":7,' + '"ib_gateway_port":4011,"ib_gateway_ip_mode":"external","ib_client_id":7,' '"service_name":"interactive-brokers-quant-taxable-main-service",' '"account_ids":["U1234567"]}}}', ) @@ -216,6 +216,7 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo assert settings.ib_gateway_instance_name == "ib-gateway-main" assert settings.ib_gateway_zone == "us-central1-a" assert settings.ib_gateway_mode == "live" + assert settings.ib_gateway_port == 4011 assert settings.ib_gateway_ip_mode == "external" assert settings.ib_client_id == 7 assert settings.strategy_profile == SAMPLE_STRATEGY_PROFILE @@ -912,6 +913,7 @@ def test_load_platform_runtime_settings_uses_account_group_secret(monkeypatch): "ib_gateway_instance_name": "ib-gateway-paper", "ib_gateway_zone": "us-central1-a", "ib_gateway_mode": "live", + "ib_gateway_port": 4011, "ib_gateway_ip_mode": "external", "ib_client_id": 9, "service_name": "interactive-brokers-paper-service", @@ -938,6 +940,7 @@ def access_secret_version(self, request): assert settings.ib_gateway_instance_name == "ib-gateway-paper" assert settings.ib_gateway_zone == "us-central1-a" assert settings.ib_gateway_mode == "live" + assert settings.ib_gateway_port == 4011 assert settings.ib_gateway_ip_mode == "external" assert settings.ib_client_id == 9 assert settings.account_group == "paper" @@ -985,9 +988,10 @@ def test_load_platform_runtime_settings_requires_key_group_fields(monkeypatch): def test_parse_account_group_configs_supports_top_level_mapping(): configs = parse_account_group_configs( '{"paper": {"ib_gateway_instance_name":"ib-gateway","ib_gateway_mode":"paper",' - '"ib_client_id":"4","account_ids":["U1"],"service_name":"svc"}}' + '"ib_gateway_port":"4012","ib_client_id":"4","account_ids":["U1"],"service_name":"svc"}}' ) + assert configs["paper"].ib_gateway_port == 4012 assert configs["paper"].ib_client_id == 4 assert configs["paper"].account_ids == ("U1",) assert configs["paper"].service_name == "svc" diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 04f4c53..f1891c8 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -22,6 +22,7 @@ def _build_runtime_settings( ib_gateway_instance_name="127.0.0.1", ib_gateway_zone="", ib_gateway_mode="live", + ib_gateway_port=4001, ib_gateway_ip_mode="internal", ib_client_id=1, strategy_profile=profile,