From cf83ad03d620a65b67ebcff5802891bbb735c16d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 26 May 2026 16:54:50 +0800 Subject: [PATCH 1/2] Add IBKR execution backend routing config --- README.md | 18 +++- docs/examples/ibkr-account-groups.paper.json | 1 + docs/ibkr_runtime_rollout.md | 31 +++++- main.py | 29 ++++- requirements.txt | 2 +- runtime_config_support.py | 107 +++++++++++++++---- scripts/build_cloud_run_env_sync_plan.py | 2 + scripts/print_strategy_switch_env_plan.py | 1 + tests/test_connect_timeout_alert.py | 2 + tests/test_event_loop.py | 19 ++++ tests/test_runtime_config_support.py | 56 ++++++++++ 11 files changed, 240 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8673a3e..d6370ed 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,9 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `CRISIS_ALERT_TELEGRAM_CHAT_IDS` | No | Dedicated crisis-alert Telegram chat IDs. Separate from the strategy-cycle Telegram chat. | | `CRISIS_ALERT_TELEGRAM_BOT_TOKEN` | No | Dedicated crisis-alert Telegram bot token. For Cloud Run, prefer `CRISIS_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME` in env sync. | -The selected account-group entry must provide at least: +For the default Gateway execution backend, the selected account-group entry must provide at least: +- `execution_backend` (optional; defaults to `gateway`) - `ib_gateway_instance_name` - `ib_gateway_mode` - `ib_client_id` @@ -136,6 +137,8 @@ For the recommended Cloud Run deployment, also include: 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`. +The account-group entry may also set `execution_backend` to `quantconnect`. In that mode, Gateway host/port/client-id fields are no longer required and this Cloud Run service will fail fast instead of accidentally connecting to Gateway. The QuantConnect path is a deployment/algorithm backend: real account mapping, QuantConnect project/node/compile identifiers, API token, and IBKR brokerage credentials must stay in private runtime configuration, and a QuantConnect algorithm must consume the same strategy or target configuration before live orders can be placed. + **Recommended shared-config mode** For the current first rollout, keep GitHub / Cloud Run focused on service-level values: @@ -194,6 +197,7 @@ Recommended account-group config payload: { "groups": { "paper": { + "execution_backend": "gateway", "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -220,7 +224,8 @@ Current behavior is fail-fast: - missing `STRATEGY_PROFILE` → startup error - missing `ACCOUNT_GROUP` → startup error - missing account-group config source → startup error -- missing key fields in the selected group (`ib_gateway_instance_name`, `ib_gateway_mode`, `ib_client_id`) → startup error +- missing Gateway key fields in a `gateway` group (`ib_gateway_instance_name`, `ib_gateway_mode`, `ib_client_id`) → startup error +- direct Gateway execution requested while `execution_backend=quantconnect` → startup error before any Gateway connection attempt When `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` includes the `crisis_response_shadow` plugin, the normal strategy-cycle Telegram message still includes the compact plugin line. If the plugin signal escalates beyond `no_action` (for example `canonical_route=true_crisis`, `suggested_action=defend`/`blocked`, or `would_trade_if_enabled=true`), the service also sends independent crisis alerts through configured `CRISIS_ALERT_CHANNELS` channels. Alert results are written into the runtime report. Duplicate suppression uses stable plugin alert keys and stores markers under `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` when set, otherwise `EXECUTION_REPORT_GCS_URI`, with a local `/tmp` marker fallback. @@ -444,8 +449,9 @@ IBKR 账户 | `CRISIS_ALERT_TELEGRAM_CHAT_IDS` | 否 | 危机告警专用 Telegram chat ID,和常规策略周期 Telegram 分开。 | | `CRISIS_ALERT_TELEGRAM_BOT_TOKEN` | 否 | 危机告警专用 Telegram bot token。Cloud Run env sync 建议配置 `CRISIS_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME`。 | -选中的账号组配置里,至少要有: +默认 Gateway 执行后端下,选中的账号组配置里至少要有: +- `execution_backend`(可选;默认 `gateway`) - `ib_gateway_instance_name` - `ib_gateway_mode` - `ib_client_id` @@ -458,6 +464,8 @@ IBKR 账户 如果你配置了 `ib_gateway_zone` 让程序通过实例名解析内网 IP,Cloud Run runtime service account 需要 `roles/compute.viewer`。如果账号组配置来源是 Secret Manager,同一个 runtime service account 还需要对 `ibkr-account-groups` 具备 `roles/secretmanager.secretAccessor`。 +账号组也可以把 `execution_backend` 设为 `quantconnect`。这个模式不再要求 Gateway 主机、端口和 client id,当前 Cloud Run 服务也会 fail-fast,避免误连 Gateway。QuantConnect 路径是部署/算法后端:真实账号映射、QuantConnect project/node/compile 标识、API token 和 IBKR 券商凭证都必须留在私有运行配置里,并且需要 QuantConnect 算法项目消费同一套策略或目标配置后才能实盘下单。 + **推荐的共享配置模式** 当前第一步,建议让 GitHub / Cloud Run 只维护服务级变量: @@ -482,6 +490,7 @@ IB_GATEWAY_IP_MODE=internal { "groups": { "paper": { + "execution_backend": "gateway", "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -508,7 +517,8 @@ IB_GATEWAY_IP_MODE=internal - 没有 `STRATEGY_PROFILE` → 启动直接报错 - 没有 `ACCOUNT_GROUP` → 启动直接报错 - 没有账号组配置来源 → 启动直接报错 -- 选中的账号组缺少关键字段(`ib_gateway_instance_name`、`ib_gateway_mode`、`ib_client_id`)→ 启动直接报错 +- `gateway` 账号组缺少关键字段(`ib_gateway_instance_name`、`ib_gateway_mode`、`ib_client_id`)→ 启动直接报错 +- `execution_backend=quantconnect` 时直接请求 Gateway 执行 → 启动/执行前直接报错,不会尝试连接 Gateway 如果 `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` 挂载了 `crisis_response_shadow` 插件,常规策略周期 Telegram 仍会包含插件摘要行。当插件信号升级到非 `no_action`(例如 `canonical_route=true_crisis`、`suggested_action=defend`/`blocked`,或 `would_trade_if_enabled=true`)时,服务还会按 `CRISIS_ALERT_CHANNELS` 配置额外发送独立危机通知。 告警结果会写入 runtime report。重复发送抑制使用稳定的插件告警 key;如配置了 `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` 则写入该前缀,否则复用 `EXECUTION_REPORT_GCS_URI`,并有本地 `/tmp` marker fallback。 diff --git a/docs/examples/ibkr-account-groups.paper.json b/docs/examples/ibkr-account-groups.paper.json index 6ca66f1..cacfc37 100644 --- a/docs/examples/ibkr-account-groups.paper.json +++ b/docs/examples/ibkr-account-groups.paper.json @@ -1,6 +1,7 @@ { "groups": { "paper": { + "execution_backend": "gateway", "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", diff --git a/docs/ibkr_runtime_rollout.md b/docs/ibkr_runtime_rollout.md index c3026c8..c12d911 100644 --- a/docs/ibkr_runtime_rollout.md +++ b/docs/ibkr_runtime_rollout.md @@ -34,8 +34,9 @@ ### Secret Manager 里的 `ibkr-account-groups` 管 broker 运行身份 -至少放这些字段: +Gateway 后端至少放这些字段: +- `execution_backend`:默认是 `gateway`,建议显式写出来。 - `ib_gateway_instance_name` - `ib_gateway_mode` - `ib_client_id` @@ -46,7 +47,9 @@ - `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 里当主配置源。 +当前代码里,`execution_backend`、`ib_gateway_instance_name`、`ib_gateway_mode`、`ib_client_id` 已经不再从 Cloud Run env 读了,所以不要继续把它们放在 GitHub Repository Variables 里当主配置源。 + +如果账号组未来交给 QuantConnect Cloud 管理,可以把 `execution_backend` 设为 `quantconnect`。这时该账号组不再要求 Gateway 主机、端口和 client id,但要在私有配置里提供 QuantConnect 项目、节点、compile id 和 Secret 名。当前平台只识别并保护这个后端,不会把实盘账号配置写入仓库;真正下单仍需 QuantConnect 算法项目消费同一套策略/目标配置。 ## 2. `ibkr-account-groups` 怎么配 @@ -67,6 +70,7 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json { "groups": { "paper": { + "execution_backend": "gateway", "ib_gateway_instance_name": "interactive-brokers-quant-instance", "ib_gateway_zone": "us-central1-c", "ib_gateway_mode": "paper", @@ -83,6 +87,7 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json 说明: - `paper`:就是这次先跑通的账号组名,要和 Cloud Run env 里的 `ACCOUNT_GROUP=paper` 一致。 +- `execution_backend`:`gateway` 表示继续通过自托管 IB Gateway 连接和下单;`quantconnect` 表示该账号组交给 QuantConnect 路径,当前服务不会误连 Gateway。 - `ib_gateway_instance_name`:GCE 上 IB Gateway 实例名。 - `ib_gateway_zone`:建议现在就配上。当前推荐是按实例名解析内网 IP,这样 Cloud Run 不用手填固定私网 IP。 - `ib_gateway_mode`:`paper` 或 `live`。 @@ -92,6 +97,28 @@ cp docs/examples/ibkr-account-groups.paper.json /tmp/ibkr-account-groups.json - `service_name`:当前只是预留元数据,建议先填成现有 Cloud Run 服务名,后面多账号拆服务时更顺。 - `account_ids`:实盘账号组建议只放一个 UID。运行时会用它过滤持仓、pending/fill 检查,并写入 IBKR 订单的 `order.account`;如果一个服务配置多个 UID,实盘下单会因为账户路由不明确而失败。 +QuantConnect 后端占位结构示例: + +```json +{ + "groups": { + "qc-slot": { + "execution_backend": "quantconnect", + "service_name": "interactive-brokers-qc-slot-service", + "account_ids": ["U00000000"], + "quantconnect_project_id": 12345678, + "quantconnect_node_id": "LN-placeholder", + "quantconnect_compile_id": "compile-placeholder", + "quantconnect_version_id": "-1", + "quantconnect_credentials_secret_name": "qc-api-credentials", + "quantconnect_brokerage_secret_name": "qc-ibkr-slot" + } + } +} +``` + +上面只展示字段形状;真实账号、用户名、密码、API token 和 TOTP 不应进入仓库。 + 把 secret 建起来: ```bash diff --git a/main.py b/main.py index 1c47b2b..5097408 100644 --- a/main.py +++ b/main.py @@ -58,7 +58,11 @@ ) from application.paper_liquidation_service import execute_paper_liquidation from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace -from runtime_config_support import load_platform_runtime_settings, resolve_ib_gateway_ip_mode +from runtime_config_support import ( + EXECUTION_BACKEND_GATEWAY, + load_platform_runtime_settings, + resolve_ib_gateway_ip_mode, +) from strategy_runtime import load_strategy_runtime app = Flask(__name__) @@ -197,6 +201,9 @@ def _env_flag(name: str) -> bool: SERVICE_NAME = RUNTIME_SETTINGS.service_name ACCOUNT_IDS = RUNTIME_SETTINGS.account_ids PROJECT_ID = RUNTIME_SETTINGS.project_id +EXECUTION_BACKEND = RUNTIME_SETTINGS.execution_backend +QUANTCONNECT_PROJECT_ID = getattr(RUNTIME_SETTINGS, "quantconnect_project_id", None) +QUANTCONNECT_NODE_ID = getattr(RUNTIME_SETTINGS, "quantconnect_node_id", None) STRATEGY_RUNTIME = load_strategy_runtime( STRATEGY_PROFILE, @@ -275,8 +282,11 @@ def t(key, **kwargs): "strategy_artifact_dir": RUNTIME_SETTINGS.strategy_artifact_dir, "strategy_display_name": STRATEGY_DISPLAY_NAME, "strategy_display_name_localized": strategy_display_name, + "execution_backend": EXECUTION_BACKEND, "ib_connect_attempts": IB_CONNECT_ATTEMPTS, "ib_client_id_retry_offset": IB_CLIENT_ID_RETRY_OFFSET, + "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, + "quantconnect_node_id": QUANTCONNECT_NODE_ID, }, ).build_log_context(run_id="") @@ -392,6 +402,11 @@ def build_composer(*, dry_run_only_override: bool | None = None): env_reader=os.getenv, printer=print, runtime_target=RUNTIME_SETTINGS.runtime_target, + extra_reporting_fields={ + "execution_backend": EXECUTION_BACKEND, + "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, + "quantconnect_node_id": QUANTCONNECT_NODE_ID, + }, ) @@ -411,7 +426,18 @@ def publish_notification(*, detailed_text, compact_text): ) +def require_gateway_execution_backend(): + if EXECUTION_BACKEND == EXECUTION_BACKEND_GATEWAY: + return + raise RuntimeError( + f"IBKR execution_backend={EXECUTION_BACKEND!r} is configured; " + "Gateway connection and direct order execution are disabled for this service. " + "Run the QuantConnect deployment/algorithm path for this account group." + ) + + def connect_ib(): + require_gateway_execution_backend() return build_broker_adapters().connect_ib() @@ -565,6 +591,7 @@ def _format_liquidation_orders(orders) -> str: def run_paper_liquidation_cycle(): + require_gateway_execution_backend() if RUNTIME_SETTINGS.ib_gateway_mode != "paper": raise RuntimeError("IBKR_PAPER_LIQUIDATE_ONLY is only allowed when ib_gateway_mode=paper") return build_broker_adapters().run_paper_liquidation_cycle( diff --git a/requirements.txt b/requirements.txt index b9ca8c6..30e1f53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3193c145326920d63cf60d234fb63f8a4b6fc012 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c pandas numpy diff --git a/runtime_config_support.py b/runtime_config_support.py index 9e8d37b..771de55 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -27,10 +27,19 @@ DEFAULT_ACCOUNT_GROUP = "default" DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +EXECUTION_BACKEND_GATEWAY = "gateway" +EXECUTION_BACKEND_QUANTCONNECT = "quantconnect" +SUPPORTED_EXECUTION_BACKENDS = frozenset( + { + EXECUTION_BACKEND_GATEWAY, + EXECUTION_BACKEND_QUANTCONNECT, + } +) @dataclass(frozen=True) class AccountGroupConfig: + execution_backend: str | None = None ib_gateway_instance_name: str | None = None ib_gateway_zone: str | None = None ib_gateway_mode: str | None = None @@ -39,6 +48,12 @@ class AccountGroupConfig: ib_client_id: int | None = None service_name: str | None = None account_ids: tuple[str, ...] = () + quantconnect_project_id: int | None = None + quantconnect_node_id: str | None = None + quantconnect_compile_id: str | None = None + quantconnect_version_id: str | None = None + quantconnect_credentials_secret_name: str | None = None + quantconnect_brokerage_secret_name: str | None = None @dataclass(frozen=True) @@ -74,6 +89,12 @@ class PlatformRuntimeSettings: tg_chat_id: str | None = None notify_lang: str = "en" strategy_plugin_mounts_json: str | None = None + quantconnect_project_id: int | None = None + quantconnect_node_id: str | None = None + quantconnect_compile_id: str | None = None + quantconnect_version_id: str | None = None + quantconnect_credentials_secret_name: str | None = None + quantconnect_brokerage_secret_name: str | None = None crisis_alert_channels: tuple[str, ...] = () crisis_alert_email_recipients: tuple[str, ...] = () crisis_alert_email_sender_email: str | None = None @@ -105,6 +126,7 @@ class PlatformRuntimeSettings: crisis_alert_telegram_disable_web_page_preview: str | None = None crisis_alert_telegram_body_max_chars: str | None = None runtime_target: RuntimeTarget | None = None + execution_backend: str = EXECUTION_BACKEND_GATEWAY def load_platform_runtime_settings( @@ -141,30 +163,54 @@ def load_platform_runtime_settings( include_reconciliation_output=True, ) - instance_name = require_group_string( - group_config.ib_gateway_instance_name, - field_name="ib_gateway_instance_name", - account_group=account_group, + execution_backend = resolve_execution_backend( + first_non_empty( + group_config.execution_backend, + os.getenv("IBKR_EXECUTION_BACKEND"), + ) ) + if execution_backend == EXECUTION_BACKEND_GATEWAY: + instance_name = require_group_string( + group_config.ib_gateway_instance_name, + field_name="ib_gateway_instance_name", + account_group=account_group, + ) - ib_gateway_mode = resolve_ib_gateway_mode( - require_group_string( - group_config.ib_gateway_mode, - field_name="ib_gateway_mode", + 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, + ) + ib_client_id = require_group_int( + group_config.ib_client_id, + field_name="ib_client_id", account_group=account_group, ) - ) - ib_gateway_port = resolve_ib_gateway_port( - ( + else: + instance_name = first_non_empty(group_config.ib_gateway_instance_name) or "" + ib_gateway_mode = resolve_ib_gateway_mode( + first_non_empty(group_config.ib_gateway_mode, os.getenv("IB_GATEWAY_MODE"), "live") + ) + 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, - ) + else parse_optional_int(os.getenv("IB_GATEWAY_PORT")) or 0 + ) + ib_client_id = group_config.ib_client_id or 0 return PlatformRuntimeSettings( project_id=project_id, + execution_backend=execution_backend, ib_gateway_instance_name=instance_name, ib_gateway_zone=first_non_empty( group_config.ib_gateway_zone, @@ -177,11 +223,7 @@ def load_platform_runtime_settings( first_non_empty(group_config.ib_gateway_ip_mode, os.getenv("IB_GATEWAY_IP_MODE")), logger=logger, ), - ib_client_id=require_group_int( - group_config.ib_client_id, - field_name="ib_client_id", - account_group=account_group, - ), + ib_client_id=ib_client_id, strategy_profile=runtime_paths.strategy_profile, strategy_display_name=runtime_paths.strategy_display_name, strategy_domain=runtime_paths.strategy_domain, @@ -223,6 +265,12 @@ def load_platform_runtime_settings( os.getenv("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON") or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON") ), + quantconnect_project_id=group_config.quantconnect_project_id, + quantconnect_node_id=group_config.quantconnect_node_id, + quantconnect_compile_id=group_config.quantconnect_compile_id, + quantconnect_version_id=group_config.quantconnect_version_id, + quantconnect_credentials_secret_name=group_config.quantconnect_credentials_secret_name, + quantconnect_brokerage_secret_name=group_config.quantconnect_brokerage_secret_name, crisis_alert_channels=split_env_list(os.getenv("CRISIS_ALERT_CHANNELS")), crisis_alert_email_recipients=split_env_list( os.getenv("CRISIS_ALERT_EMAIL_RECIPIENTS") @@ -409,6 +457,7 @@ def parse_account_group_configs(payload: str) -> dict[str, AccountGroupConfig]: if not isinstance(group_payload, dict): raise ValueError(f"Account group {group_name!r} must be a JSON object") parsed[str(group_name)] = AccountGroupConfig( + execution_backend=normalize_optional_string(group_payload.get("execution_backend")), 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")), @@ -417,6 +466,16 @@ def parse_account_group_configs(payload: str) -> dict[str, AccountGroupConfig]: ib_client_id=parse_optional_int(group_payload.get("ib_client_id")), service_name=normalize_optional_string(group_payload.get("service_name")), account_ids=parse_account_ids(group_payload.get("account_ids")), + quantconnect_project_id=parse_optional_int(group_payload.get("quantconnect_project_id")), + quantconnect_node_id=normalize_optional_string(group_payload.get("quantconnect_node_id")), + quantconnect_compile_id=normalize_optional_string(group_payload.get("quantconnect_compile_id")), + quantconnect_version_id=normalize_optional_string(group_payload.get("quantconnect_version_id")), + quantconnect_credentials_secret_name=normalize_optional_string( + group_payload.get("quantconnect_credentials_secret_name") + ), + quantconnect_brokerage_secret_name=normalize_optional_string( + group_payload.get("quantconnect_brokerage_secret_name") + ), ) return parsed @@ -495,6 +554,14 @@ def require_group_int( return int(raw_value) +def resolve_execution_backend(raw_value: str | None) -> str: + backend = (raw_value or EXECUTION_BACKEND_GATEWAY).strip().lower() + if backend in SUPPORTED_EXECUTION_BACKENDS: + return backend + supported = ", ".join(sorted(SUPPORTED_EXECUTION_BACKENDS)) + raise EnvironmentError(f"IBKR_EXECUTION_BACKEND must be one of: {supported}") + + def resolve_ib_gateway_mode(raw_value: str | None) -> str: mode = (raw_value or "").strip().lower() if not mode: diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 45f2df7..5736a2c 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -31,6 +31,7 @@ "GLOBAL_TELEGRAM_CHAT_ID", "NOTIFY_LANG", "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", + "IBKR_EXECUTION_BACKEND", "IB_GATEWAY_ZONE", "IB_GATEWAY_IP_MODE", "EXECUTION_REPORT_GCS_URI", @@ -45,6 +46,7 @@ OPTIONAL_TARGET_ENV = ( "IB_GATEWAY_ZONE", "IB_GATEWAY_IP_MODE", + "IBKR_EXECUTION_BACKEND", "IBKR_FEATURE_SNAPSHOT_PATH", "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "IBKR_STRATEGY_CONFIG_PATH", diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index cf496d7..05a0b3d 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -68,6 +68,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", ] optional_env = [ + "IBKR_EXECUTION_BACKEND", "IBKR_DRY_RUN_ONLY", "IBKR_MIN_RESERVED_CASH_USD", "IBKR_RESERVED_CASH_RATIO", diff --git a/tests/test_connect_timeout_alert.py b/tests/test_connect_timeout_alert.py index 3236807..ba1bec8 100644 --- a/tests/test_connect_timeout_alert.py +++ b/tests/test_connect_timeout_alert.py @@ -98,6 +98,7 @@ def run(self, *args, **kwargs): runtime_config_support_module = types.ModuleType("runtime_config_support") runtime_config_support_module.load_platform_runtime_settings = lambda **_kwargs: types.SimpleNamespace( project_id=None, + execution_backend="gateway", secret_name="secret", strategy_profile="tqqq_growth_income", strategy_display_name="TQQQ Growth Income", @@ -133,6 +134,7 @@ def run(self, *args, **kwargs): ibkr_reconciliation_output_path=None, market_hours_source="cloud_run", ) + runtime_config_support_module.EXECUTION_BACKEND_GATEWAY = "gateway" runtime_config_support_module.resolve_ib_gateway_ip_mode = lambda *_args, **_kwargs: "internal" modules = { diff --git a/tests/test_event_loop.py b/tests/test_event_loop.py index 6077750..19a695f 100644 --- a/tests/test_event_loop.py +++ b/tests/test_event_loop.py @@ -57,6 +57,25 @@ def fake_ibkr_connect(host, port, client_id, **kwargs): assert observed["client_ids"] == [1, 101, 201] +def test_connect_ib_rejects_quantconnect_execution_backend(strategy_module_factory, monkeypatch): + module = strategy_module_factory( + IB_ACCOUNT_GROUP_CONFIG_JSON=( + '{"groups":{"default":{"execution_backend":"quantconnect",' + '"account_ids":["U00000000"],"service_name":"interactive-brokers-qc-service",' + '"quantconnect_project_id":12345678,"quantconnect_node_id":"LN-placeholder",' + '"quantconnect_compile_id":"compile-placeholder"}}}' + ) + ) + monkeypatch.setattr( + module, + "ibkr_connect_ib", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not connect gateway")), + ) + + with pytest.raises(RuntimeError, match="execution_backend='quantconnect'"): + module.connect_ib() + + def test_ib_connect_attempts_falls_back_when_invalid(strategy_module_factory): module = strategy_module_factory(IBKR_CONNECT_ATTEMPTS="0") diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 6e337e7..a962807 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -9,8 +9,11 @@ from runtime_config_support import ( DEFAULT_RESERVED_CASH_FLOOR_USD, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + EXECUTION_BACKEND_GATEWAY, + EXECUTION_BACKEND_QUANTCONNECT, load_platform_runtime_settings, parse_account_group_configs, + resolve_execution_backend, resolve_non_negative_float_env, resolve_optional_ratio_env, ) @@ -106,6 +109,7 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") assert settings.project_id == "project-1" + assert settings.execution_backend == EXECUTION_BACKEND_GATEWAY assert settings.ib_gateway_instance_name == "ib-gateway" assert settings.ib_gateway_zone == "" assert settings.ib_gateway_mode == "paper" @@ -137,6 +141,12 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.tg_token is None assert settings.tg_chat_id is None assert settings.strategy_plugin_mounts_json is None + assert settings.quantconnect_project_id is None + assert settings.quantconnect_node_id is None + assert settings.quantconnect_compile_id is None + assert settings.quantconnect_version_id is None + assert settings.quantconnect_credentials_secret_name is None + assert settings.quantconnect_brokerage_secret_name is None assert settings.crisis_alert_channels == () assert settings.crisis_alert_email_recipients == () assert settings.crisis_alert_email_sender_email is None @@ -233,6 +243,52 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo assert settings.notify_lang == "zh" +def test_load_platform_runtime_settings_supports_quantconnect_backend_without_gateway(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "qc_slot") + monkeypatch.setenv( + "IB_ACCOUNT_GROUP_CONFIG_JSON", + json.dumps( + { + "groups": { + "qc_slot": { + "execution_backend": "quantconnect", + "service_name": "interactive-brokers-qc-slot-service", + "account_ids": ["U00000000"], + "quantconnect_project_id": 12345678, + "quantconnect_node_id": "LN-placeholder", + "quantconnect_compile_id": "compile-placeholder", + "quantconnect_version_id": "-1", + "quantconnect_credentials_secret_name": "qc-api-credentials", + "quantconnect_brokerage_secret_name": "qc-ibkr-slot", + } + } + } + ), + ) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.execution_backend == EXECUTION_BACKEND_QUANTCONNECT + assert settings.ib_gateway_instance_name == "" + assert settings.ib_gateway_mode == "live" + assert settings.ib_gateway_port == 0 + assert settings.ib_client_id == 0 + assert settings.service_name == "interactive-brokers-qc-slot-service" + assert settings.account_ids == ("U00000000",) + assert settings.quantconnect_project_id == 12345678 + assert settings.quantconnect_node_id == "LN-placeholder" + assert settings.quantconnect_compile_id == "compile-placeholder" + assert settings.quantconnect_version_id == "-1" + assert settings.quantconnect_credentials_secret_name == "qc-api-credentials" + assert settings.quantconnect_brokerage_secret_name == "qc-ibkr-slot" + + +def test_resolve_execution_backend_rejects_unknown_backend(): + with pytest.raises(EnvironmentError, match="IBKR_EXECUTION_BACKEND"): + resolve_execution_backend("unsupported") + + def test_load_platform_runtime_settings_reads_crisis_alert_email_config(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) monkeypatch.setenv("ACCOUNT_GROUP", "paper") From 72eebfc9554fa17aa4cce92d40108f3a5c789ff8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 26 May 2026 16:57:45 +0800 Subject: [PATCH 2/2] Keep shared package pins compatible --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 30e1f53..b9ca8c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3193c145326920d63cf60d234fb63f8a4b6fc012 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c pandas numpy