From 6dbd8d1a9c3121a6a78ad1b9878c2ec5cfab9745 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:06:45 +0800 Subject: [PATCH] Make strategy switch setup fork friendly --- .github/workflows/manual-strategy-switch.yml | 24 +- README.md | 1 + README.zh-CN.md | 1 + ...trategy_switch_permission_control.zh-CN.md | 4 +- docs/strategy_switch_fork_guide.md | 219 +++++++++++++++++ docs/strategy_switch_fork_guide.zh-CN.md | 223 ++++++++++++++++++ scripts/build_runtime_switch.py | 10 +- scripts/runtime_settings.py | 81 ++++++- tests/strategy_switch_worker_validation.mjs | 13 + tests/test_runtime_settings.py | 22 ++ web/strategy-switch-console/README.md | 5 + web/strategy-switch-console/README.zh-CN.md | 5 + web/strategy-switch-console/index.html | 18 +- web/strategy-switch-console/page_asset.js | 2 +- web/strategy-switch-console/worker.js | 55 ++++- .../wrangler.toml.example | 14 +- 16 files changed, 660 insertions(+), 37 deletions(-) create mode 100644 docs/strategy_switch_fork_guide.md create mode 100644 docs/strategy_switch_fork_guide.zh-CN.md diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index afa67e3..aba04d2 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -155,6 +155,11 @@ jobs: PLATFORM_SYNC_WORKFLOW: ${{ inputs.platform_sync_workflow }} STRATEGY_SWITCH_CONSOLE_URL: ${{ vars.STRATEGY_SWITCH_CONSOLE_URL }} STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }} + RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON: ${{ vars.RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON }} + RUNTIME_SETTINGS_LONGBRIDGE_REPO: ${{ vars.RUNTIME_SETTINGS_LONGBRIDGE_REPO }} + RUNTIME_SETTINGS_IBKR_REPO: ${{ vars.RUNTIME_SETTINGS_IBKR_REPO }} + RUNTIME_SETTINGS_SCHWAB_REPO: ${{ vars.RUNTIME_SETTINGS_SCHWAB_REPO }} + RUNTIME_SETTINGS_FIRSTRADE_REPO: ${{ vars.RUNTIME_SETTINGS_FIRSTRADE_REPO }} steps: - name: Checkout uses: actions/checkout@v6 @@ -195,24 +200,7 @@ jobs: id: platform run: | set -euo pipefail - case "${PLATFORM}" in - longbridge) - repo="QuantStrategyLab/LongBridgePlatform" - ;; - ibkr) - repo="QuantStrategyLab/InteractiveBrokersPlatform" - ;; - schwab) - repo="QuantStrategyLab/CharlesSchwabPlatform" - ;; - firstrade) - repo="QuantStrategyLab/FirstradePlatform" - ;; - *) - echo "Unsupported platform: ${PLATFORM}" >&2 - exit 2 - ;; - esac + repo="$(python3 scripts/runtime_settings.py repository "${PLATFORM}")" echo "repository=${repo}" >> "$GITHUB_OUTPUT" - name: Fetch existing service targets diff --git a/README.md b/README.md index b65ac7d..b917a19 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Notes: ## Useful docs +- [Fork guide for the strategy switch console](docs/strategy_switch_fork_guide.md) - [Strategy switch console Worker](web/strategy-switch-console/README.md) - [Strategy switch admin backend](docs/strategy_switch_admin_backend.md) - [Manual strategy switch permission-control plan](docs/manual_strategy_switch_permission_control.zh-CN.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 7c95b07..3226b98 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,6 +68,7 @@ confirm_apply=APPLY_AND_SYNC ## 延伸文档 +- [策略切换控制台 Fork 指南](docs/strategy_switch_fork_guide.zh-CN.md) - [策略切换控制台 Worker](web/strategy-switch-console/README.zh-CN.md) - [策略切换登录权限后台方案](docs/strategy_switch_admin_backend.zh-CN.md) - [手动策略切换权限控制方案](docs/manual_strategy_switch_permission_control.zh-CN.md) diff --git a/docs/manual_strategy_switch_permission_control.zh-CN.md b/docs/manual_strategy_switch_permission_control.zh-CN.md index 92a609a..2d3edcb 100644 --- a/docs/manual_strategy_switch_permission_control.zh-CN.md +++ b/docs/manual_strategy_switch_permission_control.zh-CN.md @@ -28,13 +28,15 @@ ## Token 权限 -优先用 fine-grained PAT,只授权这些目标仓库: +优先用 fine-grained PAT,只授权你实际使用的目标平台仓库。QuantStrategyLab 默认仓库是: - `QuantStrategyLab/LongBridgePlatform` - `QuantStrategyLab/InteractiveBrokersPlatform` - `QuantStrategyLab/CharlesSchwabPlatform` - `QuantStrategyLab/FirstradePlatform` +如果你 fork 到自己的组织,把这些替换成你的平台仓库,并在本仓 repository variables 里配置 `RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON` 或 `RUNTIME_SETTINGS_LONGBRIDGE_REPO`、`RUNTIME_SETTINGS_IBKR_REPO`、`RUNTIME_SETTINGS_SCHWAB_REPO`、`RUNTIME_SETTINGS_FIRSTRADE_REPO`。完整步骤见 [策略切换控制台 Fork 指南](strategy_switch_fork_guide.zh-CN.md)。 + 需要的能力只有: - 读取和写入 GitHub Actions variables。 diff --git a/docs/strategy_switch_fork_guide.md b/docs/strategy_switch_fork_guide.md new file mode 100644 index 0000000..2a57c49 --- /dev/null +++ b/docs/strategy_switch_fork_guide.md @@ -0,0 +1,219 @@ +# Fork Guide: Strategy Switch Console + +This guide explains how to fork this repository and deploy the same public-readonly, login-to-switch console for your own platform repositories. + +## What You Need + +- A GitHub account or organization that owns your forked runtime settings repository. +- Optional platform repositories for LongBridge, IBKR, Schwab, and Firstrade automation. +- A Cloudflare account with Workers enabled. +- A GitHub OAuth App for login. +- A fine-grained GitHub token that can dispatch this repository's workflow. +- A separate GitHub Actions secret that can write variables in your platform repositories. + +Do not commit broker credentials, cloud credentials, API keys, account passwords, or personal access tokens. + +## Repository Mapping + +The default repository mapping points to QuantStrategyLab: + +```text +longbridge -> QuantStrategyLab/LongBridgePlatform +ibkr -> QuantStrategyLab/InteractiveBrokersPlatform +schwab -> QuantStrategyLab/CharlesSchwabPlatform +firstrade -> QuantStrategyLab/FirstradePlatform +``` + +Fork users can override these without editing source code. + +For GitHub Actions, set platform repository variables in your fork: + +```text +RUNTIME_SETTINGS_LONGBRIDGE_REPO=your-org/LongBridgePlatform +RUNTIME_SETTINGS_IBKR_REPO=your-org/InteractiveBrokersPlatform +RUNTIME_SETTINGS_SCHWAB_REPO=your-org/CharlesSchwabPlatform +RUNTIME_SETTINGS_FIRSTRADE_REPO=your-org/FirstradePlatform +``` + +You can also use one JSON variable: + +```json +{ + "longbridge": "your-org/LongBridgePlatform", + "ibkr": "your-org/InteractiveBrokersPlatform", + "schwab": "your-org/CharlesSchwabPlatform", + "firstrade": "your-org/FirstradePlatform" +} +``` + +Store that JSON as `RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON`. + +For the Cloudflare Worker, use the same JSON as `STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON`, or set individual Worker variables: + +```text +RUNTIME_SETTINGS_REPO=your-org/QuantRuntimeSettings +STRATEGY_SWITCH_LONGBRIDGE_REPO=your-org/LongBridgePlatform +STRATEGY_SWITCH_IBKR_REPO=your-org/InteractiveBrokersPlatform +STRATEGY_SWITCH_SCHWAB_REPO=your-org/CharlesSchwabPlatform +STRATEGY_SWITCH_FIRSTRADE_REPO=your-org/FirstradePlatform +``` + +## GitHub Actions Setup + +Create a GitHub Environment named `runtime-strategy-switch`. + +Add this secret to that environment: + +```text +RUNTIME_SETTINGS_GH_TOKEN +``` + +This token is used by `.github/workflows/manual-strategy-switch.yml` to write GitHub Actions variables in your platform repositories and optionally dispatch each platform's sync workflow. Prefer a fine-grained PAT scoped only to the repositories you actually use. + +The token should not need `contents: write`. + +## Worker Setup + +Create a GitHub OAuth App: + +```text +Homepage URL: https://your-worker-domain +Authorization callback URL: https://your-worker-domain/callback +``` + +Copy the Worker config: + +```bash +cp web/strategy-switch-console/wrangler.toml.example web/strategy-switch-console/wrangler.toml +``` + +Edit `wrangler.toml`: + +```toml +name = "your-strategy-switch-console" + +[vars] +RUNTIME_SETTINGS_REPO = "your-org/QuantRuntimeSettings" +STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON = '{"longbridge":"your-org/LongBridgePlatform","ibkr":"your-org/InteractiveBrokersPlatform","schwab":"your-org/CharlesSchwabPlatform","firstrade":"your-org/FirstradePlatform"}' +``` + +Set Worker secrets: + +```bash +cd web/strategy-switch-console +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put SESSION_SECRET +wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN +wrangler secret put STRATEGY_SWITCH_SYNC_TOKEN +wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put ALLOWED_GITHUB_ORGS +wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_ORGS +``` + +`RUNTIME_SETTINGS_DISPATCH_TOKEN` only needs permission to dispatch the runtime settings workflow in your fork. It is not the token that writes platform variables. + +## Account Options + +Copy the generic example: + +```bash +cp web/strategy-switch-console/account-options.example.json /tmp/strategy-switch-accounts.json +``` + +Edit it with your own route names, service names, account selectors, and supported strategy domains. Keep it to routing metadata only. + +Store it as a Worker secret: + +```bash +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +For editable settings, create a KV namespace: + +```bash +wrangler kv namespace create STRATEGY_SWITCH_CONFIG +``` + +Add the returned id to `wrangler.toml`, deploy, then use `/admin` to edit: + +```text +auth_config +account_options +strategy_profiles +audit_log +``` + +## Strategy Catalog + +Runtime-enabled strategies live in: + +```text +web/strategy-switch-console/strategy-profiles.example.json +``` + +Each item needs: + +```json +{ + "profile": "my_strategy_profile", + "label": "My Strategy Profile", + "domain": "us_equity", + "runtime_enabled": true +} +``` + +Supported domains are currently: + +```text +us_equity +hk_equity +``` + +After editing the strategy catalog or page: + +```bash +python3 scripts/sync_strategy_switch_page_asset.py +``` + +## Deploy and Verify + +Deploy the Worker: + +```bash +cd web/strategy-switch-console +wrangler deploy +``` + +Verify public mode: + +```bash +curl -s https://your-worker-domain/api/config +``` + +Expected unauthenticated response: + +```json +{ + "accountOptions": null +} +``` + +Then open the Worker URL: + +- Signed-out users should only see the public read-only page. +- Signed-in allowlisted users should see account, strategy, mode, current status, and the switch button. +- Admin users should be able to open `/admin`. + +## Local Checks + +Run these before opening a PR: + +```bash +jq empty web/strategy-switch-console/account-options.example.json web/strategy-switch-console/strategy-profiles.example.json +node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs +python3 scripts/runtime_settings.py validate +python3 -m unittest discover -s tests -v +git diff --check +``` diff --git a/docs/strategy_switch_fork_guide.zh-CN.md b/docs/strategy_switch_fork_guide.zh-CN.md new file mode 100644 index 0000000..38f4862 --- /dev/null +++ b/docs/strategy_switch_fork_guide.zh-CN.md @@ -0,0 +1,223 @@ +# Fork 指南:策略切换控制台 + +这份文档说明别人 fork 本仓库后,如何部署同样的“公开只读、登录后一键切换”策略控制台。 + +## 你需要准备什么 + +- 一个拥有 fork 仓库的 GitHub 个人账号或组织。 +- 可选的 LongBridge、IBKR、Schwab、Firstrade 平台自动化仓库。 +- 一个启用 Workers 的 Cloudflare 账号。 +- 一个用于登录的 GitHub OAuth App。 +- 一个能触发本仓库 workflow 的 fine-grained GitHub token。 +- 一个放在 GitHub Actions secret 里的 token,用于写入平台仓库 variables。 + +不要提交 broker 凭据、云凭据、API key、账号密码或 personal access token。 + +## 仓库映射 + +默认平台仓库映射指向 QuantStrategyLab: + +```text +longbridge -> QuantStrategyLab/LongBridgePlatform +ibkr -> QuantStrategyLab/InteractiveBrokersPlatform +schwab -> QuantStrategyLab/CharlesSchwabPlatform +firstrade -> QuantStrategyLab/FirstradePlatform +``` + +Fork 用户不需要改源码,可以用配置覆盖。 + +在 fork 后的 GitHub 仓库 variables 里设置平台仓库映射: + +```text +RUNTIME_SETTINGS_LONGBRIDGE_REPO=your-org/LongBridgePlatform +RUNTIME_SETTINGS_IBKR_REPO=your-org/InteractiveBrokersPlatform +RUNTIME_SETTINGS_SCHWAB_REPO=your-org/CharlesSchwabPlatform +RUNTIME_SETTINGS_FIRSTRADE_REPO=your-org/FirstradePlatform +``` + +也可以只设置一个 JSON 变量: + +```json +{ + "longbridge": "your-org/LongBridgePlatform", + "ibkr": "your-org/InteractiveBrokersPlatform", + "schwab": "your-org/CharlesSchwabPlatform", + "firstrade": "your-org/FirstradePlatform" +} +``` + +变量名用 `RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON`。 + +Cloudflare Worker 侧可以用同样的 JSON,变量名是 `STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON`;也可以分别设置: + +```text +RUNTIME_SETTINGS_REPO=your-org/QuantRuntimeSettings +STRATEGY_SWITCH_LONGBRIDGE_REPO=your-org/LongBridgePlatform +STRATEGY_SWITCH_IBKR_REPO=your-org/InteractiveBrokersPlatform +STRATEGY_SWITCH_SCHWAB_REPO=your-org/CharlesSchwabPlatform +STRATEGY_SWITCH_FIRSTRADE_REPO=your-org/FirstradePlatform +``` + +## GitHub Actions 配置 + +创建 GitHub Environment: + +```text +runtime-strategy-switch +``` + +在这个 Environment 里添加 secret: + +```text +RUNTIME_SETTINGS_GH_TOKEN +``` + +这个 token 由 `.github/workflows/manual-strategy-switch.yml` 使用,用来写入平台仓库的 GitHub Actions variables,并在需要时触发平台仓库的同步 workflow。建议用 fine-grained PAT,只授权你实际使用的平台仓库。 + +这个 token 不需要 `contents: write`。 + +## Worker 配置 + +创建 GitHub OAuth App: + +```text +Homepage URL: https://你的-worker-域名 +Authorization callback URL: https://你的-worker-域名/callback +``` + +复制 Worker 配置: + +```bash +cp web/strategy-switch-console/wrangler.toml.example web/strategy-switch-console/wrangler.toml +``` + +编辑 `wrangler.toml`: + +```toml +name = "your-strategy-switch-console" + +[vars] +RUNTIME_SETTINGS_REPO = "your-org/QuantRuntimeSettings" +STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON = '{"longbridge":"your-org/LongBridgePlatform","ibkr":"your-org/InteractiveBrokersPlatform","schwab":"your-org/CharlesSchwabPlatform","firstrade":"your-org/FirstradePlatform"}' +``` + +设置 Worker secrets: + +```bash +cd web/strategy-switch-console +wrangler secret put GITHUB_CLIENT_ID +wrangler secret put GITHUB_CLIENT_SECRET +wrangler secret put SESSION_SECRET +wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN +wrangler secret put STRATEGY_SWITCH_SYNC_TOKEN +wrangler secret put ALLOWED_GITHUB_LOGINS +wrangler secret put ALLOWED_GITHUB_ORGS +wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS +wrangler secret put STRATEGY_SWITCH_ADMIN_ORGS +``` + +`RUNTIME_SETTINGS_DISPATCH_TOKEN` 只需要能触发 fork 后 runtime settings 仓库里的 workflow。它不是写入平台 variables 的 token。 + +## 账号下拉配置 + +复制通用示例: + +```bash +cp web/strategy-switch-console/account-options.example.json /tmp/strategy-switch-accounts.json +``` + +把里面的 route 名称、service 名称、account selector、支持市场改成自己的。这里仍然只放路由元数据,不放任何密钥。 + +保存为 Worker secret: + +```bash +wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-accounts.json +``` + +如果希望网页后台可编辑,创建 KV: + +```bash +wrangler kv namespace create STRATEGY_SWITCH_CONFIG +``` + +把返回的 id 写入 `wrangler.toml`,部署后可以在 `/admin` 编辑这些 key: + +```text +auth_config +account_options +strategy_profiles +audit_log +``` + +## 策略目录 + +可切换策略目录在: + +```text +web/strategy-switch-console/strategy-profiles.example.json +``` + +每个策略项需要: + +```json +{ + "profile": "my_strategy_profile", + "label": "My Strategy Profile", + "domain": "us_equity", + "runtime_enabled": true +} +``` + +当前支持的 domain: + +```text +us_equity +hk_equity +``` + +修改策略目录或网页后运行: + +```bash +python3 scripts/sync_strategy_switch_page_asset.py +``` + +## 部署和验证 + +部署 Worker: + +```bash +cd web/strategy-switch-console +wrangler deploy +``` + +验证公开模式: + +```bash +curl -s https://你的-worker-域名/api/config +``` + +未登录应返回: + +```json +{ + "accountOptions": null +} +``` + +然后打开 Worker 页面: + +- 未登录用户只能看到公开只读页。 +- 登录并在 allowlist 中的用户能看到账号、策略、模式、当前状态和一键切换按钮。 +- 管理员能打开 `/admin`。 + +## 本地检查 + +开 PR 前建议跑: + +```bash +jq empty web/strategy-switch-console/account-options.example.json web/strategy-switch-console/strategy-profiles.example.json +node --experimental-default-type=module tests/strategy_switch_worker_validation.mjs +python3 scripts/runtime_settings.py validate +python3 -m unittest discover -s tests -v +git diff --check +``` diff --git a/scripts/build_runtime_switch.py b/scripts/build_runtime_switch.py index 00cb31a..8114393 100644 --- a/scripts/build_runtime_switch.py +++ b/scripts/build_runtime_switch.py @@ -14,7 +14,13 @@ if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -from runtime_settings import SUPPORTED_PLATFORMS, compact_json, env_string, validate_target # noqa: E402 +from runtime_settings import ( # noqa: E402 + SUPPORTED_PLATFORMS, + compact_json, + env_string, + platform_repository, + validate_target, +) DEFAULT_ARTIFACT_BUCKET_URI = "gs://qsl-runtime-logs-interactivebrokersquant" @@ -347,7 +353,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: "target_id": f"{platform}/{target_name}", "description": "Generated by build_runtime_switch.py for manual workflow dispatch.", "github": { - "repository": SUPPORTED_PLATFORMS[platform]["repository"], + "repository": platform_repository(platform), "variable_scope": variable_scope, }, "runtime_target": runtime_target, diff --git a/scripts/runtime_settings.py b/scripts/runtime_settings.py index 610c32e..b83dc1a 100644 --- a/scripts/runtime_settings.py +++ b/scripts/runtime_settings.py @@ -5,6 +5,7 @@ import argparse import json +import os import shlex import subprocess import sys @@ -23,6 +24,12 @@ "ibkr": {"plugin_mounts_prefix": "IBKR_", "repository": "QuantStrategyLab/InteractiveBrokersPlatform"}, "firstrade": {"plugin_mounts_prefix": "FIRSTRADE_", "repository": "QuantStrategyLab/FirstradePlatform"}, } +PLATFORM_REPOSITORY_ENV = { + "schwab": "RUNTIME_SETTINGS_SCHWAB_REPO", + "longbridge": "RUNTIME_SETTINGS_LONGBRIDGE_REPO", + "ibkr": "RUNTIME_SETTINGS_IBKR_REPO", + "firstrade": "RUNTIME_SETTINGS_FIRSTRADE_REPO", +} RUNTIME_REQUIRED_FIELDS = ( "platform_id", "strategy_profile", @@ -82,6 +89,52 @@ def env_string(value: Any) -> str: return str(value) +def is_repository_name(value: str) -> bool: + if not isinstance(value, str) or "/" not in value or len(value) > 160: + return False + allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-") + parts = value.split("/", 1) + return all(part and set(part) <= allowed for part in parts) + + +def platform_repositories(env: dict[str, str] | None = None) -> dict[str, str]: + env = env or os.environ + repositories = { + platform: config["repository"] + for platform, config in SUPPORTED_PLATFORMS.items() + } + raw_json = str(env.get("RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON") or "").strip() + if raw_json: + try: + payload = json.loads(raw_json) + except json.JSONDecodeError as exc: + raise ValueError("RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON must be valid JSON") from exc + if not isinstance(payload, dict): + raise ValueError("RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON must be a JSON object") + for platform, repository in payload.items(): + if platform not in SUPPORTED_PLATFORMS: + raise ValueError(f"unsupported platform repository override: {platform}") + repository = str(repository or "").strip() + if not is_repository_name(repository): + raise ValueError(f"repository override for {platform} must be owner/repo") + repositories[platform] = repository + + for platform, env_name in PLATFORM_REPOSITORY_ENV.items(): + repository = str(env.get(env_name) or "").strip() + if not repository: + continue + if not is_repository_name(repository): + raise ValueError(f"{env_name} must be owner/repo") + repositories[platform] = repository + return repositories + + +def platform_repository(platform: str, env: dict[str, str] | None = None) -> str: + if platform not in SUPPORTED_PLATFORMS: + raise ValueError(f"unsupported platform: {platform}") + return platform_repositories(env)[platform] + + def discover_target_paths(paths: list[str]) -> list[Path]: if paths: return [Path(path).resolve() for path in paths] @@ -350,14 +403,17 @@ def validate_target(target: dict[str, Any], path: Path | None = None) -> list[st 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 {} platform_id = runtime_target.get("platform_id") - if ( - platform_id in SUPPORTED_PLATFORMS - and github.get("repository") != SUPPORTED_PLATFORMS[platform_id]["repository"] - ): - errors.append( - "github.repository does not match platform " - f"{platform_id}: expected {SUPPORTED_PLATFORMS[platform_id]['repository']}" - ) + if platform_id in SUPPORTED_PLATFORMS: + try: + expected_repository = platform_repository(platform_id) + except ValueError as exc: + errors.append(str(exc)) + else: + if github.get("repository") != expected_repository: + errors.append( + "github.repository does not match platform " + f"{platform_id}: expected {expected_repository}" + ) return errors @@ -476,6 +532,11 @@ def command_apply(args: argparse.Namespace) -> int: return 0 +def command_repository(args: argparse.Namespace) -> int: + print(platform_repository(args.platform)) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) @@ -494,6 +555,10 @@ def build_parser() -> argparse.ArgumentParser: apply.add_argument("--yes", action="store_true", help="apply updates with gh variable set") apply.set_defaults(func=command_apply) + repository = subparsers.add_parser("repository", help="print the configured platform repository") + repository.add_argument("platform", choices=sorted(SUPPORTED_PLATFORMS)) + repository.set_defaults(func=command_repository) + return parser diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 8d17b14..1611ca4 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -36,6 +36,19 @@ const publicConfigResponse = await worker.fetch(new Request("https://switch.exam assert.equal(publicConfigResponse.status, 200); assert.deepEqual(await publicConfigResponse.json(), { accountOptions: null }); +assert.equal( + __test.platformRepositories({ STRATEGY_SWITCH_LONGBRIDGE_REPO: "ForkOrg/LongBridgePlatform" }).longbridge, + "ForkOrg/LongBridgePlatform", +); +assert.equal( + __test.platformRepositories({ + RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON: JSON.stringify({ + ibkr: "ForkOrg/InteractiveBrokersPlatform", + }), + }).ibkr, + "ForkOrg/InteractiveBrokersPlatform", +); + const headers = __test.responseHeaders({ "Content-Type": "text/html; charset=utf-8" }); assert.equal(headers.get("X-Frame-Options"), "DENY"); assert.equal(headers.get("X-Content-Type-Options"), "nosniff"); diff --git a/tests/test_runtime_settings.py b/tests/test_runtime_settings.py index 469cae6..9e527d0 100644 --- a/tests/test_runtime_settings.py +++ b/tests/test_runtime_settings.py @@ -2,9 +2,11 @@ import importlib.util import json +import os import sys import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -153,6 +155,26 @@ def test_build_switch_target_defaults_longbridge_sg_tqqq(self): self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "market_regime_control") self.assertEqual(plugin_payload["strategy_plugins"][0]["expected_schema_version"], "market_regime_control.v1") + def test_build_switch_target_uses_fork_repository_overrides(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "sg", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + + with patch.dict(os.environ, {"RUNTIME_SETTINGS_LONGBRIDGE_REPO": "ForkOrg/LongBridgePlatform"}): + target = build_runtime_switch.build_switch_target(args) + + self.assertEqual(target["github"]["repository"], "ForkOrg/LongBridgePlatform") + with patch.dict(os.environ, {"RUNTIME_SETTINGS_LONGBRIDGE_REPO": "ForkOrg/LongBridgePlatform"}): + self.assertEqual(runtime_settings.validate_target(target), []) + def test_build_switch_target_defaults_schwab_repository_scope(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( diff --git a/web/strategy-switch-console/README.md b/web/strategy-switch-console/README.md index 277b785..185f994 100644 --- a/web/strategy-switch-console/README.md +++ b/web/strategy-switch-console/README.md @@ -25,9 +25,12 @@ Optional variables: RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml RUNTIME_SETTINGS_REF=main +STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON={"longbridge":"your-org/LongBridgePlatform","ibkr":"your-org/InteractiveBrokersPlatform","schwab":"your-org/CharlesSchwabPlatform","firstrade":"your-org/FirstradePlatform"} STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` +Forks can also override one platform at a time with `STRATEGY_SWITCH_LONGBRIDGE_REPO`, `STRATEGY_SWITCH_IBKR_REPO`, `STRATEGY_SWITCH_SCHWAB_REPO`, and `STRATEGY_SWITCH_FIRSTRADE_REPO`. The GitHub Actions workflow supports the same mapping with `RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON` or `RUNTIME_SETTINGS_*_REPO` repository variables. + `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, and `STRATEGY_SWITCH_ADMIN_ORGS` are comma-separated lists. Prefer the organization name for admin access: ```text @@ -181,6 +184,8 @@ Deploy: wrangler deploy ``` +For a full fork checklist, see [docs/strategy_switch_fork_guide.md](../../docs/strategy_switch_fork_guide.md). + ## Token Scope `RUNTIME_SETTINGS_DISPATCH_TOKEN` only needs permission to dispatch workflows in the `QuantRuntimeSettings` repository. Cross-platform variable writes still happen inside `Manual Strategy Switch` with the GitHub Actions environment secret `RUNTIME_SETTINGS_GH_TOKEN`. diff --git a/web/strategy-switch-console/README.zh-CN.md b/web/strategy-switch-console/README.zh-CN.md index 2da62f3..aa1ee8f 100644 --- a/web/strategy-switch-console/README.zh-CN.md +++ b/web/strategy-switch-console/README.zh-CN.md @@ -27,9 +27,12 @@ STRATEGY_SWITCH_ADMIN_ORGS RUNTIME_SETTINGS_REPO=QuantStrategyLab/QuantRuntimeSettings RUNTIME_SETTINGS_WORKFLOW=manual-strategy-switch.yml RUNTIME_SETTINGS_REF=main +STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON={"longbridge":"your-org/LongBridgePlatform","ibkr":"your-org/InteractiveBrokersPlatform","schwab":"your-org/CharlesSchwabPlatform","firstrade":"your-org/FirstradePlatform"} STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON= ``` +Fork 用户也可以分别设置 `STRATEGY_SWITCH_LONGBRIDGE_REPO`、`STRATEGY_SWITCH_IBKR_REPO`、`STRATEGY_SWITCH_SCHWAB_REPO`、`STRATEGY_SWITCH_FIRSTRADE_REPO`。GitHub Actions workflow 侧也支持同样的映射,变量名是 `RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON` 或 `RUNTIME_SETTINGS_*_REPO`。 + `ALLOWED_GITHUB_LOGINS`、`ALLOWED_GITHUB_ORGS`、`STRATEGY_SWITCH_ADMIN_LOGINS` 和 `STRATEGY_SWITCH_ADMIN_ORGS` 用英文逗号分隔。个人系统建议用组织名做管理员入口,例如: ```text @@ -188,6 +191,8 @@ wrangler kv namespace create STRATEGY_SWITCH_CONFIG wrangler deploy ``` +完整 fork 清单见 [docs/strategy_switch_fork_guide.zh-CN.md](../../docs/strategy_switch_fork_guide.zh-CN.md)。 + ## Token 权限 `RUNTIME_SETTINGS_DISPATCH_TOKEN` 只需要能触发 `QuantRuntimeSettings` 仓库的 workflow。实际跨平台 variables 写入仍由 `Manual Strategy Switch` workflow 内部使用 GitHub Actions 环境里的 `RUNTIME_SETTINGS_GH_TOKEN` 执行。 diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index 1b71db2..7357709 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -757,7 +757,7 @@

切换摘要

\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

LongBridge

\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"; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index bd3a738..609bae3 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -15,12 +15,18 @@ const CURRENT_STRATEGIES_TIMEOUT_MS = 3500; const SUPPORTED_PLATFORMS = ["longbridge", "ibkr", "schwab", "firstrade"]; const SUPPORTED_STRATEGY_DOMAINS = ["us_equity", "hk_equity"]; -const PLATFORM_REPOSITORIES = { +const DEFAULT_PLATFORM_REPOSITORIES = { longbridge: "QuantStrategyLab/LongBridgePlatform", ibkr: "QuantStrategyLab/InteractiveBrokersPlatform", schwab: "QuantStrategyLab/CharlesSchwabPlatform", firstrade: "QuantStrategyLab/FirstradePlatform", }; +const PLATFORM_REPOSITORY_ENV = { + longbridge: ["STRATEGY_SWITCH_LONGBRIDGE_REPO", "RUNTIME_SETTINGS_LONGBRIDGE_REPO"], + ibkr: ["STRATEGY_SWITCH_IBKR_REPO", "RUNTIME_SETTINGS_IBKR_REPO"], + schwab: ["STRATEGY_SWITCH_SCHWAB_REPO", "RUNTIME_SETTINGS_SCHWAB_REPO"], + firstrade: ["STRATEGY_SWITCH_FIRSTRADE_REPO", "RUNTIME_SETTINGS_FIRSTRADE_REPO"], +}; const DEFAULT_VARIABLE_SCOPE = { longbridge: "environment", ibkr: "repository", @@ -460,6 +466,7 @@ async function configPayload(request, env) { const strategyProfiles = await loadStrategyProfilesConfig(env); return { accountOptions: accountConfig.options, + platformRepositories: platformRepositories(env), strategyProfiles, currentStrategies: await loadCurrentStrategiesSafely(accountConfig.options, env), }; @@ -474,6 +481,7 @@ async function strategyProfilesPayload(env) { async function loadCurrentStrategies(accountOptions, env) { const token = env.RUNTIME_SETTINGS_DISPATCH_TOKEN; if (!token || !accountOptions) return {}; + const repositories = platformRepositories(env); const variableCache = new Map(); const readVariable = (repository, scope, githubEnvironment, name) => { @@ -488,7 +496,7 @@ async function loadCurrentStrategies(accountOptions, env) { const platformResults = await Promise.all(SUPPORTED_PLATFORMS.map(async (platform) => { const options = Array.isArray(accountOptions[platform]) ? accountOptions[platform] : []; if (!options.length) return [platform, {}]; - const repository = PLATFORM_REPOSITORIES[platform]; + const repository = repositories[platform]; if (!repository) return [platform, {}]; const optionResults = await Promise.all(options.map(async (option) => { @@ -954,6 +962,40 @@ function inferAccountSupportedDomains(platform, option) { return ["us_equity"]; } +function platformRepositories(env) { + const repositories = { ...DEFAULT_PLATFORM_REPOSITORIES }; + const rawJson = String( + env.STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON || + env.RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON || + "", + ).trim(); + if (rawJson) { + let payload; + try { + payload = JSON.parse(rawJson); + } catch (error) { + throw new Error("platform repositories JSON must be valid JSON"); + } + if (!payload || Array.isArray(payload) || typeof payload !== "object") { + throw new Error("platform repositories JSON must be an object"); + } + for (const [platform, repository] of Object.entries(payload)) { + if (!SUPPORTED_PLATFORMS.includes(platform)) { + throw new Error(`unsupported platform repository override: ${platform}`); + } + repositories[platform] = cleanRepositoryName(repository, `${platform} repository`); + } + } + + for (const platform of SUPPORTED_PLATFORMS) { + for (const name of PLATFORM_REPOSITORY_ENV[platform] || []) { + const repository = String(env[name] || "").trim(); + if (repository) repositories[platform] = cleanRepositoryName(repository, name); + } + } + return repositories; +} + function normalizeSupportedDomains(value, fieldName) { const items = Array.isArray(value) ? value @@ -1004,6 +1046,14 @@ function cleanSlug(value, field) { return text; } +function cleanRepositoryName(value, field) { + const text = String(value || "").trim(); + if (!text || text.length > 160 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text)) { + throw new Error(`${field} must be owner/repo`); + } + return text; +} + function cleanLabel(value, field) { const text = String(value || "").trim(); if (!text || text.length > 80 || /[<>{}]/.test(text)) { @@ -1626,6 +1676,7 @@ export const __test = { loadCurrentStrategies, normalizeAccountOptionsPayload, normalizeStrategyProfilesPayload, + platformRepositories, requireSameOrigin, responseHeaders, syncDefaultStrategyForAccount, diff --git a/web/strategy-switch-console/wrangler.toml.example b/web/strategy-switch-console/wrangler.toml.example index 6e77588..54057b5 100644 --- a/web/strategy-switch-console/wrangler.toml.example +++ b/web/strategy-switch-console/wrangler.toml.example @@ -16,9 +16,17 @@ workers_dev = true # - STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON # # Optional non-secret variables can be placed here or in the Cloudflare dashboard: -# RUNTIME_SETTINGS_REPO = "QuantStrategyLab/QuantRuntimeSettings" -# RUNTIME_SETTINGS_WORKFLOW = "manual-strategy-switch.yml" -# RUNTIME_SETTINGS_REF = "main" +[vars] +RUNTIME_SETTINGS_REPO = "QuantStrategyLab/QuantRuntimeSettings" +RUNTIME_SETTINGS_WORKFLOW = "manual-strategy-switch.yml" +RUNTIME_SETTINGS_REF = "main" + +# Forks can replace the platform repository mapping without editing source code. +# STRATEGY_SWITCH_PLATFORM_REPOSITORIES_JSON = '{"longbridge":"your-org/LongBridgePlatform","ibkr":"your-org/InteractiveBrokersPlatform","schwab":"your-org/CharlesSchwabPlatform","firstrade":"your-org/FirstradePlatform"}' +# STRATEGY_SWITCH_LONGBRIDGE_REPO = "your-org/LongBridgePlatform" +# STRATEGY_SWITCH_IBKR_REPO = "your-org/InteractiveBrokersPlatform" +# STRATEGY_SWITCH_SCHWAB_REPO = "your-org/CharlesSchwabPlatform" +# STRATEGY_SWITCH_FIRSTRADE_REPO = "your-org/FirstradePlatform" # Bind this namespace to enable editable /admin login and account management. # Without it, /admin is read-only and the Worker falls back to secrets.