Skip to content

Commit b398e65

Browse files
committed
Enable runtime-derived US equity profiles
1 parent 9c575b5 commit b398e65

8 files changed

Lines changed: 248 additions & 28 deletions

.github/workflows/sync-cloud-run-env.yml

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
3737
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
3838
steps:
39-
- name: Check whether env sync is configured
39+
- name: Check whether env sync is enabled
4040
id: config
4141
run: |
4242
set -euo pipefail
@@ -47,6 +47,76 @@ jobs:
4747
exit 0
4848
fi
4949
50+
echo "enabled=true" >> "$GITHUB_OUTPUT"
51+
52+
- name: Checkout repository
53+
if: steps.config.outputs.enabled == 'true'
54+
uses: actions/checkout@v4
55+
56+
- name: Set up Python for strategy requirement resolution
57+
if: steps.config.outputs.enabled == 'true'
58+
uses: actions/setup-python@v5
59+
with:
60+
python-version: "3.12"
61+
62+
- name: Install strategy status dependencies
63+
if: steps.config.outputs.enabled == 'true'
64+
run: |
65+
set -euo pipefail
66+
python -m pip install --upgrade pip
67+
python -m pip install -r requirements.txt
68+
69+
- name: Resolve selected strategy runtime requirements
70+
id: strategy_requirements
71+
if: steps.config.outputs.enabled == 'true'
72+
run: |
73+
set -euo pipefail
74+
python - <<'PY'
75+
import json
76+
import os
77+
import subprocess
78+
import sys
79+
from us_equity_strategies import resolve_canonical_profile
80+
81+
profile = os.environ.get("STRATEGY_PROFILE", "").strip().lower()
82+
if not profile:
83+
raise SystemExit("STRATEGY_PROFILE is required")
84+
canonical_profile = resolve_canonical_profile(profile)
85+
86+
raw_status = subprocess.check_output(
87+
[sys.executable, "scripts/print_strategy_profile_status.py", "--json"],
88+
text=True,
89+
)
90+
rows = json.loads(raw_status)
91+
selected = next((row for row in rows if row["canonical_profile"] == canonical_profile), None)
92+
if selected is None:
93+
supported = ", ".join(sorted(row["canonical_profile"] for row in rows))
94+
raise SystemExit(f"Unsupported STRATEGY_PROFILE={profile!r}; supported: {supported}")
95+
if not selected.get("eligible") or not selected.get("enabled"):
96+
raise SystemExit(f"STRATEGY_PROFILE={profile!r} is not eligible/enabled: {selected}")
97+
98+
output_path = os.environ["GITHUB_OUTPUT"]
99+
with open(output_path, "a", encoding="utf-8") as output:
100+
output.write(
101+
f"requires_snapshot_artifacts={str(bool(selected.get('requires_snapshot_artifacts'))).lower()}\n"
102+
)
103+
output.write(
104+
f"requires_snapshot_manifest_path={str(bool(selected.get('requires_snapshot_manifest_path'))).lower()}\n"
105+
)
106+
output.write(
107+
f"requires_strategy_config_path={str(bool(selected.get('requires_strategy_config_path'))).lower()}\n"
108+
)
109+
PY
110+
111+
- name: Validate env sync inputs
112+
if: steps.config.outputs.enabled == 'true'
113+
env:
114+
REQUIRES_SNAPSHOT_ARTIFACTS: ${{ steps.strategy_requirements.outputs.requires_snapshot_artifacts }}
115+
REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}
116+
REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}
117+
run: |
118+
set -euo pipefail
119+
50120
required_vars=(
51121
CLOUD_RUN_REGION
52122
CLOUD_RUN_SERVICE
@@ -57,15 +127,15 @@ jobs:
57127
NOTIFY_LANG
58128
)
59129
60-
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
130+
if [ "${REQUIRES_SNAPSHOT_ARTIFACTS:-}" = "true" ]; then
61131
required_vars+=(IBKR_FEATURE_SNAPSHOT_PATH)
62132
fi
63133
64-
if [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "mega_cap_leader_rotation_dynamic_top20" ]; then
134+
if [ "${REQUIRES_SNAPSHOT_MANIFEST_PATH:-}" = "true" ]; then
65135
required_vars+=(IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH)
66136
fi
67137
68-
if [ "${STRATEGY_PROFILE:-}" = "tech_communication_pullback_enhancement" ] || [ "${STRATEGY_PROFILE:-}" = "qqq_tech_enhancement" ]; then
138+
if [ "${REQUIRES_STRATEGY_CONFIG_PATH:-}" = "true" ]; then
69139
required_vars+=(IBKR_STRATEGY_CONFIG_PATH IBKR_RECONCILIATION_OUTPUT_PATH)
70140
fi
71141
@@ -81,14 +151,11 @@ jobs:
81151
fi
82152
83153
if [ "${#missing_vars[@]}" -gt 0 ]; then
84-
echo "enabled=false" >> "$GITHUB_OUTPUT"
85154
echo "Cloud Run env sync is enabled, but these values are missing:" >&2
86155
printf ' - %s\n' "${missing_vars[@]}" >&2
87156
exit 1
88157
fi
89158
90-
echo "enabled=true" >> "$GITHUB_OUTPUT"
91-
92159
- name: Authenticate to Google Cloud
93160
id: auth
94161
if: steps.config.outputs.enabled == 'true'

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ The mainline runtime now follows one path only:
4040
- `soxl_soxx_trend_income`
4141
- `tech_communication_pullback_enhancement`
4242
- `mega_cap_leader_rotation_dynamic_top20`
43+
- `dynamic_mega_leveraged_pullback`
4344

4445

4546
**IBKR profile status**
@@ -52,6 +53,7 @@ The mainline runtime now follows one path only:
5253
| `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | No | No | `us_equity` | current IBKR live line |
5354
| `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | No | No | `us_equity` | enabled feature-snapshot alternative |
5455
| `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | No | No | `us_equity` | enabled concentrated leader rotation |
56+
| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | No | No | `us_equity` | enabled 2x mega-cap pullback line |
5557

5658
Check the current matrix locally:
5759

@@ -96,7 +98,7 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i
9698
|----------|----------|-------------|
9799
| `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. |
98100
| `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. |
99-
| `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` |
101+
| `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` |
100102
| `ACCOUNT_GROUP` | Yes | Account-group selector. No default fallback. |
101103
| `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`). |
102104
| `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:
229231

230232
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.
231233

232-
`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.
234+
`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.
233235

234236
Important:
235237

236-
- The workflow only becomes strict when `ENABLE_GITHUB_ENV_SYNC=true`. If this variable is unset, the sync job is skipped.
238+
- 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.
237239
- Here "shared config" still only means the **IBKR pair** (`InteractiveBrokersPlatform` + `IBKRGatewayManager`). `TELEGRAM_TOKEN` and `TELEGRAM_TOKEN_SECRET_NAME` remain repository-specific.
238240
- If `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` is set, the Cloud Run runtime needs Secret Manager access to that secret.
239241
- 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 账户
327329
|------|------|------|
328330
| `IB_GATEWAY_ZONE` | 可选过渡项 | GCE zone(如 `us-central1-a`)。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 |
329331
| `IB_GATEWAY_IP_MODE` | 可选过渡项 | `internal`(默认)或 `external`。推荐直接放进选中的账号组配置里;这里只保留过渡 fallback。 |
330-
| `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` |
332+
| `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` |
331333
| `ACCOUNT_GROUP` || 账号组选择器,不再提供默认回退。 |
332334
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
333335
| `IB_ACCOUNT_GROUP_CONFIG_JSON` || 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 |
@@ -426,11 +428,11 @@ IB_GATEWAY_IP_MODE=internal
426428

427429
每次 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 上这两个旧值一起删除,避免双配置源漂移。
428430

429-
`STRATEGY_PROFILE` 现在由平台能力矩阵和 rollout allowlist 一起决定。当前策略域仍是 `us_equity``eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。
431+
`STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前策略域仍是 `us_equity``eligible` 表示平台理论上能跑,`enabled` 表示当前 rollout 真正放开。`ACCOUNT_GROUP` 是严格必填项,并会选中一份账号组配置。运行身份不完整时,服务会直接失败,不再静默回退。
430432

431433
注意:
432434

433-
- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。
435+
- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过。打开后,它会通过 `scripts/print_strategy_profile_status.py --json` 动态解析目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。
434436
- 这里说的“共享配置”仍然只针对 **IBKR 这一组系统**`TELEGRAM_TOKEN``TELEGRAM_TOKEN_SECRET_NAME` 都还是这个仓库自己的配置,不建议提升成所有 quant 共用的全局配置。
435437
- 如果设置了 `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`,Cloud Run 运行时还需要有对应 Secret 的访问权限。
436438
- GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.15
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.22
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.16
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@v0.7.23
55
pandas
66
numpy
77
requests

strategy_registry.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from __future__ import annotations
22

3-
from us_equity_strategies import get_platform_runtime_adapter, get_strategy_catalog
3+
from us_equity_strategies import (
4+
get_platform_runtime_adapter,
5+
get_runtime_enabled_profiles,
6+
get_strategy_catalog,
7+
)
48

59
from quant_platform_kit.common.strategies import (
610
PlatformCapabilityMatrix,
@@ -21,17 +25,7 @@
2125
DEFAULT_STRATEGY_PROFILE = "global_etf_rotation"
2226
ROLLBACK_STRATEGY_PROFILE = DEFAULT_STRATEGY_PROFILE
2327

24-
IBKR_ROLLOUT_ALLOWLIST = frozenset(
25-
{
26-
"tech_communication_pullback_enhancement",
27-
"qqq_tech_enhancement",
28-
"global_etf_rotation",
29-
"mega_cap_leader_rotation_dynamic_top20",
30-
"russell_1000_multi_factor_defensive",
31-
"soxl_soxx_trend_income",
32-
"tqqq_growth_income",
33-
}
34-
)
28+
IBKR_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles()
3529

3630
PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = {
3731
IBKR_PLATFORM: frozenset({US_EQUITY_DOMAIN}),

strategy_runtime.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ def evaluate(
7474
run_as_of = pd.Timestamp(run_as_of).normalize()
7575
if _FEATURE_SNAPSHOT_INPUT in self.required_inputs:
7676
return self._evaluate_feature_snapshot_strategy(
77+
ib=ib,
7778
current_holdings=current_holdings,
79+
historical_close_loader=historical_close_loader,
80+
historical_candle_loader=historical_candle_loader,
7881
run_as_of=run_as_of,
7982
translator=translator,
8083
pacing_sec=pacing_sec,
@@ -232,7 +235,10 @@ def _build_value_target_market_inputs(
232235
def _evaluate_feature_snapshot_strategy(
233236
self,
234237
*,
238+
ib,
235239
current_holdings,
240+
historical_close_loader: Callable[..., Any],
241+
historical_candle_loader: Callable[..., Any] | None,
236242
run_as_of: pd.Timestamp,
237243
translator: Callable[[str], str],
238244
pacing_sec: float,
@@ -324,6 +330,24 @@ def _evaluate_feature_snapshot_strategy(
324330
return StrategyEvaluationResult(decision=decision, metadata=metadata)
325331

326332
feature_snapshot = guard_result.frame
333+
portfolio_snapshot = None
334+
if _PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs:
335+
portfolio_snapshot = fetch_portfolio_snapshot(ib)
336+
market_inputs: dict[str, Any] = {_FEATURE_SNAPSHOT_INPUT: feature_snapshot}
337+
if _MARKET_HISTORY_INPUT in self.required_inputs:
338+
market_inputs.update(build_market_history_inputs(historical_close_loader))
339+
if _BENCHMARK_HISTORY_INPUT in self.required_inputs:
340+
if historical_candle_loader is None:
341+
raise ValueError(
342+
f"IBKR strategy profile {self.profile!r} requires benchmark_history but no candle loader was provided"
343+
)
344+
market_inputs.update(
345+
build_benchmark_history_inputs(
346+
ib,
347+
historical_candle_loader,
348+
benchmark_symbol=benchmark_symbol,
349+
)
350+
)
327351
managed_symbols = self._extract_managed_symbols(
328352
feature_snapshot,
329353
benchmark_symbol=benchmark_symbol,
@@ -333,9 +357,11 @@ def _evaluate_feature_snapshot_strategy(
333357
entrypoint=self.entrypoint,
334358
runtime_adapter=self.runtime_adapter,
335359
as_of=run_as_of,
336-
market_inputs={_FEATURE_SNAPSHOT_INPUT: feature_snapshot},
360+
market_inputs=market_inputs,
361+
portfolio_snapshot=portfolio_snapshot,
337362
runtime_config=dict(self.runtime_config),
338363
current_holdings=current_holdings,
364+
ib=ib,
339365
)
340366
try:
341367
decision = self.entrypoint.evaluate(ctx)

0 commit comments

Comments
 (0)