Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 74 additions & 7 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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'
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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:

Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 直接使用。 |
Expand Down Expand Up @@ -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`。
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
numpy
requests
Expand Down
18 changes: 6 additions & 12 deletions strategy_registry.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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}),
Expand Down
28 changes: 27 additions & 1 deletion strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading