diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 1bd4e7b..2b8b4b6 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -38,7 +38,7 @@ jobs: GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} steps: - - name: Check whether env sync is configured + - name: Check whether env sync is enabled id: config run: | set -euo pipefail @@ -49,6 +49,76 @@ jobs: exit 0 fi + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.config.outputs.enabled == 'true' + uses: actions/checkout@v4 + + - name: Set up Python for strategy requirement resolution + if: steps.config.outputs.enabled == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install strategy status dependencies + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Resolve selected strategy runtime requirements + id: strategy_requirements + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + python - <<'PY' + import json + import os + import subprocess + import sys + from us_equity_strategies import resolve_canonical_profile + + profile = os.environ.get("STRATEGY_PROFILE", "").strip().lower() + if not profile: + raise SystemExit("STRATEGY_PROFILE is required") + canonical_profile = resolve_canonical_profile(profile) + + raw_status = subprocess.check_output( + [sys.executable, "scripts/print_strategy_profile_status.py", "--json"], + text=True, + ) + rows = json.loads(raw_status) + selected = next((row for row in rows if row["canonical_profile"] == canonical_profile), None) + if selected is None: + supported = ", ".join(sorted(row["canonical_profile"] for row in rows)) + raise SystemExit(f"Unsupported STRATEGY_PROFILE={profile!r}; supported: {supported}") + if not selected.get("eligible") or not selected.get("enabled"): + raise SystemExit(f"STRATEGY_PROFILE={profile!r} is not eligible/enabled: {selected}") + + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as output: + output.write( + f"requires_snapshot_artifacts={str(bool(selected.get('requires_snapshot_artifacts'))).lower()}\n" + ) + output.write( + f"requires_snapshot_manifest_path={str(bool(selected.get('requires_snapshot_manifest_path'))).lower()}\n" + ) + output.write( + f"requires_strategy_config_path={str(bool(selected.get('requires_strategy_config_path'))).lower()}\n" + ) + PY + + - name: Validate HK env sync inputs + if: steps.config.outputs.enabled == 'true' + env: + REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }} + REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }} + REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }} + run: | + set -euo pipefail + required_vars=( CLOUD_RUN_REGION CLOUD_RUN_SERVICE @@ -77,16 +147,19 @@ jobs: missing_vars+=("LONGPORT_APP_SECRET_SECRET_NAME") fi - if { [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]; } && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_PATH:-}" ]; then + if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_PATH:-}" ]; then missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_PATH") fi - if [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then + if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH") fi + if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_STRATEGY_CONFIG_PATH:-}" ]; then + missing_vars+=("LONGBRIDGE_STRATEGY_CONFIG_PATH") + fi + if [ "${#missing_vars[@]}" -gt 0 ]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" echo "HK Cloud Run env sync is enabled, but these values are missing:" >&2 echo " - If HK and SG run in different regions, set CLOUD_RUN_REGION on the longbridge-hk Environment." >&2 echo " - Set LONGPORT_APP_KEY_SECRET_NAME and LONGPORT_APP_SECRET_SECRET_NAME on the longbridge-hk Environment so HK does not fall back to shared repository defaults." >&2 @@ -94,8 +167,6 @@ jobs: exit 1 fi - echo "enabled=true" >> "$GITHUB_OUTPUT" - - name: Authenticate to Google Cloud id: auth if: steps.config.outputs.enabled == 'true' @@ -244,7 +315,7 @@ jobs: GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} steps: - - name: Check whether env sync is configured + - name: Check whether env sync is enabled id: config run: | set -euo pipefail @@ -255,6 +326,76 @@ jobs: exit 0 fi + echo "enabled=true" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + if: steps.config.outputs.enabled == 'true' + uses: actions/checkout@v4 + + - name: Set up Python for strategy requirement resolution + if: steps.config.outputs.enabled == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install strategy status dependencies + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Resolve selected strategy runtime requirements + id: strategy_requirements + if: steps.config.outputs.enabled == 'true' + run: | + set -euo pipefail + python - <<'PY' + import json + import os + import subprocess + import sys + from us_equity_strategies import resolve_canonical_profile + + profile = os.environ.get("STRATEGY_PROFILE", "").strip().lower() + if not profile: + raise SystemExit("STRATEGY_PROFILE is required") + canonical_profile = resolve_canonical_profile(profile) + + raw_status = subprocess.check_output( + [sys.executable, "scripts/print_strategy_profile_status.py", "--json"], + text=True, + ) + rows = json.loads(raw_status) + selected = next((row for row in rows if row["canonical_profile"] == canonical_profile), None) + if selected is None: + supported = ", ".join(sorted(row["canonical_profile"] for row in rows)) + raise SystemExit(f"Unsupported STRATEGY_PROFILE={profile!r}; supported: {supported}") + if not selected.get("eligible") or not selected.get("enabled"): + raise SystemExit(f"STRATEGY_PROFILE={profile!r} is not eligible/enabled: {selected}") + + output_path = os.environ["GITHUB_OUTPUT"] + with open(output_path, "a", encoding="utf-8") as output: + output.write( + f"requires_snapshot_artifacts={str(bool(selected.get('requires_snapshot_artifacts'))).lower()}\n" + ) + output.write( + f"requires_snapshot_manifest_path={str(bool(selected.get('requires_snapshot_manifest_path'))).lower()}\n" + ) + output.write( + f"requires_strategy_config_path={str(bool(selected.get('requires_strategy_config_path'))).lower()}\n" + ) + PY + + - name: Validate SG env sync inputs + if: steps.config.outputs.enabled == 'true' + env: + REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }} + REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }} + REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }} + run: | + set -euo pipefail + required_vars=( CLOUD_RUN_REGION CLOUD_RUN_SERVICE @@ -283,16 +424,19 @@ jobs: missing_vars+=("LONGPORT_APP_SECRET_SECRET_NAME") fi - if { [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]; } && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_PATH:-}" ]; then + if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_PATH:-}" ]; then missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_PATH") fi - if [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then + if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH") fi + if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_STRATEGY_CONFIG_PATH:-}" ]; then + missing_vars+=("LONGBRIDGE_STRATEGY_CONFIG_PATH") + fi + if [ "${#missing_vars[@]}" -gt 0 ]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" echo "SG Cloud Run env sync is enabled, but these values are missing:" >&2 echo " - If HK and SG run in different regions, set CLOUD_RUN_REGION on the longbridge-sg Environment." >&2 echo " - Set LONGPORT_APP_KEY_SECRET_NAME and LONGPORT_APP_SECRET_SECRET_NAME on the longbridge-sg Environment so SG does not fall back to shared repository defaults." >&2 @@ -300,8 +444,6 @@ jobs: exit 1 fi - echo "enabled=true" >> "$GITHUB_OUTPUT" - - name: Authenticate to Google Cloud id: auth if: steps.config.outputs.enabled == 'true' diff --git a/README.md b/README.md index 658d786..dfc5e0f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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 LongBridge runtime can execute all six live `us_equity` profiles from `UsEquityStrategies`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. +The LongBridge runtime can execute all seven live `us_equity` profiles from `UsEquityStrategies`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. 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. @@ -34,6 +34,7 @@ Platform execution no longer depends on `strategy/allocation.py` or hard-coded s | `global_etf_rotation` | Global ETF Rotation | Yes | Yes | No | No | `us_equity` | enabled weight-mode rotation line | | `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor | Yes | Yes | No | No | `us_equity` | enabled feature-snapshot stock baseline | | `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | No | No | `us_equity` | selectable monthly feature-snapshot leader rotation | +| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | No | No | `us_equity` | selectable 2x mega-cap pullback line | | `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | Yes | Yes | `us_equity` | current LongBridge default | | `tqqq_growth_income` | TQQQ Growth Income | Yes | Yes | No | No | `us_equity` | current SG dry-run line | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | No | No | `us_equity` | current HK feature-snapshot line | @@ -62,7 +63,7 @@ Telegram notifications include structured execution and heartbeat messages, with | `LONGPORT_APP_SECRET` | Yes | LongPort OpenAPI app secret (for token refresh); recommended to inject from the region-specific Secret Manager secret for this deployment, such as `longport-app-secret-hk` / `longport-app-secret-sg` | | `LONGPORT_SECRET_NAME` | No | Secret Manager secret name for LongPort token (default: `longport_token_hk`) | | `ACCOUNT_PREFIX` | No | Alert/log prefix for account/environment (default: `DEFAULT`) | -| `STRATEGY_PROFILE` | No | Strategy profile selector (default: `soxl_soxx_trend_income`; enabled values include `global_etf_rotation`, `mega_cap_leader_rotation_dynamic_top20`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income`) | +| `STRATEGY_PROFILE` | No | Strategy profile selector (default: `soxl_soxx_trend_income`; enabled values include `dynamic_mega_leveraged_pullback`, `global_etf_rotation`, `mega_cap_leader_rotation_dynamic_top20`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income`) | | `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | @@ -87,7 +88,7 @@ Deploy the same codebase as multiple Cloud Run services (e.g. `HK` and `SG`) by - `LONGPORT_SECRET_NAME`: point to different secrets (e.g. `longport_token_hk`, `longport_token_sg`) - `ACCOUNT_PREFIX`: e.g. `HK`, `SG` (all Telegram/log alerts will include `[ACCOUNT_PREFIX]`) - `STRATEGY_PROFILE`: set per service; current live examples are `tech_communication_pullback_enhancement` on HK and `tqqq_growth_income` on SG -- Current strategy domain is `us_equity`. `STRATEGY_PROFILE` now goes through a platform capability matrix plus a rollout allowlist: `eligible` means the platform can run it in theory, `enabled` means the current rollout really allows it. +- Current strategy domain is `us_equity`. `STRATEGY_PROFILE` now 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 (`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 @@ -122,7 +123,7 @@ Important: - `CLOUD_RUN_REGION` should be set on each GitHub Environment, not as one shared repository variable. This lets `HK` and `SG` live in different Cloud Run regions. - `LONGPORT_APP_KEY_SECRET_NAME` and `LONGPORT_APP_SECRET_SECRET_NAME` should also be set on each GitHub Environment. Do not keep one repository-level default for them when `HK` and `SG` use different LongPort credentials. -- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped and the old Google Cloud Trigger-only setup keeps working. Once you set it to `true`, missing env-sync values become a hard failure so you do not get a false green deployment. +- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped and the old Google Cloud Trigger-only setup keeps working. Once you set it to `true`, missing env-sync values become a hard failure so you do not get a false green deployment. The selected profile's snapshot/config requirements are resolved from `scripts/print_strategy_profile_status.py --json` instead of a hard-coded strategy-name list. - GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation, so `GCP_SA_KEY` is no longer required for this workflow. - Here "shared" only means **shared inside this repository** between the `HK` and `SG` Cloud Run services. The Telegram token can still be shared, but LongPort app credentials should live in Secret Manager and be referenced by per-environment secret-name variables; they are not meant to be a global secret set reused by unrelated quant repos. - If you want one cross-project shared layer across multiple quant repos, keep it small: `GLOBAL_TELEGRAM_CHAT_ID` and `NOTIFY_LANG` are reasonable; account credentials and deployment keys are not. @@ -154,7 +155,7 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc 基于 LongPort OpenAPI 和 Google Cloud Run 的量化交易系统。 这个仓库通过 `QuantPlatformKit` 复用 LongPort token 处理、上下文初始化、账户快照、行情读取和下单逻辑。Cloud Run 直接部署这个仓库。 -`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的全部 6 条 live `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_dynamic_top20`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`;仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 +`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的全部 7 条 live `us_equity` 策略:`dynamic_mega_leveraged_pullback`、`global_etf_rotation`、`mega_cap_leader_rotation_dynamic_top20`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`;仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。下面这些章节只保留 LongBridge 运行时、profile 启用状态、部署和凭据说明。 @@ -176,6 +177,7 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc | `global_etf_rotation` | Global ETF Rotation | Yes | Yes | No | No | `us_equity` | 已启用的 weight-mode 轮动线 | | `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor | Yes | Yes | No | No | `us_equity` | 已启用的 feature-snapshot 个股基线 | | `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | No | No | `us_equity` | 可选的月度 feature-snapshot 龙头轮动线 | +| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | No | No | `us_equity` | 可选的 2x 龙头回调线 | | `soxl_soxx_trend_income` | SOXL/SOXX 半导体趋势收益 | Yes | Yes | Yes | Yes | `us_equity` | 当前 LongBridge 默认回退线 | | `tqqq_growth_income` | TQQQ 增长收益 | Yes | Yes | No | No | `us_equity` | 当前 SG dry-run 线路 | | `tech_communication_pullback_enhancement` | 科技通信回调增强 | Yes | Yes | No | No | `us_equity` | 当前 HK feature-snapshot 线路 | @@ -204,7 +206,7 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `LONGPORT_APP_SECRET` | 是 | LongPort OpenAPI 应用密钥(用于刷新 Token);建议从当前部署对应区域的 Secret Manager 密钥注入,例如 `longport-app-secret-hk` / `longport-app-secret-sg` | | `LONGPORT_SECRET_NAME` | 否 | Secret Manager 中的密钥名称(默认: `longport_token_hk`) | | `ACCOUNT_PREFIX` | 否 | 通知/日志前缀,区分账户环境(默认: `DEFAULT`) | -| `STRATEGY_PROFILE` | 否 | 策略档位选择(默认: `soxl_soxx_trend_income`;已启用值还包括 `global_etf_rotation`、`mega_cap_leader_rotation_dynamic_top20`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income`) | +| `STRATEGY_PROFILE` | 否 | 策略档位选择(默认: `soxl_soxx_trend_income`;已启用值还包括 `dynamic_mega_leveraged_pullback`、`global_etf_rotation`、`mega_cap_leader_rotation_dynamic_top20`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income`) | | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | @@ -229,7 +231,7 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - `LONGPORT_SECRET_NAME`: 指向不同密钥(如 `longport_token_hk`、`longport_token_sg`) - `ACCOUNT_PREFIX`: 如 `HK`、`SG`(所有通知/日志将包含 `[ACCOUNT_PREFIX]`) - `STRATEGY_PROFILE`: 按服务分别设置;当前线上 HK 用 `tech_communication_pullback_enhancement`,SG 用 `tqqq_growth_income` -- 当前策略域是 `us_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 +- 当前策略域是 `us_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过从 `runtime_enabled` 策略元数据派生的 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 - `ACCOUNT_REGION`: 显式标记部署账户区域(`HK` / `SG`);未设置时会回退到 `ACCOUNT_PREFIX` 或 `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: 需要保持模拟运行时按服务单独设置 - `NOTIFY_LANG`: 每个部署可独立设置 `en` 或 `zh` @@ -264,7 +266,7 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - `CLOUD_RUN_REGION` 应该分别放在 `longbridge-hk` 和 `longbridge-sg` 这两个 Environment 里,不要再当成一个仓库级共享变量。这样 HK 和 SG 才能各自更新到自己的 region。 - `LONGPORT_APP_KEY_SECRET_NAME` 和 `LONGPORT_APP_SECRET_SECRET_NAME` 也应该分别放在各自的 GitHub Environment 里。既然 HK 和 SG 用的是不同 LongPort 凭据,就不要再给它们保留一个仓库级默认值。 -- 现在 workflow 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时才会严格检查配置。没打开这个开关时,它会直接跳过,不影响原来只靠 Google Cloud Trigger 的老流程;一旦打开,缺任何配置都会直接失败,避免你以为已经同步成功。 +- 现在 workflow 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时才会严格检查配置。没打开这个开关时,它会直接跳过,不影响原来只靠 Google Cloud Trigger 的老流程;一旦打开,缺任何配置都会直接失败,避免你以为已经同步成功。目标策略需要的 snapshot/config 输入会通过 `scripts/print_strategy_profile_status.py --json` 动态解析,不再维护硬编码策略名列表。 - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 - 这里的“共享”只是指 **同一个仓库里的 HK / SG 两个服务共享**。Telegram token 可以继续共用,但 LongPort app 凭据建议放到 Secret Manager,并通过各自 Environment 里的 secret-name 变量引用,不建议把它们当成所有 quant 共用的全局 secrets。 - 如果你真的要在多个 quant 仓库之间保留一层全局共享,建议只保留 `GLOBAL_TELEGRAM_CHAT_ID` 和 `NOTIFY_LANG` 这种低耦合配置。 diff --git a/main.py b/main.py index e41a12e..ff0b1a0 100644 --- a/main.py +++ b/main.py @@ -271,7 +271,12 @@ def calculate_strategy_indicators(quote_context): if "feature_snapshot" in AVAILABLE_INPUTS and not ({"benchmark_history", "qqq_history", "derived_indicators", "indicators"} & AVAILABLE_INPUTS): return {} if "market_history" in AVAILABLE_INPUTS: - return build_market_history_loader(quote_context) + market_inputs = {"market_history": build_market_history_loader(quote_context)} + if "benchmark_history" in AVAILABLE_INPUTS: + market_inputs["benchmark_history"] = fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") + if "qqq_history" in AVAILABLE_INPUTS: + market_inputs["qqq_history"] = fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") + return market_inputs if "benchmark_history" in AVAILABLE_INPUTS or "qqq_history" in AVAILABLE_INPUTS: return fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") trend_ma_window = int(STRATEGY_RUNTIME_CONFIG.get("trend_ma_window", 150)) @@ -325,15 +330,20 @@ def resolve_rebalance_plan(*, indicators, account_state): snapshot = None if "portfolio_snapshot" in AVAILABLE_INPUTS or "snapshot" in AVAILABLE_INPUTS: snapshot = build_portfolio_snapshot_from_account_state(account_state) + market_inputs = { + "market_history": indicators, + "derived_indicators": indicators, + "indicators": indicators, + "benchmark_history": indicators, + "qqq_history": indicators, + } + if isinstance(indicators, dict) and any( + key in indicators for key in ("market_history", "benchmark_history", "qqq_history") + ): + market_inputs.update(indicators) evaluation_inputs = build_strategy_evaluation_inputs( available_inputs=AVAILABLE_INPUTS, - market_inputs={ - "market_history": indicators, - "derived_indicators": indicators, - "indicators": indicators, - "benchmark_history": indicators, - "qqq_history": indicators, - }, + market_inputs=market_inputs, portfolio_snapshot=snapshot, account_state=account_state, translator=t, diff --git a/requirements.txt b/requirements.txt index 1689a7c..0210709 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.15 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.22 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.16 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.24 pandas requests pytz diff --git a/runtime_config_support.py b/runtime_config_support.py index 5e00482..f0af7be 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -2,14 +2,17 @@ import os from dataclasses import dataclass +from pathlib import Path from typing import Callable +from quant_platform_kit.common.strategies import derive_strategy_artifact_paths from strategy_registry import ( DEFAULT_STRATEGY_PROFILE as PLATFORM_DEFAULT_STRATEGY_PROFILE, LONGBRIDGE_PLATFORM, resolve_strategy_definition, resolve_strategy_metadata, ) +from us_equity_strategies import get_strategy_catalog DEFAULT_ACCOUNT_REGION = "DEFAULT" DEFAULT_STRATEGY_PROFILE = PLATFORM_DEFAULT_STRATEGY_PROFILE @@ -70,9 +73,26 @@ def load_platform_runtime_settings( strategy_definition.profile, platform_id=LONGBRIDGE_PLATFORM, ) - strategy_config_path = _first_non_empty( - os.getenv("LONGBRIDGE_STRATEGY_CONFIG_PATH"), - os.getenv("STRATEGY_CONFIG_PATH"), + artifact_root = _first_non_empty( + os.getenv("LONGBRIDGE_STRATEGY_ARTIFACT_ROOT"), + os.getenv("STRATEGY_ARTIFACT_ROOT"), + ) + derived_artifact_paths = derive_strategy_artifact_paths( + get_strategy_catalog(), + strategy_definition.profile, + artifact_root=artifact_root, + repo_root=Path(__file__).resolve().parent, + ) + strategy_config_path, strategy_config_source = resolve_strategy_config_path( + explicit_path=_first_non_empty( + os.getenv("LONGBRIDGE_STRATEGY_CONFIG_PATH"), + os.getenv("STRATEGY_CONFIG_PATH"), + ), + bundled_path=( + str(derived_artifact_paths.bundled_config_path) + if derived_artifact_paths.bundled_config_path is not None + else None + ), ) return PlatformRuntimeSettings( project_id=project_id_resolver(), @@ -92,16 +112,37 @@ def load_platform_runtime_settings( feature_snapshot_path=_first_non_empty( os.getenv("LONGBRIDGE_FEATURE_SNAPSHOT_PATH"), os.getenv("FEATURE_SNAPSHOT_PATH"), + str(derived_artifact_paths.feature_snapshot_path) + if derived_artifact_paths.feature_snapshot_path is not None + else None, ), feature_snapshot_manifest_path=_first_non_empty( os.getenv("LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH"), os.getenv("FEATURE_SNAPSHOT_MANIFEST_PATH"), + str(derived_artifact_paths.feature_snapshot_manifest_path) + if derived_artifact_paths.feature_snapshot_manifest_path is not None + else None, ), strategy_config_path=strategy_config_path, - strategy_config_source="env" if strategy_config_path else None, + strategy_config_source=strategy_config_source, ) +def resolve_strategy_config_path( + *, + explicit_path: str | None, + bundled_path: str | None, +) -> tuple[str | None, str | None]: + path = _first_non_empty(explicit_path) + if path is not None: + return path, "env" + + bundled = _first_non_empty(bundled_path) + if bundled is not None and Path(bundled).exists(): + return bundled, "bundled_canonical_default" + return None, None + + def _normalize_region(raw_value: str | None) -> str | None: if raw_value is None: return None diff --git a/strategy_registry.py b/strategy_registry.py index e46887d..c42181c 100644 --- a/strategy_registry.py +++ b/strategy_registry.py @@ -1,6 +1,10 @@ from __future__ import annotations -from us_equity_strategies import get_platform_runtime_adapter, get_strategy_catalog +from us_equity_strategies import ( + get_platform_runtime_adapter, + get_runtime_enabled_profiles, + get_strategy_catalog, +) from quant_platform_kit.common.strategies import ( PlatformCapabilityMatrix, @@ -22,17 +26,7 @@ DEFAULT_STRATEGY_PROFILE = "soxl_soxx_trend_income" ROLLBACK_STRATEGY_PROFILE = DEFAULT_STRATEGY_PROFILE -LONGBRIDGE_ROLLOUT_ALLOWLIST = frozenset( - { - "global_etf_rotation", - "mega_cap_leader_rotation_dynamic_top20", - "russell_1000_multi_factor_defensive", - "soxl_soxx_trend_income", - "tqqq_growth_income", - "tech_communication_pullback_enhancement", - "qqq_tech_enhancement", - } -) +LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = { LONGBRIDGE_PLATFORM: frozenset({US_EQUITY_DOMAIN}), diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 7ef89f7..b8c03b7 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -4,6 +4,7 @@ import sys import unittest from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import patch @@ -57,6 +58,7 @@ def test_platform_supported_profiles_are_filtered_by_registry(self): get_supported_profiles_for_platform(LONGBRIDGE_PLATFORM), frozenset( { + "dynamic_mega_leveraged_pullback", "global_etf_rotation", "mega_cap_leader_rotation_dynamic_top20", "russell_1000_multi_factor_defensive", @@ -72,6 +74,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(self): get_eligible_profiles_for_platform(LONGBRIDGE_PLATFORM), frozenset( { + "dynamic_mega_leveraged_pullback", "global_etf_rotation", "mega_cap_leader_rotation_dynamic_top20", "russell_1000_multi_factor_defensive", @@ -133,6 +136,7 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) set(by_profile), { "global_etf_rotation", + "dynamic_mega_leveraged_pullback", "mega_cap_leader_rotation_dynamic_top20", "russell_1000_multi_factor_defensive", "tqqq_growth_income", @@ -169,6 +173,9 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["eligible"]) self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["enabled"]) self.assertEqual(by_profile["mega_cap_leader_rotation_dynamic_top20"]["display_name"], "Mega Cap Leader Rotation Dynamic Top20") + self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["eligible"]) + self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["enabled"]) + self.assertEqual(by_profile["dynamic_mega_leveraged_pullback"]["display_name"], "Dynamic Mega Leveraged Pullback") def test_loads_feature_snapshot_env_for_tech_profile(self): with patch.dict( @@ -189,6 +196,28 @@ 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_derives_feature_snapshot_paths_from_artifact_root(self): + with TemporaryDirectory() as tmp_dir: + with patch.dict( + os.environ, + { + "STRATEGY_PROFILE": "dynamic_mega_leveraged_pullback", + "STRATEGY_ARTIFACT_ROOT": tmp_dir, + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + expected_dir = Path(tmp_dir) / "dynamic_mega_leveraged_pullback" + self.assertEqual( + settings.feature_snapshot_path, + str(expected_dir / "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv"), + ) + self.assertEqual( + settings.feature_snapshot_manifest_path, + str(expected_dir / "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv.manifest.json"), + ) + def test_print_strategy_profile_status_json_matches_registry(self): result = subprocess.run( [sys.executable, str(SCRIPT_PATH), "--json"], @@ -230,6 +259,13 @@ def test_print_strategy_profile_status_json_matches_registry(self): self.assertEqual(by_profile["mega_cap_leader_rotation_dynamic_top20"]["input_mode"], "feature_snapshot") self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_snapshot_artifacts"]) self.assertFalse(by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_strategy_config_path"]) + self.assertEqual(by_profile["dynamic_mega_leveraged_pullback"]["profile_group"], "snapshot_backed") + self.assertEqual( + by_profile["dynamic_mega_leveraged_pullback"]["input_mode"], + "feature_snapshot+market_history+benchmark_history+portfolio_snapshot", + ) + self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["requires_snapshot_artifacts"]) + self.assertFalse(by_profile["dynamic_mega_leveraged_pullback"]["requires_strategy_config_path"]) self.assertFalse( by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] ) @@ -346,6 +382,42 @@ def test_print_strategy_switch_env_plan_for_mega_cap_dynamic_top20(self): "mega_cap_leader_rotation_dynamic_top20_feature_snapshot_latest.csv", ) + def test_print_strategy_switch_env_plan_for_dynamic_mega_leveraged_pullback_sg(self): + result = subprocess.run( + [ + sys.executable, + str(SWITCH_PLAN_SCRIPT_PATH), + "--profile", + "dynamic_mega_leveraged_pullback", + "--account-region", + "sg", + "--json", + ], + check=True, + capture_output=True, + text=True, + ) + + plan = json.loads(result.stdout) + self.assertEqual(plan["canonical_profile"], "dynamic_mega_leveraged_pullback") + self.assertEqual(plan["set_env"]["ACCOUNT_REGION"], "SG") + self.assertEqual(plan["set_env"]["ACCOUNT_PREFIX"], "SG") + self.assertEqual(plan["profile_group"], "snapshot_backed") + self.assertEqual( + plan["input_mode"], + "feature_snapshot+market_history+benchmark_history+portfolio_snapshot", + ) + self.assertTrue(plan["requires_snapshot_artifacts"]) + self.assertFalse(plan["requires_strategy_config_path"]) + self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_PATH"], "") + self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH"], "") + self.assertIn("LONGBRIDGE_DRY_RUN_ONLY", plan["optional_env"]) + self.assertIn("LONGBRIDGE_STRATEGY_CONFIG_PATH", plan["remove_if_present"]) + self.assertEqual( + plan["hints"]["feature_snapshot_filename"], + "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_strategy_loader.py b/tests/test_strategy_loader.py index dc163e8..499f74a 100644 --- a/tests/test_strategy_loader.py +++ b/tests/test_strategy_loader.py @@ -57,6 +57,22 @@ def test_load_strategy_entrypoint_resolves_mega_cap_dynamic_top20(self): self.assertEqual(entrypoint.manifest.profile, "mega_cap_leader_rotation_dynamic_top20") self.assertEqual(entrypoint.manifest.required_inputs, frozenset({"feature_snapshot"})) + def test_load_strategy_entrypoint_resolves_dynamic_mega_leveraged_pullback(self): + try: + from strategy_loader import load_strategy_entrypoint_for_profile + + entrypoint = load_strategy_entrypoint_for_profile("dynamic_mega_leveraged_pullback") + except ModuleNotFoundError as exc: + if exc.name in {"numpy", "pandas"}: + self.skipTest(f"{exc.name} is not installed") + raise + + self.assertEqual(entrypoint.manifest.profile, "dynamic_mega_leveraged_pullback") + self.assertEqual( + entrypoint.manifest.required_inputs, + frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), + ) + def test_load_strategy_entrypoint_rejects_legacy_soxl_alias(self): try: from strategy_loader import load_strategy_entrypoint_for_profile @@ -115,6 +131,19 @@ def test_load_strategy_runtime_adapter_declares_mega_cap_inputs(self): self.assertTrue(adapter.require_snapshot_manifest) self.assertEqual(adapter.status_icon, "👑") + def test_load_strategy_runtime_adapter_declares_dynamic_mega_leveraged_inputs(self): + from strategy_loader import load_strategy_runtime_adapter_for_profile + + adapter = load_strategy_runtime_adapter_for_profile("dynamic_mega_leveraged_pullback") + + self.assertEqual( + adapter.available_inputs, + frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), + ) + self.assertEqual(adapter.portfolio_input_name, "portfolio_snapshot") + self.assertTrue(adapter.require_snapshot_manifest) + self.assertEqual(adapter.status_icon, "2x") + def test_load_strategy_runtime_adapter_declares_hybrid_inputs(self): from strategy_loader import load_strategy_runtime_adapter_for_profile diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 208be55..25e778c 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -58,6 +58,21 @@ def evaluate(self, ctx): return StrategyDecision(diagnostics={"signal_description": "broad risk on"}) +class _DynamicMegaLeveragedEntrypoint: + manifest = StrategyManifest( + profile="dynamic_mega_leveraged_pullback", + domain="us_equity", + display_name="Dynamic Mega Leveraged Pullback", + description="test entrypoint", + required_inputs=frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), + default_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + ) + + def evaluate(self, ctx): + self.ctx = ctx + return StrategyDecision(diagnostics={"signal_description": "leveraged pullback"}) + + def _build_runtime_settings(profile: str, *, feature_snapshot_path: str | None = None) -> PlatformRuntimeSettings: return PlatformRuntimeSettings( project_id=None, @@ -272,6 +287,69 @@ def test_feature_snapshot_runtime_loads_russell_snapshot_into_context(self): self.assertEqual(result.metadata["managed_symbols"], ("AAPL", "MSFT", "BOXX")) self.assertEqual(result.metadata["status_icon"], "📏") + def test_feature_snapshot_runtime_keeps_hybrid_inputs_for_dynamic_mega_leveraged_pullback(self): + entrypoint = _DynamicMegaLeveragedEntrypoint() + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=entrypoint, + runtime_adapter=StrategyRuntimeAdapter( + status_icon="2x", + required_feature_columns=frozenset({"symbol", "underlying_symbol", "sector", "candidate_rank", "product_leverage", "product_available"}), + managed_symbols_extractor=lambda *_args, **_kwargs: ("AAPU", "BOXX"), + portfolio_input_name="portfolio_snapshot", + ), + runtime_settings=_build_runtime_settings( + "dynamic_mega_leveraged_pullback", + feature_snapshot_path="gs://bucket/dynamic.csv", + ), + merged_runtime_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + logger=lambda _message: None, + ) + + def market_history_loader(*_args, **_kwargs): + return [1.0, 2.0, 3.0] + + portfolio = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=1000.0, + buying_power=200.0, + positions=(), + ) + + with patch.object( + strategy_runtime_module, + "load_feature_snapshot_guarded", + return_value=type( + "GuardResult", + (), + { + "frame": [ + { + "symbol": "AAPU", + "underlying_symbol": "AAPL", + "sector": "Technology", + "candidate_rank": 1, + "product_leverage": 2.0, + "product_available": True, + } + ], + "metadata": {"snapshot_guard_decision": "proceed", "snapshot_as_of": "2026-04-08"}, + }, + )(), + ): + result = runtime.evaluate( + market_history=market_history_loader, + benchmark_history=[{"close": 1.0, "high": 1.0, "low": 1.0}], + portfolio_snapshot=portfolio, + translator=lambda key, **_kwargs: key, + ) + + self.assertEqual(entrypoint.ctx.market_data["feature_snapshot"][0]["symbol"], "AAPU") + self.assertIs(entrypoint.ctx.market_data["market_history"], market_history_loader) + self.assertEqual(entrypoint.ctx.market_data["benchmark_history"][0]["close"], 1.0) + self.assertIs(entrypoint.ctx.portfolio, portfolio) + self.assertEqual(result.metadata["managed_symbols"], ("AAPU", "BOXX")) + self.assertEqual(result.metadata["status_icon"], "2x") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index f8390e7..7a17358 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -10,6 +10,16 @@ grep -Fq 'permissions:' "$workflow_file" grep -Fq 'id-token: write' "$workflow_file" grep -Fq 'workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}' "$workflow_file" grep -Fq 'service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}' "$workflow_file" +grep -Fq 'uses: actions/checkout@v4' "$workflow_file" +grep -Fq 'uses: actions/setup-python@v5' "$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 'requires_snapshot_artifacts=' "$workflow_file" +grep -Fq 'requires_snapshot_manifest_path=' "$workflow_file" +grep -Fq 'requires_strategy_config_path=' "$workflow_file" grep -Fq 'Wait for Cloud Run deployment of current commit' "$workflow_file" 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" @@ -45,7 +55,13 @@ grep -Fq 'missing_vars+=("LONGPORT_APP_KEY_SECRET_NAME")' "$workflow_file" grep -Fq 'missing_vars+=("LONGPORT_APP_SECRET_SECRET_NAME")' "$workflow_file" grep -Fq 'missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_PATH")' "$workflow_file" grep -Fq 'missing_vars+=("LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH")' "$workflow_file" -grep -Fq '[ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]' "$workflow_file" +grep -Fq 'missing_vars+=("LONGBRIDGE_STRATEGY_CONFIG_PATH")' "$workflow_file" +grep -Fq 'REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }}' "$workflow_file" +grep -Fq 'REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}' "$workflow_file" +grep -Fq 'REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}' "$workflow_file" +grep -Fq 'if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_PATH:-}" ]; then' "$workflow_file" +grep -Fq 'if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH:-}" ]; then' "$workflow_file" +grep -Fq 'if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ] && [ -z "${LONGBRIDGE_STRATEGY_CONFIG_PATH:-}" ]; then' "$workflow_file" grep -Fq 'secret_pairs+=("TELEGRAM_TOKEN=${TELEGRAM_TOKEN_SECRET_NAME}:latest")' "$workflow_file" grep -Fq 'secret_pairs+=("LONGPORT_APP_KEY=${LONGPORT_APP_KEY_SECRET_NAME}:latest")' "$workflow_file" grep -Fq 'secret_pairs+=("LONGPORT_APP_SECRET=${LONGPORT_APP_SECRET_SECRET_NAME}:latest")' "$workflow_file"