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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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",
Expand All @@ -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:
Expand Down Expand Up @@ -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`。
Expand Down Expand Up @@ -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",
Expand All @@ -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` → 启动直接报错
Expand Down
1 change: 1 addition & 0 deletions docs/examples/ibkr-account-groups.paper.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions docs/ibkr_runtime_rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 里当主配置源。
Expand All @@ -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",
Expand All @@ -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 服务名,后面多账号拆服务时更顺。
Expand Down
12 changes: 5 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -575,14 +572,15 @@ 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)
return run_rebalance_cycle(
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()),
)


Expand Down
40 changes: 33 additions & 7 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"))
),
Comment on lines +159 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop using legacy env as implicit port override

When ib_gateway_port is omitted from the selected account group, this path now pulls IB_GATEWAY_PORT from the process env before applying the live/paper defaults. In deployments that still carry a legacy IB_GATEWAY_PORT, that stale global value silently overrides group behavior and can route a paper account group to the live gateway port (or vice versa), which undermines the per-account-port rollout and can direct orders to the wrong Gateway session.

Useful? React with 👍 / 👎.

gateway_mode=ib_gateway_mode,
)

return PlatformRuntimeSettings(
project_id=project_id,
ib_gateway_instance_name=instance_name,
Expand All @@ -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,
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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,
*,
Expand Down
1 change: 1 addition & 0 deletions tests/test_account_group_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions tests/test_event_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 6 additions & 2 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}}}',
)
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/test_strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading