diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe49f33..ea2c5d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,11 +63,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("tech_communication_pullback_enhancement") == "tech_communication_pullback_enhancement" + 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 0aaaa29..05aeef3 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -38,6 +38,7 @@ jobs: GITHUB_ENVIRONMENT_NAME: ${{ matrix.target.environment }} 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 }} # Set CLOUD_RUN_REGION per Environment so paper/HK/SG can target different regions. CLOUD_RUN_REGION: ${{ vars.CLOUD_RUN_REGION }} @@ -53,6 +54,11 @@ jobs: LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }} LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }} LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }} + LONGBRIDGE_MARKET: ${{ vars.LONGBRIDGE_MARKET }} + LONGBRIDGE_MARKET_CALENDAR: ${{ vars.LONGBRIDGE_MARKET_CALENDAR }} + LONGBRIDGE_MARKET_TIMEZONE: ${{ vars.LONGBRIDGE_MARKET_TIMEZONE }} + LONGBRIDGE_SYMBOL_SUFFIX: ${{ vars.LONGBRIDGE_SYMBOL_SUFFIX }} + LONGBRIDGE_TRADING_CURRENCY: ${{ vars.LONGBRIDGE_TRADING_CURRENCY }} LONGBRIDGE_MIN_RESERVED_CASH_USD: ${{ vars.LONGBRIDGE_MIN_RESERVED_CASH_USD }} LONGBRIDGE_RESERVED_CASH_RATIO: ${{ vars.LONGBRIDGE_RESERVED_CASH_RATIO }} LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }} @@ -108,6 +114,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 ${DEPLOYMENT_LABEL} 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 @@ -154,7 +168,7 @@ jobs: import os import subprocess import sys - from us_equity_strategies import resolve_canonical_profile + from strategy_registry import LONGBRIDGE_PLATFORM, resolve_strategy_definition raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip() if not raw_runtime_target: @@ -163,7 +177,10 @@ jobs: profile = str(runtime_target.get("strategy_profile") or "").strip().lower() if not profile: raise SystemExit("RUNTIME_TARGET_JSON.strategy_profile is required") - canonical_profile = resolve_canonical_profile(profile) + canonical_profile = resolve_strategy_definition( + profile, + platform_id=LONGBRIDGE_PLATFORM, + ).profile runtime_target["strategy_profile"] = canonical_profile expected_service = os.environ.get("CLOUD_RUN_SERVICE", "").strip() @@ -516,6 +533,36 @@ jobs: remove_env_vars+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON") fi + if [ -n "${LONGBRIDGE_MARKET:-}" ]; then + env_pairs+=("LONGBRIDGE_MARKET=${LONGBRIDGE_MARKET}") + else + remove_env_vars+=("LONGBRIDGE_MARKET") + fi + + if [ -n "${LONGBRIDGE_MARKET_CALENDAR:-}" ]; then + env_pairs+=("LONGBRIDGE_MARKET_CALENDAR=${LONGBRIDGE_MARKET_CALENDAR}") + else + remove_env_vars+=("LONGBRIDGE_MARKET_CALENDAR") + fi + + if [ -n "${LONGBRIDGE_MARKET_TIMEZONE:-}" ]; then + env_pairs+=("LONGBRIDGE_MARKET_TIMEZONE=${LONGBRIDGE_MARKET_TIMEZONE}") + else + remove_env_vars+=("LONGBRIDGE_MARKET_TIMEZONE") + fi + + if [ -n "${LONGBRIDGE_SYMBOL_SUFFIX:-}" ]; then + env_pairs+=("LONGBRIDGE_SYMBOL_SUFFIX=${LONGBRIDGE_SYMBOL_SUFFIX}") + else + remove_env_vars+=("LONGBRIDGE_SYMBOL_SUFFIX") + fi + + if [ -n "${LONGBRIDGE_TRADING_CURRENCY:-}" ]; then + env_pairs+=("LONGBRIDGE_TRADING_CURRENCY=${LONGBRIDGE_TRADING_CURRENCY}") + else + remove_env_vars+=("LONGBRIDGE_TRADING_CURRENCY") + fi + if [ -n "${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD:-}" ]; then env_pairs+=("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}") else diff --git a/README.md b/README.md index d3547a1..75c2ba2 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Quant system on LongPort OpenAPI and Google Cloud Run. This repository uses `QuantPlatformKit` for LongPort token handling, context bootstrap, account snapshot access, market data, and order submission. Cloud Run deploys this repository directly. -The runtime now carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` alongside the compatibility `STRATEGY_PROFILE` selector. Strategy-owned defaults come from `UsEquityStrategies`; platform variables are only explicit overrides. -The LongBridge runtime can execute the seven current `runtime_enabled` `us_equity` profiles from `UsEquityStrategies`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. +The runtime now carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` alongside the compatibility `STRATEGY_PROFILE` selector. Strategy-owned defaults come from `UsEquityStrategies` and `HkEquityStrategies`; platform variables are only explicit overrides. +The LongBridge runtime can execute the current `runtime_enabled` `us_equity` profiles from `UsEquityStrategies`. It also carries eligible-but-disabled HK profiles from `HkEquityStrategies`: `hk_blue_chip_leader_rotation`, `hk_index_mean_reversion`, `hk_etf_regime_rotation`, and `hk_listed_global_etf_rotation`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. `STRATEGY_PROFILE` remains the compatibility selector for strategy routing, while `RuntimeTarget` describes the running service identity. -Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). The sections below focus on LongBridge runtime behavior, profile enablement, deployment, and credentials. -This runtime matrix is the authoritative enablement source for LongBridge. `UsEquityStrategies` carries strategy-layer logic, cadence, compatibility, and metadata. +Strategy documentation lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies) and [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies). Snapshot artifact contracts for the HK profile are produced by [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines). The sections below focus on LongBridge runtime behavior, profile enablement, deployment, and credentials. +This runtime matrix is the authoritative enablement source for LongBridge. Strategy packages carry strategy-layer logic, cadence, compatibility, and metadata. ### Execution boundary @@ -40,6 +40,10 @@ Platform execution no longer depends on `strategy/allocation.py` or hard-coded s | `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | `us_equity` | current SG deployment | | `tqqq_growth_income` | TQQQ Growth Income | Yes | Yes | `us_equity` | selectable growth line | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | current PAPER deployment | +| `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: @@ -49,11 +53,13 @@ python3 scripts/print_strategy_profile_status.py ### Strategy documentation boundary -Strategy logic, cadence, asset universes, parameters, and research/backtest notes live in `UsEquityStrategies`. This platform README keeps only LongBridge profile enablement, env vars, deployment wiring, broker execution behavior, and notification transport. +Strategy logic, cadence, asset universes, parameters, and research/backtest notes live in the strategy repositories (`UsEquityStrategies` / `HkEquityStrategies`). This platform README keeps only LongBridge profile enablement, env vars, deployment wiring, broker execution behavior, and notification transport. + +For the HK-equity runtime scope, platform matrix, and env defaults, see [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md). ### Notifications -Telegram notifications include structured execution and heartbeat messages, with English and Chinese variants. Strategy-specific signal/status fields come from the selected `UsEquityStrategies` profile; LongBridge-specific fields cover order submission, fill/reject/error reporting, account prefix, and region. +Telegram notifications include structured execution and heartbeat messages, with English and Chinese variants. Strategy-specific signal/status fields come from the selected strategy package profile; LongBridge-specific fields cover order submission, fill/reject/error reporting, account prefix, region, and market scope. ### Environment variables @@ -67,6 +73,11 @@ Telegram notifications include structured execution and heartbeat messages, with | `ACCOUNT_PREFIX` | No | Alert/log prefix for account/environment (default: `DEFAULT`) | | `STRATEGY_PROFILE` | Yes | Strategy profile selector for compatibility and strategy routing. Set explicitly per deployment; enabled values include `global_etf_confidence_vol_gate`, `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income`. The structured runtime target is carried separately as `RUNTIME_TARGET_JSON`. | | `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `PAPER`, `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) | +| `LONGBRIDGE_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_REGION=HK`, otherwise `US`. | +| `LONGBRIDGE_MARKET_CALENDAR` | No | Market calendar for market-hours checks. Defaults to `XHKG` for HK and `NYSE` for US. | +| `LONGBRIDGE_MARKET_TIMEZONE` | No | Market timezone. Defaults to `Asia/Hong_Kong` for HK and `America/New_York` for US. | +| `LONGBRIDGE_SYMBOL_SUFFIX` | No | Market-data symbol suffix. Defaults to `.HK` for HK and `.US` for US. | +| `LONGBRIDGE_TRADING_CURRENCY` | No | Trading-currency cash/reporting scope. Defaults to `HKD` for HK and `USD` for US. | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. | | `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional LongBridge-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | @@ -114,7 +125,7 @@ Deploy the same codebase as multiple Cloud Run services by setting different val - `LONGPORT_SECRET_NAME`: point to different secrets (e.g. `longport_token_paper`, `longport_token_hk`, `longport_token_sg`) - `ACCOUNT_PREFIX`: e.g. `PAPER`, `HK`, `SG` (all Telegram/log alerts will include `[ACCOUNT_PREFIX]`) - `STRATEGY_PROFILE`: set per service. The deployment control plane now also carries `RUNTIME_TARGET_JSON`; treat `STRATEGY_PROFILE` as a compatibility input that still selects the strategy implementation, not the only identity key. -- Current strategy domain is `us_equity`. `STRATEGY_PROFILE` still goes through a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata: `eligible` means the platform can run it in theory, `enabled` means the current rollout really allows it. +- Current strategy domains are `us_equity` and `hk_equity`. `STRATEGY_PROFILE` still goes through a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata: `eligible` means the platform can run it in theory, `enabled` means the current rollout really allows it. - `ACCOUNT_REGION`: explicitly mark the deployed account region (`PAPER` / `HK` / `SG`); if unset, the app falls back to `ACCOUNT_PREFIX` or `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: set per service when that deployment should stay dry-run - `NOTIFY_LANG`: set `en` or `zh` per deployment @@ -123,6 +134,8 @@ Deploy the same codebase as multiple Cloud Run services by setting different val This repo includes `.github/workflows/sync-cloud-run-env.yml` for GitHub-managed Cloud Run automation. Set `ENABLE_GITHUB_CLOUD_RUN_DEPLOY=true` on each GitHub Environment when GitHub Actions should build and deploy the container image; set `ENABLE_GITHUB_ENV_SYNC=true` when GitHub Actions should sync runtime env vars. You can enable either flag independently while migrating away from Google Cloud Triggers. +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 (shared):** @@ -135,15 +148,15 @@ Recommended setup: - Optional fallback only: `CRISIS_ALERT_EMAIL_SENDER_PASSWORD` - **GitHub Environment: `longbridge-paper`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`, `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_MARKET`, `LONGBRIDGE_MARKET_CALENDAR`, `LONGBRIDGE_MARKET_TIMEZONE`, `LONGBRIDGE_SYMBOL_SUFFIX`, `LONGBRIDGE_TRADING_CURRENCY`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-paper`, `longport-app-secret-paper` - **GitHub Environment: `longbridge-sg`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`, `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_MARKET`, `LONGBRIDGE_MARKET_CALENDAR`, `LONGBRIDGE_MARKET_TIMEZONE`, `LONGBRIDGE_SYMBOL_SUFFIX`, `LONGBRIDGE_TRADING_CURRENCY`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg` - **GitHub Environment: `longbridge-hk`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`, `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_MARKET`, `LONGBRIDGE_MARKET_CALENDAR`, `LONGBRIDGE_MARKET_TIMEZONE`, `LONGBRIDGE_SYMBOL_SUFFIX`, `LONGBRIDGE_TRADING_CURRENCY`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` On every push to `main`, the workflow can build and deploy the configured Cloud Run services, update their shared and per-environment values, and remove `TELEGRAM_CHAT_ID` from each Cloud Run service. @@ -175,7 +188,7 @@ Important: 2. Create secret `longport_token_paper` for paper / `longport_token_hk` for HK / `longport_token_sg` for SG (or your custom `LONGPORT_SECRET_NAME`) in Secret Manager and add your LongPort access token as the first version. 3. Set the required env vars above on the Cloud Run service. 4. Deploy the app to Cloud Run (e.g. `gcloud run deploy` from repo root with Dockerfile or buildpack). -5. Create two Cloud Scheduler 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`; this platform repo only owns the runtime trigger wiring. +5. Create two Cloud Scheduler 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; this platform repo only owns the runtime trigger wiring. IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Accessor for the configured `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, and `LONGPORT_APP_SECRET_SECRET_NAME`, such as `longport_token_paper`, `longport-app-key-paper`, `longport-app-secret-paper`) and **Logs Writer**. Build/deploy typically uses a separate account with Artifact Registry Writer, Cloud Run Admin, Service Account User. @@ -189,9 +202,9 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc 这个仓库通过 `QuantPlatformKit` 复用 LongPort token 处理、上下文初始化、账户快照、行情读取和下单逻辑。Cloud Run 直接部署这个仓库。 LongBridge 的账户身份按 `paper`、`HK`、`SG` 三个维度建模。 -`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的 7 条 `runtime_enabled` `us_equity` 策略:`global_etf_confidence_vol_gate`、`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`。较弱或重复的研究 profile 已从 LongBridge 可配置入口移除。仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 +`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的 `runtime_enabled` `us_equity` 策略,同时带有 `HkEquityStrategies` 里的港股 eligible-but-disabled profile:`hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation` 和 `hk_listed_global_etf_rotation`。这些港股 profile 当前仅用于框架和 feed/dry-run 兼容性检查,未 enabled。仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 -完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。下面这些章节只保留 LongBridge 运行时、profile 启用状态、部署和凭据说明。 +策略说明放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies) 和 [`HkEquityStrategies`](https://github.com/QuantStrategyLab/HkEquityStrategies);港股 snapshot artifact 由 [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 生成。下面这些章节只保留 LongBridge 运行时、profile 启用状态、部署和凭据说明。 ### 执行边界 @@ -215,6 +228,10 @@ LongBridge 的账户身份按 `paper`、`HK`、`SG` 三个维度建模。 | `soxl_soxx_trend_income` | SOXL/SOXX 半导体趋势收益 | Yes | Yes | `us_equity` | 当前 SG 部署线路 | | `tqqq_growth_income` | TQQQ 增长收益 | Yes | Yes | `us_equity` | 可选增长线路 | | `tech_communication_pullback_enhancement` | 科技通信回调增强 | Yes | Yes | `us_equity` | 当前 paper feature-snapshot 线路 | +| `hk_blue_chip_leader_rotation` | HK Blue Chip Leader Rotation | Yes | No | `hk_equity` | 仅架构占位,暂未 runtime-enabled | +| `hk_index_mean_reversion` | HK Index Mean Reversion | Yes | No | `hk_equity` | market-history 研究候选,暂未 runtime-enabled | +| `hk_etf_regime_rotation` | HK ETF Regime Rotation | Yes | No | `hk_equity` | market-history 研究候选,暂未 runtime-enabled | +| `hk_listed_global_etf_rotation` | HK-listed Global ETF Rotation | Yes | No | `hk_equity` | 波动率目标 market-history 研究候选,暂未 runtime-enabled | 本地可直接查看当前矩阵: @@ -224,11 +241,13 @@ python3 scripts/print_strategy_profile_status.py ### 策略文档边界 -策略逻辑、策略频率、标的池、参数和研究/回测说明都放在 `UsEquityStrategies`。这个平台 README 只保留 LongBridge profile 启用状态、环境变量、部署 wiring、券商执行行为和通知通道说明。 +策略逻辑、策略频率、标的池、参数和研究/回测说明都放在策略仓库(`UsEquityStrategies` / `HkEquityStrategies`)。这个平台 README 只保留 LongBridge profile 启用状态、环境变量、部署 wiring、券商执行行为和通知通道说明。 + +港股运行时范围、平台矩阵和环境变量默认值见 [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md)。 ### 通知格式 -Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换。策略相关的信号/状态字段来自当前选择的 `UsEquityStrategies` profile;LongBridge 侧只负责下单、成交/拒单/异常、账户前缀和区域字段。 +Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换。策略相关的信号/状态字段来自当前选择的策略 package profile;LongBridge 侧负责下单、成交/拒单/异常、账户前缀、区域和市场范围字段。 ### 环境变量 @@ -242,6 +261,11 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `ACCOUNT_PREFIX` | 否 | 通知/日志前缀,区分账户环境(默认: `DEFAULT`) | | `STRATEGY_PROFILE` | 是 | 策略档位选择。每个部署都要显式设置;已启用值包括 `global_etf_confidence_vol_gate`、`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income` | | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `PAPER`、`HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | +| `LONGBRIDGE_MARKET` | 否 | 市场范围。`ACCOUNT_REGION=HK` 时默认 `HK`,其他情况默认 `US`。 | +| `LONGBRIDGE_MARKET_CALENDAR` | 否 | 市场开闭市检查用的日历。港股默认 `XHKG`,美股默认 `NYSE`。 | +| `LONGBRIDGE_MARKET_TIMEZONE` | 否 | 市场时区。港股默认 `Asia/Hong_Kong`,美股默认 `America/New_York`。 | +| `LONGBRIDGE_SYMBOL_SUFFIX` | 否 | 行情标的后缀。港股默认 `.HK`,美股默认 `.US`。 | +| `LONGBRIDGE_TRADING_CURRENCY` | 否 | 交易现金和报表口径。港股默认 `HKD`,美股默认 `USD`。 | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | | `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 LongBridge 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | @@ -289,7 +313,7 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - `LONGPORT_SECRET_NAME`: 指向不同密钥(如 `longport_token_paper`、`longport_token_hk`、`longport_token_sg`) - `ACCOUNT_PREFIX`: 如 `PAPER`、`HK`、`SG`(所有通知/日志将包含 `[ACCOUNT_PREFIX]`) - `STRATEGY_PROFILE`: 按服务分别设置。控制面会另外携带 `RUNTIME_TARGET_JSON`,`STRATEGY_PROFILE` 继续只作为兼容选择器。 -- 当前策略域是 `us_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过从 `runtime_enabled` 策略元数据派生的 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 +- 当前策略域是 `us_equity` 和 `hk_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过从 `runtime_enabled` 策略元数据派生的 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 - `ACCOUNT_REGION`: 显式标记部署账户区域(`PAPER` / `HK` / `SG`);未设置时会回退到 `ACCOUNT_PREFIX` 或 `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: 需要保持模拟运行时按服务单独设置 - `NOTIFY_LANG`: 每个部署可独立设置 `en` 或 `zh` @@ -298,6 +322,8 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo 这个仓库已经提供 `.github/workflows/sync-cloud-run-env.yml` 作为 GitHub 管理 Cloud Run 的入口。每个 GitHub Environment 设置 `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(共享):** @@ -312,15 +338,15 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 仅保留为 fallback:`CRISIS_ALERT_EMAIL_SENDER_PASSWORD` - **GitHub Environment: `longbridge-paper`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`、`CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_MARKET`、`LONGBRIDGE_MARKET_CALENDAR`、`LONGBRIDGE_MARKET_TIMEZONE`、`LONGBRIDGE_SYMBOL_SUFFIX`、`LONGBRIDGE_TRADING_CURRENCY`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-paper`、`longport-app-secret-paper` - **GitHub Environment: `longbridge-sg`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`、`CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_MARKET`、`LONGBRIDGE_MARKET_CALENDAR`、`LONGBRIDGE_MARKET_TIMEZONE`、`LONGBRIDGE_SYMBOL_SUFFIX`、`LONGBRIDGE_TRADING_CURRENCY`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg` - **GitHub Environment: `longbridge-hk`** - Variables: `ENABLE_GITHUB_CLOUD_RUN_DEPLOY`、`CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_MARKET`、`LONGBRIDGE_MARKET_CALENDAR`、`LONGBRIDGE_MARKET_TIMEZONE`、`LONGBRIDGE_SYMBOL_SUFFIX`、`LONGBRIDGE_TRADING_CURRENCY`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` 每次 push 到 `main` 时,这个 workflow 可以构建并部署配置的 Cloud Run 服务,把共享和各自隔离的变量同步进去,并删除旧的 `TELEGRAM_CHAT_ID`。 @@ -351,6 +377,6 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo 2. 在 Secret Manager 中为 paper 创建 `longport_token_paper`、为 HK 创建 `longport_token_hk`、为 SG 创建 `longport_token_sg`(或使用你自定义的 `LONGPORT_SECRET_NAME`),并将 LongPort access token 作为第一个版本写入。 3. 在 Cloud Run 服务上配置上述环境变量。 4. 部署至 Cloud Run(如从仓库根目录执行 `gcloud run deploy`)。 -5. 创建两个 Cloud Scheduler 定时任务,POST 到 Cloud Run URL。开盘后窗口走 `"/precheck"`,临近收盘窗口走 `"/"`。cron 频率以 `UsEquityStrategies` 里的策略层 cadence 为准;这个平台仓只维护运行时触发 wiring。 +5. 创建两个 Cloud Scheduler 定时任务,POST 到 Cloud Run URL。开盘后窗口走 `"/precheck"`,临近收盘窗口走 `"/"`。cron 频率以所选策略仓库里的策略层 cadence 为准;这个平台仓只维护运行时触发 wiring。 IAM: Cloud Run 服务账号需要 **Secret Manager Admin**(或当前 `LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` 对应 secret 的 Secret Accessor,例如 `longport_token_paper`、`longport-app-key-paper`、`longport-app-secret-paper`)和 **Logs Writer** 权限。 diff --git a/application/longbridge_portfolio.py b/application/longbridge_portfolio.py index 7abbb33..a6721eb 100644 --- a/application/longbridge_portfolio.py +++ b/application/longbridge_portfolio.py @@ -40,6 +40,7 @@ def fetch_strategy_account_state( t_ctx: Any, strategy_assets: Iterable[str], *, + cash_currency: str = "USD", position_log_fn: Callable[[str], None] | None = None, warning_log_fn: Callable[[str], None] | None = None, ) -> dict[str, Any]: @@ -47,6 +48,7 @@ def warn(message: str) -> None: if warning_log_fn is not None: warning_log_fn(message) + trading_currency = str(cash_currency or "USD").strip().upper() available_cash = 0.0 cash_by_currency: dict[str, float] = {} try: @@ -64,7 +66,7 @@ def warn(message: str) -> None: continue cash_amount = float(getattr(cash_info, "available_cash", 0.0)) cash_by_currency[currency] = cash_by_currency.get(currency, 0.0) + cash_amount - if currency == "USD": + if currency == trading_currency: available_cash += cash_amount assets = [str(symbol).strip().upper() for symbol in strategy_assets if str(symbol).strip()] @@ -139,4 +141,5 @@ def warn(message: str) -> None: "quantities": quantities, "sellable_quantities": sellable_quantities, "total_strategy_equity": available_cash + sum(market_values.values()), + "trading_currency": trading_currency, } diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index de25b1a..1601406 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -42,13 +42,15 @@ class LongBridgeBrokerAdapters: submit_order_fn: Callable[..., Any] clock: Callable[[], datetime] = _utcnow price_history_lookback: int = DEFAULT_SEMICONDUCTOR_ROTATION_HISTORY_LOOKBACK + symbol_suffix: str = ".US" + currency: str = "USD" def normalize_market_symbol(self, symbol: str) -> str: value = str(symbol or "").strip().upper() if not value: raise ValueError("Market data symbol must be non-empty.") if "." not in value: - return f"{value}.US" + return f"{value}{self.symbol_suffix}" return value def fetch_daily_price_history(self, quote_context, symbol: str, *, lookback: int | None = None): @@ -115,6 +117,7 @@ def load_quote(symbol: str) -> QuoteSnapshot: symbol=normalized_symbol, as_of=self.clock(), last_price=float(price), + currency=self.currency, ) quote_cache[normalized_symbol] = snapshot return snapshot @@ -138,7 +141,7 @@ def load_price_series(symbol: str) -> PriceSeries: ) series = PriceSeries( symbol=normalized_symbol, - currency="USD", + currency=self.currency, points=tuple(points), ) price_series_cache[normalized_symbol] = series @@ -216,6 +219,8 @@ def build_runtime_broker_adapters( submit_order_fn: Callable[..., Any], clock: Callable[[], datetime] = _utcnow, price_history_lookback: int = DEFAULT_SEMICONDUCTOR_ROTATION_HISTORY_LOOKBACK, + symbol_suffix: str = ".US", + currency: str = "USD", ) -> LongBridgeBrokerAdapters: return LongBridgeBrokerAdapters( strategy_symbols=tuple(strategy_symbols), @@ -225,4 +230,6 @@ def build_runtime_broker_adapters( submit_order_fn=submit_order_fn, clock=clock, price_history_lookback=int(price_history_lookback), + symbol_suffix=str(symbol_suffix or ""), + currency=str(currency or "USD").upper(), ) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 9dd5b4a..3108ac5 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -40,6 +40,9 @@ class LongBridgeRuntimeComposer: order_poll_interval_sec: int order_poll_max_attempts: int safe_haven_cash_substitute_threshold_usd: float + market: str = "US" + symbol_suffix: str = ".US" + trading_currency: str = "USD" dry_run_only: bool = False broker_adapters: Any = None strategy_adapters: Any = None @@ -121,6 +124,9 @@ def build_reporting_adapters(self): extra_context_fields=build_runtime_context_fields( { "account_prefix": self.account_prefix, + "market": self.market, + "symbol_suffix": self.symbol_suffix, + "trading_currency": self.trading_currency, "strategy_display_name": self.strategy_display_name, "strategy_display_name_localized": self.strategy_display_name_localized, **dict(self.extra_reporting_fields), @@ -175,6 +181,12 @@ def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False) - ) def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeRebalanceConfig: + market_scope_line = self.translator( + "market_scope_detail", + market=self.market, + currency=self.trading_currency, + symbol_suffix=self.symbol_suffix or "", + ) return LongBridgeRebalanceConfig( limit_sell_discount=self.limit_sell_discount, limit_buy_premium=self.limit_buy_premium, @@ -188,7 +200,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb post_sell_refresh_interval_sec=self.order_poll_interval_sec, safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd, sleeper=self.sleeper, - extra_notification_lines=(), + extra_notification_lines=(market_scope_line,), strategy_plugin_signals=tuple(strategy_plugin_signals or ()), ) @@ -230,7 +242,6 @@ def build_runtime_composer( order_poll_max_attempts: int, safe_haven_cash_substitute_threshold_usd: float, dry_run_only: bool, - dry_run_only_override: bool | None = None, broker_adapters: Any, strategy_adapters: Any, estimate_max_purchase_quantity_fn: Callable[..., float], @@ -246,6 +257,10 @@ def build_runtime_composer( runtime_target: RuntimeTarget | None, env_reader: Callable[[str, str], str | None], sleeper: Callable[[float], None], + market: str = "US", + symbol_suffix: str = ".US", + trading_currency: str = "USD", + dry_run_only_override: bool | None = None, printer: Callable[..., Any] = print, extra_reporting_fields: Mapping[str, Any] | None = None, ) -> LongBridgeRuntimeComposer: @@ -271,6 +286,9 @@ def build_runtime_composer( order_poll_interval_sec=int(order_poll_interval_sec), order_poll_max_attempts=int(order_poll_max_attempts), safe_haven_cash_substitute_threshold_usd=float(safe_haven_cash_substitute_threshold_usd), + market=str(market or "US").upper(), + symbol_suffix=str(symbol_suffix or ""), + trading_currency=str(trading_currency or "USD").upper(), dry_run_only=bool(dry_run_only if dry_run_only_override is None else dry_run_only_override), broker_adapters=broker_adapters, strategy_adapters=strategy_adapters, diff --git a/decision_mapper.py b/decision_mapper.py index 39e5979..105d12f 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -4,8 +4,6 @@ from dataclasses import replace from typing import Any -from us_equity_strategies.catalog import resolve_canonical_profile - from quant_platform_kit.strategy_contracts import ( PositionTarget, StrategyDecision, @@ -17,6 +15,7 @@ resolve_decision_target_mode, translate_decision_to_target_mode, ) +from strategy_registry import LONGBRIDGE_PLATFORM, resolve_strategy_definition _SAFE_HAVEN_SYMBOLS = frozenset({"BOXX", "BIL"}) _INCOME_SYMBOLS = frozenset({"QQQI", "SPYI"}) @@ -82,6 +81,13 @@ def _symbol_role(symbol: str) -> str | None: return None +def _resolve_canonical_profile(strategy_profile: str) -> str: + return resolve_strategy_definition( + strategy_profile, + platform_id=LONGBRIDGE_PLATFORM, + ).profile + + def _default_threshold_value(total_equity: float) -> float: return max(_DEFAULT_MIN_TRADE_FLOOR, float(total_equity) * _DEFAULT_REBALANCE_THRESHOLD_RATIO) @@ -274,7 +280,7 @@ def _normalize_to_value_target_decision( def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[str, ...], dict[str, Any]]: - strategy_profile = resolve_canonical_profile(strategy_profile) + strategy_profile = _resolve_canonical_profile(strategy_profile) if strategy_profile == "tqqq_growth_income": return ( "risk_safe_income", @@ -407,7 +413,7 @@ def map_strategy_decision_to_plan( strategy_profile: str, runtime_metadata: Mapping[str, Any] | None = None, ) -> dict[str, Any]: - canonical_profile = resolve_canonical_profile(strategy_profile) + canonical_profile = _resolve_canonical_profile(strategy_profile) portfolio_inputs = _build_portfolio_inputs(account_state=account_state, snapshot=snapshot) normalized_decision, normalized_annotations = _normalize_to_value_target_decision( decision, diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md new file mode 100644 index 0000000..22b8d93 --- /dev/null +++ b/docs/hk_equity_runtime.md @@ -0,0 +1,79 @@ +# LongBridge 港股运行时接入说明 + +## 结论 + +QuantStrategyLab 现有平台仓库里,能做港股股票交易运行时接入的平台是: + +| 平台仓库 | 港股交易接入判断 | 当前处理 | +| --- | --- | --- | +| `LongBridgePlatform` | 可接入。LongBridge 支持港股账户、`.HK` 行情符号和 HKD 现金口径。 | 已加入 HK market scope 配置、通知和结构化日志字段。 | +| `InteractiveBrokersPlatform` | 可接入。IBKR 需要账户有港股/SEHK 交易与行情权限。 | 在对应仓库单独接入。 | +| `CharlesSchwabPlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 | +| `FirstradePlatform` | 不适合作为港股交易入口。 | 保持 US equity 边界,不改。 | +| `BinancePlatform` | 加密货币平台,不是港股股票交易入口。 | 不改。 | + +## 运行时设计 + +平台运行时已具备港股市场维度,并接入 `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` 策略定义、运行入口和 LongBridge runtime adapter。 +2. [`HkEquitySnapshotPipelines`](https://github.com/QuantStrategyLab/HkEquitySnapshotPipelines) 产出 snapshot-backed profile 的特征快照、manifest、ranking 和 release summary。 +3. 非 snapshot profile 使用平台 market-data feed 提供的 `market_history`,不需要 snapshot artifact。 +4. LongBridge 只读取 `RUNTIME_TARGET_JSON`、策略 profile、snapshot/config 路径和平台 market scope。 +5. 平台根据 market scope 选择交易币种、行情后缀、市场日历和通知/日志字段。 + +这样可以避免在平台仓库里硬编码策略逻辑,也便于同一套港股策略接入 IBKR。 + +## 港股 profile 当前状态 + +| 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 | +| `hk_listed_global_etf_rotation` | `hk_equity` | `market_history` | `weight` | not required | eligible but disabled | + +未来启用 snapshot-backed profile 后的最小策略配置示例;当前不要写入 Cloud Run: + +```bash +STRATEGY_PROFILE=hk_blue_chip_leader_rotation +RUNTIME_TARGET_JSON={"platform_id":"longbridge","strategy_profile":"hk_blue_chip_leader_rotation","deployment_selector":"HK","account_scope":"HK","execution_mode":"live"} +LONGBRIDGE_FEATURE_SNAPSHOT_PATH=gs:///hk_blue_chip_leader_rotation_feature_snapshot_latest.csv +LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=gs:///hk_blue_chip_leader_rotation_feature_snapshot_latest.csv.manifest.json +``` + +## 配置项 + +| 变量 | 默认值 | 港股建议值 | 说明 | +| --- | --- | --- | --- | +| `ACCOUNT_REGION` | `DEFAULT` | `HK` | 设置为 `HK` 时会推导港股默认 market scope。 | +| `LONGBRIDGE_MARKET` | 从 `ACCOUNT_REGION` 推导,默认 `US` | `HK` | 显式指定市场;优先级高于 `ACCOUNT_REGION`。 | +| `LONGBRIDGE_MARKET_CALENDAR` | `NYSE` / 港股为 `XHKG` | `XHKG` | 市场开闭市判断使用的 calendar 名称。 | +| `LONGBRIDGE_MARKET_TIMEZONE` | `America/New_York` / 港股为 `Asia/Hong_Kong` | `Asia/Hong_Kong` | 用于生成交易日日期。 | +| `LONGBRIDGE_SYMBOL_SUFFIX` | `.US` / 港股为 `.HK` | `.HK` | 平台行情符号后缀。 | +| `LONGBRIDGE_TRADING_CURRENCY` | `USD` / 港股为 `HKD` | `HKD` | 账户现金、报价和通知口径。 | + +最小港股配置: + +```bash +ACCOUNT_REGION=HK +# 可选显式覆盖: +LONGBRIDGE_MARKET=HK +LONGBRIDGE_MARKET_CALENDAR=XHKG +LONGBRIDGE_MARKET_TIMEZONE=Asia/Hong_Kong +LONGBRIDGE_SYMBOL_SUFFIX=.HK +LONGBRIDGE_TRADING_CURRENCY=HKD +``` + +## 通知和日志 + +- Telegram 中英文模板新增市场行:市场、交易币种、标的后缀。 +- Runtime report / structured log context 新增:`market`、`market_calendar`、`market_timezone`、`symbol_suffix`、`trading_currency`。 +- 市场开闭市跳过、market hours bypass 等事件会带上 market scope,便于区分 US/HK 服务。 + +## 风险和注意事项 + +- `XHKG` 是否可用取决于部署环境里的 `pandas_market_calendars` 版本;如不可用,可用 `LONGBRIDGE_MARKET_CALENDAR` 临时覆盖。 +- `hk_blue_chip_leader_rotation`、`hk_index_mean_reversion`、`hk_etf_regime_rotation`、`hk_listed_global_etf_rotation` 当前均未启用;不要把这些 profile 写入生产 Cloud Run。 +- `market_history` 研究候选后续真正启用前,需要先用 LongBridge HK 行情 feed 对 `02800`、`03033`、`02822`、`02840`、`03110`、`03188`、`02834`、`03175` 做 dry-run 校验,不提交真实订单。 +- LongBridge 下单仍保持整数股规则;如果未来港股策略涉及碎股或特殊交易单位,需要在策略层明确 lot-size 约束后再扩展。 diff --git a/entrypoints/cloud_run.py b/entrypoints/cloud_run.py index f239a81..f9dcab9 100644 --- a/entrypoints/cloud_run.py +++ b/entrypoints/cloud_run.py @@ -8,11 +8,12 @@ import pytz -def is_market_open_now(*, calendar_name="NYSE"): +def is_market_open_now(*, calendar_name="NYSE", timezone_name="America/New_York"): try: calendar = mcal.get_calendar(calendar_name) - now_utc = datetime.now(pytz.utc) - schedule = calendar.schedule(start_date=now_utc, end_date=now_utc) - return False if schedule.empty else calendar.open_at_time(schedule, now_utc) + market_tz = pytz.timezone(timezone_name) + now_market = datetime.now(market_tz) + schedule = calendar.schedule(start_date=now_market.date(), end_date=now_market.date()) + return False if schedule.empty else calendar.open_at_time(schedule, now_market) except Exception as exc: return False, exc diff --git a/main.py b/main.py index 59e4d12..83bf60a 100644 --- a/main.py +++ b/main.py @@ -69,6 +69,11 @@ def get_project_id(): STRATEGY_PROFILE = RUNTIME_SETTINGS.strategy_profile STRATEGY_DISPLAY_NAME = RUNTIME_SETTINGS.strategy_display_name ACCOUNT_REGION = RUNTIME_SETTINGS.account_region +MARKET = RUNTIME_SETTINGS.market +MARKET_CALENDAR = RUNTIME_SETTINGS.market_calendar +MARKET_TIMEZONE = RUNTIME_SETTINGS.market_timezone +SYMBOL_SUFFIX = RUNTIME_SETTINGS.symbol_suffix +TRADING_CURRENCY = RUNTIME_SETTINGS.trading_currency NOTIFY_LANG = RUNTIME_SETTINGS.notify_lang TG_TOKEN = RUNTIME_SETTINGS.tg_token TG_CHAT_ID = RUNTIME_SETTINGS.tg_chat_id @@ -128,6 +133,7 @@ def log_runtime_warning(message): quote_context, trade_context, list(MANAGED_SYMBOLS), + cash_currency=TRADING_CURRENCY, position_log_fn=( log_position_snapshot if getattr(RUNTIME_SETTINGS, "debug_position_snapshot", False) @@ -136,6 +142,8 @@ def log_runtime_warning(message): warning_log_fn=log_runtime_warning, ), submit_order_fn=submit_order, + symbol_suffix=SYMBOL_SUFFIX, + currency=TRADING_CURRENCY, ) STRATEGY_ADAPTERS = build_runtime_strategy_adapters( strategy_runtime=STRATEGY_RUNTIME, @@ -192,6 +200,9 @@ def build_composer(*, dry_run_only_override: bool | None = None): order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, safe_haven_cash_substitute_threshold_usd=_safe_haven_cash_substitute_threshold_usd(), + market=MARKET, + symbol_suffix=SYMBOL_SUFFIX, + trading_currency=TRADING_CURRENCY, dry_run_only=RUNTIME_SETTINGS.dry_run_only, dry_run_only_override=dry_run_only_override, broker_adapters=BROKER_ADAPTERS, @@ -210,6 +221,13 @@ def build_composer(*, dry_run_only_override: bool | None = None): env_reader=os.getenv, sleeper=time.sleep, printer=print, + extra_reporting_fields={ + "market": MARKET, + "market_calendar": MARKET_CALENDAR, + "market_timezone": MARKET_TIMEZONE, + "symbol_suffix": SYMBOL_SUFFIX, + "trading_currency": TRADING_CURRENCY, + }, ) @@ -269,7 +287,10 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali ) print(composer.with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) - market_open = is_market_open_now() + market_open = is_market_open_now( + calendar_name=MARKET_CALENDAR, + timezone_name=MARKET_TIMEZONE, + ) if isinstance(market_open, tuple): market_open, error = market_open reporting_adapters.log_event( @@ -278,6 +299,9 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali message="Market hours check failed", severity="WARNING", error_message=str(error), + market=MARKET, + market_calendar=MARKET_CALENDAR, + market_timezone=MARKET_TIMEZONE, ) print(composer.with_prefix(f"Market hours check failed: {error}"), flush=True) if not market_open and not force_run: @@ -285,6 +309,9 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali log_context, "outside_market_hours", message="Outside market hours; skip execution", + market=MARKET, + market_calendar=MARKET_CALENDAR, + market_timezone=MARKET_TIMEZONE, ) finalize_runtime_report( report, @@ -300,6 +327,9 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali log_context, "market_hours_bypassed", message=f"Market hours bypassed for {validation_label} execution", + market=MARKET, + market_calendar=MARKET_CALENDAR, + market_timezone=MARKET_TIMEZONE, ) print( composer.with_prefix( diff --git a/notifications/telegram.py b/notifications/telegram.py index 07bb591..563c145 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -19,6 +19,7 @@ "rebalance_title": "🔔 【调仓指令】", "dry_run_banner": "🧪 模拟运行模式,本次不会真实下单", "strategy_label": "🧭 策略: {name}", + "market_scope_detail": "🌏 市场: {market} | 交易币种: {currency} | 标的后缀: {symbol_suffix}", "market_status": "📊 市场状态: {status}", "signal_monthly_snapshot_waiting": "月度快照节奏 | 等待进入执行窗口", "status_monthly_snapshot_waiting_window": "不执行 | 原因=当前不在月度执行窗口 | 快照日期={snapshot_as_of} | 允许日期={allowed_dates}", @@ -135,6 +136,7 @@ "rebalance_title": "🔔 【Trade Execution Report】", "dry_run_banner": "🧪 Dry run mode, no real orders will be submitted", "strategy_label": "🧭 Strategy: {name}", + "market_scope_detail": "🌏 Market: {market} | trading currency: {currency} | symbol suffix: {symbol_suffix}", "market_status": "📊 Market: {status}", "signal_monthly_snapshot_waiting": "monthly snapshot cadence | waiting inside execution window", "status_monthly_snapshot_waiting_window": "no-op | reason=outside monthly execution window | snapshot_as_of={snapshot_as_of} | allowed={allowed_dates}", diff --git a/requirements.txt b/requirements.txt index 42db7c1..a2b7311 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@400ef6145bff0b89b46ec00ebb235987ac499a61 pandas requests pytz diff --git a/runtime_config_support.py b/runtime_config_support.py index 47912bc..7867018 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -17,13 +17,23 @@ ) from strategy_registry import ( LONGBRIDGE_PLATFORM, + STRATEGY_CATALOG, resolve_strategy_definition, resolve_strategy_metadata, ) -from us_equity_strategies import get_strategy_catalog DEFAULT_ACCOUNT_REGION = "DEFAULT" DEFAULT_LONGPORT_SECRET_NAME = "longport_token_hk" +DEFAULT_MARKET = "US" +DEFAULT_MARKET_CALENDAR = "NYSE" +DEFAULT_MARKET_TIMEZONE = "America/New_York" +DEFAULT_SYMBOL_SUFFIX = ".US" +DEFAULT_TRADING_CURRENCY = "USD" +HK_MARKET = "HK" +HK_MARKET_CALENDAR = "XHKG" +HK_MARKET_TIMEZONE = "Asia/Hong_Kong" +HK_SYMBOL_SUFFIX = ".HK" +HK_TRADING_CURRENCY = "HKD" DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 DEFAULT_RESERVED_CASH_RATIO = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @@ -42,6 +52,11 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool + market: str = DEFAULT_MARKET + market_calendar: str = DEFAULT_MARKET_CALENDAR + market_timezone: str = DEFAULT_MARKET_TIMEZONE + symbol_suffix: str = DEFAULT_SYMBOL_SUFFIX + trading_currency: str = DEFAULT_TRADING_CURRENCY reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD @@ -109,6 +124,41 @@ def infer_account_region( return DEFAULT_ACCOUNT_REGION +def infer_market(raw_value: str | None, *, account_region: str) -> str: + for candidate in (raw_value, account_region): + value = str(candidate or "").strip().upper() + if not value: + continue + if value in {HK_MARKET, "HONG_KONG", "HONGKONG"}: + return HK_MARKET + if value in {DEFAULT_MARKET, "USA", "NYSE", "NASDAQ"}: + return DEFAULT_MARKET + return DEFAULT_MARKET + + +def _normalize_symbol_suffix(raw_value: str | None, *, default: str) -> str: + value = str(raw_value if raw_value is not None else default).strip().upper() + if not value: + return "" + return value if value.startswith(".") else f".{value}" + + +def _market_defaults(market: str) -> dict[str, str]: + if market == HK_MARKET: + return { + "market_calendar": HK_MARKET_CALENDAR, + "market_timezone": HK_MARKET_TIMEZONE, + "symbol_suffix": HK_SYMBOL_SUFFIX, + "trading_currency": HK_TRADING_CURRENCY, + } + return { + "market_calendar": DEFAULT_MARKET_CALENDAR, + "market_timezone": DEFAULT_MARKET_TIMEZONE, + "symbol_suffix": DEFAULT_SYMBOL_SUFFIX, + "trading_currency": DEFAULT_TRADING_CURRENCY, + } + + def load_platform_runtime_settings( *, project_id_resolver: Callable[[], str | None], @@ -131,13 +181,19 @@ def load_platform_runtime_settings( platform_id=LONGBRIDGE_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="LONGBRIDGE", env=os.environ, repo_root=Path(__file__).resolve().parent, ) + account_region = infer_account_region( + os.getenv("ACCOUNT_REGION"), + account_prefix=account_prefix, + ) + market = infer_market(os.getenv("LONGBRIDGE_MARKET"), account_region=account_region) + market_defaults = _market_defaults(market) return PlatformRuntimeSettings( project_id=project_id_resolver(), secret_name=os.getenv("LONGPORT_SECRET_NAME", DEFAULT_LONGPORT_SECRET_NAME), @@ -145,10 +201,29 @@ def load_platform_runtime_settings( strategy_profile=runtime_paths.strategy_profile, strategy_display_name=runtime_paths.strategy_display_name, strategy_domain=runtime_paths.strategy_domain, - account_region=infer_account_region( - os.getenv("ACCOUNT_REGION"), - account_prefix=account_prefix, + account_region=account_region, + market=market, + market_calendar=_first_non_empty( + os.getenv("LONGBRIDGE_MARKET_CALENDAR"), + market_defaults["market_calendar"], + ) + or DEFAULT_MARKET_CALENDAR, + market_timezone=_first_non_empty( + os.getenv("LONGBRIDGE_MARKET_TIMEZONE"), + market_defaults["market_timezone"], + ) + or DEFAULT_MARKET_TIMEZONE, + symbol_suffix=_normalize_symbol_suffix( + os.getenv("LONGBRIDGE_SYMBOL_SUFFIX"), + default=market_defaults["symbol_suffix"], ), + trading_currency=( + _first_non_empty( + os.getenv("LONGBRIDGE_TRADING_CURRENCY"), + market_defaults["trading_currency"], + ) + or DEFAULT_TRADING_CURRENCY + ).upper(), notify_lang=os.getenv("NOTIFY_LANG", "en"), tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), diff --git a/scripts/print_strategy_profile_status.py b/scripts/print_strategy_profile_status.py index a3af964..9a46577 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 LONGBRIDGE_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 + LONGBRIDGE_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 92751b7..9ea56c8 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 LONGBRIDGE_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, *, account_region: str | None = None) -> dict[str, object]: @@ -34,7 +35,7 @@ def build_switch_plan(profile: str, *, account_region: str | None = None) -> dic 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, ) @@ -72,17 +73,23 @@ def build_switch_plan(profile: str, *, account_region: str | None = None) -> dic ] optional_env = [ "LONGBRIDGE_DRY_RUN_ONLY", + "LONGBRIDGE_MARKET", + "LONGBRIDGE_MARKET_CALENDAR", + "LONGBRIDGE_MARKET_TIMEZONE", "LONGBRIDGE_MIN_RESERVED_CASH_USD", "LONGBRIDGE_RESERVED_CASH_RATIO", "LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", + "LONGBRIDGE_SYMBOL_SUFFIX", + "LONGBRIDGE_TRADING_CURRENCY", ] remove_if_present: list[str] = [] notes = [ - "Keep ACCOUNT_PREFIX and ACCOUNT_REGION aligned to the current paper or SG service identity.", + "Keep ACCOUNT_PREFIX and ACCOUNT_REGION aligned to the current paper, HK, or SG service identity.", + "For HK-equity deployments set LONGBRIDGE_MARKET=HK, or rely on ACCOUNT_REGION=HK to derive .HK/HKD/XHKG defaults.", ] if not normalized_region: - notes.append("Pass --account-region PAPER or --account-region SG if you want ACCOUNT_PREFIX/ACCOUNT_REGION placeholders filled in.") + notes.append("Pass --account-region PAPER, HK, or SG if you want ACCOUNT_PREFIX/ACCOUNT_REGION placeholders filled in.") if requires_feature_snapshot: set_env["LONGBRIDGE_FEATURE_SNAPSHOT_PATH"] = "" @@ -123,6 +130,7 @@ def build_switch_plan(profile: str, *, account_region: str | None = None) -> dic "platform": LONGBRIDGE_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 5e21b62..2e522ed 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 LONGBRIDGE_PLATFORM, resolve_strategy_definition +from strategy_registry import LONGBRIDGE_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 91040f6..d670fd0 100644 --- a/strategy_registry.py +++ b/strategy_registry.py @@ -1,14 +1,26 @@ 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, @@ -22,16 +34,71 @@ ) LONGBRIDGE_PLATFORM = "longbridge" +HK_EQUITY_DOMAIN = "hk_equity" NASDAQ_SP500_SMART_DCA_PROFILE = "nasdaq_sp500_smart_dca" -LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() - frozenset( - {NASDAQ_SP500_SMART_DCA_PROFILE} -) - PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = { - LONGBRIDGE_PLATFORM: frozenset({US_EQUITY_DOMAIN}), + LONGBRIDGE_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) +LONGBRIDGE_ROLLOUT_ALLOWLIST = ( + get_us_runtime_enabled_profiles() - frozenset({NASDAQ_SP500_SMART_DCA_PROFILE}) +) | get_hk_runtime_enabled_profiles() PLATFORM_CAPABILITY_MATRIX = PlatformCapabilityMatrix( platform_id=LONGBRIDGE_PLATFORM, supported_domains=PLATFORM_SUPPORTED_DOMAINS[LONGBRIDGE_PLATFORM], diff --git a/tests/test_longbridge_local_helpers.py b/tests/test_longbridge_local_helpers.py index 143af10..fadb61f 100644 --- a/tests/test_longbridge_local_helpers.py +++ b/tests/test_longbridge_local_helpers.py @@ -23,7 +23,7 @@ def __init__(self): def quote(self, symbols): self.quote_calls.append(tuple(symbols)) - prices = {"SOXL.US": 50.0, "QQQI.US": 20.0} + prices = {"SOXL.US": 50.0, "QQQI.US": 20.0, "00700.HK": 320.0} return [ type("Quote", (), {"symbol": symbol, "last_done": prices[symbol]})() for symbol in symbols @@ -77,6 +77,31 @@ def stock_positions(self): ], ) + def test_fetch_strategy_account_state_uses_configured_cash_currency(self): + class TradeContext: + def account_balance(self): + usd = types.SimpleNamespace(currency="USD", available_cash=100.0) + hkd = types.SimpleNamespace(currency="HKD", available_cash=8000.0) + return [types.SimpleNamespace(cash_infos=[usd, hkd])] + + def stock_positions(self): + return types.SimpleNamespace( + channels=[FakeChannel([FakePosition("00700.HK", 2)])] + ) + + state = fetch_strategy_account_state( + FakeQuoteContext(), + TradeContext(), + ["00700"], + cash_currency="HKD", + ) + + self.assertEqual(state["available_cash"], 8000.0) + self.assertEqual(state["cash_by_currency"], {"USD": 100.0, "HKD": 8000.0}) + self.assertEqual(state["market_values"]["00700"], 640.0) + self.assertEqual(state["trading_currency"], "HKD") + self.assertEqual(state["total_strategy_equity"], 8640.0) + def test_submit_order_retries_once_on_internal_server_error(self): longport_module = types.ModuleType("longport") openapi_module = types.ModuleType("longport.openapi") diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 3ed5119..ea02913 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -51,7 +51,7 @@ def run(self, *args, **kwargs): requests_module.post = lambda *args, **kwargs: None cloud_run_module = types.ModuleType("entrypoints.cloud_run") - cloud_run_module.is_market_open_now = lambda: True + cloud_run_module.is_market_open_now = lambda **_kwargs: True runtime_config_support_module = types.ModuleType("runtime_config_support") runtime_config_support_module.load_platform_runtime_settings = lambda **_kwargs: types.SimpleNamespace( @@ -62,6 +62,11 @@ def run(self, *args, **kwargs): strategy_display_name="SOXL/SOXX Semiconductor Trend Income", strategy_domain="us_equity", account_region="HK", + market="HK", + market_calendar="XHKG", + market_timezone="Asia/Hong_Kong", + symbol_suffix=".HK", + trading_currency="HKD", notify_lang="en", tg_token=None, tg_chat_id="shared-chat-id", @@ -144,6 +149,12 @@ def run(self, *args, **kwargs): catalog_module = types.ModuleType("us_equity_strategies.catalog") catalog_module.resolve_canonical_profile = lambda profile: profile + strategy_registry_module = types.ModuleType("strategy_registry") + strategy_registry_module.LONGBRIDGE_PLATFORM = "longbridge" + strategy_registry_module.resolve_strategy_definition = lambda profile, **_kwargs: types.SimpleNamespace( + profile=profile + ) + modules = { "flask": flask_module, "requests": requests_module, @@ -161,6 +172,7 @@ def run(self, *args, **kwargs): "longport.openapi": openapi_module, "us_equity_strategies": us_equity_strategies_module, "us_equity_strategies.catalog": catalog_module, + "strategy_registry": strategy_registry_module, } original = {name: sys.modules.get(name) for name in modules} sys.modules.update(modules) @@ -370,7 +382,7 @@ def test_run_strategy_emits_structured_runtime_events(self): module.build_run_id = lambda: "run-001" module.emit_runtime_log = lambda context, event, **fields: observed.append((context.run_id, event, fields)) - module.is_market_open_now = lambda: True + module.is_market_open_now = lambda **_kwargs: True module.run_rebalance_cycle = lambda **_kwargs: None module.run_strategy() @@ -420,7 +432,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()): return types.SimpleNamespace() module.build_composer = lambda *, dry_run_only_override=None: FakeComposer() - module.is_market_open_now = lambda: True + module.is_market_open_now = lambda **_kwargs: True module.run_rebalance_cycle = lambda **_kwargs: None observed["alerts"] = [] @@ -444,7 +456,7 @@ def test_run_strategy_force_runs_when_market_closed(self): module.build_run_id = lambda: "run-001" module.emit_runtime_log = lambda context, event, **fields: observed.append((context.run_id, event, fields)) - module.is_market_open_now = lambda: False + module.is_market_open_now = lambda **_kwargs: False module.run_rebalance_cycle = lambda **_kwargs: observed.append(("rebalance", "called", {})) module.run_strategy(force_run=True) @@ -486,7 +498,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()): return types.SimpleNamespace() module.build_composer = lambda *, dry_run_only_override=None: observed.__setitem__("override", dry_run_only_override) or FakeComposer() - module.is_market_open_now = lambda: False + module.is_market_open_now = lambda **_kwargs: False module.run_rebalance_cycle = lambda **_kwargs: None module.persist_execution_report = lambda report: types.SimpleNamespace(local_path="/tmp/report.json") module.build_run_id = lambda: "run-001" @@ -502,7 +514,7 @@ def test_run_strategy_persists_machine_readable_report(self): module.build_run_id = lambda: "run-001" module.emit_runtime_log = lambda *args, **kwargs: None - module.is_market_open_now = lambda: True + module.is_market_open_now = lambda **_kwargs: True module.run_rebalance_cycle = lambda **_kwargs: None module.persist_runtime_report = ( lambda report, **_kwargs: observed_reports.append(dict(report)) or types.SimpleNamespace( diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py index a229379..9d7edee 100644 --- a/tests/test_runtime_broker_adapters.py +++ b/tests/test_runtime_broker_adapters.py @@ -63,6 +63,51 @@ def candlesticks(self, symbol, period, lookback, adjust_type): assert [point.close for point in series.points] == [123.45, 125.67] +def test_build_market_data_port_supports_hk_suffix_and_currency(): + observed = {"quotes": [], "history": []} + openapi_module = types.ModuleType("longport.openapi") + openapi_module.Period = types.SimpleNamespace(Day="day") + openapi_module.AdjustType = types.SimpleNamespace(ForwardAdjust="forward") + + class QuoteContext: + def candlesticks(self, symbol, period, lookback, adjust_type): + observed["history"].append((symbol, period, lookback, adjust_type)) + return [types.SimpleNamespace(close=321.0, timestamp="2026-04-21T00:00:00Z")] + + adapters = build_runtime_broker_adapters( + strategy_symbols=("00700",), + account_hash="HK", + fetch_last_price_fn=lambda _quote_context, symbol: ( + observed["quotes"].append(symbol), + 321.0, + )[-1], + fetch_strategy_account_state_fn=lambda *_args, **_kwargs: {}, + submit_order_fn=lambda *_args, **_kwargs: None, + clock=lambda: datetime(2026, 4, 21, tzinfo=timezone.utc), + symbol_suffix=".HK", + currency="HKD", + ) + + original_openapi_module = sys.modules.get("longport.openapi") + sys.modules["longport.openapi"] = openapi_module + try: + market_data_port = adapters.build_market_data_port(QuoteContext()) + quote = market_data_port.get_quote("00700") + series = market_data_port.get_price_series("00700") + finally: + if original_openapi_module is None: + sys.modules.pop("longport.openapi", None) + else: + sys.modules["longport.openapi"] = original_openapi_module + + assert quote.symbol == "00700.HK" + assert quote.currency == "HKD" + assert series.symbol == "00700.HK" + assert series.currency == "HKD" + assert observed["quotes"] == ["00700.HK"] + assert observed["history"] == [("00700.HK", "day", 420, "forward")] + + def test_build_portfolio_and_execution_ports_adapt_runtime_calls(): observed = {"account_reads": [], "orders": []} adapters = build_runtime_broker_adapters( diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 90414dd..9576822 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -20,15 +20,27 @@ from runtime_config_support import ( DEFAULT_ACCOUNT_REGION, DEFAULT_LONGPORT_SECRET_NAME, + DEFAULT_MARKET, + DEFAULT_MARKET_CALENDAR, + DEFAULT_MARKET_TIMEZONE, DEFAULT_RESERVED_CASH_FLOOR_USD, DEFAULT_RESERVED_CASH_RATIO, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + DEFAULT_SYMBOL_SUFFIX, + DEFAULT_TRADING_CURRENCY, + HK_MARKET, + HK_MARKET_CALENDAR, + HK_MARKET_TIMEZONE, + HK_SYMBOL_SUFFIX, + HK_TRADING_CURRENCY, _resolve_non_negative_float_env, _resolve_ratio_env, infer_account_region, + infer_market, load_platform_runtime_settings, ) from strategy_registry import ( + HK_EQUITY_DOMAIN, LONGBRIDGE_PLATFORM, US_EQUITY_DOMAIN, get_eligible_profiles_for_platform, @@ -50,13 +62,25 @@ } ) OPTIONAL_LONGBRIDGE_PROFILES = frozenset({"global_etf_confidence_vol_gate"}) +HK_DISABLED_PROFILES = frozenset( + { + "hk_blue_chip_leader_rotation", + "hk_index_mean_reversion", + "hk_etf_regime_rotation", + "hk_listed_global_etf_rotation", + } +) -def expected_longbridge_profiles(actual_profiles) -> frozenset[str]: +def expected_longbridge_enabled_profiles(actual_profiles) -> frozenset[str]: actual = frozenset(actual_profiles) return BASE_LONGBRIDGE_PROFILES | (OPTIONAL_LONGBRIDGE_PROFILES & actual) +def expected_longbridge_profiles(actual_profiles) -> frozenset[str]: + return expected_longbridge_enabled_profiles(actual_profiles) | HK_DISABLED_PROFILES + + def runtime_target_json( strategy_profile: str, *, @@ -100,6 +124,11 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro self.assertEqual(settings.strategy_display_name, "SOXL/SOXX Semiconductor Trend Income") self.assertEqual(settings.strategy_domain, US_EQUITY_DOMAIN) self.assertEqual(settings.account_region, DEFAULT_ACCOUNT_REGION) + self.assertEqual(settings.market, DEFAULT_MARKET) + self.assertEqual(settings.market_calendar, DEFAULT_MARKET_CALENDAR) + self.assertEqual(settings.market_timezone, DEFAULT_MARKET_TIMEZONE) + self.assertEqual(settings.symbol_suffix, DEFAULT_SYMBOL_SUFFIX) + self.assertEqual(settings.trading_currency, DEFAULT_TRADING_CURRENCY) self.assertEqual(settings.notify_lang, "en") self.assertIsNone(settings.tg_token) self.assertIsNone(settings.tg_chat_id) @@ -182,7 +211,15 @@ def test_load_platform_runtime_settings_requires_strategy_profile(self): def test_platform_supported_profiles_are_filtered_by_registry(self): profiles = get_supported_profiles_for_platform(LONGBRIDGE_PLATFORM) - self.assertEqual(profiles, expected_longbridge_profiles(profiles)) + self.assertEqual(profiles, expected_longbridge_enabled_profiles(profiles)) + for profile in HK_DISABLED_PROFILES: + self.assertNotIn(profile, profiles) + + def test_platform_policy_accepts_future_hk_equity_domain(self): + from strategy_registry import PLATFORM_SUPPORTED_DOMAINS + + self.assertIn(HK_EQUITY_DOMAIN, PLATFORM_SUPPORTED_DOMAINS[LONGBRIDGE_PLATFORM]) + self.assertIn(US_EQUITY_DOMAIN, PLATFORM_SUPPORTED_DOMAINS[LONGBRIDGE_PLATFORM]) def test_platform_eligible_profiles_are_exposed_by_capability_matrix(self): profiles = get_eligible_profiles_for_platform(LONGBRIDGE_PLATFORM) @@ -466,6 +503,48 @@ def test_account_region_defaults_when_prefix_missing(self): ) self.assertEqual(region, DEFAULT_ACCOUNT_REGION) + def test_market_defaults_to_hk_for_hk_account_region(self): + market = infer_market(None, account_region="hk") + self.assertEqual(market, HK_MARKET) + + with patch.dict( + os.environ, + { + "ACCOUNT_REGION": "hk", + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.market, HK_MARKET) + self.assertEqual(settings.market_calendar, HK_MARKET_CALENDAR) + self.assertEqual(settings.market_timezone, HK_MARKET_TIMEZONE) + self.assertEqual(settings.symbol_suffix, HK_SYMBOL_SUFFIX) + self.assertEqual(settings.trading_currency, HK_TRADING_CURRENCY) + + def test_market_env_overrides_region_defaults(self): + with patch.dict( + os.environ, + { + "ACCOUNT_REGION": "hk", + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + "LONGBRIDGE_MARKET": "US", + "LONGBRIDGE_MARKET_CALENDAR": "XNYS", + "LONGBRIDGE_MARKET_TIMEZONE": "Etc/UTC", + "LONGBRIDGE_SYMBOL_SUFFIX": "US", + "LONGBRIDGE_TRADING_CURRENCY": "usd", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.market, DEFAULT_MARKET) + self.assertEqual(settings.market_calendar, "XNYS") + self.assertEqual(settings.market_timezone, "Etc/UTC") + self.assertEqual(settings.symbol_suffix, DEFAULT_SYMBOL_SUFFIX) + self.assertEqual(settings.trading_currency, DEFAULT_TRADING_CURRENCY) + def test_unsupported_strategy_profile_fails_fast(self): with patch.dict( os.environ, @@ -524,6 +603,50 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) by_profile["mega_cap_leader_rotation_top50_balanced"]["display_name"], "Mega Cap Leader Rotation Top50 Balanced", ) + self.assertEqual( + 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": False, + "platform": "longbridge", + }, + ) + self.assertEqual( + 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": "longbridge", + }, + ) + self.assertEqual( + 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": "longbridge", + }, + ) + self.assertEqual( + 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": "longbridge", + }, + ) def test_loads_feature_snapshot_env_for_tech_profile(self): with patch.dict( @@ -546,6 +669,22 @@ def test_loads_feature_snapshot_env_for_tech_profile(self): self.assertEqual(settings.strategy_config_path, "/workspace/configs/tech.json") self.assertEqual(settings.strategy_config_source, "env") + def test_rejects_hk_profiles_until_runtime_enabled(self): + for profile in sorted(HK_DISABLED_PROFILES): + with self.subTest(profile=profile): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(profile), + "ACCOUNT_REGION": "HK", + "LONGBRIDGE_FEATURE_SNAPSHOT_PATH": "gs://bucket/hk.csv", + "LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH": "gs://bucket/hk.csv.manifest.json", + }, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "Unsupported STRATEGY_PROFILE"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + def test_derives_feature_snapshot_paths_from_artifact_root(self): with TemporaryDirectory() as tmp_dir: with patch.dict( @@ -609,6 +748,17 @@ def test_print_strategy_profile_status_json_matches_registry(self): self.assertEqual(by_profile["mega_cap_leader_rotation_top50_balanced"]["input_mode"], "feature_snapshot") self.assertTrue(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_snapshot_artifacts"]) self.assertFalse(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"]) + self.assertEqual(by_profile["hk_blue_chip_leader_rotation"]["profile_group"], "snapshot_backed") + self.assertEqual(by_profile["hk_blue_chip_leader_rotation"]["input_mode"], "feature_snapshot") + self.assertTrue(by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_artifacts"]) + self.assertTrue(by_profile["hk_blue_chip_leader_rotation"]["requires_snapshot_manifest_path"]) + self.assertFalse(by_profile["hk_blue_chip_leader_rotation"]["requires_strategy_config_path"]) + for profile in ("hk_index_mean_reversion", "hk_etf_regime_rotation", "hk_listed_global_etf_rotation"): + self.assertEqual(by_profile[profile]["profile_group"], "direct_runtime_inputs") + self.assertEqual(by_profile[profile]["input_mode"], "market_history") + self.assertFalse(by_profile[profile]["requires_snapshot_artifacts"]) + self.assertFalse(by_profile[profile]["requires_snapshot_manifest_path"]) + self.assertFalse(by_profile[profile]["requires_strategy_config_path"]) self.assertFalse( by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] ) @@ -628,8 +778,16 @@ def test_print_strategy_profile_status_table_contains_expected_headers(self): self.assertIn("requires_snapshot_artifacts", result.stdout) self.assertIn("soxl_soxx_trend_income", result.stdout) self.assertIn("global_etf_rotation", result.stdout) + self.assertIn("hk_blue_chip_leader_rotation", result.stdout) + self.assertIn("hk_index_mean_reversion", result.stdout) + self.assertIn("hk_etf_regime_rotation", result.stdout) + self.assertIn("hk_listed_global_etf_rotation", result.stdout) self.assertIn("russell_1000_multi_factor_defensive", result.stdout) self.assertIn("Global ETF Rotation", result.stdout) + self.assertIn("HK Blue Chip Leader Rotation", result.stdout) + self.assertIn("HK Index Mean Reversion", result.stdout) + self.assertIn("HK ETF Regime Rotation", result.stdout) + self.assertIn("HK-listed Global ETF Rotation", result.stdout) self.assertIn("Russell 1000 Multi-Factor", result.stdout) self.assertIn("Tech/Communication Pullback Enhancement", result.stdout) @@ -667,6 +825,11 @@ def test_print_strategy_switch_env_plan_for_global_etf_rotation(self): self.assertIn("LONGBRIDGE_MIN_RESERVED_CASH_USD", plan["optional_env"]) self.assertIn("LONGBRIDGE_RESERVED_CASH_RATIO", plan["optional_env"]) self.assertIn("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", plan["optional_env"]) + self.assertIn("LONGBRIDGE_MARKET", plan["optional_env"]) + self.assertIn("LONGBRIDGE_MARKET_CALENDAR", plan["optional_env"]) + self.assertIn("LONGBRIDGE_MARKET_TIMEZONE", plan["optional_env"]) + self.assertIn("LONGBRIDGE_SYMBOL_SUFFIX", plan["optional_env"]) + self.assertIn("LONGBRIDGE_TRADING_CURRENCY", plan["optional_env"]) self.assertIn("LONGBRIDGE_FEATURE_SNAPSHOT_PATH", plan["remove_if_present"]) def test_print_strategy_switch_env_plan_for_russell(self): @@ -752,6 +915,26 @@ def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced(self): "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv", ) + def test_print_strategy_switch_env_plan_rejects_hk_disabled_profiles(self): + for profile in sorted(HK_DISABLED_PROFILES): + with self.subTest(profile=profile): + result = subprocess.run( + [ + sys.executable, + str(SWITCH_PLAN_SCRIPT_PATH), + "--profile", + profile, + "--account-region", + "hk", + "--json", + ], + capture_output=True, + text=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported STRATEGY_PROFILE", result.stderr) + def test_print_strategy_switch_env_plan_rejects_archived_dynamic_mega_leveraged_pullback_sg(self): result = subprocess.run( [ diff --git a/tests/test_shared_chat_id_fallback.py b/tests/test_shared_chat_id_fallback.py index 1b25af6..1c5f833 100644 --- a/tests/test_shared_chat_id_fallback.py +++ b/tests/test_shared_chat_id_fallback.py @@ -63,6 +63,11 @@ def run(self, *args, **kwargs): strategy_display_name="SOXL/SOXX Semiconductor Trend Income", strategy_domain="us_equity", account_region="HK", + market="HK", + market_calendar="XHKG", + market_timezone="Asia/Hong_Kong", + symbol_suffix=".HK", + trading_currency="HKD", notify_lang="en", tg_token=None, tg_chat_id="shared-chat-id", @@ -138,6 +143,12 @@ def run(self, *args, **kwargs): catalog_module = types.ModuleType("us_equity_strategies.catalog") catalog_module.resolve_canonical_profile = lambda profile: profile + strategy_registry_module = types.ModuleType("strategy_registry") + strategy_registry_module.LONGBRIDGE_PLATFORM = "longbridge" + strategy_registry_module.resolve_strategy_definition = lambda profile, **_kwargs: types.SimpleNamespace( + profile=profile + ) + modules = { "flask": flask_module, "requests": requests_module, @@ -155,6 +166,7 @@ def run(self, *args, **kwargs): "longport.openapi": openapi_module, "us_equity_strategies": us_equity_strategies_module, "us_equity_strategies.catalog": catalog_module, + "strategy_registry": strategy_registry_module, } original = {name: sys.modules.get(name) for name in modules} sys.modules.update(modules) diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index d75e84c..3146798 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -6,7 +6,7 @@ workflow_file="$repo_dir/.github/workflows/sync-cloud-run-env.yml" grep -Fq 'GCP_WORKLOAD_IDENTITY_PROVIDER: projects/252919773759/locations/global/workloadIdentityPools/github-actions/providers/github-main' "$workflow_file" grep -Fq 'GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: longbridge-platform-deploy@longbridgequant.iam.gserviceaccount.com' "$workflow_file" -grep -Fq 'name: Sync ${{ matrix.target.label }} Cloud Run Env' "$workflow_file" +grep -Fq 'name: Deploy / Sync ${{ matrix.target.label }} Cloud Run' "$workflow_file" grep -Fq 'fail-fast: false' "$workflow_file" grep -Fq 'environment: longbridge-paper' "$workflow_file" grep -Fq 'environment: longbridge-hk' "$workflow_file" @@ -21,8 +21,8 @@ grep -Fq 'uses: actions/setup-python@v6' "$workflow_file" grep -Fq 'python -m pip install -r requirements.txt' "$workflow_file" grep -Fq 'id: strategy_requirements' "$workflow_file" grep -Fq 'scripts/print_strategy_profile_status.py' "$workflow_file" -grep -Fq 'from us_equity_strategies import resolve_canonical_profile' "$workflow_file" -grep -Fq 'canonical_profile = resolve_canonical_profile(profile)' "$workflow_file" +grep -Fq 'from strategy_registry import LONGBRIDGE_PLATFORM, resolve_strategy_definition' "$workflow_file" +grep -Fq 'canonical_profile = resolve_strategy_definition(' "$workflow_file" grep -Fq 'requires_snapshot_artifacts=' "$workflow_file" grep -Fq 'requires_snapshot_manifest_path=' "$workflow_file" grep -Fq 'requires_strategy_config_path=' "$workflow_file" @@ -34,6 +34,7 @@ grep -Fq 'target_sha="${GITHUB_SHA}"' "$workflow_file" grep -Fq "gcloud run services describe \"\${CLOUD_RUN_SERVICE}\" --region \"\${CLOUD_RUN_REGION}\" --format='value(spec.template.metadata.labels.commit-sha)'" "$workflow_file" grep -Fq 'Timed out waiting for Cloud Run service ${CLOUD_RUN_SERVICE} to deploy commit ${target_sha}. Last seen commit: ${deployed_sha:-}' "$workflow_file" grep -Fq 'ENABLE_GITHUB_ENV_SYNC: ${{ vars.ENABLE_GITHUB_ENV_SYNC }}' "$workflow_file" +grep -Fq 'ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION: ${{ vars.ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION }}' "$workflow_file" grep -Fq 'GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}' "$workflow_file" grep -Fq 'TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}' "$workflow_file" grep -Fq 'CRISIS_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.CRISIS_ALERT_EMAIL_SENDER_PASSWORD }}' "$workflow_file" @@ -84,7 +85,8 @@ grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}' "$workfl grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'ACCOUNT_REGION: ${{ vars.ACCOUNT_REGION || matrix.target.default_account_region }}' "$workflow_file" grep -Fq 'echo "enabled=false" >> "$GITHUB_OUTPUT"' "$workflow_file" -grep -Fq 'Skipping ${DEPLOYMENT_LABEL} Cloud Run env sync because ENABLE_GITHUB_ENV_SYNC is not set to true.' "$workflow_file" +grep -Fq 'Skipping ${DEPLOYMENT_LABEL} Cloud Run automation because ENABLE_GITHUB_CLOUD_RUN_DEPLOY and ENABLE_GITHUB_ENV_SYNC are not true.' "$workflow_file" +grep -Fq 'Skipping ${DEPLOYMENT_LABEL} Cloud Run automation on push because ENABLE_MAIN_PUSH_CLOUD_RUN_AUTOMATION is not true.' "$workflow_file" grep -Fq '${DEPLOYMENT_LABEL} Cloud Run env sync is enabled, but these values are missing:' "$workflow_file" grep -Fq 'Set CLOUD_RUN_REGION on the ${GITHUB_ENVIRONMENT_NAME} Environment so each service can target its own region.' "$workflow_file" grep -Fq 'Set LONGPORT_APP_KEY_SECRET_NAME and LONGPORT_APP_SECRET_SECRET_NAME on the ${GITHUB_ENVIRONMENT_NAME} Environment so credentials do not fall back to shared defaults.' "$workflow_file"