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 @@


-
+

[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",