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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ jobs:
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
CONFIRM_APPLY: ${{ inputs.confirm_apply }}
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 }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the Worker sync token for workflow callbacks

When STRATEGY_SWITCH_CONSOLE_URL is configured but the new STRATEGY_SWITCH_SYNC_TOKEN secret is omitted, this fallback sends RUNTIME_SETTINGS_GH_TOKEN to /api/internal/sync-account-default. The Worker validates against env.STRATEGY_SWITCH_SYNC_TOKEN || env.RUNTIME_SETTINGS_DISPATCH_TOKEN, and the README documents the Worker-side default as RUNTIME_SETTINGS_DISPATCH_TOKEN, so the documented optional-secret setup will get a 401 after applying variables and the workflow will fail instead of syncing account defaults. Either require the sync secret explicitly here or fall back to the same dispatch token value the Worker expects.

Useful? React with 👍 / 👎.

steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -356,3 +358,56 @@ jobs:
;;
esac
echo "Dispatched ${workflow} in ${TARGET_REPOSITORY}."

- name: Sync strategy switch account defaults
if: env.APPLY_SWITCH == 'true' && env.STRATEGY_SWITCH_CONSOLE_URL != ''
run: |
set -euo pipefail
python - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request

base_url = os.environ["STRATEGY_SWITCH_CONSOLE_URL"].rstrip("/")
token = os.environ["STRATEGY_SWITCH_SYNC_TOKEN"]
if not token:
raise SystemExit("STRATEGY_SWITCH_SYNC_TOKEN is required when STRATEGY_SWITCH_CONSOLE_URL is set")
with open(os.environ["TARGET_FILE"], encoding="utf-8") as handle:
target = json.load(handle)
runtime_target = target["runtime_target"]
github = target["github"]
payload = {
"platform": runtime_target["platform_id"],
"target_name": target["target_id"].split("/", 1)[1],
"strategy_profile": runtime_target["strategy_profile"],
"execution_mode": runtime_target["execution_mode"],
"variable_scope": github["variable_scope"],
"plugin_mode": os.environ["PLUGIN_MODE"],
"deployment_selector": runtime_target["deployment_selector"],
"account_selector": ",".join(runtime_target["account_selector"]),
"account_scope": runtime_target["account_scope"],
"service_name": runtime_target["service_name"],
}
if github.get("environment"):
payload["github_environment"] = github["environment"]

request = urllib.request.Request(
f"{base_url}/api/internal/sync-account-default",
data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
print(body, file=sys.stderr)
raise
print(body)
PY
4 changes: 2 additions & 2 deletions scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def _build_target_entry(
dry_run_variable = PLATFORM_DRY_RUN_VARIABLES.get(platform)
if dry_run_variable:
entry[dry_run_variable] = env_string(runtime_target["dry_run_only"])
if mounts and mounts_variable:
if mounts_variable:
entry[mounts_variable] = {"strategy_plugins": mounts}
entry.update(extra_variables)
return entry
Expand Down Expand Up @@ -329,7 +329,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
field_name="existing_service_targets_json_file",
)
top_level_mounts = mounts
plugin_mounts_variable: str | None = mounts_variable if mounts else None
plugin_mounts_variable: str | None = mounts_variable
if service_targets:
patched_service_targets = _patch_service_targets(
current_payload=service_targets,
Expand Down
43 changes: 43 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,49 @@ assert.deepEqual(accountOptions.longbridge[0].supported_domains, ["us_equity", "
assert.deepEqual(accountOptions.longbridge[1].supported_domains, ["us_equity", "hk_equity"]);
assert.deepEqual(accountOptions.ibkr[0].supported_domains, ["us_equity", "hk_equity"]);

const updatedAccountOptions = __test.updateAccountOptionsDefaultStrategy(
accountOptions,
{
platform: "longbridge",
target_name: "sg",
account_selector: "SG",
deployment_selector: "SG",
account_scope: "SG",
service_name: "longbridge-quant-sg-service",
github_environment: "longbridge-sg",
strategy_profile: "soxl_soxx_trend_income",
execution_mode: "live",
variable_scope: "environment",
plugin_mode: "auto",
},
);
assert.equal(updatedAccountOptions.changed, true);
assert.equal(updatedAccountOptions.options.longbridge[1].default_strategy_profile, "soxl_soxx_trend_income");

const kvWrites = new Map();
const syncResult = await __test.syncDefaultStrategyForAccount(
{
STRATEGY_SWITCH_CONFIG: {
get: async (key) => key === "audit_log" ? "[]" : null,
put: async (key, value) => kvWrites.set(key, value),
},
},
accountOptions,
{
platform: "longbridge",
target_name: "sg",
account_selector: "SG",
strategy_profile: "soxl_soxx_trend_income",
execution_mode: "live",
variable_scope: "default",
plugin_mode: "auto",
},
{ login: "pigbibi" },
);
assert.equal(syncResult.synced, true);
assert.equal(syncResult.changed, true);
assert.equal(JSON.parse(kvWrites.get("account_options")).longbridge[1].default_strategy_profile, "soxl_soxx_trend_income");

const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const requestUrl = String(url);
Expand Down
86 changes: 85 additions & 1 deletion tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,32 @@ def test_build_switch_target_defaults_schwab_repository_scope(self):
self.assertNotIn("environment", target["github"])
self.assertEqual(target["runtime_target"]["service_name"], "charles-schwab-quant-service")
self.assertEqual(assignments["SCHWAB_DRY_RUN_ONLY"], "false")
self.assertNotIn("SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON", assignments)
self.assertEqual(
json.loads(assignments["SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON"]),
{"strategy_plugins": []},
)

def test_build_switch_target_clears_plugin_mounts_for_unmounted_strategy(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"longbridge",
"--target-name",
"sg",
"--strategy-profile",
"soxl_soxx_trend_income",
]
)

target = build_runtime_switch.build_switch_target(args)
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}

self.assertEqual(assignments["STRATEGY_PROFILE"], "soxl_soxx_trend_income")
self.assertEqual(
json.loads(assignments["LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON"]),
{"strategy_plugins": []},
)

def test_build_switch_target_defaults_firstrade_repository_scope(self):
parser = build_runtime_switch.build_parser()
Expand Down Expand Up @@ -295,6 +320,65 @@ 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_patches_ibkr_service_targets_with_empty_plugin_mounts(self):
existing = {
"targets": [
{
"service": "interactive-brokers-live-u1599-tqqq-service",
"ACCOUNT_GROUP": "live-u1599-tqqq",
"runtime_target": {
"platform_id": "ibkr",
"strategy_profile": "tqqq_growth_income",
"dry_run_only": False,
"deployment_selector": "live-u1599-tqqq",
"account_selector": ["U15998061"],
"account_scope": "live-u1599-tqqq",
"service_name": "interactive-brokers-live-u1599-tqqq-service",
"execution_mode": "live",
},
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {
"strategy_plugins": [
{
"strategy": "tqqq_growth_income",
"plugin": "market_regime_control",
"signal_path": "gs://bucket/old/latest_signal.json",
"enabled": True,
"expected_mode": "shadow",
}
]
},
},
],
}
path = ROOT / ".pytest_runtime_service_targets_empty_mounts.json"
path.write_text(runtime_settings.compact_json(existing), encoding="utf-8")
self.addCleanup(lambda: path.unlink(missing_ok=True))
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"live-u1599-tqqq",
"--strategy-profile",
"soxl_soxx_trend_income",
"--account-selector",
"U15998061",
"--service-name",
"interactive-brokers-live-u1599-tqqq-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"])
selected = patched["targets"][0]

self.assertEqual(selected["runtime_target"]["strategy_profile"], "soxl_soxx_trend_income")
self.assertEqual(selected["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"], {"strategy_plugins": []})


if __name__ == "__main__":
unittest.main()
4 changes: 4 additions & 0 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ The Worker validates dispatch inputs against this config, including whether the

For signed-in users, `/api/config` also reads the target repositories' current GitHub Variables. It prefers account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON`, then matching `RUNTIME_TARGET_JSON.strategy_profile`, then `STRATEGY_PROFILE`; if none can be read safely, the page falls back to `default_strategy_profile`.

Successful strategy switches also sync the selected account's `default_strategy_profile` back to the KV `account_options` key. The web endpoint does this immediately after dispatching the workflow, and the manual GitHub workflow calls the Worker's internal sync endpoint after applying platform variables when the `runtime-strategy-switch` environment variable `STRATEGY_SWITCH_CONSOLE_URL` is set. For that workflow callback, set the GitHub environment secret `STRATEGY_SWITCH_SYNC_TOKEN` to the same value as the Worker secret with that name.

## Strategy Profile Alignment

Treat `strategy_profile` as the canonical strategy id across the switch console, runtime settings, and platform repositories.
Expand All @@ -127,6 +129,7 @@ When adding or renaming a strategy profile:
- Use `["us_equity", "hk_equity"]` for LongBridge and IBKR accounts unless you intentionally want to narrow a specific account.
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
- Let `manual-strategy-switch.yml` manage platform plugin mounts. It writes an empty `*_STRATEGY_PLUGIN_MOUNTS_JSON` payload for strategies without plugin mounts, so old strategy plugin config is cleared instead of lingering.
- Use lower-case ids with letters, numbers, dot, underscore, dash, or equals only. Do not encode account names or secrets in profile ids.

The console only allows live-enabled profiles whose `domain` is included in the selected account's `supported_domains`. If a profile is dynamically read from GitHub Variables but is missing from the catalog, add it to the catalog before switching to it.
Expand Down Expand Up @@ -156,6 +159,7 @@ 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 # optional; defaults to RUNTIME_SETTINGS_DISPATCH_TOKEN
wrangler secret put ALLOWED_GITHUB_LOGINS
wrangler secret put ALLOWED_GITHUB_ORGS
wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS
Expand Down
4 changes: 4 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校

登录用户访问 `/api/config` 时,Worker 还会读取目标平台仓库的当前 GitHub Variables。读取优先级是账号匹配的 `CLOUD_RUN_SERVICE_TARGETS_JSON`、匹配的 `RUNTIME_TARGET_JSON.strategy_profile`、`STRATEGY_PROFILE`;都读不到时,页面才回退到 `default_strategy_profile`。

策略切换成功后也会把当前账号的 `default_strategy_profile` 同步回 KV 的 `account_options` key。网页接口会在触发 workflow 成功后立即同步;如果 `runtime-strategy-switch` 环境变量里配置了 `STRATEGY_SWITCH_CONSOLE_URL`,手动 GitHub workflow 在写入平台变量后也会回调 Worker 内部接口同步。这个 workflow 回调需要 GitHub 环境 secret `STRATEGY_SWITCH_SYNC_TOKEN`,值要和 Worker 里同名 secret 保持一致。

## 策略 Profile 对齐规范

`strategy_profile` 是切换页、runtime settings 和各平台仓库之间的统一策略 ID。
Expand All @@ -134,6 +136,7 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校
- LongBridge 和 IBKR 账号默认写 `["us_equity", "hk_equity"]`,除非你明确要把某个账号限制成单市场。
- 用 `strategy-profiles.example.json` 更新已部署 KV 的 `strategy_profiles` key。
- 确认平台仓库当前的 `RUNTIME_TARGET_JSON.strategy_profile` 或账号级 `CLOUD_RUN_SERVICE_TARGETS_JSON` 使用同一个 id。
- 让 `manual-strategy-switch.yml` 统一管理平台 plugin mounts。策略不需要插件时,它会写入空的 `*_STRATEGY_PLUGIN_MOUNTS_JSON`,清掉旧策略留下的插件配置。
- profile id 只使用小写字母、数字、点、下划线、短横线或等号。不要把账号名、密码、token、密钥信息写进 profile id。

切换页只允许选择 runtime-enabled 且 `domain` 属于当前账号 `supported_domains` 的策略。如果从 GitHub Variables 动态读到了未登记 profile,先补进策略目录再切换。
Expand Down Expand Up @@ -163,6 +166,7 @@ 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 # 可选;默认复用 RUNTIME_SETTINGS_DISPATCH_TOKEN
wrangler secret put ALLOWED_GITHUB_LOGINS
wrangler secret put ALLOWED_GITHUB_ORGS
wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS
Expand Down
Loading