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
21 changes: 17 additions & 4 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ jobs:
TELEGRAM_TOKEN_SECRET_NAME: ${{ vars.TELEGRAM_TOKEN_SECRET_NAME }}
SCHWAB_API_KEY_SECRET_NAME: ${{ vars.SCHWAB_API_KEY_SECRET_NAME }}
SCHWAB_APP_SECRET_SECRET_NAME: ${{ vars.SCHWAB_APP_SECRET_SECRET_NAME }}
STRATEGY_PROFILE: ${{ vars.STRATEGY_PROFILE || 'tqqq_growth_income' }}
RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}
SCHWAB_DRY_RUN_ONLY: ${{ vars.SCHWAB_DRY_RUN_ONLY }}
SCHWAB_FEATURE_SNAPSHOT_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_PATH }}
SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH }}
SCHWAB_STRATEGY_CONFIG_PATH: ${{ vars.SCHWAB_STRATEGY_CONFIG_PATH }}
SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON }}
# Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults.
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
DUAL_DRIVE_UNLEVERED_SYMBOL: ${{ vars.DUAL_DRIVE_UNLEVERED_SYMBOL }}
Expand Down Expand Up @@ -82,9 +83,13 @@ jobs:
import sys
from us_equity_strategies import resolve_canonical_profile

profile = os.environ.get("STRATEGY_PROFILE", "").strip().lower()
raw_runtime_target = os.environ.get("RUNTIME_TARGET_JSON", "").strip()
if not raw_runtime_target:
raise SystemExit("RUNTIME_TARGET_JSON is required")
runtime_target = json.loads(raw_runtime_target)
profile = str(runtime_target.get("strategy_profile") or "").strip().lower()
if not profile:
raise SystemExit("STRATEGY_PROFILE is required")
raise SystemExit("RUNTIME_TARGET_JSON.strategy_profile is required")
canonical_profile = resolve_canonical_profile(profile)

raw_status = subprocess.check_output(
Expand Down Expand Up @@ -113,6 +118,7 @@ jobs:
output.write(
f"config_source_policy={str(selected.get('config_source_policy') or 'none')}\n"
)
output.write(f"runtime_target_json={json.dumps(runtime_target, sort_keys=True)}\n")
PY

- name: Validate env sync inputs
Expand All @@ -122,6 +128,7 @@ jobs:
REQUIRES_SNAPSHOT_MANIFEST_PATH: ${{ steps.strategy_requirements.outputs.requires_snapshot_manifest_path }}
REQUIRES_STRATEGY_CONFIG_PATH: ${{ steps.strategy_requirements.outputs.requires_strategy_config_path }}
CONFIG_SOURCE_POLICY: ${{ steps.strategy_requirements.outputs.config_source_policy }}
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -165,6 +172,10 @@ jobs:
missing_vars+=("SCHWAB_STRATEGY_CONFIG_PATH")
fi

if [ -z "${RUNTIME_TARGET_JSON:-}" ]; then
missing_vars+=("RUNTIME_TARGET_JSON")
fi

if [ "${#missing_vars[@]}" -gt 0 ]; then
echo "Cloud Run env sync is enabled, but these values are missing:" >&2
printf ' - %s\n' "${missing_vars[@]}" >&2
Expand Down Expand Up @@ -212,13 +223,15 @@ jobs:

- name: Sync Cloud Run environment
if: steps.config.outputs.enabled == 'true'
env:
RUNTIME_TARGET_JSON: ${{ steps.strategy_requirements.outputs.runtime_target_json }}
run: |
set -euo pipefail

env_pairs=(
"GLOBAL_TELEGRAM_CHAT_ID=${GLOBAL_TELEGRAM_CHAT_ID}"
"NOTIFY_LANG=${NOTIFY_LANG}"
"STRATEGY_PROFILE=${STRATEGY_PROFILE}"
"RUNTIME_TARGET_JSON=${RUNTIME_TARGET_JSON}"
)
secret_pairs=()
remove_env_vars=(
Expand Down
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
## English

Automated trading service for Charles Schwab accounts, deployed on GCP Cloud Run. This repository runs shared `us_equity` strategy profiles from `UsEquityStrategies`; strategy logic, cadence, asset universes, parameters, and research/backtest notes live in that strategy repository.
The runtime now carries a structured `RuntimeTarget` / `RUNTIME_TARGET_JSON` alongside the compatibility `STRATEGY_PROFILE` selector. Strategy-owned defaults come from `UsEquityStrategies`; platform variables are only explicit overrides.

This repository uses `QuantPlatformKit` for Schwab client bootstrap, account snapshot access, market data, and order submission. Cloud Run deploys this repository directly.
The Schwab runtime can execute the six current `runtime_enabled` `us_equity` profiles from `UsEquityStrategies`.

Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). The sections below focus on Schwab runtime behavior, profile enablement, deployment, and credentials.
This runtime matrix is the authoritative enablement source for Schwab. `UsEquityStrategies` carries strategy-layer logic, cadence, compatibility, and metadata.
`STRATEGY_PROFILE` remains the compatibility selector for strategy routing, while `RuntimeTarget` describes the running service identity.

### Execution boundary

Expand Down Expand Up @@ -72,9 +74,9 @@ Each HTTP request runs one broker execution cycle. The Cloud Scheduler cron shou
| `SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON` | Optional Schwab-side strategy plugin mount JSON. Prefer this Schwab-specific variable; `STRATEGY_PLUGIN_MOUNTS_JSON` is only a shared fallback. |
| `SCHWAB_MIN_RESERVED_CASH_USD` | Optional platform cash-reserve floor in USD. Default policy: keep `150 USD` or `3% of total equity`, whichever is larger. Runtime formula: `max(floor, ratio * total_equity)`. |
| `SCHWAB_RESERVED_CASH_RATIO` | Optional platform cash-reserve ratio. Default policy: keep `150 USD` or `3% of total equity`, whichever is larger. Runtime formula: `max(floor, ratio * total_equity)`. |
| `INCOME_THRESHOLD_USD` | Optional override for the strategy income-layer threshold. Leave unset to use the `UsEquityStrategies` live default, which disables the income layer for normal account sizes. |
| `QQQI_INCOME_RATIO` | Optional override for QQQI share of the income layer, 0–1. Only relevant when the income layer is enabled. |
| `DUAL_DRIVE_UNLEVERED_SYMBOL` | Optional `tqqq_growth_income` override for the tradable unlevered growth sleeve. Leave unset for `QQQ`; set to `QQQM` for smaller Schwab accounts while retaining `QQQ` as the signal source. |
| `INCOME_THRESHOLD_USD` | Optional strategy override for the income-layer threshold. Leave unset to use the `UsEquityStrategies` live default, which disables the income layer for normal account sizes. |
| `QQQI_INCOME_RATIO` | Optional strategy override for QQQI share of the income layer, 0–1. Only relevant when the income layer is enabled. |
| `DUAL_DRIVE_UNLEVERED_SYMBOL` | Optional strategy override for the tradable unlevered growth sleeve. Leave unset for `QQQ`; set to `QQQM` only when the deployment intentionally uses QQQM instead of QQQ. |
| `NOTIFY_LANG` | Notification language: `en` (English, default) or `zh` (Chinese) |

Strategy plugin mount JSON belongs to platform/deployment configuration, not strategy code. It decides which plugin artifacts this runtime reads, and must not set `mode`; the plugin artifact is self-identifying and carries the effective mode. Invalid plugin mount config is recorded in the runtime report diagnostics and does not block the base strategy cycle.
Expand Down Expand Up @@ -106,9 +108,9 @@ Recommended setup:
- `STRATEGY_PROFILE` (set explicitly to one enabled profile: `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, or `tech_communication_pullback_enhancement`)
- Optional: `SCHWAB_FEATURE_SNAPSHOT_PATH`, `SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH`, `SCHWAB_STRATEGY_CONFIG_PATH` for feature-snapshot profiles
- Optional: `SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON` for strategy plugin artifact mounts. Do not include `mode` in this platform mount JSON.
- Optional: `INCOME_THRESHOLD_USD`
- Optional: `QQQI_INCOME_RATIO`
- Optional: `DUAL_DRIVE_UNLEVERED_SYMBOL`
- Optional: `INCOME_THRESHOLD_USD` (strategy override only)
- Optional: `QQQI_INCOME_RATIO` (strategy override only)
- Optional: `DUAL_DRIVE_UNLEVERED_SYMBOL` (strategy override only)
- Optional: `GOOGLE_CLOUD_PROJECT`
- **Repository Secrets**
- Optional fallback only: `TELEGRAM_TOKEN`
Expand All @@ -125,7 +127,7 @@ Important:
- 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 + manual Cloud Run env setup keeps working. 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.
- `STRATEGY_PROFILE` is driven by the platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata. Today `enabled` includes six live `us_equity` profiles: `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, and `tech_communication_pullback_enhancement`; archived research-only profiles remain eligible in the capability matrix but are not enabled.
- The current strategy domain is `us_equity`, and the repo now keeps a thin strategy registry so future expansion can grow by domain + profile instead of mixing strategy and platform in one layer.
- `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`, and `DUAL_DRIVE_UNLEVERED_SYMBOL` are optional in env sync. Leave them unset to inherit the `UsEquityStrategies` profile defaults; the current `tqqq_growth_income` live default is the no-income QQQ/TQQQ dual-drive mode. Set `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM` when the Schwab account should trade QQQM instead of whole-share QQQ.
- `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO`, and `DUAL_DRIVE_UNLEVERED_SYMBOL` are optional env-sync overrides, not platform defaults. Leave them unset to inherit the `UsEquityStrategies` profile defaults; the current `tqqq_growth_income` live default is the no-income QQQ/TQQQ dual-drive mode. Set `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM` only when the deployment intentionally uses QQQM instead of whole-share QQQ.
- GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation. `GCP_SA_KEY` is no longer required for this workflow.
- If you deploy with `gcloud run deploy --source` or a Cloud Run source trigger, also grant `roles/storage.objectViewer` on `gs://run-sources-<project>-<region>` to the build service account, the deploy service account, and the default compute service account. Without that bucket access, source deploy fails before Cloud Build starts with `storage.objects.get` denied.
- The Telegram token and Schwab API credentials should live in Secret Manager and be referenced by the secret-name variables above. Across multiple quant repos, only `GLOBAL_TELEGRAM_CHAT_ID` and `NOTIFY_LANG` are good cross-project shared settings.
Expand Down Expand Up @@ -205,7 +207,7 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取

### GitHub 统一管理 Cloud Run 环境变量

如果代码部署继续走 Google Cloud Trigger,但你想把运行时环境变量统一放在 GitHub 管理,这个仓库现在提供了 `.github/workflows/sync-cloud-run-env.yml`。
如果代码部署继续走 Google Cloud Trigger,但你想把运行时环境变量统一放在 GitHub 管理,这个仓库现在提供了 `.github/workflows/sync-cloud-run-env.yml`。这个 workflow 现在也会发出 `RUNTIME_TARGET_JSON`,让控制面带上结构化运行目标,而不是只看 `STRATEGY_PROFILE`。

推荐配置方式:

Expand All @@ -219,9 +221,9 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取
- `STRATEGY_PROFILE`(显式设置为任一已启用 profile:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 或 `tech_communication_pullback_enhancement`)
- 可选:`SCHWAB_FEATURE_SNAPSHOT_PATH`、`SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH`、`SCHWAB_STRATEGY_CONFIG_PATH`,用于 feature-snapshot 策略
- 可选:`SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON`,用于策略插件 artifact 挂载。不要在这个平台挂载 JSON 里放 `mode`
- 可选:`INCOME_THRESHOLD_USD`
- 可选:`QQQI_INCOME_RATIO`
- 可选:`DUAL_DRIVE_UNLEVERED_SYMBOL`
- 可选:`INCOME_THRESHOLD_USD`(仅策略 override)
- 可选:`QQQI_INCOME_RATIO`(仅策略 override)
- 可选:`DUAL_DRIVE_UNLEVERED_SYMBOL`(仅策略 override)
- 可选:`GOOGLE_CLOUD_PROJECT`
- **仓库级 Secrets**
- 仅保留为 fallback:`TELEGRAM_TOKEN`
Expand All @@ -236,9 +238,9 @@ Schwab OAuth token payload 当前从 Secret Manager 的 `schwab_token` 里读取
注意:

- 只有在 `ENABLE_GITHUB_ENV_SYNC=true` 时,这个 workflow 才会严格校验并执行同步。没打开时会直接跳过,不影响原来 Google Cloud Trigger + 手工 Cloud Run env 的老流程。打开后,它会通过 `scripts/print_strategy_profile_status.py --json` 动态解析目标策略需要的 snapshot/config 输入,不再维护硬编码策略名列表。
- `STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前 `enabled` 包含 6 条 live `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 和 `tech_communication_pullback_enhancement`;research-only 存档 profile 仍保留能力矩阵兼容性,但不会启用。
- 当前策略域是 `us_equity`,本地策略注册表只用于域和 profile 校验。
- `INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` 和 `DUAL_DRIVE_UNLEVERED_SYMBOL` 在 env-sync 里是可选项。不填时会继承 `UsEquityStrategies` 的 profile 默认值;当前 `tqqq_growth_income` 实盘默认是不带收入层的 QQQ/TQQQ 双轮模式。Schwab 小账户需要用 QQQM 替代整股 QQQ 时,设置 `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM`。
- `STRATEGY_PROFILE` 现在由平台能力矩阵和从 `runtime_enabled` 策略元数据派生的 rollout allowlist 一起决定。当前 `enabled` 包含 6 条 live `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income` 和 `tech_communication_pullback_enhancement`;research-only 存档 profile 仍保留能力矩阵兼容性,但不会启用。`RUNTIME_TARGET_JSON` 则表示实际运行目标,`STRATEGY_PROFILE` 继续只负责兼容选择策略实现。
- 当前策略域是 `us_equity`,本地策略注册表只用于域和 profile 校验;结构化运行目标会通过 `RUNTIME_TARGET_JSON` 往下传
- `INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` 和 `DUAL_DRIVE_UNLEVERED_SYMBOL` 在 env-sync 里是可选 override,不是平台默认值来源。不填时会继承 `UsEquityStrategies` 的 profile 默认值;当前 `tqqq_growth_income` 实盘默认是不带收入层的 QQQ/TQQQ 双轮模式。只有在明确要用 QQQM 替代整股 QQQ 时,才设置 `DUAL_DRIVE_UNLEVERED_SYMBOL=QQQM`。
- GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。
- 如果你用 `gcloud run deploy --source` 或 Cloud Run source trigger 部署,还要确保 `gs://run-sources-<project>-<region>` 这个 staging bucket 给 build service account、deploy service account、默认 compute service account 都加上 `roles/storage.objectViewer`。少了这层权限,会在 Cloud Build 启动前直接报 `storage.objects.get denied`。
- Telegram token 和 Schwab API 凭据建议放到 Secret Manager,并通过上面的 secret-name 变量引用。对多个 quant 仓库来说,真正适合跨项目共享的通常只有 `GLOBAL_TELEGRAM_CHAT_ID` 和 `NOTIFY_LANG`。
Expand Down
30 changes: 23 additions & 7 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any

from application.runtime_dependencies import SchwabRebalanceConfig, SchwabRebalanceRuntime
from application.runtime_notification_adapters import build_runtime_notification_adapters
from application.runtime_reporting_adapters import build_runtime_reporting_adapters
from quant_platform_kit.common.runtime_assembly import build_runtime_assembly
from quant_platform_kit.common.runtime_target import build_runtime_context_fields
from quant_platform_kit.common.runtime_target import RuntimeTarget
from notifications.telegram import build_sender


Expand Down Expand Up @@ -48,6 +51,8 @@ class SchwabRuntimeComposer:
sender_builder: Callable[..., Callable[[str], None]] = build_sender
notification_builder: Callable[..., Any] = build_runtime_notification_adapters
reporting_builder: Callable[..., Any] = build_runtime_reporting_adapters
runtime_target: RuntimeTarget | None = None
extra_reporting_fields: dict[str, Any] = field(default_factory=dict)

def send_tg_message(self, message: str) -> None:
sender = self.sender_builder(self.tg_token, self.tg_chat_id)
Expand All @@ -60,17 +65,24 @@ def build_notification_adapters(self):
)

def build_reporting_adapters(self):
return self.reporting_builder(
runtime_assembly = build_runtime_assembly(
platform="charles_schwab",
deploy_target="cloud_run",
service_name=self.service_name,
strategy_profile=self.strategy_profile,
strategy_domain=self.strategy_domain,
runtime_target=self.runtime_target,
project_id=self.project_id,
extra_context_fields={
"strategy_display_name": self.strategy_display_name,
"strategy_display_name_localized": self.strategy_display_name_localized,
},
extra_context_fields=build_runtime_context_fields(
{
"strategy_display_name": self.strategy_display_name,
"strategy_display_name_localized": self.strategy_display_name_localized,
**dict(self.extra_reporting_fields),
},
),
)
return self.reporting_builder(
runtime_assembly=runtime_assembly,
strategy_domain=self.strategy_domain,
managed_symbols=self.managed_symbols,
benchmark_symbol=self.benchmark_symbol,
strategy_display_name=self.strategy_display_name,
Expand Down Expand Up @@ -169,6 +181,8 @@ def build_runtime_composer(
env_reader: Callable[[str, str], str | None],
sleeper: Callable[[float], None] | None = None,
printer: Callable[..., Any] = print,
extra_reporting_fields: dict[str, Any] | None = None,
runtime_target: RuntimeTarget | None = None,
) -> SchwabRuntimeComposer:
return SchwabRuntimeComposer(
project_id=project_id,
Expand Down Expand Up @@ -202,4 +216,6 @@ def build_runtime_composer(
env_reader=env_reader,
sleeper=sleeper,
printer=printer,
runtime_target=runtime_target,
extra_reporting_fields=dict(extra_reporting_fields or {}),
)
Loading