读取策略配置
\n正在读取登录状态、账号配置和当前状态。
\n \ndiff --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健康不等于已批准 live;正常实盘、资金和杠杆变更仍需人工确认。
\n登录后查看账号配置。
\n\n允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可执行切换。
\n \n