From 0bfe04f89c3b37a46f350c4955612d86118acccf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 31 May 2026 23:43:09 +0800 Subject: [PATCH 1/6] Add HK equity strategy runtime support --- .github/workflows/ci.yml | 2 + .github/workflows/sync-cloud-run-env.yml | 6 + README.md | 60 ++++--- application/ibkr_order_execution.py | 25 ++- application/ibkr_portfolio.py | 121 +++++++++++++ application/runtime_broker_adapters.py | 4 + decision_mapper.py | 13 +- docs/hk_equity_runtime.md | 85 +++++++++ main.py | 108 +++++++++++- notifications/telegram.py | 2 + requirements.txt | 1 + runtime_config_support.py | 86 ++++++++- scripts/build_cloud_run_env_sync_plan.py | 17 +- scripts/print_strategy_profile_status.py | 10 +- scripts/print_strategy_switch_env_plan.py | 17 +- strategy_loader.py | 3 +- strategy_registry.py | 82 ++++++++- tests/test_ibkr_order_execution.py | 22 ++- tests/test_ibkr_portfolio.py | 70 ++++++++ tests/test_runtime_config_support.py | 205 ++++++++++++++++++---- 20 files changed, 853 insertions(+), 86 deletions(-) create mode 100644 application/ibkr_portfolio.py create mode 100644 docs/hk_equity_runtime.md create mode 100644 tests/test_ibkr_portfolio.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca86fba..67347ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,11 +88,13 @@ jobs: set -euo pipefail python - <<'PY' from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort + from hk_equity_strategies import resolve_canonical_profile as resolve_hk_canonical_profile from us_equity_strategies import resolve_canonical_profile assert CallableNotificationPort assert CallablePortfolioPort assert resolve_canonical_profile("mega_cap_leader_rotation_top50_balanced") == "mega_cap_leader_rotation_top50_balanced" + assert resolve_hk_canonical_profile("hk_blue_chip_leader_rotation") == "hk_blue_chip_leader_rotation" PY - name: Install editable shared repositories diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 1a2ed3d..44b4340 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -39,6 +39,12 @@ jobs: IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} + IBKR_MARKET: ${{ vars.IBKR_MARKET }} + IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} + IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} + IBKR_MARKET_DATA_SYMBOL_SUFFIX: ${{ vars.IBKR_MARKET_DATA_SYMBOL_SUFFIX }} + IBKR_MARKET_EXCHANGE: ${{ vars.IBKR_MARKET_EXCHANGE }} + IBKR_MARKET_TIMEZONE: ${{ vars.IBKR_MARKET_TIMEZONE }} IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }} IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }} IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} diff --git a/README.md b/README.md index 4f675a7..19889b8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Python](https://img.shields.io/badge/Python-3.9%2B-blue) ![Platform](https://img.shields.io/badge/Broker-Interactive%20Brokers-red) -![Strategy](https://img.shields.io/badge/Strategy-US%20Equity%20Profiles-green) +![Strategy](https://img.shields.io/badge/Strategy-US%2FHK%20Equity%20Profiles-green) ![GCP](https://img.shields.io/badge/GCP-Cloud%20Run%20%2B%20GCE-4285F4) [English](#english) | [中文](#中文) @@ -12,10 +12,10 @@ ## English -IBKR runtime for shared `us_equity` strategy profiles from `UsEquityStrategies`. Strategy logic, cadence, asset universes, parameters, and research/backtest notes live there. -The runtime carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` for the running service identity. Strategy-owned defaults come from `UsEquityStrategies`; platform variables are only explicit overrides. +IBKR runtime for shared `us_equity` profiles from `UsEquityStrategies` and `hk_equity` profiles from `HkEquityStrategies`. Strategy logic, cadence, asset universes, parameters, and research/backtest notes live in the strategy repositories. +The runtime carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` for the running service identity. Strategy-owned defaults come from `UsEquityStrategies` and `HkEquityStrategies`; platform variables are only explicit overrides. -Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). This README focuses on IBKR runtime behavior, profile enablement, deployment, and credentials, and this profile matrix remains the authoritative IBKR enablement source. +Strategy documentation lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies) and [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies). HK snapshot artifact contracts are produced by [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines). This README focuses on IBKR runtime behavior, profile enablement, deployment, and credentials, and this profile matrix remains the authoritative IBKR enablement source. ### Execution boundary @@ -39,6 +39,7 @@ The mainline runtime now follows one path only: - `tech_communication_pullback_enhancement` - `mega_cap_leader_rotation_top50_balanced` - `nasdaq_sp500_smart_dca` +- `hk_blue_chip_leader_rotation` **IBKR profile status** @@ -52,6 +53,7 @@ The mainline runtime now follows one path only: | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation | | `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile | +| `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | Yes | `hk_equity` | HK feature-snapshot strategy for SEHK/HKD accounts | Check the current matrix locally: @@ -61,7 +63,9 @@ python3 scripts/print_strategy_profile_status.py ### Feature snapshot inputs -Snapshot-backed profiles use upstream artifacts from `UsEquitySnapshotPipelines`. This runtime only needs the artifact location, for example `IBKR_FEATURE_SNAPSHOT_PATH`; strategy logic, cadence, feature definitions, and snapshot schema details live in `UsEquityStrategies` / `UsEquitySnapshotPipelines`. +Snapshot-backed profiles use upstream artifacts from `UsEquitySnapshotPipelines` or `HkEquitySnapshotPipelines`. This runtime only needs the artifact location, for example `IBKR_FEATURE_SNAPSHOT_PATH`; strategy logic, cadence, feature definitions, and snapshot schema details live in the strategy/snapshot repositories. + +For the HK-equity runtime scope, platform matrix, and env defaults, see [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md). Example runtime pointer: @@ -73,7 +77,7 @@ IBKR_FEATURE_SNAPSHOT_PATH=/var/data/r1000_feature_snapshot.csv ### Architecture ``` -Cloud Scheduler (cron chosen from the strategy-layer cadence in `UsEquityStrategies`) +Cloud Scheduler (cron chosen from the selected strategy-layer cadence) ↓ HTTP POST Cloud Run (Flask: strategy + orchestration) ↓ shared adapter package @@ -86,7 +90,7 @@ IBKR Account ### Notifications -Telegram alerts support English/Chinese execution and heartbeat messages. Strategy-specific signal/status fields come from the selected `UsEquityStrategies` profile; IBKR-specific fields cover order submission, order IDs, account-group context, and runtime state. +Telegram alerts support English/Chinese execution and heartbeat messages. Strategy-specific signal/status fields come from the selected strategy package profile; IBKR-specific fields cover order submission, order IDs, account-group context, market scope, and runtime state. ### Runtime env vars @@ -101,9 +105,15 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. | | `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca` | +| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`, `hk_blue_chip_leader_rotation` | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | -| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | +| `IBKR_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_GROUP` contains `hk`, otherwise `US`. | +| `IBKR_MARKET_CALENDAR` | No | Market calendar. Defaults to `XHKG` for HK and `NYSE` for US. | +| `IBKR_MARKET_TIMEZONE` | No | Market timezone. Defaults to `Asia/Hong_Kong` for HK and `America/New_York` for US. | +| `IBKR_MARKET_EXCHANGE` | No | Stock contract exchange. Defaults to `SEHK` for HK and `SMART` for US. | +| `IBKR_MARKET_CURRENCY` | No | Stock contract currency and portfolio currency scope. Defaults to `HKD` for HK and `USD` for US. | +| `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | No | Suffix used only for yfinance fallback symbols. Defaults to `.HK` for HK and empty for US. | +| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, and `hk_blue_chip_leader_rotation`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | | `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; defaults to `50.0`. | | `IBKR_MIN_RESERVED_CASH_USD` | No | Platform-level minimum cash reserve in USD. Defaults to `0`; the effective reserve is the max of this floor and the effective cash reserve ratio. | @@ -251,7 +261,7 @@ Recommended setup: - `STRATEGY_PROFILE` (set explicitly to one enabled profile, such as `soxl_soxx_trend_income`) - `ACCOUNT_GROUP` (recommended: `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` - - Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`, `IBKR_MIN_RESERVED_CASH_USD`, `IBKR_RESERVED_CASH_RATIO`, `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` + - Optional: `IBKR_MARKET`, `IBKR_MARKET_CALENDAR`, `IBKR_MARKET_CURRENCY`, `IBKR_MARKET_DATA_SYMBOL_SUFFIX`, `IBKR_MARKET_EXCHANGE`, `IBKR_MARKET_TIMEZONE`, `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`, `IBKR_MIN_RESERVED_CASH_USD`, `IBKR_RESERVED_CASH_RATIO`, `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` - Optional: `CRISIS_ALERT_EMAIL_RECIPIENTS`, `CRISIS_ALERT_EMAIL_SENDER_EMAIL`, `CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME` - Optional: `CRISIS_ALERT_EMAIL_SMTP_HOST`, `CRISIS_ALERT_EMAIL_SMTP_PORT`, `CRISIS_ALERT_EMAIL_SMTP_SECURITY` - `GLOBAL_TELEGRAM_CHAT_ID` @@ -265,7 +275,7 @@ Recommended setup: On every push to `main`, the workflow can build one container image, deploy it to one or more configured Cloud Run services, build a Cloud Run sync plan, update the configured Cloud Run service env vars, and remove legacy env vars that should now live in the account-group config (`IB_CLIENT_ID`, `IB_GATEWAY_INSTANCE_NAME`, `IB_GATEWAY_MODE`) plus the older transport vars (`IB_GATEWAY_HOST`, `IB_GATEWAY_PORT`, `TELEGRAM_CHAT_ID`). If `IB_GATEWAY_ZONE` or `IB_GATEWAY_IP_MODE` are blank in the selected sync target, the workflow also removes them from Cloud Run to avoid drift. -`STRATEGY_PROFILE` is resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domain is `us_equity`: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. `RUNTIME_TARGET_JSON` carries the structured runtime identity; strategy defaults continue to come from `UsEquityStrategies`. +`STRATEGY_PROFILE` is resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domains are `us_equity` and `hk_equity`: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. `RUNTIME_TARGET_JSON` carries the structured runtime identity; strategy defaults continue to come from the strategy packages. For slot deployments, use `CLOUD_RUN_SERVICE_TARGETS_JSON` instead of a shared `RUNTIME_TARGET_JSON`. This keeps shared alert policy in one place while each Cloud Run service owns its runtime identity: @@ -350,7 +360,7 @@ Important: 3. **Cloud Run**: Deploy or update this Flask app with Direct VPC egress. Set `STRATEGY_PROFILE`, `ACCOUNT_GROUP`, and `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`. Keep `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` only as transition fallbacks if the selected account-group payload does not already contain them. The workflow emits `RUNTIME_TARGET_JSON` to describe the structured deployment target. The runtime service account needs `roles/secretmanager.secretAccessor` and, for instance-name resolution, `roles/compute.viewer`. - For Cloud Run source deploy, also grant `roles/storage.objectViewer` on `gs://run-sources-${PROJECT_ID}-${REGION}` to the build service account, the deploy service account, and `${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`. 4. **Firewall**: Allow TCP `4001` (`live`) or `4002` (`paper`) from the Cloud Run egress subnet CIDR to the GCE instance. -5. **Cloud Scheduler**: Create two jobs that POST to the Cloud Run URL. Use `"/precheck"` after the open window and `"/"` near the close window. Choose both crons from the strategy-layer cadence in `UsEquityStrategies`; daily profiles can still use a near-close weekday schedule such as `45 15 * * 1-5` in `America/New_York`. +5. **Cloud Scheduler**: Create two jobs that POST to the Cloud Run URL. Use `"/precheck"` after the open window and `"/"` near the close window. Choose both crons from the selected strategy-layer cadence; US daily profiles can still use a near-close weekday schedule such as `45 15 * * 1-5` in `America/New_York`, while HK profiles should use an `Asia/Hong_Kong` schedule aligned with XHKG. 6. **Optional public-IP mode**: Only if you cannot use VPC, set `IB_GATEWAY_IP_MODE=external`, expose the GCE public IP deliberately, and restrict source ranges tightly. This is not the default path. Example deploy/update command: @@ -386,9 +396,9 @@ gcloud run services update ibkr-quant \ ## 中文 -IBKR runtime 负责把共享的 `us_equity` 策略档位部署到 GCP Cloud Run,并连接 GCE 上的 IB Gateway 执行。策略逻辑、策略频率、标的池、参数和研究/回测说明都放在 `UsEquityStrategies`;这个仓库只维护 IBKR 运行时、账号组、Gateway 连接、下单和通知。 +IBKR runtime 负责把共享的 `us_equity` / `hk_equity` 策略档位部署到 GCP Cloud Run,并连接 GCE 上的 IB Gateway 执行。策略逻辑、策略频率、标的池、参数和研究/回测说明放在策略仓库;这个仓库只维护 IBKR 运行时、账号组、Gateway 连接、下单和通知。 -完整策略说明放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。这个 README 只保留 IBKR 运行时、profile 启用状态、部署和凭据说明。 +策略说明放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies) 和 [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies);港股 snapshot artifact 由 [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 生成。这个 README 只保留 IBKR 运行时、profile 启用状态、部署和凭据说明。 ### 执行边界 @@ -403,12 +413,14 @@ IBKR runtime 负责把共享的 `us_equity` 策略档位部署到 GCP Cloud Run ### 策略输入边界 -feature-snapshot 类策略使用 `UsEquitySnapshotPipelines` 发布的上游 artifact。这个运行时只需要 artifact 的位置,例如 `IBKR_FEATURE_SNAPSHOT_PATH`;策略逻辑、策略频率、特征定义和 snapshot schema 说明放在 `UsEquityStrategies` / `UsEquitySnapshotPipelines`。 +feature-snapshot 类策略使用 `UsEquitySnapshotPipelines` 或 `HkEquitySnapshotPipelines` 发布的上游 artifact。这个运行时只需要 artifact 的位置,例如 `IBKR_FEATURE_SNAPSHOT_PATH`;策略逻辑、策略频率、特征定义和 snapshot schema 说明放在策略/快照仓库。 + +港股运行时范围、平台矩阵和环境变量默认值见 [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md)。 ### 架构 ``` -Cloud Scheduler(cron 以 `UsEquityStrategies` 的策略层频率为准) +Cloud Scheduler(cron 以所选策略的策略层频率为准) ↓ HTTP POST Cloud Run (Flask: 策略计算 + 编排) ↓ 共享平台适配层 @@ -431,9 +443,15 @@ IBKR 账户 | `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 | | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca` | +| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`、`hk_blue_chip_leader_rotation` | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | -| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | +| `IBKR_MARKET` | 否 | 市场范围。`ACCOUNT_GROUP` 包含 `hk` 时默认 `HK`,其他情况默认 `US`。 | +| `IBKR_MARKET_CALENDAR` | 否 | 市场日历。港股默认 `XHKG`,美股默认 `NYSE`。 | +| `IBKR_MARKET_TIMEZONE` | 否 | 市场时区。港股默认 `Asia/Hong_Kong`,美股默认 `America/New_York`。 | +| `IBKR_MARKET_EXCHANGE` | 否 | 股票合约交易所。港股默认 `SEHK`,美股默认 `SMART`。 | +| `IBKR_MARKET_CURRENCY` | 否 | 股票合约币种和组合现金口径。港股默认 `HKD`,美股默认 `USD`。 | +| `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | 否 | 仅用于 yfinance fallback 的标的后缀。港股默认 `.HK`,美股默认空。 | +| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`hk_blue_chip_leader_rotation` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | | `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 限价买入的最小名义金额;默认 `50.0`。 | | `IBKR_MIN_RESERVED_CASH_USD` | 否 | 平台级最低预留现金 USD。默认 `0`;实际预留取该下限和有效预留现金比例中的最大值。 | @@ -547,7 +565,7 @@ IB_GATEWAY_IP_MODE=internal - `STRATEGY_PROFILE`(显式设置为任一已启用 profile,例如 `soxl_soxx_trend_income`) - `ACCOUNT_GROUP`(建议设为 `paper`) - `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` - - 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`、`IBKR_MIN_RESERVED_CASH_USD`、`IBKR_RESERVED_CASH_RATIO`、`IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` + - 可选:`IBKR_MARKET`、`IBKR_MARKET_CALENDAR`、`IBKR_MARKET_CURRENCY`、`IBKR_MARKET_DATA_SYMBOL_SUFFIX`、`IBKR_MARKET_EXCHANGE`、`IBKR_MARKET_TIMEZONE`、`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`、`IBKR_MIN_RESERVED_CASH_USD`、`IBKR_RESERVED_CASH_RATIO`、`IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` - 可选:`CRISIS_ALERT_EMAIL_RECIPIENTS`、`CRISIS_ALERT_EMAIL_SENDER_EMAIL`、`CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME` - 可选:`CRISIS_ALERT_EMAIL_SMTP_HOST`、`CRISIS_ALERT_EMAIL_SMTP_PORT`、`CRISIS_ALERT_EMAIL_SMTP_SECURITY` - `GLOBAL_TELEGRAM_CHAT_ID` @@ -561,7 +579,7 @@ IB_GATEWAY_IP_MODE=internal 每次 push 到 `main` 时,这个 workflow 可以先构建一份容器镜像并部署到一个或多个 Cloud Run 服务,再生成 Cloud Run sync plan,把目标值同步到配置的服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果目标 sync 配置里没有 `IB_GATEWAY_ZONE` 或 `IB_GATEWAY_IP_MODE`,workflow 也会把 Cloud Run 上这两个旧值一起删除,避免双配置源漂移。 -`STRATEGY_PROFILE` 由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 +`STRATEGY_PROFILE` 由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域是 `us_equity` 和 `hk_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 注意: @@ -588,7 +606,7 @@ IB_GATEWAY_IP_MODE=internal 3. **Cloud Run**: 部署此 Flask 应用时启用 Direct VPC egress。设置 `STRATEGY_PROFILE`、`ACCOUNT_GROUP`、`IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`;只有在账号组配置里还没放 `ib_gateway_zone` / `ib_gateway_ip_mode` 时,才临时保留 `IB_GATEWAY_ZONE` / `IB_GATEWAY_IP_MODE` 作为过渡 fallback。runtime service account 需要 `roles/secretmanager.secretAccessor`,若走实例名解析,还需要 `roles/compute.viewer`。 - 如果使用 Cloud Run source deploy,还要给 `gs://run-sources-${PROJECT_ID}-${REGION}` 这个 bucket 授权 `roles/storage.objectViewer`,对象是 build service account、deploy service account,以及 `${PROJECT_NUMBER}-compute@developer.gserviceaccount.com`。 4. **防火墙**: 只允许 Cloud Run 出口子网访问 GCE 的 `TCP 4001`(`live`)或 `TCP 4002`(`paper`)。 -5. **Cloud Scheduler**: 创建定时任务,POST 到 Cloud Run URL。cron 频率以 `UsEquityStrategies` 里的策略层 cadence 为准;日频 profile 仍可使用美股临近收盘的工作日计划,例如 `45 15 * * 1-5`(America/New_York 时区)。 +5. **Cloud Scheduler**: 创建定时任务,POST 到 Cloud Run URL。cron 频率以所选策略仓库里的策略层 cadence 为准;美股日频 profile 可使用临近收盘的工作日计划,例如 `45 15 * * 1-5`(America/New_York),港股 profile 应按 XHKG 和 `Asia/Hong_Kong` 设置。 6. **可选公网模式**: 只有在不能走 VPC 时,才设置 `IB_GATEWAY_IP_MODE=external`,并且要明确开放 GCE 公网 IP,同时严格限制来源 IP 和防火墙规则。 示例部署命令: diff --git a/application/ibkr_order_execution.py b/application/ibkr_order_execution.py index 6590314..4f5c4f4 100644 --- a/application/ibkr_order_execution.py +++ b/application/ibkr_order_execution.py @@ -11,6 +11,23 @@ DEFAULT_TIME_IN_FORCE = "DAY" +def _stock_factory_for_market( + stock_factory: Callable[..., Any] | None, + *, + exchange: str, + currency: str, +) -> Callable[..., Any]: + def factory(symbol: str, _exchange: str = "SMART", _currency: str = "USD") -> Any: + factory_impl = stock_factory + if factory_impl is None: + from ib_insync import Stock + + factory_impl = Stock + return factory_impl(symbol, exchange, currency) + + return factory + + def _intent_with_default_time_in_force(order_intent: OrderIntent) -> OrderIntent: if order_intent.time_in_force: return order_intent @@ -47,6 +64,8 @@ def submit_order_intent( combo_leg_factory: Callable[..., Any] | None = None, market_order_factory: Callable[..., Any] | None = None, limit_order_factory: Callable[..., Any] | None = None, + stock_exchange: str = "SMART", + stock_currency: str = "USD", ) -> ExecutionReport: """Submit an IBKR order with explicit TIF to avoid account-preset rejections.""" @@ -56,7 +75,11 @@ def submit_order_intent( intent, account_id=account_id, wait_seconds=wait_seconds, - stock_factory=stock_factory, + stock_factory=_stock_factory_for_market( + stock_factory, + exchange=str(stock_exchange or "SMART").upper(), + currency=str(stock_currency or "USD").upper(), + ), option_factory=option_factory, combo_contract_factory=combo_contract_factory, combo_leg_factory=combo_leg_factory, diff --git a/application/ibkr_portfolio.py b/application/ibkr_portfolio.py new file mode 100644 index 0000000..8ffd5b2 --- /dev/null +++ b/application/ibkr_portfolio.py @@ -0,0 +1,121 @@ +"""IBKR portfolio snapshot helpers with market-currency awareness.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timezone +from typing import Any + +from quant_platform_kit.common.models import PortfolioSnapshot, Position + + +def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]: + if account_ids is None: + return () + if isinstance(account_ids, str): + candidates = [account_ids] + else: + candidates = list(account_ids) + normalized = [] + for candidate in candidates: + text = str(candidate or "").strip() + if text: + normalized.append(text) + return tuple(dict.fromkeys(normalized)) + + +def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool: + if not selected_account_ids: + return True + return str(account_id or "").strip() in selected_account_ids + + +def fetch_portfolio_snapshot( + ib: Any, + *, + account_ids: Iterable[str] | str | None = None, + wait_seconds: float = 1.0, + currency: str = "USD", +) -> PortfolioSnapshot: + """Fetch stock positions and account values for the configured trading currency. + + QuantPlatformKit's default IBKR helper is USD-oriented. Keeping this small + adapter local lets the platform run US and HK services without changing the + shared package release line. + """ + + selected_account_ids = _normalize_account_ids(account_ids) + market_currency = str(currency or "USD").strip().upper() + ib.reqPositions() + if wait_seconds: + import time as time_module + + time_module.sleep(wait_seconds) + + positions = [] + option_positions = [] + for raw_position in ib.positions(): + account_id = str(getattr(raw_position, "account", "") or "").strip() or None + if not _matches_account(account_id, selected_account_ids): + continue + if raw_position.position == 0: + continue + contract = raw_position.contract + quantity = float(raw_position.position) + average_cost = float(raw_position.avgCost) + contract_currency = str(getattr(contract, "currency", "") or "").strip().upper() + if not contract_currency: + contract_currency = market_currency + if str(getattr(contract, "secType", "") or "").strip().upper() == "OPT": + option_positions.append( + { + "underlier": str(getattr(contract, "symbol", "") or "").strip().upper(), + "local_symbol": str(getattr(contract, "localSymbol", "") or "").strip(), + "expiration": str( + getattr(contract, "lastTradeDateOrContractMonth", "") or "" + ).strip(), + "right": str(getattr(contract, "right", "") or "").strip().upper(), + "strike": float(getattr(contract, "strike", 0.0) or 0.0), + "quantity": quantity, + "average_cost": average_cost, + "cost_basis": abs(quantity * average_cost), + "account_id": account_id, + "currency": contract_currency, + } + ) + continue + positions.append( + Position( + symbol=str(getattr(contract, "symbol", "") or "").strip().upper(), + quantity=quantity, + market_value=quantity * average_cost, + average_cost=average_cost, + currency=contract_currency, + ) + ) + + total_equity = 0.0 + buying_power = None + for account_value in ib.accountValues(): + account_id = str(getattr(account_value, "account", "") or "").strip() or None + if not _matches_account(account_id, selected_account_ids): + continue + if str(getattr(account_value, "currency", "") or "").strip().upper() != market_currency: + continue + if account_value.tag == "NetLiquidation": + total_equity += float(account_value.value) + elif account_value.tag == "AvailableFunds": + value = float(account_value.value) + buying_power = value if buying_power is None else buying_power + value + + return PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=total_equity, + buying_power=buying_power, + positions=tuple(positions), + metadata={ + "account_ids": selected_account_ids, + "option_positions": tuple(option_positions), + "currency": market_currency, + }, + ) diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index f3686d4..0d9c2f5 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -46,6 +46,7 @@ class IBKRRuntimeBrokerAdapters: separator: str strategy_display_name: str sleep_fn: Any + market_currency: str = "USD" printer: Any = print def validate_configured_accounts(self, ib): @@ -142,6 +143,7 @@ def build_portfolio_snapshot(self, ib, *, get_current_portfolio_fallback=None): quantity=float(details.get("quantity") or 0), market_value=float(details.get("quantity") or 0) * float(details.get("avg_cost") or 0.0), average_cost=float(details.get("avg_cost") or 0.0), + currency=self.market_currency, ) for symbol, details in dict(positions or {}).items() ), @@ -281,6 +283,7 @@ def build_runtime_broker_adapters( separator: str, strategy_display_name: str, sleep_fn, + market_currency: str = "USD", printer=print, ) -> IBKRRuntimeBrokerAdapters: return IBKRRuntimeBrokerAdapters( @@ -318,5 +321,6 @@ def build_runtime_broker_adapters( separator=str(separator or ""), strategy_display_name=str(strategy_display_name or ""), sleep_fn=sleep_fn, + market_currency=str(market_currency or "USD").upper(), printer=printer, ) diff --git a/decision_mapper.py b/decision_mapper.py index e5b0888..a78377e 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -3,14 +3,13 @@ from collections.abc import Mapping from typing import Any -from us_equity_strategies.catalog import resolve_canonical_profile - from quant_platform_kit.strategy_contracts import ( StrategyDecision, build_allocation_intent, build_allocation_payload, translate_decision_to_target_mode, ) +from strategy_registry import IBKR_PLATFORM, resolve_strategy_definition _EMERGENCY_FLAGS = frozenset({"emergency", "hard_defense"}) @@ -18,7 +17,10 @@ def _resolve_allocation_order(strategy_profile: str) -> str: - canonical_profile = resolve_canonical_profile(strategy_profile) + canonical_profile = resolve_strategy_definition( + strategy_profile, + platform_id=IBKR_PLATFORM, + ).profile if canonical_profile == "soxl_soxx_trend_income": return "risk_income_safe" return "risk_safe_income" @@ -133,7 +135,10 @@ def map_strategy_decision( runtime_metadata: Mapping[str, Any] | None = None, ) -> tuple[dict[str, float] | None, str, bool, str, dict[str, Any]]: runtime_metadata = dict(runtime_metadata or {}) - canonical_profile = resolve_canonical_profile(strategy_profile) + canonical_profile = resolve_strategy_definition( + strategy_profile, + platform_id=IBKR_PLATFORM, + ).profile diagnostics = dict(decision.diagnostics) risk_flags = tuple(str(flag) for flag in decision.risk_flags) no_execute = bool(_NO_EXECUTE_FLAGS & set(risk_flags)) diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md new file mode 100644 index 0000000..0ece83d --- /dev/null +++ b/docs/hk_equity_runtime.md @@ -0,0 +1,85 @@ +# IBKR 港股运行时接入说明 + +## 结论 + +QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 `InteractiveBrokersPlatform` 和 `LongBridgePlatform`。 + +| 平台仓库 | 港股交易接入判断 | 当前处理 | +| --- | --- | --- | +| `InteractiveBrokersPlatform` | 可接入。IBKR 支持 SEHK/HKD 合约,但账户必须开通港股交易和行情权限。 | 已加入 HK market scope、SEHK/HKD 合约参数、HKD portfolio 口径、通知和结构化日志字段。 | +| `LongBridgePlatform` | 可接入。LongBridge 支持港股账户、`.HK` 行情符号和 HKD 现金口径。 | 在对应仓库单独接入。 | +| `CharlesSchwabPlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 | +| `FirstradePlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 | +| `BinancePlatform` | 加密货币平台,不是港股股票交易入口。 | 不改。 | + +## 运行时设计 + +本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已正式接入 `HkEquityStrategies` 的 `hk_blue_chip_leader_rotation` profile,并沿用美股 snapshot 策略的架构: + +1. [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies) 提供 `hk_equity` 策略 profile、运行入口和 IBKR runtime adapter。 +2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 `_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。 +3. 平台仓库通过 `RUNTIME_TARGET_JSON`、`IBKR_FEATURE_SNAPSHOT_PATH`、`IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH` 和可选 config 路径读取策略输入。 +4. IBKR 运行时根据 market scope 选择 SEHK/HKD 合约、HKD 账户口径、XHKG 日历和通知/日志字段。 + +这样可以复用现有 US snapshot 的 artifact contract,同时保持平台仓只负责执行、账户、通知和运行报告。 + +## 已启用港股 profile + +| Profile | Domain | Inputs | Target mode | Snapshot manifest | +| --- | --- | --- | --- | --- | +| `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | + +最小策略配置示例: + +```bash +STRATEGY_PROFILE=hk_blue_chip_leader_rotation +ACCOUNT_GROUP=hk-live +RUNTIME_TARGET_JSON={"platform_id":"ibkr","strategy_profile":"hk_blue_chip_leader_rotation","deployment_selector":"hk-live","account_scope":"hk-live","execution_mode":"live"} +IBKR_FEATURE_SNAPSHOT_PATH=gs:///hk_blue_chip_leader_rotation_feature_snapshot_latest.csv +IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=gs:///hk_blue_chip_leader_rotation_feature_snapshot_latest.csv.manifest.json +``` + +## 配置项 + +| 变量 | 默认值 | 港股建议值 | 说明 | +| --- | --- | --- | --- | +| `ACCOUNT_GROUP` | 必填 | 例如 `hk-live` | 包含 `hk` 时会推导港股默认 market scope。 | +| `IBKR_MARKET` | 从 `ACCOUNT_GROUP` 推导,默认 `US` | `HK` | 显式指定市场;优先级高于 `ACCOUNT_GROUP` 推导。 | +| `IBKR_MARKET_CALENDAR` | `NYSE` / 港股为 `XHKG` | `XHKG` | 市场日历。 | +| `IBKR_MARKET_TIMEZONE` | `America/New_York` / 港股为 `Asia/Hong_Kong` | `Asia/Hong_Kong` | 市场时区。 | +| `IBKR_MARKET_EXCHANGE` | `SMART` / 港股为 `SEHK` | `SEHK` | 股票合约交易所。 | +| `IBKR_MARKET_CURRENCY` | `USD` / 港股为 `HKD` | `HKD` | 合约币种、账户净值和购买力过滤口径。 | +| `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | 空 / 港股为 `.HK` | `.HK` | yfinance fallback 行情符号后缀;IBKR 合约本身不附加该后缀。 | + +最小港股配置: + +```bash +ACCOUNT_GROUP=hk-live +# 可选显式覆盖: +IBKR_MARKET=HK +IBKR_MARKET_CALENDAR=XHKG +IBKR_MARKET_TIMEZONE=Asia/Hong_Kong +IBKR_MARKET_EXCHANGE=SEHK +IBKR_MARKET_CURRENCY=HKD +IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK +``` + +## 订单、组合和行情口径 + +- 股票订单通过 `Stock(symbol, IBKR_MARKET_EXCHANGE, IBKR_MARKET_CURRENCY)` 构造;港股默认是 `SEHK/HKD`。 +- Portfolio snapshot 只汇总配置币种的 `NetLiquidation` 和 `AvailableFunds`;港股默认是 HKD。 +- IBKR 历史行情和 quote snapshot 会使用配置的 exchange/currency。 +- yfinance fallback 会给无后缀 symbol 追加 `IBKR_MARKET_DATA_SYMBOL_SUFFIX`,例如 `00700` -> `00700.HK`。 + +## 通知和日志 + +- Telegram 中英文模板新增市场行:市场、交易币种、交易所和日历。 +- Runtime report / structured log context 新增:`market`、`market_calendar`、`market_currency`、`market_data_symbol_suffix`、`market_exchange`、`market_timezone`。 +- 市场关闭跳过等事件会带上 market scope,便于区分 US/HK 服务。 + +## 风险和注意事项 + +- IBKR 港股实盘依赖账户权限、行情权限、Gateway 登录账户可见账号和交易许可;平台配置无法替代这些权限。 +- 不同 IBKR 账户或区域对港股 symbol 格式可能有差异,首批上线前需要用 dry-run 和小范围 symbol 做实盘连接验证。 +- `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `IBKR_MARKET_CALENDAR` 临时覆盖。 +- `hk_blue_chip_leader_rotation` 已接入平台,但实盘前仍需要用最新 snapshot artifact、dry-run 和小范围 symbol / 小订单做连接验证。 diff --git a/main.py b/main.py index 397fb31..4022edd 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,10 @@ from application.cycle_result import coerce_strategy_cycle_result from application.runtime_broker_adapters import build_runtime_broker_adapters from application.runtime_composer import build_runtime_composer -from application.runtime_strategy_adapters import build_runtime_strategy_adapters +from application.runtime_strategy_adapters import ( + build_runtime_strategy_adapters, + fetch_yfinance_historical_candles, +) from application.rebalance_service import run_strategy_core as run_rebalance_cycle from application.signal_snapshot import build_signal_snapshot from decision_mapper import map_strategy_decision @@ -48,10 +51,10 @@ ensure_event_loop as ibkr_ensure_event_loop, fetch_historical_price_candles, fetch_historical_price_series, - fetch_portfolio_snapshot, fetch_quote_snapshots, ) from application.ibkr_order_execution import submit_order_intent +from application.ibkr_portfolio import fetch_portfolio_snapshot from application.execution_service import ( check_order_submitted as application_check_order_submitted, execute_rebalance as application_execute_rebalance, @@ -205,6 +208,12 @@ def _env_flag(name: str) -> bool: 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) +MARKET = RUNTIME_SETTINGS.market +MARKET_CALENDAR = RUNTIME_SETTINGS.market_calendar +MARKET_CURRENCY = RUNTIME_SETTINGS.market_currency +MARKET_DATA_SYMBOL_SUFFIX = RUNTIME_SETTINGS.market_data_symbol_suffix +MARKET_EXCHANGE = RUNTIME_SETTINGS.market_exchange +MARKET_TIMEZONE = RUNTIME_SETTINGS.market_timezone STRATEGY_RUNTIME = load_strategy_runtime( STRATEGY_PROFILE, @@ -288,6 +297,12 @@ def t(key, **kwargs): "ib_client_id_retry_offset": IB_CLIENT_ID_RETRY_OFFSET, "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, "quantconnect_node_id": QUANTCONNECT_NODE_ID, + "market": MARKET, + "market_calendar": MARKET_CALENDAR, + "market_currency": MARKET_CURRENCY, + "market_data_symbol_suffix": MARKET_DATA_SYMBOL_SUFFIX, + "market_exchange": MARKET_EXCHANGE, + "market_timezone": MARKET_TIMEZONE, }, ).build_log_context(run_id="") @@ -309,15 +324,71 @@ def build_strategy_adapters(): translator=t, pacing_sec=HIST_DATA_PACING_SEC, resolve_run_as_of_date_fn=resolve_run_as_of_date, - fetch_historical_price_series_fn=fetch_historical_price_series, - fetch_historical_price_candles_fn=fetch_historical_price_candles, + fetch_historical_price_series_fn=fetch_market_historical_price_series, + fetch_historical_price_candles_fn=fetch_market_historical_price_candles, map_strategy_decision_fn=map_strategy_decision, + fallback_historical_candles_fn=fetch_market_fallback_historical_candles, build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload, load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals, parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts, ) +def format_market_data_symbol(symbol: str) -> str: + value = str(symbol or "").strip().upper() + if not value or not MARKET_DATA_SYMBOL_SUFFIX or "." in value: + return value + return f"{value}{MARKET_DATA_SYMBOL_SUFFIX}" + + +def fetch_market_historical_price_series(ib, symbol, **kwargs): + return fetch_historical_price_series( + ib, + str(symbol).strip().upper(), + exchange=MARKET_EXCHANGE, + currency=MARKET_CURRENCY, + **kwargs, + ) + + +def fetch_market_historical_price_candles(ib, symbol, **kwargs): + return fetch_historical_price_candles( + ib, + str(symbol).strip().upper(), + exchange=MARKET_EXCHANGE, + currency=MARKET_CURRENCY, + **kwargs, + ) + + +def fetch_market_fallback_historical_candles(symbol, **kwargs): + return fetch_yfinance_historical_candles(format_market_data_symbol(symbol), **kwargs) + + +def fetch_market_quote_snapshots(ib, symbols, **kwargs): + return fetch_quote_snapshots( + ib, + symbols, + exchange=MARKET_EXCHANGE, + currency=MARKET_CURRENCY, + **kwargs, + ) + + +def submit_market_order_intent(ib, order_intent, **kwargs): + return submit_order_intent( + ib, + order_intent, + stock_exchange=MARKET_EXCHANGE, + stock_currency=MARKET_CURRENCY, + **kwargs, + ) + + +def fetch_market_portfolio_snapshot(ib, **kwargs): + return fetch_portfolio_snapshot(ib, currency=MARKET_CURRENCY, **kwargs) + + def build_broker_adapters(*, dry_run_only_override: bool | None = None): effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) return build_runtime_broker_adapters( @@ -330,9 +401,9 @@ def build_broker_adapters(*, dry_run_only_override: bool | None = None): client_id_retry_offset=IB_CLIENT_ID_RETRY_OFFSET, ensure_event_loop_fn=ensure_event_loop, connect_ib_fn=ibkr_connect_ib, - fetch_portfolio_snapshot_fn=fetch_portfolio_snapshot, - fetch_quote_snapshots_fn=fetch_quote_snapshots, - submit_order_intent_fn=submit_order_intent, + fetch_portfolio_snapshot_fn=fetch_market_portfolio_snapshot, + fetch_quote_snapshots_fn=fetch_market_quote_snapshots, + submit_order_intent_fn=submit_market_order_intent, application_get_market_prices_fn=application_get_market_prices, application_check_order_submitted_fn=application_check_order_submitted, application_execute_rebalance_fn=application_execute_rebalance, @@ -354,6 +425,7 @@ def build_broker_adapters(*, dry_run_only_override: bool | None = None): separator=SEPARATOR, strategy_display_name=strategy_display_name, sleep_fn=time.sleep, + market_currency=MARKET_CURRENCY, printer=print, ) @@ -425,6 +497,12 @@ def compute_signals_fn(ib, current_holdings): "execution_backend": EXECUTION_BACKEND, "quantconnect_project_id": QUANTCONNECT_PROJECT_ID, "quantconnect_node_id": QUANTCONNECT_NODE_ID, + "market": MARKET, + "market_calendar": MARKET_CALENDAR, + "market_currency": MARKET_CURRENCY, + "market_data_symbol_suffix": MARKET_DATA_SYMBOL_SUFFIX, + "market_exchange": MARKET_EXCHANGE, + "market_timezone": MARKET_TIMEZONE, }, ) @@ -585,6 +663,13 @@ def build_account_notification_lines() -> tuple[str, ...]: def build_extra_notification_lines(strategy_plugin_signals=()) -> tuple[str, ...]: return ( + t( + "market_scope_detail", + market=MARKET, + currency=MARKET_CURRENCY, + exchange=MARKET_EXCHANGE, + calendar=MARKET_CALENDAR, + ), *build_account_notification_lines(), *build_strategy_plugin_notification_lines(strategy_plugin_signals), ) @@ -697,12 +782,19 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body: diagnostics={"skip_reason": "already_running"}, ) return "Already Running", 200 - if not is_market_open_today(): + if not is_market_open_today( + calendar_name=MARKET_CALENDAR, + timezone_name=MARKET_TIMEZONE, + logger=lambda message: print(message, flush=True), + ): log_runtime_event( log_context, "market_closed", message="Market closed; skip strategy execution", execution_window="precheck" if dry_run_only_override else "execution", + market=MARKET, + market_calendar=MARKET_CALENDAR, + market_timezone=MARKET_TIMEZONE, ) finalize_runtime_report( report, diff --git a/notifications/telegram.py b/notifications/telegram.py index fd754b0..75b80c7 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -13,6 +13,7 @@ "canary_title": "🐤 【金丝雀检查】", "strategy_label": "🧭 策略: {name}", "account_ids_detail": "🆔 账户: {account_ids}", + "market_scope_detail": "🌏 市场: {market} | 交易币种: {currency} | 交易所: {exchange} | 日历: {calendar}", "equity": "净值", "buying_power": "购买力", "reserved_cash": "预留现金", @@ -150,6 +151,7 @@ "canary_title": "🐤 【Canary Check】", "strategy_label": "🧭 Strategy: {name}", "account_ids_detail": "🆔 Account: {account_ids}", + "market_scope_detail": "🌏 Market: {market} | trading currency: {currency} | exchange: {exchange} | calendar: {calendar}", "equity": "Equity", "buying_power": "Buying Power", "reserved_cash": "Reserved Cash", diff --git a/requirements.txt b/requirements.txt index d2a3bfa..ddce187 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.1.0 pandas numpy requests diff --git a/runtime_config_support.py b/runtime_config_support.py index 771de55..1fa99c7 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -19,12 +19,24 @@ ) from strategy_registry import ( IBKR_PLATFORM, + STRATEGY_CATALOG, resolve_strategy_definition, resolve_strategy_metadata, ) -from us_equity_strategies import get_strategy_catalog DEFAULT_ACCOUNT_GROUP = "default" +DEFAULT_MARKET = "US" +DEFAULT_MARKET_CALENDAR = "NYSE" +DEFAULT_MARKET_CURRENCY = "USD" +DEFAULT_MARKET_DATA_SYMBOL_SUFFIX = "" +DEFAULT_MARKET_EXCHANGE = "SMART" +DEFAULT_MARKET_TIMEZONE = "America/New_York" +HK_MARKET = "HK" +HK_MARKET_CALENDAR = "XHKG" +HK_MARKET_CURRENCY = "HKD" +HK_MARKET_DATA_SYMBOL_SUFFIX = ".HK" +HK_MARKET_EXCHANGE = "SEHK" +HK_MARKET_TIMEZONE = "Asia/Hong_Kong" DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 EXECUTION_BACKEND_GATEWAY = "gateway" @@ -37,6 +49,45 @@ ) +def resolve_market(raw_value: str | None, *, account_group: str) -> str: + for candidate in (raw_value, account_group): + value = str(candidate or "").strip().upper() + if not value: + continue + normalized = value.replace("-", "_") + parts = {part for part in normalized.split("_") if part} + if value in {HK_MARKET, "HONG_KONG", "HONGKONG"} or HK_MARKET in parts: + return HK_MARKET + if value in {DEFAULT_MARKET, "USA", "NYSE", "NASDAQ"} or DEFAULT_MARKET in parts: + return DEFAULT_MARKET + return DEFAULT_MARKET + + +def market_default_settings(market: str) -> dict[str, str]: + if market == HK_MARKET: + return { + "market_calendar": HK_MARKET_CALENDAR, + "market_currency": HK_MARKET_CURRENCY, + "market_data_symbol_suffix": HK_MARKET_DATA_SYMBOL_SUFFIX, + "market_exchange": HK_MARKET_EXCHANGE, + "market_timezone": HK_MARKET_TIMEZONE, + } + return { + "market_calendar": DEFAULT_MARKET_CALENDAR, + "market_currency": DEFAULT_MARKET_CURRENCY, + "market_data_symbol_suffix": DEFAULT_MARKET_DATA_SYMBOL_SUFFIX, + "market_exchange": DEFAULT_MARKET_EXCHANGE, + "market_timezone": DEFAULT_MARKET_TIMEZONE, + } + + +def normalize_market_data_symbol_suffix(raw_value: str | None) -> str: + value = str(raw_value or "").strip().upper() + if not value: + return "" + return value if value.startswith(".") else f".{value}" + + @dataclass(frozen=True) class AccountGroupConfig: execution_backend: str | None = None @@ -77,6 +128,12 @@ class PlatformRuntimeSettings: strategy_config_source: str | None reconciliation_output_path: str | None dry_run_only: bool + market: str = DEFAULT_MARKET + market_calendar: str = DEFAULT_MARKET_CALENDAR + market_currency: str = DEFAULT_MARKET_CURRENCY + market_data_symbol_suffix: str = DEFAULT_MARKET_DATA_SYMBOL_SUFFIX + market_exchange: str = DEFAULT_MARKET_EXCHANGE + market_timezone: str = DEFAULT_MARKET_TIMEZONE quantity_step: float = 1.0 min_order_notional: float = 50.0 reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD @@ -154,7 +211,7 @@ def load_platform_runtime_settings( platform_id=IBKR_PLATFORM, ) runtime_paths = resolve_strategy_runtime_path_settings( - strategy_catalog=get_strategy_catalog(), + strategy_catalog=STRATEGY_CATALOG, strategy_definition=strategy_definition, strategy_metadata=strategy_metadata, platform_env_prefix="IBKR", @@ -208,6 +265,8 @@ def load_platform_runtime_settings( ) ib_client_id = group_config.ib_client_id or 0 + market = resolve_market(os.getenv("IBKR_MARKET"), account_group=account_group) + market_defaults = market_default_settings(market) return PlatformRuntimeSettings( project_id=project_id, execution_backend=execution_backend, @@ -236,6 +295,29 @@ def load_platform_runtime_settings( strategy_config_source=runtime_paths.strategy_config_source, reconciliation_output_path=runtime_paths.reconciliation_output_path, dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")), + market=market, + market_calendar=first_non_empty( + os.getenv("IBKR_MARKET_CALENDAR"), + market_defaults["market_calendar"], + ), + market_currency=first_non_empty( + os.getenv("IBKR_MARKET_CURRENCY"), + market_defaults["market_currency"], + ).upper(), + market_data_symbol_suffix=normalize_market_data_symbol_suffix( + first_non_empty( + os.getenv("IBKR_MARKET_DATA_SYMBOL_SUFFIX"), + market_defaults["market_data_symbol_suffix"], + ) + ), + market_exchange=first_non_empty( + os.getenv("IBKR_MARKET_EXCHANGE"), + market_defaults["market_exchange"], + ).upper(), + market_timezone=first_non_empty( + os.getenv("IBKR_MARKET_TIMEZONE"), + market_defaults["market_timezone"], + ), quantity_step=1.0, min_order_notional=resolve_float_env( os.environ, diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 5736a2c..2092f6d 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -11,18 +11,19 @@ ROOT = Path(__file__).resolve().parents[1] QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" +HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" -for candidate in (ROOT, QPK_SRC, UES_SRC): +for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) from strategy_registry import ( # noqa: E402 IBKR_PLATFORM, + describe_platform_runtime_requirements, get_platform_profile_status_matrix, resolve_strategy_definition, ) -from us_equity_strategies.runtime_adapters import describe_platform_runtime_requirements # noqa: E402 TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON" @@ -32,6 +33,12 @@ "NOTIFY_LANG", "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", "IBKR_EXECUTION_BACKEND", + "IBKR_MARKET", + "IBKR_MARKET_CALENDAR", + "IBKR_MARKET_CURRENCY", + "IBKR_MARKET_DATA_SYMBOL_SUFFIX", + "IBKR_MARKET_EXCHANGE", + "IBKR_MARKET_TIMEZONE", "IB_GATEWAY_ZONE", "IB_GATEWAY_IP_MODE", "EXECUTION_REPORT_GCS_URI", @@ -47,6 +54,12 @@ "IB_GATEWAY_ZONE", "IB_GATEWAY_IP_MODE", "IBKR_EXECUTION_BACKEND", + "IBKR_MARKET", + "IBKR_MARKET_CALENDAR", + "IBKR_MARKET_CURRENCY", + "IBKR_MARKET_DATA_SYMBOL_SUFFIX", + "IBKR_MARKET_EXCHANGE", + "IBKR_MARKET_TIMEZONE", "IBKR_FEATURE_SNAPSHOT_PATH", "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "IBKR_STRATEGY_CONFIG_PATH", diff --git a/scripts/print_strategy_profile_status.py b/scripts/print_strategy_profile_status.py index 93d2e4f..4b6c7d8 100644 --- a/scripts/print_strategy_profile_status.py +++ b/scripts/print_strategy_profile_status.py @@ -8,14 +8,18 @@ ROOT = Path(__file__).resolve().parents[1] QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" +HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" -for candidate in (ROOT, QPK_SRC, UES_SRC): +for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) -from strategy_registry import IBKR_PLATFORM, get_platform_profile_status_matrix # noqa: E402 -from us_equity_strategies.runtime_adapters import describe_platform_runtime_requirements # noqa: E402 +from strategy_registry import ( # noqa: E402 + IBKR_PLATFORM, + describe_platform_runtime_requirements, + get_platform_profile_status_matrix, +) def build_status_rows() -> list[dict[str, object]]: diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 05a0b3d..983ae78 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -9,8 +9,9 @@ ROOT = Path(__file__).resolve().parents[1] QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src" UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" +HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" -for candidate in (ROOT, QPK_SRC, UES_SRC): +for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) @@ -19,12 +20,12 @@ from quant_platform_kit.common.strategies import derive_strategy_artifact_paths # noqa: E402 from strategy_registry import ( # noqa: E402 IBKR_PLATFORM, + STRATEGY_CATALOG, + describe_platform_runtime_requirements, get_platform_profile_status_matrix, resolve_strategy_definition, resolve_strategy_metadata, ) -from us_equity_strategies import get_strategy_catalog # noqa: E402 -from us_equity_strategies.runtime_adapters import describe_platform_runtime_requirements # noqa: E402 def build_switch_plan(profile: str) -> dict[str, object]: @@ -34,7 +35,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: row for row in get_platform_profile_status_matrix() if row["canonical_profile"] == definition.profile ) artifact_paths = derive_strategy_artifact_paths( - get_strategy_catalog(), + STRATEGY_CATALOG, definition.profile, repo_root=ROOT, ) @@ -70,6 +71,12 @@ def build_switch_plan(profile: str) -> dict[str, object]: optional_env = [ "IBKR_EXECUTION_BACKEND", "IBKR_DRY_RUN_ONLY", + "IBKR_MARKET", + "IBKR_MARKET_CALENDAR", + "IBKR_MARKET_CURRENCY", + "IBKR_MARKET_DATA_SYMBOL_SUFFIX", + "IBKR_MARKET_EXCHANGE", + "IBKR_MARKET_TIMEZONE", "IBKR_MIN_RESERVED_CASH_USD", "IBKR_RESERVED_CASH_RATIO", "IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", @@ -77,6 +84,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: remove_if_present: list[str] = [] notes = [ "Keep ACCOUNT_GROUP and IB account-group config aligned with the current service identity.", + "For HK-equity deployments set IBKR_MARKET=HK, or use an ACCOUNT_GROUP containing hk to derive SEHK/HKD/XHKG defaults.", ] if requires_feature_snapshot: @@ -125,6 +133,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: "platform": IBKR_PLATFORM, "canonical_profile": definition.profile, "display_name": metadata.display_name, + "domain": definition.domain, "eligible": status_row["eligible"], "enabled": status_row["enabled"], **runtime_requirements, diff --git a/strategy_loader.py b/strategy_loader.py index 1c7f875..aaf33fe 100644 --- a/strategy_loader.py +++ b/strategy_loader.py @@ -5,9 +5,8 @@ load_strategy_entrypoint, ) from quant_platform_kit.strategy_contracts import StrategyEntrypoint, StrategyRuntimeAdapter -from us_equity_strategies import get_platform_runtime_adapter -from strategy_registry import IBKR_PLATFORM, resolve_strategy_definition +from strategy_registry import IBKR_PLATFORM, get_platform_runtime_adapter, resolve_strategy_definition def load_strategy_definition(raw_profile: str | None) -> StrategyDefinition: diff --git a/strategy_registry.py b/strategy_registry.py index aecd955..f22bd98 100644 --- a/strategy_registry.py +++ b/strategy_registry.py @@ -1,15 +1,28 @@ from __future__ import annotations from us_equity_strategies import ( - get_platform_runtime_adapter, - get_runtime_enabled_profiles, - get_strategy_catalog, + get_platform_runtime_adapter as get_us_platform_runtime_adapter, + get_runtime_enabled_profiles as get_us_runtime_enabled_profiles, + get_strategy_catalog as get_us_strategy_catalog, +) +from us_equity_strategies.runtime_adapters import ( + describe_platform_runtime_requirements as describe_us_platform_runtime_requirements, +) +from hk_equity_strategies import ( + get_platform_runtime_adapter as get_hk_platform_runtime_adapter, + get_runtime_enabled_profiles as get_hk_runtime_enabled_profiles, + get_strategy_catalog as get_hk_strategy_catalog, +) +from hk_equity_strategies.runtime_adapters import ( + describe_platform_runtime_requirements as describe_hk_platform_runtime_requirements, ) from quant_platform_kit.common.strategies import ( PlatformCapabilityMatrix, PlatformStrategyPolicy, + StrategyCatalog, StrategyDefinition, + StrategyMetadata, US_EQUITY_DOMAIN, build_platform_profile_matrix, build_platform_profile_status_matrix, @@ -21,13 +34,68 @@ ) IBKR_PLATFORM = "ibkr" - -IBKR_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() +HK_EQUITY_DOMAIN = "hk_equity" PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = { - IBKR_PLATFORM: frozenset({US_EQUITY_DOMAIN}), + IBKR_PLATFORM: frozenset({US_EQUITY_DOMAIN, HK_EQUITY_DOMAIN}), } -STRATEGY_CATALOG = get_strategy_catalog() + + +def _merge_strategy_catalogs(*catalogs: StrategyCatalog) -> StrategyCatalog: + definitions: dict[str, StrategyDefinition] = {} + metadata: dict[str, StrategyMetadata] = {} + compatible_platforms: dict[str, frozenset[str]] = {} + profile_aliases: dict[str, str] = {} + for catalog in catalogs: + for profile, definition in catalog.definitions.items(): + if profile in definitions and definitions[profile] != definition: + raise ValueError(f"Duplicate strategy definition for profile {profile!r}") + definitions[profile] = definition + for profile, value in catalog.metadata.items(): + if profile in metadata and metadata[profile] != value: + raise ValueError(f"Duplicate strategy metadata for profile {profile!r}") + metadata[profile] = value + for profile, platforms in catalog.compatible_platforms.items(): + if profile in compatible_platforms and compatible_platforms[profile] != platforms: + raise ValueError(f"Duplicate strategy platform compatibility for profile {profile!r}") + compatible_platforms[profile] = platforms + for alias, profile in catalog.profile_aliases.items(): + if alias in profile_aliases and profile_aliases[alias] != profile: + raise ValueError(f"Duplicate strategy alias {alias!r}") + profile_aliases[alias] = profile + return StrategyCatalog( + definitions=definitions, + metadata=metadata, + compatible_platforms=compatible_platforms, + profile_aliases=profile_aliases, + ) + + +def _canonical_profile(profile: str | None) -> str: + normalized = str(profile or "").strip().lower() + return STRATEGY_CATALOG.profile_aliases.get(normalized, normalized) + + +def get_platform_runtime_adapter(profile: str | None, *, platform_id: str): + canonical_profile = _canonical_profile(profile) + if canonical_profile in HK_STRATEGY_PROFILES: + return get_hk_platform_runtime_adapter(canonical_profile, platform_id=platform_id) + return get_us_platform_runtime_adapter(canonical_profile, platform_id=platform_id) + + +def describe_platform_runtime_requirements(profile: str | None, *, platform_id: str) -> dict[str, object]: + canonical_profile = _canonical_profile(profile) + if canonical_profile in HK_STRATEGY_PROFILES: + return describe_hk_platform_runtime_requirements(canonical_profile, platform_id=platform_id) + return describe_us_platform_runtime_requirements(canonical_profile, platform_id=platform_id) + + +US_STRATEGY_CATALOG = get_us_strategy_catalog() +HK_STRATEGY_CATALOG = get_hk_strategy_catalog() +STRATEGY_CATALOG = _merge_strategy_catalogs(US_STRATEGY_CATALOG, HK_STRATEGY_CATALOG) +US_STRATEGY_PROFILES = frozenset(US_STRATEGY_CATALOG.definitions) +HK_STRATEGY_PROFILES = frozenset(HK_STRATEGY_CATALOG.definitions) +IBKR_ROLLOUT_ALLOWLIST = get_us_runtime_enabled_profiles() | get_hk_runtime_enabled_profiles() PLATFORM_CAPABILITY_MATRIX = PlatformCapabilityMatrix( platform_id=IBKR_PLATFORM, supported_domains=PLATFORM_SUPPORTED_DOMAINS[IBKR_PLATFORM], diff --git a/tests/test_ibkr_order_execution.py b/tests/test_ibkr_order_execution.py index 00561a0..2b7f45b 100644 --- a/tests/test_ibkr_order_execution.py +++ b/tests/test_ibkr_order_execution.py @@ -22,12 +22,14 @@ def __init__(self, side, quantity, limit_price): class FakeIB: def __init__(self): + self.placed_contract = None self.placed_order = None def qualifyContracts(self, _contract): return None - def placeOrder(self, _contract, order): + def placeOrder(self, contract, order): + self.placed_contract = contract self.placed_order = order return SimpleNamespace( order=SimpleNamespace(orderId=42), @@ -95,6 +97,24 @@ def test_submit_order_intent_preserves_account_id(): assert report.raw_payload["account_id"] == "U1234567" +def test_submit_order_intent_can_target_hk_stock_exchange_and_currency(): + ib = FakeIB() + + submit_order_intent( + ib, + OrderIntent(symbol="00700", side="buy", quantity=100), + wait_seconds=0, + stock_factory=fake_stock, + market_order_factory=FakeMarketOrder, + stock_exchange="SEHK", + stock_currency="HKD", + ) + + assert ib.placed_contract.symbol == "00700" + assert ib.placed_contract.exchange == "SEHK" + assert ib.placed_contract.currency == "HKD" + + def test_submit_order_intent_passes_option_factory_and_default_tif(): ib = FakeIB() diff --git a/tests/test_ibkr_portfolio.py b/tests/test_ibkr_portfolio.py new file mode 100644 index 0000000..1f58925 --- /dev/null +++ b/tests/test_ibkr_portfolio.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace + +from application.ibkr_portfolio import fetch_portfolio_snapshot + + +class FakeIB: + def __init__(self): + self.req_positions_called = 0 + + def reqPositions(self): + self.req_positions_called += 1 + + def positions(self): + return [ + SimpleNamespace( + account="UHK123", + contract=SimpleNamespace(secType="STK", symbol="00700", currency="HKD"), + position=100, + avgCost=320.5, + ), + SimpleNamespace( + account="UUS999", + contract=SimpleNamespace(secType="STK", symbol="AAPL", currency="USD"), + position=5, + avgCost=190.0, + ), + SimpleNamespace( + account="UHK123", + contract=SimpleNamespace( + secType="OPT", + symbol="00700", + currency="HKD", + localSymbol="TCEHY 260619C00350000", + lastTradeDateOrContractMonth="20260619", + right="C", + strike=350.0, + ), + position=1, + avgCost=12.0, + ), + ] + + def accountValues(self): + return [ + SimpleNamespace(account="UHK123", currency="HKD", tag="NetLiquidation", value="100000"), + SimpleNamespace(account="UHK123", currency="HKD", tag="AvailableFunds", value="80000"), + SimpleNamespace(account="UHK123", currency="USD", tag="NetLiquidation", value="999"), + SimpleNamespace(account="UUS999", currency="HKD", tag="NetLiquidation", value="123"), + ] + + +def test_fetch_portfolio_snapshot_filters_account_and_market_currency(): + ib = FakeIB() + + snapshot = fetch_portfolio_snapshot( + ib, + account_ids=("UHK123",), + wait_seconds=0, + currency="HKD", + ) + + assert ib.req_positions_called == 1 + assert snapshot.total_equity == 100000.0 + assert snapshot.buying_power == 80000.0 + assert len(snapshot.positions) == 1 + assert snapshot.positions[0].symbol == "00700" + assert snapshot.positions[0].currency == "HKD" + assert snapshot.metadata["currency"] == "HKD" + assert snapshot.metadata["account_ids"] == ("UHK123",) + assert snapshot.metadata["option_positions"][0]["currency"] == "HKD" diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 9c6a02c..56e03e6 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -7,17 +7,32 @@ import pytest from runtime_config_support import ( + DEFAULT_MARKET, + DEFAULT_MARKET_CALENDAR, + DEFAULT_MARKET_CURRENCY, + DEFAULT_MARKET_DATA_SYMBOL_SUFFIX, + DEFAULT_MARKET_EXCHANGE, + DEFAULT_MARKET_TIMEZONE, DEFAULT_RESERVED_CASH_FLOOR_USD, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, EXECUTION_BACKEND_GATEWAY, EXECUTION_BACKEND_QUANTCONNECT, + HK_MARKET, + HK_MARKET_CALENDAR, + HK_MARKET_CURRENCY, + HK_MARKET_DATA_SYMBOL_SUFFIX, + HK_MARKET_EXCHANGE, + HK_MARKET_TIMEZONE, load_platform_runtime_settings, + normalize_market_data_symbol_suffix, parse_account_group_configs, + resolve_market, resolve_execution_backend, resolve_non_negative_float_env, resolve_optional_ratio_env, ) from strategy_registry import ( + HK_EQUITY_DOMAIN, IBKR_PLATFORM, US_EQUITY_DOMAIN, get_eligible_profiles_for_platform, @@ -31,10 +46,26 @@ '{"groups":{"paper":{"ib_gateway_instance_name":"ib-gateway",' '"ib_gateway_mode":"paper","ib_client_id":1}}}' ) +MINIMAL_HK_GROUP_JSON = ( + '{"groups":{"hk-live":{"ib_gateway_instance_name":"ib-gateway",' + '"ib_gateway_mode":"live","ib_client_id":1}}}' +) SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_profile_status.py" SWITCH_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_switch_env_plan.py" SYNC_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "build_cloud_run_env_sync_plan.py" SAMPLE_STRATEGY_PROFILE = "global_etf_rotation" +EXPECTED_IBKR_PROFILES = frozenset( + { + "global_etf_rotation", + "hk_blue_chip_leader_rotation", + "mega_cap_leader_rotation_top50_balanced", + "nasdaq_sp500_smart_dca", + "russell_1000_multi_factor_defensive", + "soxl_soxx_trend_income", + "tech_communication_pullback_enhancement", + "tqqq_growth_income", + } +) def runtime_target_json( @@ -129,6 +160,12 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.strategy_config_source is None assert settings.reconciliation_output_path is None assert settings.dry_run_only is False + assert settings.market == DEFAULT_MARKET + assert settings.market_calendar == DEFAULT_MARKET_CALENDAR + assert settings.market_currency == DEFAULT_MARKET_CURRENCY + assert settings.market_data_symbol_suffix == DEFAULT_MARKET_DATA_SYMBOL_SUFFIX + assert settings.market_exchange == DEFAULT_MARKET_EXCHANGE + assert settings.market_timezone == DEFAULT_MARKET_TIMEZONE assert settings.quantity_step == 1.0 assert settings.min_order_notional == 50.0 assert settings.reserved_cash_floor_usd == DEFAULT_RESERVED_CASH_FLOOR_USD @@ -243,6 +280,59 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo assert settings.notify_lang == "zh" +def test_load_platform_runtime_settings_derives_hk_market_from_account_group(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "hk-live") + monkeypatch.setenv( + "IB_ACCOUNT_GROUP_CONFIG_JSON", + '{"groups":{"hk-live":{"ib_gateway_instance_name":"ib-gateway-hk",' + '"ib_gateway_mode":"live","ib_client_id":8}}}', + ) + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.market == HK_MARKET + assert settings.market_calendar == HK_MARKET_CALENDAR + assert settings.market_currency == HK_MARKET_CURRENCY + assert settings.market_data_symbol_suffix == HK_MARKET_DATA_SYMBOL_SUFFIX + assert settings.market_exchange == HK_MARKET_EXCHANGE + assert settings.market_timezone == HK_MARKET_TIMEZONE + + +def test_load_platform_runtime_settings_allows_market_env_overrides(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) + monkeypatch.setenv("ACCOUNT_GROUP", "hk-live") + monkeypatch.setenv( + "IB_ACCOUNT_GROUP_CONFIG_JSON", + '{"groups":{"hk-live":{"ib_gateway_instance_name":"ib-gateway-hk",' + '"ib_gateway_mode":"live","ib_client_id":8}}}', + ) + monkeypatch.setenv("IBKR_MARKET", "US") + monkeypatch.setenv("IBKR_MARKET_CALENDAR", "XNYS") + monkeypatch.setenv("IBKR_MARKET_CURRENCY", "usd") + monkeypatch.setenv("IBKR_MARKET_DATA_SYMBOL_SUFFIX", "US") + monkeypatch.setenv("IBKR_MARKET_EXCHANGE", "smart") + monkeypatch.setenv("IBKR_MARKET_TIMEZONE", "Etc/UTC") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.market == DEFAULT_MARKET + assert settings.market_calendar == "XNYS" + assert settings.market_currency == DEFAULT_MARKET_CURRENCY + assert settings.market_data_symbol_suffix == ".US" + assert settings.market_exchange == DEFAULT_MARKET_EXCHANGE + assert settings.market_timezone == "Etc/UTC" + + +def test_market_helpers_normalize_hk_and_symbol_suffix(): + assert resolve_market(None, account_group="hk-live") == HK_MARKET + assert resolve_market("US", account_group="hk-live") == DEFAULT_MARKET + assert resolve_market("hong_kong", account_group="paper") == HK_MARKET + assert normalize_market_data_symbol_suffix("hk") == ".HK" + assert normalize_market_data_symbol_suffix(".HK") == ".HK" + assert normalize_market_data_symbol_suffix("") == "" + + 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") @@ -456,31 +546,18 @@ def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeyp def test_platform_supported_profiles_are_filtered_by_registry(): - assert get_supported_profiles_for_platform(IBKR_PLATFORM) == frozenset( - { - "soxl_soxx_trend_income", - "tqqq_growth_income", - "tech_communication_pullback_enhancement", - "global_etf_rotation", - "mega_cap_leader_rotation_top50_balanced", - "nasdaq_sp500_smart_dca", - "russell_1000_multi_factor_defensive", - } - ) + assert get_supported_profiles_for_platform(IBKR_PLATFORM) == EXPECTED_IBKR_PROFILES + + +def test_platform_policy_accepts_future_hk_equity_domain(): + from strategy_registry import PLATFORM_SUPPORTED_DOMAINS + + assert HK_EQUITY_DOMAIN in PLATFORM_SUPPORTED_DOMAINS[IBKR_PLATFORM] + assert US_EQUITY_DOMAIN in PLATFORM_SUPPORTED_DOMAINS[IBKR_PLATFORM] def test_platform_eligible_profiles_are_exposed_by_capability_matrix(): - assert get_eligible_profiles_for_platform(IBKR_PLATFORM) == frozenset( - { - "soxl_soxx_trend_income", - "tqqq_growth_income", - "tech_communication_pullback_enhancement", - "global_etf_rotation", - "mega_cap_leader_rotation_top50_balanced", - "nasdaq_sp500_smart_dca", - "russell_1000_multi_factor_defensive", - } - ) + assert get_eligible_profiles_for_platform(IBKR_PLATFORM) == EXPECTED_IBKR_PROFILES def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enhancement(monkeypatch): @@ -499,6 +576,29 @@ def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enha assert settings.strategy_target_mode == "weight" +def test_load_platform_runtime_settings_accepts_hk_blue_chip_leader_rotation(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("hk_blue_chip_leader_rotation")) + monkeypatch.setenv("ACCOUNT_GROUP", "hk-live") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_HK_GROUP_JSON) + monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "gs://bucket/hk.csv") + monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "gs://bucket/hk.csv.manifest.json") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "hk_blue_chip_leader_rotation" + assert settings.strategy_display_name == "HK Blue Chip Leader Rotation" + assert settings.strategy_domain == "hk_equity" + assert settings.strategy_target_mode == "weight" + assert settings.market == HK_MARKET + assert settings.market_calendar == HK_MARKET_CALENDAR + assert settings.market_timezone == HK_MARKET_TIMEZONE + assert settings.market_exchange == HK_MARKET_EXCHANGE + assert settings.market_currency == HK_MARKET_CURRENCY + assert settings.market_data_symbol_suffix == HK_MARKET_DATA_SYMBOL_SUFFIX + assert settings.feature_snapshot_path == "gs://bucket/hk.csv" + assert settings.feature_snapshot_manifest_path == "gs://bucket/hk.csv.manifest.json" + + @pytest.mark.parametrize( "archived_profile", ( @@ -564,15 +664,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): rows = get_platform_profile_status_matrix() by_profile = {row["canonical_profile"]: row for row in rows} - assert set(by_profile) == { - "global_etf_rotation", - "russell_1000_multi_factor_defensive", - "soxl_soxx_trend_income", - "tqqq_growth_income", - "tech_communication_pullback_enhancement", - "mega_cap_leader_rotation_top50_balanced", - "nasdaq_sp500_smart_dca", - } + assert set(by_profile) == EXPECTED_IBKR_PROFILES assert by_profile["global_etf_rotation"] == { "canonical_profile": "global_etf_rotation", "display_name": "Global ETF Rotation", @@ -590,6 +682,14 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): assert by_profile["nasdaq_sp500_smart_dca"]["display_name"] == "Nasdaq/S&P 500 Smart DCA" assert by_profile["nasdaq_sp500_smart_dca"]["eligible"] is True assert by_profile["nasdaq_sp500_smart_dca"]["enabled"] is True + assert by_profile["hk_blue_chip_leader_rotation"] == { + "canonical_profile": "hk_blue_chip_leader_rotation", + "display_name": "HK Blue Chip Leader Rotation", + "domain": "hk_equity", + "eligible": True, + "enabled": True, + "platform": "ibkr", + } def test_print_strategy_profile_status_json_matches_registry(): @@ -631,6 +731,11 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["mega_cap_leader_rotation_top50_balanced"]["input_mode"] == "feature_snapshot" assert by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_snapshot_artifacts"] is True assert by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"] is False + assert by_profile["hk_blue_chip_leader_rotation"]["profile_group"] == "snapshot_backed" + assert by_profile["hk_blue_chip_leader_rotation"]["input_mode"] == "feature_snapshot" + assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_artifacts"] is True + assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_manifest_path"] is True + assert by_profile["hk_blue_chip_leader_rotation"]["requires_strategy_config_path"] is False assert by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] is False @@ -648,7 +753,9 @@ def test_print_strategy_profile_status_table_contains_expected_headers(): assert "input_mode" in result.stdout assert "requires_snapshot_artifacts" in result.stdout assert "global_etf_rotation" in result.stdout + assert "hk_blue_chip_leader_rotation" in result.stdout assert "Tech/Communication Pullback Enhancement" in result.stdout + assert "HK Blue Chip Leader Rotation" in result.stdout assert "TQQQ Growth Income" in result.stdout @@ -678,6 +785,12 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income(): assert "IBKR_MIN_RESERVED_CASH_USD" in plan["optional_env"] assert "IBKR_RESERVED_CASH_RATIO" in plan["optional_env"] assert "IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD" in plan["optional_env"] + assert "IBKR_MARKET" in plan["optional_env"] + assert "IBKR_MARKET_CALENDAR" in plan["optional_env"] + assert "IBKR_MARKET_CURRENCY" in plan["optional_env"] + assert "IBKR_MARKET_DATA_SYMBOL_SUFFIX" in plan["optional_env"] + assert "IBKR_MARKET_EXCHANGE" in plan["optional_env"] + assert "IBKR_MARKET_TIMEZONE" in plan["optional_env"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] @@ -689,6 +802,12 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", "IB_GATEWAY_ZONE": "us-central1-c", "IB_GATEWAY_IP_MODE": "internal", + "IBKR_MARKET": "HK", + "IBKR_MARKET_CALENDAR": "XHKG", + "IBKR_MARKET_CURRENCY": "HKD", + "IBKR_MARKET_DATA_SYMBOL_SUFFIX": ".HK", + "IBKR_MARKET_EXCHANGE": "SEHK", + "IBKR_MARKET_TIMEZONE": "Asia/Hong_Kong", "EXECUTION_REPORT_GCS_URI": "gs://runtime/execution-reports", }, "targets": [ @@ -755,6 +874,9 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): assert slot_a["env"]["ACCOUNT_GROUP"] == "live-slot-a" assert slot_a["env"]["STRATEGY_PROFILE"] == "tqqq_growth_income" + assert slot_a["env"]["IBKR_MARKET"] == "HK" + assert slot_a["env"]["IBKR_MARKET_CURRENCY"] == "HKD" + assert slot_a["env"]["IBKR_MARKET_EXCHANGE"] == "SEHK" assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in slot_a["remove_env_vars"] assert "gs://stale-paper/snapshot.csv" not in json.dumps(slot_a) @@ -842,6 +964,27 @@ def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced_profile(): assert plan["hints"]["feature_snapshot_filename"] == "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv" +def test_print_strategy_switch_env_plan_for_hk_blue_chip_leader_rotation(): + result = subprocess.run( + [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "hk_blue_chip_leader_rotation", "--json"], + check=True, + capture_output=True, + text=True, + ) + + plan = json.loads(result.stdout) + assert plan["canonical_profile"] == "hk_blue_chip_leader_rotation" + assert plan["domain"] == "hk_equity" + assert plan["profile_group"] == "snapshot_backed" + assert plan["input_mode"] == "feature_snapshot" + assert plan["requires_snapshot_artifacts"] is True + assert plan["requires_snapshot_manifest_path"] is True + assert plan["requires_strategy_config_path"] is False + assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "" + assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"] == "" + assert plan["hints"]["feature_snapshot_filename"] == "hk_blue_chip_leader_rotation_feature_snapshot_latest.csv" + + def test_print_strategy_switch_env_plan_for_feature_snapshot_profile(): result = subprocess.run( [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "tech_communication_pullback_enhancement", "--json"], From 5147eb13c9fe75308aaf40796d9fa4230564ae36 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 31 May 2026 23:55:10 +0800 Subject: [PATCH 2/6] Keep HK strategy scaffold disabled --- README.md | 12 ++++---- docs/hk_equity_runtime.md | 14 ++++----- requirements.txt | 2 +- tests/test_runtime_config_support.py | 43 +++++++--------------------- 4 files changed, 25 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 19889b8..6d7dda0 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The mainline runtime now follows one path only: - `tech_communication_pullback_enhancement` - `mega_cap_leader_rotation_top50_balanced` - `nasdaq_sp500_smart_dca` -- `hk_blue_chip_leader_rotation` +- `hk_blue_chip_leader_rotation` (architecture scaffold only; eligible but disabled) **IBKR profile status** @@ -53,7 +53,7 @@ The mainline runtime now follows one path only: | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation | | `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile | -| `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | Yes | `hk_equity` | HK feature-snapshot strategy for SEHK/HKD accounts | +| `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | No | `hk_equity` | architecture scaffold only; not runtime-enabled | Check the current matrix locally: @@ -105,7 +105,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. | | `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`, `hk_blue_chip_leader_rotation` | +| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Enabled values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`. `hk_blue_chip_leader_rotation` is present as an architecture scaffold but is not enabled. | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `IBKR_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_GROUP` contains `hk`, otherwise `US`. | | `IBKR_MARKET_CALENDAR` | No | Market calendar. Defaults to `XHKG` for HK and `NYSE` for US. | @@ -113,7 +113,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_MARKET_EXCHANGE` | No | Stock contract exchange. Defaults to `SEHK` for HK and `SMART` for US. | | `IBKR_MARKET_CURRENCY` | No | Stock contract currency and portfolio currency scope. Defaults to `HKD` for HK and `USD` for US. | | `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | No | Suffix used only for yfinance fallback symbols. Defaults to `.HK` for HK and empty for US. | -| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, and `hk_blue_chip_leader_rotation`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | +| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for enabled snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. The HK scaffold will also require this after promotion. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | | `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; defaults to `50.0`. | | `IBKR_MIN_RESERVED_CASH_USD` | No | Platform-level minimum cash reserve in USD. Defaults to `0`; the effective reserve is the max of this floor and the effective cash reserve ratio. | @@ -443,7 +443,7 @@ IBKR 账户 | `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 | | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`、`hk_blue_chip_leader_rotation` | +| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前已启用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`。`hk_blue_chip_leader_rotation` 只是架构占位,未启用 | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_MARKET` | 否 | 市场范围。`ACCOUNT_GROUP` 包含 `hk` 时默认 `HK`,其他情况默认 `US`。 | | `IBKR_MARKET_CALENDAR` | 否 | 市场日历。港股默认 `XHKG`,美股默认 `NYSE`。 | @@ -451,7 +451,7 @@ IBKR 账户 | `IBKR_MARKET_EXCHANGE` | 否 | 股票合约交易所。港股默认 `SEHK`,美股默认 `SMART`。 | | `IBKR_MARKET_CURRENCY` | 否 | 股票合约币种和组合现金口径。港股默认 `HKD`,美股默认 `USD`。 | | `IBKR_MARKET_DATA_SYMBOL_SUFFIX` | 否 | 仅用于 yfinance fallback 的标的后缀。港股默认 `.HK`,美股默认空。 | -| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`hk_blue_chip_leader_rotation` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | +| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等已启用快照策略需要;港股架构占位后续启用时也会需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | | `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 限价买入的最小名义金额;默认 `50.0`。 | | `IBKR_MIN_RESERVED_CASH_USD` | 否 | 平台级最低预留现金 USD。默认 `0`;实际预留取该下限和有效预留现金比例中的最大值。 | diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md index 0ece83d..6a9d156 100644 --- a/docs/hk_equity_runtime.md +++ b/docs/hk_equity_runtime.md @@ -14,7 +14,7 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 ## 运行时设计 -本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已正式接入 `HkEquityStrategies` 的 `hk_blue_chip_leader_rotation` profile,并沿用美股 snapshot 策略的架构: +本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的 `hk_blue_chip_leader_rotation` 架构占位;该 profile 是 `architecture_scaffold`,只用于框架 wiring 和兼容性验证,尚未 runtime-enabled。整体沿用美股 snapshot 策略的架构: 1. [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies) 提供 `hk_equity` 策略 profile、运行入口和 IBKR runtime adapter。 2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 `_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。 @@ -23,13 +23,13 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 这样可以复用现有 US snapshot 的 artifact contract,同时保持平台仓只负责执行、账户、通知和运行报告。 -## 已启用港股 profile +## 港股 profile 当前状态 -| Profile | Domain | Inputs | Target mode | Snapshot manifest | -| --- | --- | --- | --- | --- | -| `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | +| Profile | Domain | Inputs | Target mode | Snapshot manifest | Status | +| --- | --- | --- | --- | --- | --- | +| `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | eligible but disabled | -最小策略配置示例: +未来启用后的最小策略配置示例;当前不要写入 Cloud Run: ```bash STRATEGY_PROFILE=hk_blue_chip_leader_rotation @@ -82,4 +82,4 @@ IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK - IBKR 港股实盘依赖账户权限、行情权限、Gateway 登录账户可见账号和交易许可;平台配置无法替代这些权限。 - 不同 IBKR 账户或区域对港股 symbol 格式可能有差异,首批上线前需要用 dry-run 和小范围 symbol 做实盘连接验证。 - `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `IBKR_MARKET_CALENDAR` 临时覆盖。 -- `hk_blue_chip_leader_rotation` 已接入平台,但实盘前仍需要用最新 snapshot artifact、dry-run 和小范围 symbol / 小订单做连接验证。 +- `hk_blue_chip_leader_rotation` 当前未启用;不要把该 profile 写入生产 Cloud Run。后续真正启用前,需要用最新 snapshot artifact、dry-run 和小范围 symbol / 小订单做连接验证。 diff --git a/requirements.txt b/requirements.txt index ddce187..478ecb5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.1.0 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.1.1 pandas numpy requests diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 56e03e6..711fa19 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -54,10 +54,9 @@ SWITCH_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "print_strategy_switch_env_plan.py" SYNC_PLAN_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "build_cloud_run_env_sync_plan.py" SAMPLE_STRATEGY_PROFILE = "global_etf_rotation" -EXPECTED_IBKR_PROFILES = frozenset( +EXPECTED_IBKR_ENABLED_PROFILES = frozenset( { "global_etf_rotation", - "hk_blue_chip_leader_rotation", "mega_cap_leader_rotation_top50_balanced", "nasdaq_sp500_smart_dca", "russell_1000_multi_factor_defensive", @@ -66,6 +65,7 @@ "tqqq_growth_income", } ) +EXPECTED_IBKR_PROFILES = EXPECTED_IBKR_ENABLED_PROFILES | frozenset({"hk_blue_chip_leader_rotation"}) def runtime_target_json( @@ -546,7 +546,8 @@ def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeyp def test_platform_supported_profiles_are_filtered_by_registry(): - assert get_supported_profiles_for_platform(IBKR_PLATFORM) == EXPECTED_IBKR_PROFILES + assert get_supported_profiles_for_platform(IBKR_PLATFORM) == EXPECTED_IBKR_ENABLED_PROFILES + assert "hk_blue_chip_leader_rotation" not in get_supported_profiles_for_platform(IBKR_PLATFORM) def test_platform_policy_accepts_future_hk_equity_domain(): @@ -576,27 +577,15 @@ def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enha assert settings.strategy_target_mode == "weight" -def test_load_platform_runtime_settings_accepts_hk_blue_chip_leader_rotation(monkeypatch): +def test_load_platform_runtime_settings_rejects_hk_blue_chip_until_runtime_enabled(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("hk_blue_chip_leader_rotation")) monkeypatch.setenv("ACCOUNT_GROUP", "hk-live") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_HK_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "gs://bucket/hk.csv") monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "gs://bucket/hk.csv.manifest.json") - settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - - assert settings.strategy_profile == "hk_blue_chip_leader_rotation" - assert settings.strategy_display_name == "HK Blue Chip Leader Rotation" - assert settings.strategy_domain == "hk_equity" - assert settings.strategy_target_mode == "weight" - assert settings.market == HK_MARKET - assert settings.market_calendar == HK_MARKET_CALENDAR - assert settings.market_timezone == HK_MARKET_TIMEZONE - assert settings.market_exchange == HK_MARKET_EXCHANGE - assert settings.market_currency == HK_MARKET_CURRENCY - assert settings.market_data_symbol_suffix == HK_MARKET_DATA_SYMBOL_SUFFIX - assert settings.feature_snapshot_path == "gs://bucket/hk.csv" - assert settings.feature_snapshot_manifest_path == "gs://bucket/hk.csv.manifest.json" + with pytest.raises(ValueError, match="Unsupported STRATEGY_PROFILE"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") @pytest.mark.parametrize( @@ -687,7 +676,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): "display_name": "HK Blue Chip Leader Rotation", "domain": "hk_equity", "eligible": True, - "enabled": True, + "enabled": False, "platform": "ibkr", } @@ -964,25 +953,15 @@ def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced_profile(): assert plan["hints"]["feature_snapshot_filename"] == "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv" -def test_print_strategy_switch_env_plan_for_hk_blue_chip_leader_rotation(): +def test_print_strategy_switch_env_plan_rejects_hk_scaffold_profile(): result = subprocess.run( [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "hk_blue_chip_leader_rotation", "--json"], - check=True, capture_output=True, text=True, ) - plan = json.loads(result.stdout) - assert plan["canonical_profile"] == "hk_blue_chip_leader_rotation" - assert plan["domain"] == "hk_equity" - assert plan["profile_group"] == "snapshot_backed" - assert plan["input_mode"] == "feature_snapshot" - assert plan["requires_snapshot_artifacts"] is True - assert plan["requires_snapshot_manifest_path"] is True - assert plan["requires_strategy_config_path"] is False - assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "" - assert plan["set_env"]["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"] == "" - assert plan["hints"]["feature_snapshot_filename"] == "hk_blue_chip_leader_rotation_feature_snapshot_latest.csv" + assert result.returncode != 0 + assert "Unsupported STRATEGY_PROFILE" in result.stderr def test_print_strategy_switch_env_plan_for_feature_snapshot_profile(): From 34591ef65d14dfccf1b80c4da38619028d8438db Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:13:39 +0800 Subject: [PATCH 3/6] Guard Cloud Run automation on main pushes --- .github/workflows/sync-cloud-run-env.yml | 9 +++++++++ README.md | 4 ++++ tests/test_sync_cloud_run_env_workflow.sh | 4 +++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 44b4340..7fe0c47 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -22,6 +22,7 @@ jobs: env: ENABLE_GITHUB_CLOUD_RUN_DEPLOY: ${{ vars.ENABLE_GITHUB_CLOUD_RUN_DEPLOY }} ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }} + ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }} GCP_ARTIFACT_REGISTRY_HOSTNAME: ${{ vars.GCP_ARTIFACT_REGISTRY_HOSTNAME }} CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }} @@ -99,6 +100,14 @@ jobs: deploy_enabled=false env_sync_enabled=false + if [ "${GITHUB_EVENT_NAME:-}" = "push" ] && [ "${ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION:-}" != "true" ]; then + echo "deploy_enabled=false" >> "$GITHUB_OUTPUT" + echo "env_sync_enabled=false" >> "$GITHUB_OUTPUT" + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true." >&2 + exit 0 + fi + if [ "${ENABLE_GITHUB_CLOUD_RUN_DEPLOY:-}" = "true" ]; then deploy_enabled=true fi diff --git a/README.md b/README.md index 6d7dda0..ce90b53 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,8 @@ Alert results are written into the runtime report. Duplicate suppression uses st This repo includes `.github/workflows/sync-cloud-run-env.yml` for GitHub-managed Cloud Run automation. Set `ENABLE_GITHUB_CLOUD_RUN_DEPLOY=true` to build and deploy the container image from GitHub Actions; set `ENABLE_GITHUB_ENV_SYNC=true` to sync runtime env vars. You can enable either flag independently during migration from a Google Cloud Trigger. The workflow also emits `RUNTIME_TARGET_JSON`, so the control plane carries a structured runtime target alongside the legacy `STRATEGY_PROFILE` selector. +Pushes to `main` have an additional deployment guard: keep `ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION` unset or not `true` to allow framework changes to merge without touching Cloud Run. Manual `workflow_dispatch` runs still follow the deploy/env-sync flags above. + Recommended setup: - **Repository Variables** @@ -550,6 +552,8 @@ IB_GATEWAY_IP_MODE=internal 这个仓库提供 `.github/workflows/sync-cloud-run-env.yml` 作为 GitHub 管理 Cloud Run 的入口。设置 `ENABLE_GITHUB_CLOUD_RUN_DEPLOY=true` 时,GitHub Actions 会构建并发布容器镜像;设置 `ENABLE_GITHUB_ENV_SYNC=true` 时,GitHub Actions 会同步运行时环境变量。迁移期间两个开关可以独立启用,旧的 Google Cloud Trigger 也可以先保留。 +`push main` 还有一层发布保护:保持 `ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION` 未设置或不是 `true`,即可让框架代码合入主线但不触碰 Cloud Run。手动 `workflow_dispatch` 仍按上面的部署/同步开关执行。 + 推荐配置方式: - **仓库级 Variables** diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 34f3efc..65d56ed 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -14,6 +14,7 @@ grep -Fq 'uses: actions/checkout@v6' "$workflow_file" grep -Fq 'uses: actions/setup-python@v6' "$workflow_file" grep -Fq 'python -m pip install -r requirements.txt' "$workflow_file" +grep -Fq 'ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICE: ${{ vars.CLOUD_RUN_SERVICE }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICES: ${{ vars.CLOUD_RUN_SERVICES }}' "$workflow_file" grep -Fq 'CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON }}' "$workflow_file" @@ -31,7 +32,8 @@ grep -Fq 'TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.CRISIS_ALERT_EMAIL_SENDER_PASSWORD }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_TELEGRAM_BOT_TOKEN: ${{ secrets.CRISIS_ALERT_TELEGRAM_BOT_TOKEN }}' "$workflow_file" -grep -Fq 'Skipping Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not set to true.' "$workflow_file" +grep -Fq 'Skipping Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true.' "$workflow_file" +grep -Fq 'Skipping Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true.' "$workflow_file" grep -Fq 'scripts/build_cloud_run_env_sync_plan.py --json' "$workflow_file" grep -Fq 'sync_plan_json<<__SYNC_PLAN_JSON__' "$workflow_file" grep -Fq 'SYNC_PLAN_JSON: ${{ steps.strategy_requirements.outputs.sync_plan_json }}' "$workflow_file" From 69ea495c729e0eb214307ff0edf62a372078249c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:10:43 +0800 Subject: [PATCH 4/6] Expose HK market-history profiles as disabled --- README.md | 8 +++-- docs/hk_equity_runtime.md | 16 +++++---- requirements.txt | 2 +- tests/test_runtime_config_support.py | 51 ++++++++++++++++++++++++---- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ce90b53..6119752 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ The mainline runtime now follows one path only: - `mega_cap_leader_rotation_top50_balanced` - `nasdaq_sp500_smart_dca` - `hk_blue_chip_leader_rotation` (architecture scaffold only; eligible but disabled) +- `hk_index_mean_reversion` (market-history research candidate; eligible but disabled) +- `hk_etf_regime_rotation` (market-history research candidate; eligible but disabled) **IBKR profile status** @@ -54,6 +56,8 @@ The mainline runtime now follows one path only: | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation | | `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile | | `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | No | `hk_equity` | architecture scaffold only; not runtime-enabled | +| `hk_index_mean_reversion` | HK Index Mean Reversion | Yes | No | `hk_equity` | market-history research candidate; not runtime-enabled | +| `hk_etf_regime_rotation` | HK ETF Regime Rotation | Yes | No | `hk_equity` | market-history research candidate; not runtime-enabled | Check the current matrix locally: @@ -105,7 +109,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. | | `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Enabled values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`. `hk_blue_chip_leader_rotation` is present as an architecture scaffold but is not enabled. | +| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Enabled values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`. `hk_blue_chip_leader_rotation`, `hk_index_mean_reversion`, and `hk_etf_regime_rotation` are present as eligible-but-disabled HK profiles and are not enabled. | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `IBKR_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_GROUP` contains `hk`, otherwise `US`. | | `IBKR_MARKET_CALENDAR` | No | Market calendar. Defaults to `XHKG` for HK and `NYSE` for US. | @@ -445,7 +449,7 @@ IBKR 账户 | `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 | | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前已启用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`。`hk_blue_chip_leader_rotation` 只是架构占位,未启用 | +| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前已启用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`。`hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation` 是 eligible-but-disabled 港股档位,未启用 | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_MARKET` | 否 | 市场范围。`ACCOUNT_GROUP` 包含 `hk` 时默认 `HK`,其他情况默认 `US`。 | | `IBKR_MARKET_CALENDAR` | 否 | 市场日历。港股默认 `XHKG`,美股默认 `NYSE`。 | diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md index 6a9d156..90117b6 100644 --- a/docs/hk_equity_runtime.md +++ b/docs/hk_equity_runtime.md @@ -14,12 +14,13 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 ## 运行时设计 -本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的 `hk_blue_chip_leader_rotation` 架构占位;该 profile 是 `architecture_scaffold`,只用于框架 wiring 和兼容性验证,尚未 runtime-enabled。整体沿用美股 snapshot 策略的架构: +本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的港股 profile 元数据:`hk_blue_chip_leader_rotation` 是架构占位,`hk_index_mean_reversion` 和 `hk_etf_regime_rotation` 是 `market_history` 研究候选。三者都只用于框架 wiring、feed/dry-run 兼容性检查,尚未 runtime-enabled。整体沿用美股策略的架构: 1. [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies) 提供 `hk_equity` 策略 profile、运行入口和 IBKR runtime adapter。 -2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 `_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。 -3. 平台仓库通过 `RUNTIME_TARGET_JSON`、`IBKR_FEATURE_SNAPSHOT_PATH`、`IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH` 和可选 config 路径读取策略输入。 -4. IBKR 运行时根据 market scope 选择 SEHK/HKD 合约、HKD 账户口径、XHKG 日历和通知/日志字段。 +2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 snapshot-backed profile 的 `_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。 +3. 非 snapshot profile 使用平台 market-data feed 提供的 `market_history`,不需要 snapshot artifact。 +4. 平台仓库通过 `RUNTIME_TARGET_JSON`、snapshot/config 路径和平台 market scope 读取策略输入。 +5. IBKR 运行时根据 market scope 选择 SEHK/HKD 合约、HKD 账户口径、XHKG 日历和通知/日志字段。 这样可以复用现有 US snapshot 的 artifact contract,同时保持平台仓只负责执行、账户、通知和运行报告。 @@ -28,8 +29,10 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 | Profile | Domain | Inputs | Target mode | Snapshot manifest | Status | | --- | --- | --- | --- | --- | --- | | `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | eligible but disabled | +| `hk_index_mean_reversion` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | +| `hk_etf_regime_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | -未来启用后的最小策略配置示例;当前不要写入 Cloud Run: +未来启用 snapshot-backed profile 后的最小策略配置示例;当前不要写入 Cloud Run: ```bash STRATEGY_PROFILE=hk_blue_chip_leader_rotation @@ -82,4 +85,5 @@ IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK - IBKR 港股实盘依赖账户权限、行情权限、Gateway 登录账户可见账号和交易许可;平台配置无法替代这些权限。 - 不同 IBKR 账户或区域对港股 symbol 格式可能有差异,首批上线前需要用 dry-run 和小范围 symbol 做实盘连接验证。 - `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `IBKR_MARKET_CALENDAR` 临时覆盖。 -- `hk_blue_chip_leader_rotation` 当前未启用;不要把该 profile 写入生产 Cloud Run。后续真正启用前,需要用最新 snapshot artifact、dry-run 和小范围 symbol / 小订单做连接验证。 +- `hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation` 当前均未启用;不要把这些 profile 写入生产 Cloud Run。 +- `market_history` 研究候选后续真正启用前,需要先用 IBKR HK 行情 feed 对 `02800`、`03033`、`02822`、`02840`、`03110`、`03188` 做 dry-run 校验,不提交真实订单。 diff --git a/requirements.txt b/requirements.txt index 478ecb5..ce0bab7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@v0.1.1 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2390d05785235a186cc900d2b4eca954d4e7ec0e pandas numpy requests diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 711fa19..2244705 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -65,7 +65,14 @@ "tqqq_growth_income", } ) -EXPECTED_IBKR_PROFILES = EXPECTED_IBKR_ENABLED_PROFILES | frozenset({"hk_blue_chip_leader_rotation"}) +HK_DISABLED_PROFILES = frozenset( + { + "hk_blue_chip_leader_rotation", + "hk_index_mean_reversion", + "hk_etf_regime_rotation", + } +) +EXPECTED_IBKR_PROFILES = EXPECTED_IBKR_ENABLED_PROFILES | HK_DISABLED_PROFILES def runtime_target_json( @@ -546,8 +553,10 @@ def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeyp def test_platform_supported_profiles_are_filtered_by_registry(): - assert get_supported_profiles_for_platform(IBKR_PLATFORM) == EXPECTED_IBKR_ENABLED_PROFILES - assert "hk_blue_chip_leader_rotation" not in get_supported_profiles_for_platform(IBKR_PLATFORM) + supported_profiles = get_supported_profiles_for_platform(IBKR_PLATFORM) + assert supported_profiles == EXPECTED_IBKR_ENABLED_PROFILES + for profile in HK_DISABLED_PROFILES: + assert profile not in supported_profiles def test_platform_policy_accepts_future_hk_equity_domain(): @@ -577,8 +586,9 @@ def test_load_platform_runtime_settings_accepts_tech_communication_pullback_enha assert settings.strategy_target_mode == "weight" -def test_load_platform_runtime_settings_rejects_hk_blue_chip_until_runtime_enabled(monkeypatch): - monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("hk_blue_chip_leader_rotation")) +@pytest.mark.parametrize("profile", sorted(HK_DISABLED_PROFILES)) +def test_load_platform_runtime_settings_rejects_hk_profiles_until_runtime_enabled(monkeypatch, profile): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(profile)) monkeypatch.setenv("ACCOUNT_GROUP", "hk-live") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_HK_GROUP_JSON) monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "gs://bucket/hk.csv") @@ -679,6 +689,22 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): "enabled": False, "platform": "ibkr", } + assert by_profile["hk_index_mean_reversion"] == { + "canonical_profile": "hk_index_mean_reversion", + "display_name": "HK Index Mean Reversion", + "domain": "hk_equity", + "eligible": True, + "enabled": False, + "platform": "ibkr", + } + assert by_profile["hk_etf_regime_rotation"] == { + "canonical_profile": "hk_etf_regime_rotation", + "display_name": "HK ETF Regime Rotation", + "domain": "hk_equity", + "eligible": True, + "enabled": False, + "platform": "ibkr", + } def test_print_strategy_profile_status_json_matches_registry(): @@ -725,6 +751,12 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_artifacts"] is True assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_manifest_path"] is True assert by_profile["hk_blue_chip_leader_rotation"]["requires_strategy_config_path"] is False + for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation"): + assert by_profile[profile]["profile_group"] == "direct_runtime_inputs" + assert by_profile[profile]["input_mode"] == "market_history" + assert by_profile[profile]["requires_snapshot_artifacts"] is False + assert by_profile[profile]["requires_snapshot_manifest_path"] is False + assert by_profile[profile]["requires_strategy_config_path"] is False assert by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] is False @@ -743,8 +775,12 @@ def test_print_strategy_profile_status_table_contains_expected_headers(): assert "requires_snapshot_artifacts" in result.stdout assert "global_etf_rotation" in result.stdout assert "hk_blue_chip_leader_rotation" in result.stdout + assert "hk_index_mean_reversion" in result.stdout + assert "hk_etf_regime_rotation" in result.stdout assert "Tech/Communication Pullback Enhancement" in result.stdout assert "HK Blue Chip Leader Rotation" in result.stdout + assert "HK Index Mean Reversion" in result.stdout + assert "HK ETF Regime Rotation" in result.stdout assert "TQQQ Growth Income" in result.stdout @@ -953,9 +989,10 @@ def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced_profile(): assert plan["hints"]["feature_snapshot_filename"] == "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv" -def test_print_strategy_switch_env_plan_rejects_hk_scaffold_profile(): +@pytest.mark.parametrize("profile", sorted(HK_DISABLED_PROFILES)) +def test_print_strategy_switch_env_plan_rejects_hk_disabled_profiles(profile): result = subprocess.run( - [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "hk_blue_chip_leader_rotation", "--json"], + [sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", profile, "--json"], capture_output=True, text=True, ) From 3fd65d7b9d4825db424c5cbc65c3f4308ee3d14b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:36:47 +0800 Subject: [PATCH 5/6] Expose HK-listed global ETF rotation as disabled --- README.md | 6 ++++-- docs/hk_equity_runtime.md | 7 ++++--- requirements.txt | 2 +- tests/test_runtime_config_support.py | 13 ++++++++++++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6119752..ab3a599 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The mainline runtime now follows one path only: - `hk_blue_chip_leader_rotation` (architecture scaffold only; eligible but disabled) - `hk_index_mean_reversion` (market-history research candidate; eligible but disabled) - `hk_etf_regime_rotation` (market-history research candidate; eligible but disabled) +- `hk_listed_global_etf_rotation` (volatility-targeted market-history research candidate; eligible but disabled) **IBKR profile status** @@ -58,6 +59,7 @@ The mainline runtime now follows one path only: | `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | No | `hk_equity` | architecture scaffold only; not runtime-enabled | | `hk_index_mean_reversion` | HK Index Mean Reversion | Yes | No | `hk_equity` | market-history research candidate; not runtime-enabled | | `hk_etf_regime_rotation` | HK ETF Regime Rotation | Yes | No | `hk_equity` | market-history research candidate; not runtime-enabled | +| `hk_listed_global_etf_rotation` | HK-listed Global ETF Rotation | Yes | No | `hk_equity` | volatility-targeted market-history research candidate; not runtime-enabled | Check the current matrix locally: @@ -109,7 +111,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. | | `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Enabled values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`. `hk_blue_chip_leader_rotation`, `hk_index_mean_reversion`, and `hk_etf_regime_rotation` are present as eligible-but-disabled HK profiles and are not enabled. | +| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Enabled values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca`. `hk_blue_chip_leader_rotation`, `hk_index_mean_reversion`, `hk_etf_regime_rotation`, and `hk_listed_global_etf_rotation` are present as eligible-but-disabled HK profiles and are not enabled. | | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `IBKR_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_GROUP` contains `hk`, otherwise `US`. | | `IBKR_MARKET_CALENDAR` | No | Market calendar. Defaults to `XHKG` for HK and `NYSE` for US. | @@ -449,7 +451,7 @@ IBKR 账户 | `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 | | `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 | | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前已启用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`。`hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation` 是 eligible-but-disabled 港股档位,未启用 | +| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前已启用值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca`。`hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation`、`hk_listed_global_etf_rotation` 是 eligible-but-disabled 港股档位,未启用 | | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_MARKET` | 否 | 市场范围。`ACCOUNT_GROUP` 包含 `hk` 时默认 `HK`,其他情况默认 `US`。 | | `IBKR_MARKET_CALENDAR` | 否 | 市场日历。港股默认 `XHKG`,美股默认 `NYSE`。 | diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md index 90117b6..7ea1d68 100644 --- a/docs/hk_equity_runtime.md +++ b/docs/hk_equity_runtime.md @@ -14,7 +14,7 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 ## 运行时设计 -本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的港股 profile 元数据:`hk_blue_chip_leader_rotation` 是架构占位,`hk_index_mean_reversion` 和 `hk_etf_regime_rotation` 是 `market_history` 研究候选。三者都只用于框架 wiring、feed/dry-run 兼容性检查,尚未 runtime-enabled。整体沿用美股策略的架构: +本仓库只做券商运行时能力,不把港股策略逻辑硬编码进平台。当前已接入 `HkEquityStrategies` 的港股 profile 元数据:`hk_blue_chip_leader_rotation` 是架构占位,`hk_index_mean_reversion`、`hk_etf_regime_rotation` 和 `hk_listed_global_etf_rotation` 是 `market_history` 研究候选。这些 profile 都只用于框架 wiring、feed/dry-run 兼容性检查,尚未 runtime-enabled。整体沿用美股策略的架构: 1. [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies) 提供 `hk_equity` 策略 profile、运行入口和 IBKR runtime adapter。 2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 发布 snapshot-backed profile 的 `_feature_snapshot_latest.csv`、manifest、ranking 和 release summary。 @@ -31,6 +31,7 @@ QuantStrategyLab 现有平台仓库里,能接入港股股票交易的平台是 | `hk_blue_chip_leader_rotation` | `hk_equity` | `feature_snapshot` | `weight` | required | eligible but disabled | | `hk_index_mean_reversion` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | | `hk_etf_regime_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | +| `hk_listed_global_etf_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | 未来启用 snapshot-backed profile 后的最小策略配置示例;当前不要写入 Cloud Run: @@ -85,5 +86,5 @@ IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK - IBKR 港股实盘依赖账户权限、行情权限、Gateway 登录账户可见账号和交易许可;平台配置无法替代这些权限。 - 不同 IBKR 账户或区域对港股 symbol 格式可能有差异,首批上线前需要用 dry-run 和小范围 symbol 做实盘连接验证。 - `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `IBKR_MARKET_CALENDAR` 临时覆盖。 -- `hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation` 当前均未启用;不要把这些 profile 写入生产 Cloud Run。 -- `market_history` 研究候选后续真正启用前,需要先用 IBKR HK 行情 feed 对 `02800`、`03033`、`02822`、`02840`、`03110`、`03188` 做 dry-run 校验,不提交真实订单。 +- `hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation`、`hk_listed_global_etf_rotation` 当前均未启用;不要把这些 profile 写入生产 Cloud Run。 +- `market_history` 研究候选后续真正启用前,需要先用 IBKR HK 行情 feed 对 `02800`、`03033`、`02822`、`02840`、`03110`、`03188`、`02834`、`03175` 做 dry-run 校验,不提交真实订单。 diff --git a/requirements.txt b/requirements.txt index ce0bab7..6c06539 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.35 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.49 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2390d05785235a186cc900d2b4eca954d4e7ec0e +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@400ef6145bff0b89b46ec00ebb235987ac499a61 pandas numpy requests diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 2244705..691ad4c 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -70,6 +70,7 @@ "hk_blue_chip_leader_rotation", "hk_index_mean_reversion", "hk_etf_regime_rotation", + "hk_listed_global_etf_rotation", } ) EXPECTED_IBKR_PROFILES = EXPECTED_IBKR_ENABLED_PROFILES | HK_DISABLED_PROFILES @@ -705,6 +706,14 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): "enabled": False, "platform": "ibkr", } + assert by_profile["hk_listed_global_etf_rotation"] == { + "canonical_profile": "hk_listed_global_etf_rotation", + "display_name": "HK-listed Global ETF Rotation", + "domain": "hk_equity", + "eligible": True, + "enabled": False, + "platform": "ibkr", + } def test_print_strategy_profile_status_json_matches_registry(): @@ -751,7 +760,7 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_artifacts"] is True assert by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_manifest_path"] is True assert by_profile["hk_blue_chip_leader_rotation"]["requires_strategy_config_path"] is False - for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation"): + for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation", "hk_listed_global_etf_rotation"): assert by_profile[profile]["profile_group"] == "direct_runtime_inputs" assert by_profile[profile]["input_mode"] == "market_history" assert by_profile[profile]["requires_snapshot_artifacts"] is False @@ -777,10 +786,12 @@ def test_print_strategy_profile_status_table_contains_expected_headers(): assert "hk_blue_chip_leader_rotation" in result.stdout assert "hk_index_mean_reversion" in result.stdout assert "hk_etf_regime_rotation" in result.stdout + assert "hk_listed_global_etf_rotation" in result.stdout assert "Tech/Communication Pullback Enhancement" in result.stdout assert "HK Blue Chip Leader Rotation" in result.stdout assert "HK Index Mean Reversion" in result.stdout assert "HK ETF Regime Rotation" in result.stdout + assert "HK-listed Global ETF Rotation" in result.stdout assert "TQQQ Growth Income" in result.stdout From 52c280c919a32a74fd57c457b2c476d099662b75 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:57:49 +0800 Subject: [PATCH 6/6] Update IBKR request tests for market scope --- tests/test_connect_timeout_alert.py | 23 ++++++++++++++++++++++- tests/test_request_handling.py | 18 +++++++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/test_connect_timeout_alert.py b/tests/test_connect_timeout_alert.py index 38aa693..d15b967 100644 --- a/tests/test_connect_timeout_alert.py +++ b/tests/test_connect_timeout_alert.py @@ -104,6 +104,12 @@ def run(self, *args, **kwargs): strategy_profile="tqqq_growth_income", strategy_display_name="TQQQ Growth Income", strategy_domain="us_equity", + market="US", + market_calendar="NYSE", + market_currency="USD", + market_data_symbol_suffix="", + market_exchange="SMART", + market_timezone="America/New_York", account_group="default", account_ids=("U1234567",), service_name="interactive-brokers-platform", @@ -153,10 +159,25 @@ def run(self, *args, **kwargs): "runtime_config_support": runtime_config_support_module, } original = {name: sys.modules.get(name) for name in modules} + preexisting_modules = set(sys.modules) sys.modules.update(modules) try: yield finally: + project_module_prefixes = ( + "application", + "decision_mapper", + "entrypoints", + "main", + "notifications", + "quant_platform_kit", + "runtime_logging", + ) + for name in list(sys.modules): + if name in preexisting_modules: + continue + if name == "main" or name.startswith(project_module_prefixes): + sys.modules.pop(name, None) for name, previous in original.items(): if previous is None: sys.modules.pop(name, None) @@ -171,7 +192,7 @@ def test_handle_request_sends_ibkr_connect_timeout_notification(self): module = importlib.import_module("main") observed = {"messages": []} - module.is_market_open_today = lambda: True + module.is_market_open_today = lambda **_kwargs: True module.run_strategy_core = lambda **_kwargs: (_ for _ in ()).throw( TimeoutError("IBKR API handshake timed out") ) diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index fa2d573..aed399c 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -23,7 +23,7 @@ def fake_run_strategy_core(**_kwargs): observed["called"] = True return "OK - executed" - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "run_strategy_core", fake_run_strategy_core) with strategy_module.app.test_request_context("/", method="POST"): @@ -45,7 +45,7 @@ def test_handle_request_sends_escalated_strategy_plugin_alert(strategy_module, m ) observed = {"alerts": []} - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((signal,), None)) monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None) @@ -75,7 +75,7 @@ def test_handle_precheck_post_uses_dry_run_override(strategy_module, monkeypatch monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"}) monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json") monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda context, event, **fields: observed["events"].append((event, fields))) - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None)) monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None) @@ -125,7 +125,7 @@ def test_handle_precheck_ignores_paper_liquidate_only(strategy_module, monkeypat monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"}) monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: "/tmp/runtime-report.json") monkeypatch.setattr(strategy_module, "emit_runtime_log", lambda *args, **kwargs: None) - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None)) monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None) monkeypatch.setattr(strategy_module, "PAPER_LIQUIDATE_ONLY", True) @@ -290,7 +290,7 @@ def test_handle_request_emits_structured_runtime_events(strategy_module, monkeyp "emit_runtime_log", lambda context, event, **fields: observed.append((context.run_id, event, fields)), ) - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: "OK - executed") with strategy_module.app.test_request_context( @@ -315,7 +315,7 @@ def test_handle_request_persists_machine_readable_report(strategy_module, monkey observed = {} monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001") - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: "OK - executed") monkeypatch.setattr( strategy_module, @@ -362,7 +362,7 @@ def test_handle_request_enriches_runtime_report_with_cycle_details(strategy_modu observed = {} monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001") - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) def fake_run_strategy_core(**_kwargs): return StrategyCycleResult( @@ -405,7 +405,7 @@ def test_handle_request_post_returns_market_closed_when_schedule_empty(strategy_ def fail_if_called(): raise AssertionError("Closed market should not execute strategy") - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: False) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: False) monkeypatch.setattr(strategy_module, "run_strategy_core", fail_if_called) monkeypatch.setattr( strategy_module, @@ -426,7 +426,7 @@ def test_handle_request_error_persists_machine_readable_report(strategy_module, observed = {"messages": []} monkeypatch.setattr(strategy_module, "build_run_id", lambda: "run-001") - monkeypatch.setattr(strategy_module, "is_market_open_today", lambda: True) + monkeypatch.setattr(strategy_module, "is_market_open_today", lambda **_kwargs: True) monkeypatch.setattr( strategy_module, "run_strategy_core",