From b398e650b2ef4b8f4301aa3d2503c7f1b523463a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 16 Apr 2026 00:24:32 +0800 Subject: [PATCH 1/2] Enable runtime-derived US equity profiles --- .github/workflows/sync-cloud-run-env.yml | 81 +++++++++++++++++++-- README.md | 14 ++-- requirements.txt | 4 +- strategy_registry.py | 18 ++--- strategy_runtime.py | 28 +++++++- tests/test_runtime_config_support.py | 27 +++++++ tests/test_strategy_runtime.py | 88 +++++++++++++++++++++++ tests/test_sync_cloud_run_env_workflow.sh | 16 +++++ 8 files changed, 248 insertions(+), 28 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 9c79362..654b026 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -36,7 +36,7 @@ jobs: NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} 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 @@ -47,6 +47,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 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 @@ -57,15 +127,15 @@ jobs: NOTIFY_LANG ) - if [ "${STRATEGY_PROFILE:-}" = "russell_1000_multi_factor_defensive" ] || [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]; then + if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ]; then required_vars+=(IBKR_FEATURE_SNAPSHOT_PATH) fi - if [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]; then + if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then required_vars+=(IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH) fi - if [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ]; then + if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ]; then required_vars+=(IBKR_STRATEGY_CONFIG_PATH IBKR_RECONCILIATION_OUTPUT_PATH) fi @@ -81,14 +151,11 @@ jobs: fi if [ "${#missing_vars[@]}" -gt 0 ]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" echo "Cloud Run env sync is enabled, but these values are missing:" >&2 printf ' - %s\n' "${missing_vars[@]}" >&2 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 b8887bd..a028b5b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The mainline runtime now follows one path only: - `soxl_soxx_trend_income` - `tech_communication_pullback_enhancement` - `mega_cap_leader_rotation_dynamic_top20` +- `dynamic_mega_leveraged_pullback` **IBKR profile status** @@ -52,6 +53,7 @@ The mainline runtime now follows one path only: | `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | No | No | `us_equity` | current IBKR live line | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | No | No | `us_equity` | enabled feature-snapshot alternative | | `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | No | No | `us_equity` | enabled concentrated leader rotation | +| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | No | No | `us_equity` | enabled 2x mega-cap pullback line | Check the current matrix locally: @@ -96,7 +98,7 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i |----------|----------|-------------| | `IB_GATEWAY_ZONE` | Optional fallback | GCE zone (for example `us-central1-a`). Recommended to keep in the selected account-group entry; this env var is only a transition fallback. | | `IB_GATEWAY_IP_MODE` | Optional fallback | `internal` (default) or `external`. Recommended to keep in the selected account-group entry; this env var is only a transition fallback. | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_dynamic_top20` | +| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_dynamic_top20`, `dynamic_mega_leveraged_pullback` | | `ACCOUNT_GROUP` | Yes | Account-group selector. No default fallback. | | `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_dynamic_top20`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. | @@ -229,11 +231,11 @@ Recommended setup: On every push to `main`, the workflow updates the existing Cloud Run service with the values above and removes legacy env vars that should now live in the account-group config (`IB_CLIENT_ID`, `IB_GATEWAY_INSTANCE_NAME`, `IB_GATEWAY_MODE`) plus the older transport vars (`IB_GATEWAY_HOST`, `IB_GATEWAY_PORT`, `TELEGRAM_CHAT_ID`). If `IB_GATEWAY_ZONE` or `IB_GATEWAY_IP_MODE` are blank in GitHub, the workflow also removes them from Cloud Run to avoid drift. -`STRATEGY_PROFILE` is now resolved from a platform capability matrix plus a rollout allowlist. The current strategy domain is `us_equity`, and the repo keeps the runtime registry thin: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` now selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. +`STRATEGY_PROFILE` is now resolved from a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. The current strategy domain is `us_equity`, and the repo keeps the runtime registry thin: `eligible` means the platform can run the strategy in theory, while `enabled` means the current rollout really allows it. `ACCOUNT_GROUP` now selects one account-group config entry, and the service fails fast if that runtime identity is incomplete. Important: -- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped. +- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped. When enabled, it resolves the selected profile's snapshot/config requirements from `scripts/print_strategy_profile_status.py --json` instead of a hard-coded strategy-name list. - Here "shared config" still only means the **IBKR pair** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). `TELEGRAM_TOKEN` and `TELEGRAM_TOKEN_SECRET_NAME` remain repository-specific. - If `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` is set, the Cloud Run runtime needs Secret Manager access to that secret. - GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation, so `GCP_SA_KEY` is no longer required for this workflow. @@ -327,7 +329,7 @@ IBKR 账户 |------|------|------| | `IB_GATEWAY_ZONE` | 可选过渡项 | GCE zone(如 `us-central1-a`)。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 | | `IB_GATEWAY_IP_MODE` | 可选过渡项 | `internal`(默认)或 `external`。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_dynamic_top20` | +| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_dynamic_top20`、`dynamic_mega_leveraged_pullback` | | `ACCOUNT_GROUP` | 是 | 账号组选择器,不再提供默认回退。 | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 | | `IB_ACCOUNT_GROUP_CONFIG_JSON` | 否 | 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 | @@ -426,11 +428,11 @@ IB_GATEWAY_IP_MODE=internal 每次 push 到 `main` 时,这个 workflow 会把上面这些值同步到现有 Cloud Run 服务里,并清掉已经转移到账号组配置里的旧 env(`IB_CLIENT_ID`、`IB_GATEWAY_INSTANCE_NAME`、`IB_GATEWAY_MODE`)以及更早的传输层 env(`IB_GATEWAY_HOST`、`IB_GATEWAY_PORT`、`TELEGRAM_CHAT_ID`)。如果 GitHub 里没有配置 `IB_GATEWAY_ZONE` 或 `IB_GATEWAY_IP_MODE`,workflow 也会把 Cloud Run 上这两个旧值一起删除,避免双配置源漂移。 -`STRATEGY_PROFILE` 现在由平台能力矩阵和 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 +`STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity`:`eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。 注意: -- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。 +- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。打开后,它会通过 `scripts/print_strategy_profile_status.py --json` 动态解析目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。 - 这里说的“共享配置”仍然只针对 **IBKR 这一组系统**。`TELEGRAM_TOKEN` 和 `TELEGRAM_TOKEN_SECRET_NAME` 都还是这个仓库自己的配置,不建议提升成所有 quant 共用的全局配置。 - 如果设置了 `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`,Cloud Run 运行时还需要有对应 Secret 的访问权限。 - GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。 diff --git a/requirements.txt b/requirements.txt index 9aed4df..d003111 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.23 pandas numpy requests diff --git a/strategy_registry.py b/strategy_registry.py index 3931e30..accc28d 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, @@ -21,17 +25,7 @@ DEFAULT_STRATEGY_PROFILE = "global_etf_rotation" ROLLBACK_STRATEGY_PROFILE = DEFAULT_STRATEGY_PROFILE -IBKR_ROLLOUT_ALLOWLIST = frozenset( - { - "tech_communication_pullback_enhancement", - "qqq_tech_enhancement", - "global_etf_rotation", - "mega_cap_leader_rotation_dynamic_top20", - "russell_1000_multi_factor_defensive", - "soxl_soxx_trend_income", - "tqqq_growth_income", - } -) +IBKR_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = { IBKR_PLATFORM: frozenset({US_EQUITY_DOMAIN}), diff --git a/strategy_runtime.py b/strategy_runtime.py index 9dbcf1f..746dfff 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -74,7 +74,10 @@ def evaluate( run_as_of = pd.Timestamp(run_as_of).normalize() if _FEATURE_SNAPSHOT_INPUT in self.required_inputs: return self._evaluate_feature_snapshot_strategy( + ib=ib, current_holdings=current_holdings, + historical_close_loader=historical_close_loader, + historical_candle_loader=historical_candle_loader, run_as_of=run_as_of, translator=translator, pacing_sec=pacing_sec, @@ -232,7 +235,10 @@ def _build_value_target_market_inputs( def _evaluate_feature_snapshot_strategy( self, *, + ib, current_holdings, + historical_close_loader: Callable[..., Any], + historical_candle_loader: Callable[..., Any] | None, run_as_of: pd.Timestamp, translator: Callable[[str], str], pacing_sec: float, @@ -324,6 +330,24 @@ def _evaluate_feature_snapshot_strategy( return StrategyEvaluationResult(decision=decision, metadata=metadata) feature_snapshot = guard_result.frame + portfolio_snapshot = None + if _PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs: + portfolio_snapshot = fetch_portfolio_snapshot(ib) + market_inputs: dict[str, Any] = {_FEATURE_SNAPSHOT_INPUT: feature_snapshot} + if _MARKET_HISTORY_INPUT in self.required_inputs: + market_inputs.update(build_market_history_inputs(historical_close_loader)) + if _BENCHMARK_HISTORY_INPUT in self.required_inputs: + if historical_candle_loader is None: + raise ValueError( + f"IBKR strategy profile {self.profile!r} requires benchmark_history but no candle loader was provided" + ) + market_inputs.update( + build_benchmark_history_inputs( + ib, + historical_candle_loader, + benchmark_symbol=benchmark_symbol, + ) + ) managed_symbols = self._extract_managed_symbols( feature_snapshot, benchmark_symbol=benchmark_symbol, @@ -333,9 +357,11 @@ def _evaluate_feature_snapshot_strategy( entrypoint=self.entrypoint, runtime_adapter=self.runtime_adapter, as_of=run_as_of, - market_inputs={_FEATURE_SNAPSHOT_INPUT: feature_snapshot}, + market_inputs=market_inputs, + portfolio_snapshot=portfolio_snapshot, runtime_config=dict(self.runtime_config), current_holdings=current_holdings, + ib=ib, ) try: decision = self.entrypoint.evaluate(ctx) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 9ea4ce1..1d20d21 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -154,6 +154,7 @@ def test_platform_supported_profiles_are_filtered_by_registry(): "tqqq_growth_income", "tech_communication_pullback_enhancement", "global_etf_rotation", + "dynamic_mega_leveraged_pullback", "mega_cap_leader_rotation_dynamic_top20", "russell_1000_multi_factor_defensive", } @@ -167,6 +168,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix(): "tqqq_growth_income", "tech_communication_pullback_enhancement", "global_etf_rotation", + "dynamic_mega_leveraged_pullback", "mega_cap_leader_rotation_dynamic_top20", "russell_1000_multi_factor_defensive", } @@ -203,6 +205,23 @@ def test_load_platform_runtime_settings_accepts_mega_cap_leader_rotation_dynamic assert settings.strategy_config_path is None +def test_load_platform_runtime_settings_accepts_dynamic_mega_leveraged_pullback(monkeypatch): + monkeypatch.setenv("STRATEGY_PROFILE", "dynamic_mega_leveraged_pullback") + monkeypatch.setenv("ACCOUNT_GROUP", "default") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) + monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_PATH", "/tmp/dynamic-mega.csv") + monkeypatch.setenv("IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH", "/tmp/dynamic-mega.csv.manifest.json") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.strategy_profile == "dynamic_mega_leveraged_pullback" + assert settings.strategy_display_name == "Dynamic Mega Leveraged Pullback" + assert settings.strategy_target_mode == "weight" + assert settings.feature_snapshot_path == "/tmp/dynamic-mega.csv" + assert settings.feature_snapshot_manifest_path == "/tmp/dynamic-mega.csv.manifest.json" + assert settings.strategy_config_path is None + + def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch): monkeypatch.setenv("STRATEGY_PROFILE", "tqqq_growth_income") monkeypatch.setenv("ACCOUNT_GROUP", "default") @@ -239,6 +258,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout(): assert set(by_profile) == { "global_etf_rotation", + "dynamic_mega_leveraged_pullback", "russell_1000_multi_factor_defensive", "soxl_soxx_trend_income", "tqqq_growth_income", @@ -301,6 +321,13 @@ def test_print_strategy_profile_status_json_matches_registry(): assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["input_mode"] == "feature_snapshot" assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_snapshot_artifacts"] is True assert by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_strategy_config_path"] is False + assert by_profile["dynamic_mega_leveraged_pullback"]["profile_group"] == "snapshot_backed" + assert ( + by_profile["dynamic_mega_leveraged_pullback"]["input_mode"] + == "feature_snapshot+market_history+benchmark_history+portfolio_snapshot" + ) + assert by_profile["dynamic_mega_leveraged_pullback"]["requires_snapshot_artifacts"] is True + assert by_profile["dynamic_mega_leveraged_pullback"]["requires_strategy_config_path"] is False assert by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] is False diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 58806a7..891f593 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -226,6 +226,94 @@ def fake_guard(path, **kwargs): assert result.metadata["managed_symbols"] == ("AAPL", "BOXX") +def test_feature_snapshot_runtime_can_add_daily_market_benchmark_and_portfolio_inputs(monkeypatch): + captured = {} + + class FakeEntrypoint: + manifest = StrategyManifest( + profile="dynamic_mega_leveraged_pullback", + domain="us_equity", + display_name="Dynamic Mega Leveraged Pullback", + description="test", + required_inputs=frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), + default_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + ) + + def evaluate(self, ctx): + captured["market_data"] = dict(ctx.market_data) + captured["portfolio"] = ctx.portfolio + captured["capabilities"] = dict(ctx.capabilities) + return StrategyDecision( + positions=( + PositionTarget(symbol="NVDL", target_weight=0.4), + PositionTarget(symbol="BOXX", target_weight=0.6, role="safe_haven"), + ), + diagnostics={"signal_description": "leveraged", "status_description": "ok"}, + ) + + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=FakeEntrypoint(), + runtime_adapter=StrategyRuntimeAdapter( + status_icon="2x", + available_inputs=frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), + required_feature_columns=frozenset({"symbol", "underlying_symbol"}), + snapshot_date_columns=("as_of",), + max_snapshot_month_lag=1, + require_snapshot_manifest=False, + snapshot_contract_version=None, + managed_symbols_extractor=lambda *_args, **_kwargs: ("NVDL", "BOXX"), + portfolio_input_name="portfolio_snapshot", + ), + runtime_settings=_build_runtime_settings(profile="dynamic_mega_leveraged_pullback"), + runtime_config={}, + merged_runtime_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"}, + status_icon="2x", + logger=lambda _message: None, + ) + + monkeypatch.setattr( + strategy_runtime_module, + "load_feature_snapshot_guarded", + lambda path, **_kwargs: SimpleNamespace( + frame=[{"as_of": "2026-03-31", "symbol": "NVDL", "underlying_symbol": "NVDA"}], + metadata={ + "snapshot_guard_decision": "proceed", + "snapshot_as_of": "2026-03-31", + "snapshot_path": path, + "snapshot_age_days": 1, + }, + ), + ) + portfolio_snapshot = SimpleNamespace(total_equity=50000.0) + monkeypatch.setattr(strategy_runtime_module, "fetch_portfolio_snapshot", lambda _ib: portfolio_snapshot) + + def close_loader(_ib, symbol, **_kwargs): + return [100.0, 101.0] if symbol == "NVDA" else [] + + def candle_loader(_ib, symbol, duration="2 Y", bar_size="1 day"): + assert symbol == "QQQ" + assert duration == "2 Y" + assert bar_size == "1 day" + return [{"close": 100.0, "high": 101.0, "low": 99.0}] + + result = runtime.evaluate( + ib="fake-ib", + current_holdings={"NVDL"}, + historical_close_loader=close_loader, + historical_candle_loader=candle_loader, + run_as_of=strategy_runtime_module.pd.Timestamp("2026-04-01"), + translator=lambda key, **_kwargs: key, + pacing_sec=0.5, + ) + + assert captured["market_data"]["feature_snapshot"][0]["symbol"] == "NVDL" + assert captured["market_data"]["market_history"] is close_loader + assert captured["market_data"]["benchmark_history"][0]["high"] == 101.0 + assert captured["portfolio"] is portfolio_snapshot + assert captured["capabilities"]["broker_client"] == "fake-ib" + assert result.metadata["managed_symbols"] == ("NVDL", "BOXX") + + def test_market_history_runtime_uses_canonical_market_history_key(): captured = {} diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 5256ba3..b80fe7c 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" @@ -42,6 +52,12 @@ grep -Fq 'remove_secret_vars=(' "$workflow_file" grep -Fq 'STRATEGY_PROFILE=${STRATEGY_PROFILE}' "$workflow_file" grep -Fq 'ACCOUNT_GROUP=${ACCOUNT_GROUP}' "$workflow_file" grep -Fq 'IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME=${IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME}' "$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" ]; then' "$workflow_file" +grep -Fq 'if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then' "$workflow_file" +grep -Fq 'if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ]; then' "$workflow_file" grep -Fq 'required_vars+=(IBKR_FEATURE_SNAPSHOT_PATH)' "$workflow_file" grep -Fq 'required_vars+=(IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH)' "$workflow_file" grep -Fq 'required_vars+=(IBKR_STRATEGY_CONFIG_PATH IBKR_RECONCILIATION_OUTPUT_PATH)' "$workflow_file" From 998ba5c74ca254f146bec7671d49e6f1ef88b6e2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:06:20 +0800 Subject: [PATCH 2/2] Use UsEquityStrategies 0.7.24 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d003111..5718f02 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.16 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.23 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.24 pandas numpy requests