diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index a5a975c..2251a21 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -56,7 +56,7 @@ on: required: false type: string service_name: - description: "Cloud Run service name. blank = platform default." + description: "Runtime service name. blank = platform default." required: false type: string plugin_mode: @@ -102,7 +102,7 @@ on: - enabled - disabled service_targets_mode: - description: "auto updates an existing IBKR target; allow_create explicitly permits a new target." + description: "auto updates an existing multi-service target when configured; allow_create explicitly permits a new target." required: true type: choice default: auto @@ -116,7 +116,7 @@ on: type: boolean default: false trigger_platform_sync: - description: "After apply, dispatch the target platform sync-cloud-run-env workflow." + description: "After apply, dispatch a supported Cloud Run environment sync workflow." required: true type: boolean default: false @@ -125,7 +125,7 @@ on: required: false type: string platform_sync_workflow: - description: "Target platform workflow filename." + description: "Cloud Run target platform workflow filename." required: false type: string default: sync-cloud-run-env.yml @@ -192,6 +192,28 @@ jobs: exit 2 fi + settings_activation="$( + python3 python/scripts/runtime_settings.py settings-activation "${PLATFORM}" + )" + if [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then + case "${settings_activation}" in + cloud_run_sync_workflow) + ;; + next_runtime_workflow_dispatch) + echo "Oracle/VPS runtime activates settings on its next externally scheduled GitHub Actions dispatch; trigger_platform_sync is unsupported for ${PLATFORM}." >&2 + exit 2 + ;; + not_wired) + echo "QMT has no live runtime configuration; trigger_platform_sync is unsupported." >&2 + exit 2 + ;; + *) + echo "Unsupported settings activation mode for ${PLATFORM}: ${settings_activation}" >&2 + exit 2 + ;; + esac + fi + if [ "${APPLY_SWITCH}" = "true" ]; then if [ -z "${GH_TOKEN:-}" ]; then echo "RUNTIME_SETTINGS_GH_TOKEN is required for apply=true." >&2 @@ -208,12 +230,20 @@ jobs: fi fi - if [ "${PLATFORM}" = "ibkr" ] \ - && [ "${SERVICE_TARGETS_MODE}" != "off" ] \ - && [ -z "${GH_TOKEN:-}" ]; then - echo "RUNTIME_SETTINGS_GH_TOKEN is required for IBKR service-target preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2 - exit 2 - fi + case "${PLATFORM}" in + longbridge|ibkr|schwab|firstrade) + if [ "${SERVICE_TARGETS_MODE}" != "off" ] && [ -z "${GH_TOKEN:-}" ]; then + echo "RUNTIME_SETTINGS_GH_TOKEN is required for multi-service preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2 + exit 2 + fi + ;; + *) + if [ "${SERVICE_TARGETS_MODE}" = "allow_create" ]; then + echo "service_targets_mode=allow_create is not supported for ${PLATFORM}." >&2 + exit 2 + fi + ;; + esac - name: Resolve platform repository id: platform @@ -223,22 +253,30 @@ jobs: echo "repository=${repo}" >> "$GITHUB_OUTPUT" - name: Fetch existing service targets - if: env.SERVICE_TARGETS_MODE != 'off' && env.PLATFORM == 'ibkr' + if: >- + (env.APPLY_SWITCH == 'true' || env.SERVICE_TARGETS_MODE != 'off') && + contains(fromJSON('["longbridge","ibkr","schwab","firstrade"]'), env.PLATFORM) env: TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} run: | set -euo pipefail output_file="${RUNNER_TEMP}/existing-service-targets.json" target_environment="" - if [ "${VARIABLE_SCOPE}" = "environment" ]; then + if [ "${PLATFORM}" = "ibkr" ] && [ "${VARIABLE_SCOPE}" = "environment" ]; then target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}" fi - python - <<'PY' "${TARGET_REPOSITORY}" "${output_file}" "${target_environment}" + python - <<'PY' \ + "${TARGET_REPOSITORY}" \ + "${output_file}" \ + "${target_environment}" \ + "${PLATFORM}" \ + "${SERVICE_TARGETS_MODE}" import json + import os import subprocess import sys - repo, output_path, environment = sys.argv[1:4] + repo, output_path, environment, platform, mode = sys.argv[1:6] command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"] if environment: command.extend(["--env", environment]) @@ -253,8 +291,14 @@ jobs: value = str(item.get("value") or "").strip() break if not value: - with open(output_path, "w", encoding="utf-8") as handle: - handle.write("{}") + if (platform == "ibkr" and mode != "off") or mode == "allow_create": + with open(output_path, "w", encoding="utf-8") as handle: + handle.write("{}") + else: + try: + os.unlink(output_path) + except FileNotFoundError: + pass raise SystemExit(0) try: payload = json.loads(value) @@ -263,7 +307,18 @@ jobs: with open(output_path, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) PY - echo "EXISTING_SERVICE_TARGETS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" + if [ -f "${output_file}" ]; then + echo "EXISTING_SERVICE_TARGETS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" + fi + + - name: Reject unsafe service inventory bypass + if: env.APPLY_SWITCH == 'true' && env.SERVICE_TARGETS_MODE == 'off' + run: | + set -euo pipefail + if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then + echo "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires service_targets_mode=patch or allow_create" >&2 + exit 2 + fi - name: Build switch target run: | @@ -370,10 +425,6 @@ jobs: schwab|firstrade) gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main ;; - qmt|binance) - echo "${PLATFORM} platform Cloud Run sync workflow is not configured yet; skipped." - exit 0 - ;; *) echo "No platform sync dispatch rule for ${PLATFORM}" >&2 exit 2 diff --git a/README.md b/README.md index e8f3822..e523040 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ python3 -m unittest discover -s python/tests -v ## Manual Strategy Switch -`.github/workflows/manual-strategy-switch.yml` provides a central manual switch entrypoint. It builds a transient runtime target from workflow inputs, validates it with `python/scripts/runtime_settings.py`, and writes GitHub variables into the target platform repository. It currently supports `longbridge`, `ibkr`, `schwab`, and `firstrade`. +`.github/workflows/manual-strategy-switch.yml` provides a central manual switch entrypoint. It builds a transient runtime target from workflow inputs, validates it with `python/scripts/runtime_settings.py`, and writes GitHub variables into the target platform repository. It supports `longbridge`, `ibkr`, `schwab`, `firstrade`, `qmt`, and `binance`. Recommended flow: 1. Run once with `apply=false` to preview the assignments. 2. Check `repository`, `environment`, `strategy_profile`, `service_name`, `execution_mode`, and plugin mounts. 3. Re-run with `apply=true` and `confirm_apply=APPLY` to write variables. -4. Set `trigger_platform_sync=true` and `confirm_apply=APPLY_AND_SYNC` when the target platform should dispatch its Cloud Run env sync workflow. +4. For a Cloud Run platform, set `trigger_platform_sync=true` and `confirm_apply=APPLY_AND_SYNC` to dispatch its environment sync workflow. Example: @@ -82,12 +82,16 @@ confirm_apply=APPLY_AND_SYNC Notes: - This is a GitHub Actions `workflow_dispatch` form, not a public web app. The default `apply=false` mode only previews assignments and writes nothing remotely. -- LongBridge defaults to environment-scoped variables; `target_name=sg` resolves to `longbridge-sg`. +- LongBridge defaults to environment-scoped variables; `target_name=sg` resolves to `longbridge-sg`. When a repository-level multi-service target list exists, the same switch also updates its exact service entry. +- Multi-service targets use `service_name` as their primary identity. Multiple services may share one `account_scope`; a switch never selects a sibling service by account scope. +- `CLOUD_RUN_SERVICE_TARGETS_JSON` supports both a bare array and an object with `targets`. New services require the explicit `allow_create` mode. - Schwab defaults to repository-scoped variables. - Firstrade defaults to repository-scoped variables; `target_name=live` uses `firstrade-quant-service` and `account_scope=US`. -- IBKR `service_targets_mode=auto` only patches an existing service/account-scope entry inside `CLOUD_RUN_SERVICE_TARGETS_JSON`, so other services are preserved and an unknown target fails closed. Use `allow_create` only when intentionally provisioning a new target. +- IBKR `service_targets_mode=auto` patches the exact existing service entry inside `CLOUD_RUN_SERVICE_TARGETS_JSON`; account scope is only a fallback for a legacy entry with no service identity. Other services are preserved and an unknown target fails closed. Use `allow_create` only when intentionally provisioning a new target. - Cross-repository variable writes and workflow dispatches require a `RUNTIME_SETTINGS_GH_TOKEN` secret in this repository with sufficient target-repository variable/workflow permissions. The workflow does not fall back to the default `github.token` for remote writes. -- IBKR `service_targets_mode=auto` must read and patch the target repository's `CLOUD_RUN_SERVICE_TARGETS_JSON`, so even preview mode requires `RUNTIME_SETTINGS_GH_TOKEN` for IBKR. +- LongBridge, IBKR, Schwab, and Firstrade `service_targets_mode=auto` checks the target repository's multi-service inventory, so even preview mode requires `RUNTIME_SETTINGS_GH_TOKEN`. +- Binance runs through an Oracle Cloud VPS self-hosted runner. Repository variable writes are consumed by the next externally scheduled `main.yml` dispatch; the central switch does not dispatch that runtime workflow because it may execute live trading. A strategy cadence change also requires a separate review of the external VPS scheduler. +- QMT remains dry-run only and has no live deployment configuration. Its generated target can stage repository variables, but `trigger_platform_sync=true` is rejected. - The workflow is bound to the `runtime-strategy-switch` GitHub Environment. For a personal system, required reviewers are optional; prefer storing `RUNTIME_SETTINGS_GH_TOKEN` as an Environment secret and rely on preview, confirmation text, and a least-privilege token for day-to-day safety. - Follow the simplified permission-control plan before enabling real switches: [docs/manual_strategy_switch_permission_control.zh-CN.md](docs/manual_strategy_switch_permission_control.zh-CN.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 47edee8..96cc0c9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,14 +44,14 @@ python3 -m unittest discover -s python/tests -v ## 一键切换策略 -`.github/workflows/manual-strategy-switch.yml` 提供手动触发的中控切换入口。它会根据表单参数生成运行目标,复用 `python/scripts/runtime_settings.py` 校验并写入目标平台仓库的 GitHub variables。当前支持 `longbridge`、`ibkr`、`schwab`、`firstrade`。 +`.github/workflows/manual-strategy-switch.yml` 提供手动触发的中控切换入口。它会根据表单参数生成运行目标,复用 `python/scripts/runtime_settings.py` 校验并写入目标平台仓库的 GitHub variables。当前支持 `longbridge`、`ibkr`、`schwab`、`firstrade`、`qmt`、`binance`。 推荐流程: 1. 第一次运行保持 `apply=false`,只看 preview。 2. 确认 `repository`、`environment`、`strategy_profile`、`service_name`、`execution_mode`,以及由 `strategy_profile` 派生的 `scheduler` / 插件挂载正确。 3. 再运行 `apply=true`,并填写 `confirm_apply=APPLY`,写入目标仓库变量。 -4. 如果要让平台仓同步 Cloud Run 环境,额外设置 `trigger_platform_sync=true`,并填写 `confirm_apply=APPLY_AND_SYNC`。 +4. 对 Cloud Run 平台,如需同步运行环境,额外设置 `trigger_platform_sync=true`,并填写 `confirm_apply=APPLY_AND_SYNC`。 常用例子: @@ -69,12 +69,15 @@ confirm_apply=APPLY_AND_SYNC 注意: - 这是 GitHub Actions 的 `workflow_dispatch` 手动表单,不是公开网页。默认 `apply=false` 只生成预览,不写任何远端变量。 -- LongBridge 默认写入 environment variables,例如 `target_name=sg` 会落到 `longbridge-sg`。 +- LongBridge 默认写入 environment variables,例如 `target_name=sg` 会落到 `longbridge-sg`;如果仓库已有多服务目标清单,同一次切换也会更新其 repository-level 精确服务条目。 - Schwab 默认写入 repository variables。 - Firstrade 默认写入 repository variables,`target_name=live` 会使用 `firstrade-quant-service` 和 `account_scope=US`。 -- IBKR 如果目标仓库已有 `CLOUD_RUN_SERVICE_TARGETS_JSON`,workflow 会 patch 指定 service/account_scope 的 target entry,避免覆盖其他 IBKR 服务。 +- 多服务目标以 `service_name` 为唯一主标识;同一 `account_scope` 可以有多个策略服务,切换只更新精确服务,不会覆盖兄弟目标。 +- `CLOUD_RUN_SERVICE_TARGETS_JSON` 同时支持数组和 `{targets:[...]}`;新增服务必须显式选择 `service_targets_mode=allow_create`。 - 跨仓写 variables 和触发 workflow 必须在本仓配置 `RUNTIME_SETTINGS_GH_TOKEN` secret,token 至少需要目标仓库的 variables/workflow 写权限;不会回退到默认 `github.token` 写远端变量。 -- IBKR 的 `service_targets_mode=auto` 需要读取并 patch 目标仓库的 `CLOUD_RUN_SERVICE_TARGETS_JSON`,因此即使只做 preview 也需要 `RUNTIME_SETTINGS_GH_TOKEN`。 +- LongBridge、IBKR、Schwab、Firstrade 的 `service_targets_mode=auto` 会检查目标仓库是否已有多服务清单,因此即使只做 preview 也需要 `RUNTIME_SETTINGS_GH_TOKEN`。 +- Binance 运行在 Oracle Cloud VPS 的 self-hosted runner。仓库变量会在外部调度器下一次触发 `main.yml` 时被读取;中控不会自动触发该运行 workflow,因为它可能直接执行实盘。切换到不同运行频率的策略时,还必须单独复核 VPS 外部调度器。 +- QMT 当前仅支持 dry-run,尚无实盘部署配置;可以生成目标并暂存仓库变量,但会拒绝 `trigger_platform_sync=true`。 - workflow 绑定 GitHub Environment `runtime-strategy-switch`。个人系统默认不需要 required reviewers;建议把 `RUNTIME_SETTINGS_GH_TOKEN` 配成这个 Environment 的 secret,真实写入靠 preview、确认词和 token 最小权限控制。 - 启用真实切换前请按 [手动策略切换权限控制方案](docs/manual_strategy_switch_permission_control.zh-CN.md) 完成最简 secret、token 权限和回滚准备。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4c5b1f..62cb739 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,23 @@ QuantRuntimeSettings is a **config-driven** runtime settings package that serves as the central control plane for QuantStrategyLab deployments. It defines versioned strategy-to-platform assignments and hosts a Cloudflare Workers-based strategy switch console. -The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for a switch. `scheduler` and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is part of the target and validated against strategy-profile policy. +The generated `RUNTIME_TARGET_JSON` payload is the canonical desired-state contract for one deployment target. `scheduler`, `market`, `market_calendar`, `market_timezone`, and plugin mount outputs are derived from `strategy_profile`, while `execution_mode` is validated against strategy-profile policy. + +### Multi-strategy identity and storage + +- A platform may run multiple strategies through multiple deployment targets, but one `service_name` has one active `strategy_profile`. +- `service_name` is the primary target identity. `account_scope` may be shared by multiple services and is only a legacy fallback when an existing entry has no service identity. +- `CLOUD_RUN_SERVICE_TARGETS_JSON` accepts either a bare target array or an object with `targets`. +- Repository-scoped multi-service platforms update only the exact service entry. Creating a new service requires the explicit `allow_create` path. +- LongBridge keeps its selected `RUNTIME_TARGET_JSON` and account variables in the GitHub Environment, while the repository-scoped service-target aggregate is updated in the same switch operation for deployment reconciliation. +- Legacy targets may omit market metadata. Newly generated targets always carry the complete market metadata trio; partial metadata is rejected. + +### Deployment topology + +- LongBridge, IBKR, Schwab, and Firstrade use Cloud Run and may activate settings through their environment-sync workflows. +- Binance uses an Oracle Cloud VPS self-hosted GitHub Actions runner. Settings become active on the next externally scheduled runtime workflow dispatch; the settings control plane never dispatches this live-capable workflow as a sync substitute and does not reconfigure the external VPS scheduler. +- QMT has no live deployment configuration and remains dry-run only. +- `platform-config.json` records `runtime_model`, `settings_activation`, and `live_configured` for every platform. Unsupported activation combinations fail validation. The repository has a **three-tier architecture** built around a single source of truth: @@ -85,7 +101,7 @@ platform-config.json (single source of truth) Defines the entire runtime configuration universe: -- **4 domains**: `us_equity`, `hk_equity`, `cn_equity`, `crypto` +- **4 domains**: `us_equity`, `hk_equity`, `cn_equity`, `crypto`, including market calendar and timezone metadata - **6 platforms**: `longbridge`, `ibkr`, `schwab`, `firstrade`, `qmt`, `binance` - **18 strategy profiles** with features: income layer, option overlay, DCA, combo - **Platform capabilities, CSS theming, default accounts, repositories, variable scopes** diff --git a/docs/manual_strategy_switch_permission_control.zh-CN.md b/docs/manual_strategy_switch_permission_control.zh-CN.md index 98b3a6d..c766e13 100644 --- a/docs/manual_strategy_switch_permission_control.zh-CN.md +++ b/docs/manual_strategy_switch_permission_control.zh-CN.md @@ -42,7 +42,7 @@ 需要的能力只有: - 读取和写入 GitHub Actions variables。 -- 如果要自动同步 Cloud Run,允许 dispatch 目标平台 workflow。 +- 对已接入的 Cloud Run 平台,允许 dispatch 目标平台环境同步 workflow。 不需要: @@ -68,7 +68,8 @@ - `apply=false` 默认只预览,不改远端。 - `apply=true` 必须写确认词。 - 没有 `RUNTIME_SETTINGS_GH_TOKEN` 时不能真实写入。 -- IBKR 会 patch 指定 target,不覆盖其他 IBKR 服务。 +- 多服务平台按 `service_name` 精确 patch target;`account_scope` 即使重复也不会覆盖其他策略服务。 +- LongBridge 会同时维护环境级运行目标和仓库级多服务清单,避免页面状态与实际部署输入分叉。 - `extra_variables_json` 不能覆盖系统自动生成的核心变量。 - `extra_variables_json` 会拒绝疑似 secret 的变量名,例如 `PASSWORD`、`TOKEN`、`API_KEY`、`ACCESS_KEY`、`CLIENT_SECRET`、`SECRET`。 @@ -79,7 +80,7 @@ - 未登录或不在 allowlist:只能看页面、填参数、复制 preview,不能执行切换。 - 已登录且 GitHub 用户名在 allowlist:页面启用“一键执行”,由后端触发 GitHub workflow。 - 前端不保存 GitHub token,不保存 broker secret,不把敏感值写进 localStorage、URL 或日志。 -- 后端只做登录校验、allowlist 校验和 workflow dispatch,不直接写平台仓 variables,也不直接改 Cloud Run。 +- 后端只做登录校验、allowlist 校验和 workflow dispatch,不直接写平台仓 variables,也不直接改 Cloud Run 或 Oracle/VPS。 - 后端使用 `RUNTIME_SETTINGS_DISPATCH_TOKEN` 触发 workflow;GitHub Actions 内部再使用 `RUNTIME_SETTINGS_GH_TOKEN` 写目标平台 variables。 - 真正跨平台变量写入仍由 `Manual Strategy Switch` workflow 执行,继续复用 preview、确认词和 secret 变量名校验。 @@ -94,7 +95,7 @@ 1. 选择上一个稳定 `strategy_profile`。 2. 保持同一个 `platform` 和 `target_name`。 3. 运行 `apply=true`。 -4. 如果之前同步过 Cloud Run,这次也用 `APPLY_AND_SYNC`。 +4. Cloud Run 平台如果之前同步过运行环境,这次也用 `APPLY_AND_SYNC`;Binance 等待外部调度器下一次触发 VPS self-hosted runtime,QMT 当前没有实盘同步步骤。 ## 可选增强 diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index a288181..b5bdc64 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -13,7 +13,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "BinancePlatform", @@ -27,14 +27,14 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CharlesSchwabPlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -48,7 +48,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -76,14 +76,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CnEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CryptoLivePoolPipelines", @@ -118,21 +118,21 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "CryptoStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "FirstradePlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "FirstradePlatform", @@ -146,7 +146,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "FirstradePlatform", @@ -174,14 +174,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "HkEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -195,7 +195,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -216,7 +216,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -237,7 +237,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "LongBridgePlatform", @@ -258,7 +258,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "LongBridgePlatform", @@ -342,14 +342,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" }, { "consumer_repo": "UsEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" + "ref": "92458590a463e7219f0369a3505031ee74414135" } ] } diff --git a/platform-config.json b/platform-config.json index 72c6fa0..d0f532a 100644 --- a/platform-config.json +++ b/platform-config.json @@ -28,21 +28,33 @@ "us_equity": { "label_zh": "美股", "label_en": "US Equity", + "market": "US", + "market_calendar": "NYSE", + "market_timezone": "America/New_York", "scheduler_profile": "us_daily" }, "hk_equity": { "label_zh": "港股", "label_en": "HK Equity", + "market": "HK", + "market_calendar": "XHKG", + "market_timezone": "Asia/Hong_Kong", "scheduler_profile": "hk_daily" }, "cn_equity": { "label_zh": "A股", "label_en": "CN A-share", + "market": "CN", + "market_calendar": "SSE", + "market_timezone": "Asia/Shanghai", "scheduler_profile": "cn_daily" }, "crypto": { "label_zh": "加密", "label_en": "Crypto", + "market": "CRYPTO", + "market_calendar": "24/7", + "market_timezone": "UTC", "scheduler_profile": "crypto_intraday" } }, @@ -56,9 +68,9 @@ }, "us_dca_month_end": { "timezone": "America/New_York", - "main_time": "45 15 25-28 * *", - "probe_time": "35 9,15 25-28 * *", - "precheck_time": "45 9 25-28 * *" + "main_time": "45 15 25-29 * *", + "probe_time": "35 9,15 25-29 * *", + "precheck_time": "45 9 25-29 * *" }, "us_snapshot_month_start": { "timezone": "America/New_York", @@ -131,6 +143,9 @@ "deployment": { "default_execution_mode": "live", "dry_run_only": false, + "runtime_model": "cloud_run", + "settings_activation": "cloud_run_sync_workflow", + "live_configured": true, "env_repo_key": [ "STRATEGY_SWITCH_LONGBRIDGE_REPO", "RUNTIME_SETTINGS_LONGBRIDGE_REPO" @@ -170,6 +185,9 @@ "deployment": { "default_execution_mode": "live", "dry_run_only": false, + "runtime_model": "cloud_run", + "settings_activation": "cloud_run_sync_workflow", + "live_configured": true, "env_repo_key": [ "STRATEGY_SWITCH_IBKR_REPO", "RUNTIME_SETTINGS_IBKR_REPO" @@ -207,6 +225,9 @@ "deployment": { "default_execution_mode": "live", "dry_run_only": false, + "runtime_model": "cloud_run", + "settings_activation": "cloud_run_sync_workflow", + "live_configured": true, "env_repo_key": [ "STRATEGY_SWITCH_SCHWAB_REPO", "RUNTIME_SETTINGS_SCHWAB_REPO" @@ -244,6 +265,9 @@ "deployment": { "default_execution_mode": "live", "dry_run_only": false, + "runtime_model": "cloud_run", + "settings_activation": "cloud_run_sync_workflow", + "live_configured": true, "env_repo_key": [ "STRATEGY_SWITCH_FIRSTRADE_REPO", "RUNTIME_SETTINGS_FIRSTRADE_REPO" @@ -280,6 +304,9 @@ "deployment": { "default_execution_mode": "paper", "dry_run_only": true, + "runtime_model": "not_configured", + "settings_activation": "not_wired", + "live_configured": false, "env_repo_key": [ "STRATEGY_SWITCH_QMT_REPO", "RUNTIME_SETTINGS_QMT_REPO" @@ -316,11 +343,14 @@ "deployment": { "default_execution_mode": "live", "dry_run_only": false, + "runtime_model": "oracle_vps_self_hosted", + "settings_activation": "next_runtime_workflow_dispatch", + "live_configured": true, "env_repo_key": [ "STRATEGY_SWITCH_BINANCE_REPO", "RUNTIME_SETTINGS_BINANCE_REPO" ], - "service_name": "" + "service_name": "binance-platform" } } }, diff --git a/python/scripts/build_config.py b/python/scripts/build_config.py index bf2043f..da237f4 100644 --- a/python/scripts/build_config.py +++ b/python/scripts/build_config.py @@ -38,6 +38,13 @@ "blocked_live_reason", } SCHEDULER_FIELDS = {"timezone", "main_time", "probe_time", "precheck_time"} +MARKET_FIELDS = {"market", "market_calendar", "market_timezone"} +RUNTIME_MODELS = {"cloud_run", "oracle_vps_self_hosted", "not_configured"} +SETTINGS_ACTIVATION_MODES = { + "cloud_run_sync_workflow", + "next_runtime_workflow_dispatch", + "not_wired", +} def load_config() -> dict: @@ -79,6 +86,34 @@ def validate(config: dict) -> list[str]: errors.append(f"platform {pid}: missing default_account") if "supported_domains" not in pdata: errors.append(f"platform {pid}: missing supported_domains") + deployment = pdata.get("deployment") + if not isinstance(deployment, dict): + errors.append(f"platform {pid}: missing deployment") + continue + runtime_model = deployment.get("runtime_model") + settings_activation = deployment.get("settings_activation") + if runtime_model not in RUNTIME_MODELS: + errors.append(f"platform {pid}: unsupported runtime_model {runtime_model!r}") + if settings_activation not in SETTINGS_ACTIVATION_MODES: + errors.append( + f"platform {pid}: unsupported settings_activation {settings_activation!r}" + ) + if not isinstance(deployment.get("live_configured"), bool): + errors.append(f"platform {pid}: live_configured must be boolean") + if settings_activation == "cloud_run_sync_workflow" and runtime_model != "cloud_run": + errors.append( + f"platform {pid}: settings_activation {settings_activation!r} " + "requires runtime_model 'cloud_run'" + ) + if settings_activation == "next_runtime_workflow_dispatch" and runtime_model != "oracle_vps_self_hosted": + errors.append( + f"platform {pid}: settings_activation {settings_activation!r} " + "requires runtime_model 'oracle_vps_self_hosted'" + ) + if settings_activation == "not_wired" and deployment.get("live_configured") is not False: + errors.append( + f"platform {pid}: settings_activation 'not_wired' requires live_configured false" + ) domains = config.get("domains", {}) for domain, domain_data in domains.items(): if not isinstance(domain_data, dict): @@ -89,6 +124,31 @@ def validate(config: dict) -> list[str]: errors.append( f"domain {domain}: unknown scheduler_profile {scheduler_profile!r}" ) + for field in MARKET_FIELDS: + value = domain_data.get(field) + if not isinstance(value, str) or not value.strip(): + errors.append(f"domain {domain}: {field} must be a non-empty string") + market_timezone = domain_data.get("market_timezone") + try: + ZoneInfo(str(market_timezone or "")) + except (ZoneInfoNotFoundError, ValueError): + errors.append(f"domain {domain}: invalid market_timezone {market_timezone!r}") + scheduler_data = ( + scheduler_profiles.get(scheduler_profile) + if isinstance(scheduler_profile, str) + else None + ) + if isinstance(scheduler_data, dict): + scheduler_timezone = scheduler_data.get("timezone") + if ( + isinstance(scheduler_timezone, str) + and isinstance(market_timezone, str) + and scheduler_timezone != market_timezone + ): + errors.append( + f"domain {domain}: scheduler timezone {scheduler_timezone!r} " + f"must match market_timezone {market_timezone!r}" + ) for sid, sdata in config.get("strategies", {}).items(): if "domain" not in sdata: errors.append(f"strategy {sid}: missing domain") @@ -116,6 +176,23 @@ def validate(config: dict) -> list[str]: errors.append( f"strategy {sid}: unknown scheduler_profile {scheduler_profile!r}" ) + else: + scheduler_data = scheduler_profiles.get(scheduler_profile) + scheduler_timezone = ( + scheduler_data.get("timezone") + if isinstance(scheduler_data, dict) + else None + ) + market_timezone = domain_data.get("market_timezone") + if ( + isinstance(scheduler_timezone, str) + and isinstance(market_timezone, str) + and scheduler_timezone != market_timezone + ): + errors.append( + f"strategy {sid}: scheduler timezone {scheduler_timezone!r} " + f"must match market_timezone {market_timezone!r}" + ) plugin_overrides = sdata.get("scheduler_profile_by_plugin", {}) if not isinstance(plugin_overrides, dict): errors.append(f"strategy {sid}: scheduler_profile_by_plugin must be an object") @@ -125,6 +202,24 @@ def validate(config: dict) -> list[str]: errors.append( f"strategy {sid}: plugin {plugin} references unknown scheduler_profile {override!r}" ) + continue + scheduler_data = scheduler_profiles.get(override) + scheduler_timezone = ( + scheduler_data.get("timezone") + if isinstance(scheduler_data, dict) + else None + ) + market_timezone = domain_data.get("market_timezone") + if ( + isinstance(scheduler_timezone, str) + and isinstance(market_timezone, str) + and scheduler_timezone != market_timezone + ): + errors.append( + f"strategy {sid}: plugin {plugin} scheduler timezone " + f"{scheduler_timezone!r} must match market_timezone " + f"{market_timezone!r}" + ) return errors diff --git a/python/scripts/build_runtime_switch.py b/python/scripts/build_runtime_switch.py index d695244..6ba7eeb 100644 --- a/python/scripts/build_runtime_switch.py +++ b/python/scripts/build_runtime_switch.py @@ -20,6 +20,7 @@ compact_json, env_string, platform_repository, + select_service_target_entry_index, validate_target, ) @@ -164,11 +165,14 @@ "qmt": "repository", "binance": "repository", } +REPOSITORY_SCOPED_SERVICE_INVENTORY_PLATFORMS = frozenset( + {"longbridge", "schwab", "firstrade"} +) DEFAULT_SERVICE_NAME = { "schwab": "charles-schwab-quant-service", "firstrade": "firstrade-quant-service", "qmt": "qmt-quant-service", - "binance": "", + "binance": "binance-platform", } PLATFORM_ALIASES = { "firsttrade": "firstrade", @@ -249,11 +253,16 @@ def _load_json_object(value: str, *, field_name: str) -> dict[str, Any]: return payload -def _load_json_from_file(path: str | None, *, field_name: str) -> dict[str, Any]: +def _load_json_from_file(path: str | None, *, field_name: str) -> dict[str, Any] | list[Any]: if not path: return {} - text = Path(path).read_text(encoding="utf-8") - return _load_json_object(text, field_name=field_name) + try: + payload = json.loads(Path(path).read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"{field_name} must be valid JSON") from exc + if not isinstance(payload, (dict, list)): + raise ValueError(f"{field_name} must decode to an object or array") + return payload def _parse_extra_variables(pairs: list[str], raw_json: str) -> dict[str, Any]: @@ -715,6 +724,29 @@ def _scheduler_plan_for_strategy( return {str(key): str(value) for key, value in scheduler.items()} +def _market_plan_for_strategy(strategy_profile: str) -> dict[str, str]: + profile = str(strategy_profile or "").strip().lower() + config = _load_platform_config() + strategies = config.get("strategies") + domains = config.get("domains") + strategy = strategies.get(profile) if isinstance(strategies, dict) else None + if not isinstance(strategy, dict) or not isinstance(domains, dict): + raise ValueError(f"strategy {profile!r} is missing from the market catalog") + domain = domains.get(strategy.get("domain")) + if not isinstance(domain, dict): + raise ValueError(f"strategy {profile!r} has no configured market domain") + market = { + field: str(domain.get(field) or "").strip() + for field in ("market", "market_calendar", "market_timezone") + } + missing = [field for field, value in market.items() if not value] + if missing: + raise ValueError( + f"strategy {profile!r} market domain is missing {', '.join(missing)}" + ) + return market + + def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: platform = _normalize_platform(args.platform) target_name = _normalize_target_name(args.target_name) @@ -741,6 +773,7 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: "service_name": service_name, "execution_mode": execution_mode, "scheduler": _scheduler_plan_for_strategy(strategy_profile), + **_market_plan_for_strategy(strategy_profile), } execution_windows = _load_json_object(args.execution_windows_json, field_name="execution_windows_json") if execution_windows: @@ -796,19 +829,24 @@ def _preserve_reserved_cash_fields( def _patch_service_targets( *, - current_payload: dict[str, Any], + current_payload: dict[str, Any] | list[Any], platform: str, runtime_target: dict[str, Any], mounts_variable: str, mounts: list[dict[str, Any]], extra_variables: dict[str, Any], allow_create: bool, -) -> dict[str, Any]: - payload = dict(current_payload) - raw_entries = payload.get("targets") if isinstance(payload.get("targets"), list) else [] - entries = [dict(item) for item in raw_entries if isinstance(item, dict)] - service_name = str(runtime_target["service_name"]) - account_scope = str(runtime_target["account_scope"]) +) -> dict[str, Any] | list[dict[str, Any]]: + payload = dict(current_payload) if isinstance(current_payload, dict) else None + if payload is not None: + raw_entries = payload.get("targets", []) + if not isinstance(raw_entries, list): + raise ValueError("service targets must be an array") + else: + raw_entries = current_payload + if any(not isinstance(item, dict) for item in raw_entries): + raise ValueError("service target entries must be objects") + entries = [dict(item) for item in raw_entries] replacement = _build_target_entry( platform=platform, runtime_target=runtime_target, @@ -817,33 +855,29 @@ def _patch_service_targets( extra_variables=extra_variables, ) - replaced = False - for index, entry in enumerate(entries): - entry_runtime_target = entry.get("runtime_target") if isinstance(entry.get("runtime_target"), dict) else {} - candidates = { - str(entry.get("service") or "").strip(), - str(entry.get("service_name") or "").strip(), - str(entry_runtime_target.get("service_name") or "").strip(), - str(entry_runtime_target.get("account_scope") or "").strip(), - str(entry.get("ACCOUNT_GROUP") or "").strip(), - } - if service_name in candidates or account_scope in candidates: - _preserve_reserved_cash_fields( - platform=platform, - current_entry=entry, - replacement=replacement, - ) - entries[index] = {**entry, **replacement} - replaced = True - break - - if not replaced and not allow_create: + matched_index = select_service_target_entry_index( + runtime_target, + entries, + allow_account_scope_fallback=not allow_create, + ) + if matched_index is not None: + current_entry = entries[matched_index] + _preserve_reserved_cash_fields( + platform=platform, + current_entry=current_entry, + replacement=replacement, + ) + entries[matched_index] = {**current_entry, **replacement} + elif not allow_create: + platform_label = "IBKR " if platform == "ibkr" else "" raise ValueError( - "existing IBKR service target was not found; " + f"existing {platform_label}service target was not found; " "use --allow-create-service-target to append a new target" ) - if not replaced: + else: entries.append(replacement) + if payload is None: + return entries payload["targets"] = entries return payload @@ -894,6 +928,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: top_level_mounts = mounts plugin_mounts_variable: str | None = mounts_variable + repository_variables: dict[str, Any] = {} if args.existing_service_targets_json_file: service_targets = _load_json_from_file( args.existing_service_targets_json_file, @@ -908,9 +943,15 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: extra_variables=extra_variables, allow_create=args.allow_create_service_target, ) - extra_variables = {"CLOUD_RUN_SERVICE_TARGETS_JSON": patched_service_targets} - top_level_mounts = [] - plugin_mounts_variable = None + if ( + variable_scope == "environment" + and platform in REPOSITORY_SCOPED_SERVICE_INVENTORY_PLATFORMS + ): + repository_variables = {"CLOUD_RUN_SERVICE_TARGETS_JSON": patched_service_targets} + else: + extra_variables = {"CLOUD_RUN_SERVICE_TARGETS_JSON": patched_service_targets} + top_level_mounts = [] + plugin_mounts_variable = None target: dict[str, Any] = { "target_id": f"{platform}/{target_name}", @@ -927,6 +968,8 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: if plugin_mounts_variable: target["plugin_mounts_variable"] = plugin_mounts_variable target["plugin_mounts"] = top_level_mounts + if repository_variables: + target["repository_variables"] = repository_variables errors = validate_target(target) if errors: raise ValueError("; ".join(errors)) diff --git a/python/scripts/runtime_settings.py b/python/scripts/runtime_settings.py index f7aebc6..53d213f 100644 --- a/python/scripts/runtime_settings.py +++ b/python/scripts/runtime_settings.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError ROOT = Path(__file__).resolve().parents[2] LOCAL_TARGETS_DIR = ROOT / "local" / "targets" @@ -51,6 +52,7 @@ "execution": {"live", "paper", "dry_run"}, } SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"}) +MARKET_FIELDS = ("market", "market_calendar", "market_timezone") GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"} SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET") LEGACY_INCOME_LAYER_VARIABLES = frozenset( @@ -218,6 +220,20 @@ def platform_repository(platform: str, env: dict[str, str] | None = None) -> str return platform_repositories(env)[platform] +def platform_settings_activation(platform: str) -> str: + if platform not in SUPPORTED_PLATFORMS: + raise ValueError(f"unsupported platform: {platform}") + try: + payload = json.loads(PLATFORM_CONFIG_PATH.read_text(encoding="utf-8")) + value = payload["platforms"][platform]["deployment"]["settings_activation"] + except (KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError(f"platform {platform} settings_activation is not configured") from exc + value = str(value or "").strip() + if not value: + raise ValueError(f"platform {platform} settings_activation is not configured") + return value + + def discover_target_paths(paths: list[str]) -> list[Path]: if paths: return [Path(path).resolve() for path in paths] @@ -390,12 +406,32 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: if not isinstance(value, str) or len(value.split()) not in {2, 5}: errors.append(f"runtime_target.scheduler.{field} must have 2 time fields or 5 cron fields") + configured_market_fields = [] + for field in MARKET_FIELDS: + value = runtime_target.get(field) + if value is not None: + configured_market_fields.append(field) + if value is not None and (not isinstance(value, str) or not value.strip()): + errors.append(f"runtime_target.{field} must be a non-empty string when present") + if configured_market_fields and len(configured_market_fields) != len(MARKET_FIELDS): + errors.append( + "runtime_target market metadata must include " + "market, market_calendar, and market_timezone together" + ) + market_timezone = runtime_target.get("market_timezone") + if isinstance(market_timezone, str) and market_timezone.strip(): + try: + ZoneInfo(market_timezone) + except (ZoneInfoNotFoundError, ValueError): + errors.append(f"runtime_target.market_timezone is invalid: {market_timezone!r}") + def validate_runtime_target_strategy_policy(runtime_target: dict[str, Any], errors: list[str]) -> None: config = load_platform_config() strategies = config.get("strategies", {}) platforms = config.get("platforms", {}) - if not isinstance(strategies, dict) or not isinstance(platforms, dict): + domains = config.get("domains", {}) + if not isinstance(strategies, dict) or not isinstance(platforms, dict) or not isinstance(domains, dict): return profile = str(runtime_target.get("strategy_profile") or "").strip() strategy = strategies.get(profile) @@ -405,11 +441,42 @@ def validate_runtime_target_strategy_policy(runtime_target: dict[str, Any], erro platform_id = str(runtime_target.get("platform_id") or "").strip() platform = platforms.get(platform_id) domain = str(strategy.get("domain") or "").strip() + domain_config = domains.get(domain) supported_domains = platform.get("supported_domains", []) if isinstance(platform, dict) else [] if domain and isinstance(supported_domains, list) and supported_domains and domain not in supported_domains: errors.append(f"runtime_target.strategy_profile domain {domain} is not supported by {platform_id}") + if isinstance(domain_config, dict): + if any(runtime_target.get(field) is not None for field in MARKET_FIELDS): + for field in MARKET_FIELDS: + actual = runtime_target.get(field) + expected = domain_config.get(field) + if isinstance(actual, str) and actual.strip() and actual.strip() != expected: + errors.append( + f"runtime_target.{field} must match strategy domain {domain}: expected {expected!r}" + ) + scheduler = runtime_target.get("scheduler") + if isinstance(scheduler, dict): + actual_timezone = scheduler.get("timezone") + expected_timezone = domain_config.get("market_timezone") + if ( + isinstance(actual_timezone, str) + and actual_timezone.strip() + and actual_timezone.strip() != expected_timezone + ): + errors.append( + "runtime_target.scheduler.timezone must match strategy domain " + f"{domain}: expected {expected_timezone!r}" + ) + execution_mode = str(runtime_target.get("execution_mode") or "").strip().lower() + deployment = platform.get("deployment", {}) if isinstance(platform, dict) else {} + if ( + execution_mode == "live" + and isinstance(deployment, dict) + and deployment.get("live_configured") is False + ): + errors.append(f"platform {platform_id} has no live runtime configuration") allowed_modes = normalize_allowed_execution_modes(strategy.get("allowed_execution_modes")) if allowed_modes and execution_mode not in allowed_modes: errors.append(f"runtime_target.strategy_profile {profile} does not allow {execution_mode} execution") @@ -609,6 +676,29 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None: errors.append(f"extra_variables.{dry_run_variable} must match runtime_target.dry_run_only") +def validate_repository_variables(target: dict[str, Any], errors: list[str]) -> None: + repository_variables = target.get("repository_variables", {}) + if not isinstance(repository_variables, dict): + errors.append("repository_variables must be an object") + return + + extra_variables = target.get("extra_variables") + extra_variable_names = set(extra_variables) if isinstance(extra_variables, dict) else set() + for name, value in repository_variables.items(): + if name in GENERATED_VARIABLES: + errors.append(f"repository_variables.{name} duplicates a generated variable") + if name in extra_variable_names: + errors.append(f"repository_variables.{name} duplicates extra_variables.{name}") + if name in RESEARCH_ONLY_EXTRA_VARIABLES: + errors.append( + f"repository_variables.{name} is research-only and must not be stored in live switch settings" + ) + if is_secret_variable_name(name): + errors.append(f"repository_variables.{name} looks like a secret and must not be stored here") + if isinstance(value, str) and "\n" in value: + errors.append(f"repository_variables.{name} must be a single-line value") + + def validate_target(target: dict[str, Any], path: Path | None = None) -> list[str]: errors: list[str] = [] target_id = target.get("target_id") @@ -624,6 +714,7 @@ def validate_target(target: dict[str, Any], path: Path | None = None) -> list[st validate_runtime_target(target, errors) validate_plugin_mounts(target, errors) validate_extra_variables(target, errors) + validate_repository_variables(target, errors) runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {} github = target.get("github") if isinstance(target.get("github"), dict) else {} @@ -676,6 +767,18 @@ def build_assignments(target: dict[str, Any]) -> list[Assignment]: for name, value in sorted((target.get("extra_variables") or {}).items()): assignments.append(Assignment(target_id, repository, scope, environment, name, env_string(value))) + for name, value in sorted((target.get("repository_variables") or {}).items()): + assignments.append( + Assignment( + target_id, + repository, + "repository", + None, + name, + env_string(value), + ) + ) + return assignments @@ -774,24 +877,73 @@ def command_repository(args: argparse.Namespace) -> int: return 0 +def command_settings_activation(args: argparse.Namespace) -> int: + print(platform_settings_activation(args.platform)) + return 0 + + ACCOUNT_SYNC_CONTROL_FIELDS = { "DCA_MODE": "dca_mode", "DCA_BASE_INVESTMENT_USD": "dca_base_investment_usd", } -def _service_target_entry_matches(runtime_target: dict[str, Any], entry: dict[str, Any]) -> bool: +def _service_target_identity(entry: dict[str, Any], field: str) -> set[str]: + entry_runtime = entry.get("runtime_target") if isinstance(entry.get("runtime_target"), dict) else {} + if field == "service_name": + values = ( + entry.get("service"), + entry.get("service_name"), + entry_runtime.get("service_name"), + ) + else: + values = ( + entry.get("ACCOUNT_GROUP"), + entry.get("account_scope"), + entry_runtime.get("account_scope"), + ) + return {str(value or "").strip() for value in values if str(value or "").strip()} + + +def select_service_target_entry_index( + runtime_target: dict[str, Any], + entries: list[dict[str, Any]], + *, + allow_account_scope_fallback: bool = True, +) -> int | None: service_name = str(runtime_target.get("service_name") or "").strip() account_scope = str(runtime_target.get("account_scope") or "").strip() - entry_runtime = entry.get("runtime_target") if isinstance(entry.get("runtime_target"), dict) else {} - candidates = { - str(entry.get("service") or "").strip(), - str(entry.get("service_name") or "").strip(), - str(entry_runtime.get("service_name") or "").strip(), - str(entry_runtime.get("account_scope") or "").strip(), - str(entry.get("ACCOUNT_GROUP") or "").strip(), - } - return service_name in candidates or account_scope in candidates + service_matches = [ + index + for index, entry in enumerate(entries) + if service_name and service_name in _service_target_identity(entry, "service_name") + ] + if len(service_matches) > 1: + raise ValueError(f"multiple service targets match service_name {service_name!r}") + if service_matches: + return service_matches[0] + + if not allow_account_scope_fallback: + return None + scope_matches = [ + index + for index, entry in enumerate(entries) + if ( + account_scope + and not _service_target_identity(entry, "service_name") + and account_scope in _service_target_identity(entry, "account_scope") + ) + ] + if len(scope_matches) > 1: + raise ValueError( + f"multiple service targets match account_scope {account_scope!r}; " + "service_name must match an existing target" + ) + return scope_matches[0] if scope_matches else None + + +def _service_target_entry_matches(runtime_target: dict[str, Any], entry: dict[str, Any]) -> bool: + return select_service_target_entry_index(runtime_target, [entry]) == 0 def extract_account_sync_controls(target: dict[str, Any]) -> dict[str, str]: @@ -803,6 +955,10 @@ def extract_account_sync_controls(target: dict[str, Any]) -> dict[str, str]: controls[payload_key] = str(value).strip() service_targets = extra_variables.get("CLOUD_RUN_SERVICE_TARGETS_JSON") + if service_targets is None: + repository_variables = target.get("repository_variables") + if isinstance(repository_variables, dict): + service_targets = repository_variables.get("CLOUD_RUN_SERVICE_TARGETS_JSON") if isinstance(service_targets, str): try: service_targets = json.loads(service_targets) @@ -810,16 +966,11 @@ def extract_account_sync_controls(target: dict[str, Any]) -> dict[str, str]: service_targets = None runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {} - if isinstance(service_targets, dict): - entries = service_targets.get("targets") if isinstance(service_targets.get("targets"), list) else [] - matched = next( - ( - entry - for entry in entries - if isinstance(entry, dict) and _service_target_entry_matches(runtime_target, entry) - ), - None, - ) + if isinstance(service_targets, (dict, list)): + raw_entries = service_targets.get("targets") if isinstance(service_targets, dict) else service_targets + entries = [entry for entry in raw_entries or [] if isinstance(entry, dict)] + matched_index = select_service_target_entry_index(runtime_target, entries) + matched = entries[matched_index] if matched_index is not None else None if matched: for source_key, payload_key in ACCOUNT_SYNC_CONTROL_FIELDS.items(): if payload_key in controls: @@ -858,6 +1009,13 @@ def build_parser() -> argparse.ArgumentParser: repository.add_argument("platform", choices=sorted(SUPPORTED_PLATFORMS)) repository.set_defaults(func=command_repository) + settings_activation = subparsers.add_parser( + "settings-activation", + help="print how repository variable changes become active for a platform", + ) + settings_activation.add_argument("platform", choices=sorted(SUPPORTED_PLATFORMS)) + settings_activation.set_defaults(func=command_settings_activation) + return parser diff --git a/python/tests/test_internal_dependency_matrix.py b/python/tests/test_internal_dependency_matrix.py index ef859d3..d386253 100644 --- a/python/tests/test_internal_dependency_matrix.py +++ b/python/tests/test_internal_dependency_matrix.py @@ -99,12 +99,15 @@ def test_current_matrix_matches_local_workspace(self): self.assertEqual(report.missing_files, []) self.assertEqual(report.issues, []) - def test_qpk_rollout_consumers_use_canonical_pin(self): - canonical_pin = "ff09c889ed21e2eb6fcb37f6cdaa159190ec82da" - rollout_consumers = { + def test_qpk_migrated_consumers_use_current_canonical_pin(self): + canonical_pin = "92458590a463e7219f0369a3505031ee74414135" + migrated_consumers = { "BinancePlatform", "CharlesSchwabPlatform", + "CnEquityStrategies", "CryptoStrategies", + "FirstradePlatform", + "HkEquityStrategies", "InteractiveBrokersPlatform", "LongBridgePlatform", "UsEquityStrategies", @@ -113,29 +116,12 @@ def test_qpk_rollout_consumers_use_canonical_pin(self): refs = { (pin.consumer_repo, pin.path): pin.ref for pin in matrix_pins - if pin.consumer_repo in rollout_consumers and pin.source_repo == "QuantPlatformKit" + if pin.consumer_repo in migrated_consumers and pin.source_repo == "QuantPlatformKit" } - self.assertEqual(len(refs), len(rollout_consumers) * 2) + self.assertEqual(len(refs), len(migrated_consumers) * 2) self.assertEqual(set(refs.values()), {canonical_pin}) - def test_qpk_legacy_consumers_remain_on_previous_canonical_pin(self): - previous_canonical_pin = "651c9ac4f37ce6e7fe1bac84dc7646cd5abc9e6e" - legacy_consumers = { - "CnEquityStrategies", - "FirstradePlatform", - "HkEquityStrategies", - } - matrix_pins = check_internal_dependency_matrix.load_matrix(ROOT / "internal_dependency_matrix.json") - refs = { - (pin.consumer_repo, pin.path): pin.ref - for pin in matrix_pins - if pin.consumer_repo in legacy_consumers and pin.source_repo == "QuantPlatformKit" - } - - self.assertEqual(len(refs), len(legacy_consumers) * 2) - self.assertEqual(set(refs.values()), {previous_canonical_pin}) - def test_require_consumer_files_treats_missing_paths_as_issues(self): projects_root = self._make_projects_root({}) expected = [ diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index 96a09cf..a933b91 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -92,13 +92,73 @@ def test_manual_switch_platform_choices_cover_supported_platforms(self): self.assertEqual(set(platform_choices), set(runtime_settings.SUPPORTED_PLATFORMS)) + def test_platform_deployment_topology_matches_runtime_hosts(self): + config = build_config.load_config() + cloud_run_platforms = {"longbridge", "ibkr", "schwab", "firstrade"} + + for platform in cloud_run_platforms: + deployment = config["platforms"][platform]["deployment"] + self.assertEqual(deployment["runtime_model"], "cloud_run") + self.assertEqual(deployment["settings_activation"], "cloud_run_sync_workflow") + self.assertTrue(deployment["live_configured"]) + + binance = config["platforms"]["binance"]["deployment"] + self.assertEqual(binance["runtime_model"], "oracle_vps_self_hosted") + self.assertEqual(binance["settings_activation"], "next_runtime_workflow_dispatch") + self.assertTrue(binance["live_configured"]) + + qmt = config["platforms"]["qmt"]["deployment"] + self.assertEqual(qmt["runtime_model"], "not_configured") + self.assertEqual(qmt["settings_activation"], "not_wired") + self.assertFalse(qmt["live_configured"]) + + def test_manual_switch_rejects_unsupported_sync_before_variable_write(self): + workflow = (ROOT / ".github" / "workflows" / "manual-strategy-switch.yml").read_text(encoding="utf-8") + + self.assertIn('runtime_settings.py settings-activation "${PLATFORM}"', workflow) + self.assertIn("Oracle/VPS runtime activates settings on its next externally scheduled", workflow) + self.assertIn("QMT has no live runtime configuration", workflow) + self.assertLess( + workflow.index('runtime_settings.py settings-activation "${PLATFORM}"'), + workflow.index("Apply GitHub variable updates"), + ) + + def test_manual_switch_rejects_inventory_bypass_before_variable_write(self): + workflow = (ROOT / ".github" / "workflows" / "manual-strategy-switch.yml").read_text(encoding="utf-8") + + self.assertIn("env.APPLY_SWITCH == 'true' ||", workflow) + self.assertIn( + "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires " + "service_targets_mode=patch or allow_create", + workflow, + ) + self.assertLess( + workflow.index( + "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires " + "service_targets_mode=patch or allow_create" + ), + workflow.index("Apply GitHub variable updates"), + ) + + def test_settings_activation_comes_from_platform_config(self): + self.assertEqual( + runtime_settings.platform_settings_activation("binance"), + "next_runtime_workflow_dispatch", + ) + self.assertEqual(runtime_settings.platform_settings_activation("qmt"), "not_wired") + def test_manual_switch_reads_ibkr_targets_from_selected_environment_scope(self): workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8") - assert 'if [ "${VARIABLE_SCOPE}" = "environment" ]; then' in workflow + assert ( + 'if [ "${PLATFORM}" = "ibkr" ] && [ "${VARIABLE_SCOPE}" = "environment" ]; then' + in workflow + ) assert 'target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}"' in workflow assert 'command.extend(["--env", environment])' in workflow assert 'handle.write("{}")' in workflow + assert 'contains(fromJSON(\'["longbridge","ibkr","schwab","firstrade"]\'), env.PLATFORM)' in workflow + assert 'if [ -f "${output_file}" ]; then' in workflow def test_live_candidate_queue_lists_profiles_needing_promotion_review(self): catalog = [ @@ -459,6 +519,38 @@ def test_extract_account_sync_controls_reads_ibkr_service_targets(self): }, ) + def test_extract_account_sync_controls_prefers_exact_service_when_scope_is_shared(self): + target = { + "target_id": "ibkr/shared-b", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "nasdaq_sp500_smart_dca", + "service_name": "interactive-brokers-shared-b-service", + "account_scope": "shared-account", + }, + "extra_variables": { + "CLOUD_RUN_SERVICE_TARGETS_JSON": { + "targets": [ + { + "service": "interactive-brokers-shared-a-service", + "ACCOUNT_GROUP": "shared-account", + "DCA_MODE": "fixed", + }, + { + "service": "interactive-brokers-shared-b-service", + "ACCOUNT_GROUP": "shared-account", + "DCA_MODE": "smart", + }, + ] + } + }, + } + + self.assertEqual( + runtime_settings.extract_account_sync_controls(target), + {"dca_mode": "smart"}, + ) + def test_extract_account_sync_controls_prefers_top_level_extra_variables(self): target = { "target_id": "firstrade/default", @@ -607,6 +699,9 @@ def test_build_switch_target_defaults_longbridge_sg_tqqq(self): self.assertEqual(target["github"]["environment"], "longbridge-sg") self.assertEqual(target["runtime_target"]["service_name"], "longbridge-quant-sg-service") self.assertEqual(target["runtime_target"]["account_scope"], "SG") + self.assertEqual(target["runtime_target"]["market"], "US") + self.assertEqual(target["runtime_target"]["market_calendar"], "NYSE") + self.assertEqual(target["runtime_target"]["market_timezone"], "America/New_York") self.assertEqual( target["runtime_target"]["scheduler"], { @@ -628,6 +723,20 @@ def test_build_switch_target_defaults_longbridge_sg_tqqq(self): self.assertEqual(plugin_payload["strategy_plugins"][0]["expected_schema_version"], "market_regime_control.v1") self.assertEqual(runtime_settings.validate_target(target), []) + def test_market_plan_covers_every_catalog_strategy(self): + config = build_runtime_switch._load_platform_config() + + for profile, strategy in config["strategies"].items(): + domain = config["domains"][strategy["domain"]] + self.assertEqual( + build_runtime_switch._market_plan_for_strategy(profile), + { + "market": domain["market"], + "market_calendar": domain["market_calendar"], + "market_timezone": domain["market_timezone"], + }, + ) + def test_build_switch_target_uses_fork_repository_overrides(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( @@ -756,6 +865,9 @@ def test_build_switch_target_defaults_qmt_repository_scope(self): self.assertEqual(target["runtime_target"]["account_scope"], "CN") self.assertEqual(target["runtime_target"]["service_name"], "qmt-quant-service") self.assertEqual(target["runtime_target"]["dry_run_only"], True) + self.assertEqual(target["runtime_target"]["market"], "CN") + self.assertEqual(target["runtime_target"]["market_calendar"], "SSE") + self.assertEqual(target["runtime_target"]["market_timezone"], "Asia/Shanghai") self.assertEqual(assignments["QMT_DRY_RUN_ONLY"], "true") self.assertEqual(assignments["STRATEGY_PROFILE"], "cn_industry_etf_rotation") self.assertEqual( @@ -768,6 +880,26 @@ def test_build_switch_target_defaults_qmt_repository_scope(self): }, ) + def test_build_switch_target_rejects_live_qmt_without_live_runtime_configuration(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "qmt", + "--target-name", + "industry-etf", + "--strategy-profile", + "cn_industry_etf_rotation", + "--execution-mode", + "live", + ] + ) + + with self.assertRaisesRegex( + ValueError, + "platform qmt has no live runtime configuration", + ): + build_runtime_switch.build_switch_target(args) + def test_build_switch_target_defaults_binance_repository_scope(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( @@ -789,6 +921,10 @@ def test_build_switch_target_defaults_binance_repository_scope(self): self.assertEqual(target["github"]["repository"], "QuantStrategyLab/BinancePlatform") self.assertEqual(target["github"]["variable_scope"], "repository") self.assertEqual(target["runtime_target"]["platform_id"], "binance") + self.assertEqual(target["runtime_target"]["service_name"], "binance-platform") + self.assertEqual(target["runtime_target"]["market"], "CRYPTO") + self.assertEqual(target["runtime_target"]["market_calendar"], "24/7") + self.assertEqual(target["runtime_target"]["market_timezone"], "UTC") self.assertEqual(assignments["BINANCE_DRY_RUN"], "false") self.assertEqual( target["runtime_target"]["scheduler"], @@ -821,9 +957,9 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self): target["runtime_target"]["scheduler"], { "timezone": "America/New_York", - "main_time": "45 15 25-28 * *", - "probe_time": "35 9,15 25-28 * *", - "precheck_time": "45 9 25-28 * *", + "main_time": "45 15 25-29 * *", + "probe_time": "35 9,15 25-29 * *", + "precheck_time": "45 9 25-29 * *", }, ) @@ -1382,6 +1518,57 @@ def test_build_config_reports_malformed_scheduler_timezone(self): build_config.validate(config), ) + def test_build_config_rejects_invalid_deployment_topology(self): + config = build_config.load_config() + config["platforms"]["binance"]["deployment"]["settings_activation"] = "cloud_run_sync_workflow" + + self.assertIn( + "platform binance: settings_activation 'cloud_run_sync_workflow' " + "requires runtime_model 'cloud_run'", + build_config.validate(config), + ) + + def test_build_config_requires_complete_domain_market_metadata(self): + config = build_config.load_config() + config["domains"]["us_equity"].pop("market_calendar") + + self.assertIn( + "domain us_equity: market_calendar must be a non-empty string", + build_config.validate(config), + ) + + def test_build_config_requires_scheduler_and_market_timezones_to_match(self): + config = build_config.load_config() + config["domains"]["us_equity"]["market_timezone"] = "America/Chicago" + + self.assertIn( + "domain us_equity: scheduler timezone 'America/New_York' " + "must match market_timezone 'America/Chicago'", + build_config.validate(config), + ) + + def test_build_config_rejects_strategy_scheduler_market_timezone_mismatch(self): + config = build_config.load_config() + config["strategies"]["global_etf_rotation"]["scheduler_profile"] = "hk_daily" + + self.assertIn( + "strategy global_etf_rotation: scheduler timezone 'Asia/Hong_Kong' " + "must match market_timezone 'America/New_York'", + build_config.validate(config), + ) + + def test_build_config_rejects_plugin_scheduler_market_timezone_mismatch(self): + config = build_config.load_config() + config["strategies"]["ibit_smart_dca"]["scheduler_profile_by_plugin"] = { + "ibit_zscore_exit": "hk_daily" + } + + self.assertIn( + "strategy ibit_smart_dca: plugin ibit_zscore_exit scheduler timezone " + "'Asia/Hong_Kong' must match market_timezone 'America/New_York'", + build_config.validate(config), + ) + def test_build_config_reports_non_string_scheduler_references(self): config = build_config.load_config() config["domains"]["us_equity"]["scheduler_profile"] = [] @@ -1432,6 +1619,70 @@ def test_runtime_target_scheduler_requires_timezone(self): runtime_settings.validate_target(target), ) + def test_runtime_target_market_metadata_must_be_complete_when_present(self): + _, target = self.load_target("examples/targets/schwab/live.example.json") + target["runtime_target"]["market"] = "US" + + self.assertIn( + "runtime_target market metadata must include market, market_calendar, and market_timezone together", + runtime_settings.validate_target(target), + ) + + def test_runtime_target_market_metadata_must_match_strategy_domain(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "schwab", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + target = build_runtime_switch.build_switch_target(args) + target["runtime_target"].update( + { + "market": "HK", + "market_calendar": "XHKG", + "market_timezone": "Asia/Hong_Kong", + } + ) + + errors = runtime_settings.validate_target(target) + + self.assertIn( + "runtime_target.market must match strategy domain us_equity: expected 'US'", + errors, + ) + self.assertIn( + "runtime_target.market_calendar must match strategy domain us_equity: expected 'NYSE'", + errors, + ) + self.assertIn( + "runtime_target.market_timezone must match strategy domain us_equity: expected 'America/New_York'", + errors, + ) + + def test_runtime_target_scheduler_timezone_must_match_strategy_domain(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "schwab", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + target = build_runtime_switch.build_switch_target(args) + target["runtime_target"]["scheduler"]["timezone"] = "Asia/Hong_Kong" + + self.assertIn( + "runtime_target.scheduler.timezone must match strategy domain us_equity: " + "expected 'America/New_York'", + runtime_settings.validate_target(target), + ) + def test_build_switch_target_rejects_secret_extra_variable(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( @@ -1547,6 +1798,259 @@ def test_build_switch_target_patches_ibkr_service_targets_json(self): ) self.assertEqual(untouched["runtime_target"]["strategy_profile"], "soxl_soxx_trend_income") + def test_build_switch_target_prefers_exact_service_when_account_scope_is_shared(self): + existing = { + "targets": [ + { + "service": "interactive-brokers-shared-a-service", + "ACCOUNT_GROUP": "shared-account", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "global_etf_rotation", + "dry_run_only": False, + "deployment_selector": "shared-a", + "account_selector": ["SHARED_ACCOUNT"], + "account_scope": "shared-account", + "service_name": "interactive-brokers-shared-a-service", + "execution_mode": "live", + }, + }, + { + "service": "interactive-brokers-shared-b-service", + "ACCOUNT_GROUP": "shared-account", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "soxl_soxx_trend_income", + "dry_run_only": False, + "deployment_selector": "shared-b", + "account_selector": ["SHARED_ACCOUNT"], + "account_scope": "shared-account", + "service_name": "interactive-brokers-shared-b-service", + "execution_mode": "live", + }, + }, + ] + } + path = ROOT / ".pytest_runtime_service_targets_shared_scope.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "shared-b", + "--strategy-profile", + "tqqq_growth_income", + "--account-selector", + "SHARED_ACCOUNT", + "--account-scope", + "shared-account", + "--service-name", + "interactive-brokers-shared-b-service", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + patched = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])["targets"] + + self.assertEqual(patched[0]["runtime_target"]["strategy_profile"], "global_etf_rotation") + self.assertEqual(patched[1]["runtime_target"]["strategy_profile"], "tqqq_growth_income") + + def test_build_switch_target_allow_create_appends_service_with_shared_scope(self): + existing = [ + { + "service": "interactive-brokers-shared-a-service", + "ACCOUNT_GROUP": "shared-account", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "global_etf_rotation", + "account_scope": "shared-account", + "service_name": "interactive-brokers-shared-a-service", + }, + } + ] + path = ROOT / ".pytest_runtime_service_targets_shared_scope_create.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "shared-b", + "--strategy-profile", + "tqqq_growth_income", + "--account-scope", + "shared-account", + "--service-name", + "interactive-brokers-shared-b-service", + "--existing-service-targets-json-file", + str(path), + "--allow-create-service-target", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + patched = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"]) + + self.assertIsInstance(patched, list) + self.assertEqual(len(patched), 2) + self.assertEqual( + patched[0]["runtime_target"]["service_name"], + "interactive-brokers-shared-a-service", + ) + self.assertEqual( + patched[1]["runtime_target"]["service_name"], + "interactive-brokers-shared-b-service", + ) + + def test_service_target_selection_does_not_treat_scope_as_service_identity(self): + runtime_target = { + "service_name": "interactive-brokers-new-service", + "account_scope": "shared-account", + } + entries = [ + { + "service": "interactive-brokers-existing-service", + "ACCOUNT_GROUP": "shared-account", + } + ] + + self.assertIsNone( + runtime_settings.select_service_target_entry_index(runtime_target, entries) + ) + self.assertEqual( + runtime_settings.select_service_target_entry_index( + runtime_target, + [{"ACCOUNT_GROUP": "shared-account"}], + ), + 0, + ) + + def test_build_switch_target_mirrors_longbridge_service_targets_at_repository_scope(self): + existing = { + "targets": [ + { + "service": "longbridge-quant-sg-service", + "runtime_target": { + "platform_id": "longbridge", + "strategy_profile": "soxl_soxx_trend_income", + "dry_run_only": False, + "deployment_selector": "SG", + "account_selector": ["SG"], + "account_scope": "SG", + "service_name": "longbridge-quant-sg-service", + "execution_mode": "live", + }, + } + ] + } + path = ROOT / ".pytest_longbridge_service_targets.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "sg", + "--strategy-profile", + "tqqq_growth_income", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = runtime_settings.build_assignments(target) + repository_target = next( + item for item in assignments if item.name == "CLOUD_RUN_SERVICE_TARGETS_JSON" + ) + patched = json.loads(repository_target.value)["targets"][0]["runtime_target"] + + self.assertEqual(target["github"]["variable_scope"], "environment") + self.assertEqual(repository_target.variable_scope, "repository") + self.assertIsNone(repository_target.environment) + self.assertEqual(patched["strategy_profile"], "tqqq_growth_income") + self.assertEqual(patched["market_calendar"], "NYSE") + self.assertTrue( + any( + item.name == "LONGBRIDGE_DRY_RUN_ONLY" + and item.variable_scope == "environment" + for item in assignments + ) + ) + self.assertTrue( + any( + item.name == "LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON" + and item.variable_scope == "environment" + for item in assignments + ) + ) + + def test_build_switch_target_keeps_repository_service_inventory_for_environment_switches(self): + cases = ( + ("schwab", "charles-schwab-quant-service"), + ("firstrade", "firstrade-quant-service"), + ) + for platform, service_name in cases: + with self.subTest(platform=platform): + existing = { + "targets": [ + { + "service": service_name, + "runtime_target": { + "platform_id": platform, + "strategy_profile": "tqqq_growth_income", + "dry_run_only": False, + "deployment_selector": platform, + "account_selector": [platform], + "account_scope": "US", + "service_name": service_name, + "execution_mode": "live", + }, + } + ] + } + path = ROOT / f".pytest_{platform}_service_targets.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda path=path: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + platform, + "--target-name", + "live", + "--strategy-profile", + "soxl_soxx_trend_income", + "--variable-scope", + "environment", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = runtime_settings.build_assignments(target) + service_inventory = next( + item for item in assignments if item.name == "CLOUD_RUN_SERVICE_TARGETS_JSON" + ) + + self.assertEqual(service_inventory.variable_scope, "repository") + self.assertIsNone(service_inventory.environment) + self.assertTrue( + any( + item.name == "RUNTIME_TARGET_JSON" + and item.variable_scope == "environment" + for item in assignments + ) + ) + def test_build_switch_target_can_clear_preserved_ibkr_reserved_cash_fields(self): existing = { "targets": [ @@ -1687,6 +2191,54 @@ def test_build_switch_target_rejects_unknown_ibkr_service_target_by_default(self with self.assertRaisesRegex(ValueError, "existing IBKR service target was not found"): build_runtime_switch.build_switch_target(args) + def test_build_switch_target_rejects_non_object_service_target_entry(self): + path = ROOT / ".pytest_runtime_service_targets_invalid_entry.json" + path.write_text( + '{"targets":[{"service":"interactive-brokers-demo-service",' + '"runtime_target":{"service_name":"interactive-brokers-demo-service"}},' + '"invalid"]}', + encoding="utf-8", + ) + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "demo", + "--strategy-profile", + "tqqq_growth_income", + "--service-name", + "interactive-brokers-demo-service", + "--existing-service-targets-json-file", + str(path), + ] + ) + + with self.assertRaisesRegex(ValueError, "service target entries must be objects"): + build_runtime_switch.build_switch_target(args) + + def test_build_switch_target_rejects_non_array_targets_wrapper(self): + path = ROOT / ".pytest_runtime_service_targets_invalid_wrapper.json" + path.write_text('{"targets":{}}', encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "demo", + "--strategy-profile", + "tqqq_growth_income", + "--existing-service-targets-json-file", + str(path), + "--allow-create-service-target", + ] + ) + + with self.assertRaisesRegex(ValueError, "service targets must be an array"): + build_runtime_switch.build_switch_target(args) + def test_build_switch_target_can_explicitly_append_ibkr_service_target(self): path = ROOT / ".pytest_runtime_service_targets_create.json" path.write_text("{}", encoding="utf-8") diff --git a/schemas/runtime-target.schema.json b/schemas/runtime-target.schema.json index cea415f..01d77ac 100644 --- a/schemas/runtime-target.schema.json +++ b/schemas/runtime-target.schema.json @@ -47,6 +47,11 @@ "execution_mode" ], "additionalProperties": false, + "dependentRequired": { + "market": ["market_calendar", "market_timezone"], + "market_calendar": ["market", "market_timezone"], + "market_timezone": ["market", "market_calendar"] + }, "properties": { "platform_id": { "type": "string" @@ -77,6 +82,18 @@ "type": "string", "enum": ["live", "paper", "dry_run"] }, + "market": { + "type": "string", + "minLength": 1 + }, + "market_calendar": { + "type": "string", + "minLength": 1 + }, + "market_timezone": { + "type": "string", + "minLength": 1 + }, "scheduler": { "type": "object", "additionalProperties": false, @@ -173,6 +190,12 @@ "additionalProperties": { "type": ["string", "number", "integer", "boolean", "object", "array", "null"] } + }, + "repository_variables": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "integer", "boolean", "object", "array", "null"] + } } } } diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 4405450..733d6cd 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -1168,6 +1168,148 @@ try { globalThis.fetch = originalFetch; } +globalThis.fetch = async (url) => { + const requestUrl = String(url); + if (requestUrl.includes("/CharlesSchwabPlatform/actions/variables/CLOUD_RUN_SERVICE_TARGETS_JSON")) { + return new Response(JSON.stringify({ + value: JSON.stringify({ + targets: [ + { + service: "schwab-shared-a-service", + ACCOUNT_GROUP: "shared-account", + runtime_target: { + platform_id: "schwab", + strategy_profile: "global_etf_rotation", + account_scope: "shared-account", + service_name: "schwab-shared-a-service", + }, + }, + { + service: "schwab-shared-b-service", + ACCOUNT_GROUP: "shared-account", + runtime_target: { + platform_id: "schwab", + strategy_profile: "tqqq_growth_income", + account_scope: "shared-account", + service_name: "schwab-shared-b-service", + }, + }, + ], + }), + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (requestUrl.includes("/FirstradePlatform/actions/variables/CLOUD_RUN_SERVICE_TARGETS_JSON")) { + return new Response(JSON.stringify({ + value: JSON.stringify([ + { + service: "firstrade-shared-a-service", + ACCOUNT_GROUP: "shared-account", + runtime_target: { + platform_id: "firstrade", + strategy_profile: "nasdaq_sp500_smart_dca", + account_scope: "shared-account", + service_name: "firstrade-shared-a-service", + }, + }, + { + service: "firstrade-shared-b-service", + ACCOUNT_GROUP: "shared-account", + runtime_target: { + platform_id: "firstrade", + strategy_profile: "ibit_smart_dca", + account_scope: "shared-account", + service_name: "firstrade-shared-b-service", + }, + }, + ]), + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("", { status: 404 }); +}; +try { + const currentStrategies = await __test.loadCurrentStrategies( + { + schwab: [ + { + key: "shared-a", + target_name: "shared-a", + account_scope: "shared-account", + service_name: "schwab-shared-a-service", + }, + { + key: "shared-b", + target_name: "shared-b", + account_scope: "shared-account", + service_name: "schwab-shared-b-service", + }, + ], + firstrade: [ + { + key: "shared-a", + target_name: "shared-a", + account_scope: "shared-account", + service_name: "firstrade-shared-a-service", + }, + { + key: "shared-b", + target_name: "shared-b", + account_scope: "shared-account", + service_name: "firstrade-shared-b-service", + }, + ], + }, + { RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" }, + ); + assert.equal(currentStrategies.schwab["shared-a"].strategy_profile, "global_etf_rotation"); + assert.equal(currentStrategies.schwab["shared-b"].strategy_profile, "tqqq_growth_income"); + assert.equal(currentStrategies.firstrade["shared-a"].strategy_profile, "nasdaq_sp500_smart_dca"); + assert.equal(currentStrategies.firstrade["shared-b"].strategy_profile, "ibit_smart_dca"); + assert.equal(currentStrategies.schwab["shared-b"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON"); + assert.equal(currentStrategies.firstrade["shared-b"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON"); +} finally { + globalThis.fetch = originalFetch; +} + +globalThis.fetch = async (url) => { + const requestUrl = String(url); + if (requestUrl.endsWith("/CLOUD_RUN_SERVICE_TARGETS_JSON")) { + return new Response(JSON.stringify({ + value: JSON.stringify([ + { + service: "interactive-brokers-shared-a-service", + ACCOUNT_GROUP: "demo-ibkr-tqqq", + runtime_target: { + platform_id: "ibkr", + strategy_profile: "global_etf_rotation", + account_scope: "demo-ibkr-tqqq", + service_name: "interactive-brokers-shared-a-service", + }, + }, + { + service: "interactive-brokers-demo-ibkr-tqqq-service", + ACCOUNT_GROUP: "demo-ibkr-tqqq", + runtime_target: { + platform_id: "ibkr", + strategy_profile: "tqqq_growth_income", + account_scope: "demo-ibkr-tqqq", + service_name: "interactive-brokers-demo-ibkr-tqqq-service", + }, + }, + ]), + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("", { status: 404 }); +}; +try { + const currentStrategies = await __test.loadCurrentStrategies( + { ibkr: accountOptions.ibkr }, + { RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" }, + ); + assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "tqqq_growth_income"); +} finally { + globalThis.fetch = originalFetch; +} + globalThis.fetch = async (url) => { const requestUrl = String(url); if (requestUrl.endsWith("/CLOUD_RUN_SERVICE_TARGETS_JSON")) { diff --git a/web/strategy-switch-console/config.js b/web/strategy-switch-console/config.js index 34c4784..5c71abc 100644 --- a/web/strategy-switch-console/config.js +++ b/web/strategy-switch-console/config.js @@ -65,7 +65,7 @@ export const PLATFORM_CONFIG = { "option_overlay": false, "dca": false, "execution_mode": "live", - "service_name": "", + "service_name": "binance-platform", "default_execution_mode": "live" } }; @@ -147,7 +147,7 @@ export const DEFAULT_ACCOUNT_OPTIONS = { "crypto" ], "cash_currency": "USD", - "service_name": "", + "service_name": "binance-platform", "default_execution_mode": "live" } ] diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index bbca498..fbd1c89 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -10,8 +10,8 @@ \n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n\n
\n
\n
\n 策略健康 / 只读\n

先看机器结论,再决定动作。

\n

健康不等于已批准 live;正常实盘、资金和杠杆变更仍需人工确认。

\n
\n
\n 等待登录\n 最近计算:—\n
\n
\n
登录后读取私有策略健康快照;没有快照时不会展示虚构指标。
\n
\n
策略总数0
\n
健康0
\n
观察0
\n
需要复核0
\n
严重0
\n
\n
\n

策略状态

\n
\n \n \n \n \n \n
\n
\n
\n
\n\n \n
\n\n \n\n\n"; +export const PAGE_HTML = "\n\n\n \n \n \n QuantRuntimeSettings Strategy Switch\n \n \n\n\n\n\n\n
\n
\n

策略切换

\n

选平台、目标账号和策略,一次执行完成切换。

\n
\n
\n \n \n \n \n
\n
\n\n
\n
\n 初始化控制台\n

读取策略配置

\n

正在读取登录状态、账号配置和当前状态。

\n
\n
\n
\n\n
\n \n\n
\n
\n
\n 策略健康 / 只读\n

先看机器结论,再决定动作。

\n

健康不等于已批准 live;正常实盘、资金和杠杆变更仍需人工确认。

\n
\n
\n 等待登录\n 最近计算:—\n
\n
\n
登录后读取私有策略健康快照;没有快照时不会展示虚构指标。
\n
\n
策略总数0
\n
健康0
\n
观察0
\n
需要复核0
\n
严重0
\n
\n
\n

策略状态

\n
\n \n \n \n \n \n
\n
\n
\n
\n\n \n
\n\n \n\n\n"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 8b81050..4489977 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -965,7 +965,7 @@ async function resolveCurrentStrategyForAccount({ platform, option, optionsCount } function usesServiceTargetsAsRuntimeSource(platform) { - return platform === "ibkr"; + return ["ibkr", "schwab", "firstrade"].includes(platform); } function logout(request) { @@ -2256,15 +2256,18 @@ function resolveGithubEnvironment(platform, option, variableScope) { } function runtimeTargetFromServiceTargets(rawValue, platform, option) { - const payload = parseJsonObject(rawValue); - const targets = Array.isArray(payload?.targets) ? payload.targets : []; + const payload = parseJsonValue(rawValue); + const targets = Array.isArray(payload) + ? payload + : (Array.isArray(payload?.targets) ? payload.targets : []); + const matches = []; for (const entry of targets) { if (!entry || Array.isArray(entry) || typeof entry !== "object") continue; const runtimeTarget = entry.runtime_target && typeof entry.runtime_target === "object" ? entry.runtime_target : {}; if (!runtimeTargetMatchesAccount(runtimeTarget, platform, option, entry)) continue; - return { + matches.push({ ...runtimeTarget, strategy_profile: runtimeTarget.strategy_profile || entry.strategy_profile, ...reservedCashPayloadFromObject(platform, entry), @@ -2273,9 +2276,9 @@ function runtimeTargetFromServiceTargets(rawValue, platform, option) { ...runtimeTargetEnabledPayloadFromObject(entry), ...dcaPayloadFromObject(entry), ...cashOnlyPayloadFromObject(platform, entry), - }; + }); } - return null; + return matches.length === 1 ? matches[0] : null; } async function readCashOnlyVariables({ platform, repository, variableScope, githubEnvironment, readVariable }) { @@ -2522,11 +2525,14 @@ function runtimeTargetMatchesAccount(runtimeTarget, platform, option, entry = {} if (runtimePlatform && runtimePlatform !== platform) return false; const serviceName = String(option?.service_name || defaultCurrentServiceName(platform, option?.target_name || option?.key) || ""); - if (serviceName && hasCandidate(serviceName, [ + const serviceCandidates = [ runtimeTarget?.service_name, entry?.service, entry?.service_name, - ])) return true; + ].filter((value) => normalizeMatchValue(value)); + if (serviceName && serviceCandidates.length) { + return hasCandidate(serviceName, serviceCandidates); + } if (hasCandidate(option?.account_scope, [ runtimeTarget?.account_scope, @@ -2595,6 +2601,19 @@ function parseJsonObject(value) { return null; } +function parseJsonValue(value) { + const text = String(value || "").trim(); + if (!text) return null; + for (const candidate of [text, text.replaceAll("\\n", "\n")]) { + try { + return JSON.parse(candidate); + } catch { + // Try the next representation. + } + } + return null; +} + function cleanCurrentStrategy(value) { const text = String(value || "").trim().toLowerCase(); if (!text || text.length > 120 || !/^[a-z0-9._=-]+$/.test(text)) return "";