Skip to content

Commit 1192c30

Browse files
committed
Sync switch config after strategy changes
1 parent 35a9c48 commit 1192c30

8 files changed

Lines changed: 308 additions & 4 deletions

File tree

.github/workflows/manual-strategy-switch.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ jobs:
153153
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
154154
CONFIRM_APPLY: ${{ inputs.confirm_apply }}
155155
PLATFORM_SYNC_WORKFLOW: ${{ inputs.platform_sync_workflow }}
156+
STRATEGY_SWITCH_CONSOLE_URL: ${{ vars.STRATEGY_SWITCH_CONSOLE_URL }}
157+
STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }}
156158
steps:
157159
- name: Checkout
158160
uses: actions/checkout@v6
@@ -356,3 +358,56 @@ jobs:
356358
;;
357359
esac
358360
echo "Dispatched ${workflow} in ${TARGET_REPOSITORY}."
361+
362+
- name: Sync strategy switch account defaults
363+
if: env.APPLY_SWITCH == 'true' && env.STRATEGY_SWITCH_CONSOLE_URL != ''
364+
run: |
365+
set -euo pipefail
366+
python - <<'PY'
367+
import json
368+
import os
369+
import sys
370+
import urllib.error
371+
import urllib.request
372+
373+
base_url = os.environ["STRATEGY_SWITCH_CONSOLE_URL"].rstrip("/")
374+
token = os.environ["STRATEGY_SWITCH_SYNC_TOKEN"]
375+
if not token:
376+
raise SystemExit("STRATEGY_SWITCH_SYNC_TOKEN is required when STRATEGY_SWITCH_CONSOLE_URL is set")
377+
with open(os.environ["TARGET_FILE"], encoding="utf-8") as handle:
378+
target = json.load(handle)
379+
runtime_target = target["runtime_target"]
380+
github = target["github"]
381+
payload = {
382+
"platform": runtime_target["platform_id"],
383+
"target_name": target["target_id"].split("/", 1)[1],
384+
"strategy_profile": runtime_target["strategy_profile"],
385+
"execution_mode": runtime_target["execution_mode"],
386+
"variable_scope": github["variable_scope"],
387+
"plugin_mode": os.environ["PLUGIN_MODE"],
388+
"deployment_selector": runtime_target["deployment_selector"],
389+
"account_selector": ",".join(runtime_target["account_selector"]),
390+
"account_scope": runtime_target["account_scope"],
391+
"service_name": runtime_target["service_name"],
392+
}
393+
if github.get("environment"):
394+
payload["github_environment"] = github["environment"]
395+
396+
request = urllib.request.Request(
397+
f"{base_url}/api/internal/sync-account-default",
398+
data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
399+
headers={
400+
"Authorization": f"Bearer {token}",
401+
"Content-Type": "application/json",
402+
},
403+
method="POST",
404+
)
405+
try:
406+
with urllib.request.urlopen(request, timeout=20) as response:
407+
body = response.read().decode("utf-8")
408+
except urllib.error.HTTPError as exc:
409+
body = exc.read().decode("utf-8", errors="replace")
410+
print(body, file=sys.stderr)
411+
raise
412+
print(body)
413+
PY

scripts/build_runtime_switch.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def _build_target_entry(
252252
dry_run_variable = PLATFORM_DRY_RUN_VARIABLES.get(platform)
253253
if dry_run_variable:
254254
entry[dry_run_variable] = env_string(runtime_target["dry_run_only"])
255-
if mounts and mounts_variable:
255+
if mounts_variable:
256256
entry[mounts_variable] = {"strategy_plugins": mounts}
257257
entry.update(extra_variables)
258258
return entry
@@ -329,7 +329,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
329329
field_name="existing_service_targets_json_file",
330330
)
331331
top_level_mounts = mounts
332-
plugin_mounts_variable: str | None = mounts_variable if mounts else None
332+
plugin_mounts_variable: str | None = mounts_variable
333333
if service_targets:
334334
patched_service_targets = _patch_service_targets(
335335
current_payload=service_targets,

tests/strategy_switch_worker_validation.mjs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,49 @@ assert.deepEqual(accountOptions.longbridge[0].supported_domains, ["us_equity", "
136136
assert.deepEqual(accountOptions.longbridge[1].supported_domains, ["us_equity", "hk_equity"]);
137137
assert.deepEqual(accountOptions.ibkr[0].supported_domains, ["us_equity", "hk_equity"]);
138138

139+
const updatedAccountOptions = __test.updateAccountOptionsDefaultStrategy(
140+
accountOptions,
141+
{
142+
platform: "longbridge",
143+
target_name: "sg",
144+
account_selector: "SG",
145+
deployment_selector: "SG",
146+
account_scope: "SG",
147+
service_name: "longbridge-quant-sg-service",
148+
github_environment: "longbridge-sg",
149+
strategy_profile: "soxl_soxx_trend_income",
150+
execution_mode: "live",
151+
variable_scope: "environment",
152+
plugin_mode: "auto",
153+
},
154+
);
155+
assert.equal(updatedAccountOptions.changed, true);
156+
assert.equal(updatedAccountOptions.options.longbridge[1].default_strategy_profile, "soxl_soxx_trend_income");
157+
158+
const kvWrites = new Map();
159+
const syncResult = await __test.syncDefaultStrategyForAccount(
160+
{
161+
STRATEGY_SWITCH_CONFIG: {
162+
get: async (key) => key === "audit_log" ? "[]" : null,
163+
put: async (key, value) => kvWrites.set(key, value),
164+
},
165+
},
166+
accountOptions,
167+
{
168+
platform: "longbridge",
169+
target_name: "sg",
170+
account_selector: "SG",
171+
strategy_profile: "soxl_soxx_trend_income",
172+
execution_mode: "live",
173+
variable_scope: "default",
174+
plugin_mode: "auto",
175+
},
176+
{ login: "pigbibi" },
177+
);
178+
assert.equal(syncResult.synced, true);
179+
assert.equal(syncResult.changed, true);
180+
assert.equal(JSON.parse(kvWrites.get("account_options")).longbridge[1].default_strategy_profile, "soxl_soxx_trend_income");
181+
139182
const originalFetch = globalThis.fetch;
140183
globalThis.fetch = async (url) => {
141184
const requestUrl = String(url);

tests/test_runtime_settings.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,32 @@ def test_build_switch_target_defaults_schwab_repository_scope(self):
176176
self.assertNotIn("environment", target["github"])
177177
self.assertEqual(target["runtime_target"]["service_name"], "charles-schwab-quant-service")
178178
self.assertEqual(assignments["SCHWAB_DRY_RUN_ONLY"], "false")
179-
self.assertNotIn("SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON", assignments)
179+
self.assertEqual(
180+
json.loads(assignments["SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON"]),
181+
{"strategy_plugins": []},
182+
)
183+
184+
def test_build_switch_target_clears_plugin_mounts_for_unmounted_strategy(self):
185+
parser = build_runtime_switch.build_parser()
186+
args = parser.parse_args(
187+
[
188+
"--platform",
189+
"longbridge",
190+
"--target-name",
191+
"sg",
192+
"--strategy-profile",
193+
"soxl_soxx_trend_income",
194+
]
195+
)
196+
197+
target = build_runtime_switch.build_switch_target(args)
198+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
199+
200+
self.assertEqual(assignments["STRATEGY_PROFILE"], "soxl_soxx_trend_income")
201+
self.assertEqual(
202+
json.loads(assignments["LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON"]),
203+
{"strategy_plugins": []},
204+
)
180205

181206
def test_build_switch_target_defaults_firstrade_repository_scope(self):
182207
parser = build_runtime_switch.build_parser()
@@ -295,6 +320,65 @@ def test_build_switch_target_patches_ibkr_service_targets_json(self):
295320
)
296321
self.assertEqual(untouched["runtime_target"]["strategy_profile"], "soxl_soxx_trend_income")
297322

323+
def test_build_switch_target_patches_ibkr_service_targets_with_empty_plugin_mounts(self):
324+
existing = {
325+
"targets": [
326+
{
327+
"service": "interactive-brokers-live-u1599-tqqq-service",
328+
"ACCOUNT_GROUP": "live-u1599-tqqq",
329+
"runtime_target": {
330+
"platform_id": "ibkr",
331+
"strategy_profile": "tqqq_growth_income",
332+
"dry_run_only": False,
333+
"deployment_selector": "live-u1599-tqqq",
334+
"account_selector": ["U15998061"],
335+
"account_scope": "live-u1599-tqqq",
336+
"service_name": "interactive-brokers-live-u1599-tqqq-service",
337+
"execution_mode": "live",
338+
},
339+
"IBKR_STRATEGY_PLUGIN_MOUNTS_JSON": {
340+
"strategy_plugins": [
341+
{
342+
"strategy": "tqqq_growth_income",
343+
"plugin": "market_regime_control",
344+
"signal_path": "gs://bucket/old/latest_signal.json",
345+
"enabled": True,
346+
"expected_mode": "shadow",
347+
}
348+
]
349+
},
350+
},
351+
],
352+
}
353+
path = ROOT / ".pytest_runtime_service_targets_empty_mounts.json"
354+
path.write_text(runtime_settings.compact_json(existing), encoding="utf-8")
355+
self.addCleanup(lambda: path.unlink(missing_ok=True))
356+
parser = build_runtime_switch.build_parser()
357+
args = parser.parse_args(
358+
[
359+
"--platform",
360+
"ibkr",
361+
"--target-name",
362+
"live-u1599-tqqq",
363+
"--strategy-profile",
364+
"soxl_soxx_trend_income",
365+
"--account-selector",
366+
"U15998061",
367+
"--service-name",
368+
"interactive-brokers-live-u1599-tqqq-service",
369+
"--existing-service-targets-json-file",
370+
str(path),
371+
]
372+
)
373+
374+
target = build_runtime_switch.build_switch_target(args)
375+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
376+
patched = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])
377+
selected = patched["targets"][0]
378+
379+
self.assertEqual(selected["runtime_target"]["strategy_profile"], "soxl_soxx_trend_income")
380+
self.assertEqual(selected["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"], {"strategy_plugins": []})
381+
298382

299383
if __name__ == "__main__":
300384
unittest.main()

web/strategy-switch-console/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ The Worker validates dispatch inputs against this config, including whether the
114114

115115
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`.
116116

117+
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.
118+
117119
## Strategy Profile Alignment
118120

119121
Treat `strategy_profile` as the canonical strategy id across the switch console, runtime settings, and platform repositories.
@@ -127,6 +129,7 @@ When adding or renaming a strategy profile:
127129
- Use `["us_equity", "hk_equity"]` for LongBridge and IBKR accounts unless you intentionally want to narrow a specific account.
128130
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
129131
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
132+
- 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.
130133
- Use lower-case ids with letters, numbers, dot, underscore, dash, or equals only. Do not encode account names or secrets in profile ids.
131134

132135
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.
@@ -156,6 +159,7 @@ wrangler secret put GITHUB_CLIENT_ID
156159
wrangler secret put GITHUB_CLIENT_SECRET
157160
wrangler secret put SESSION_SECRET
158161
wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN
162+
wrangler secret put STRATEGY_SWITCH_SYNC_TOKEN # optional; defaults to RUNTIME_SETTINGS_DISPATCH_TOKEN
159163
wrangler secret put ALLOWED_GITHUB_LOGINS
160164
wrangler secret put ALLOWED_GITHUB_ORGS
161165
wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS

web/strategy-switch-console/README.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校
121121

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

124+
策略切换成功后也会把当前账号的 `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 保持一致。
125+
124126
## 策略 Profile 对齐规范
125127

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

139142
切换页只允许选择 runtime-enabled 且 `domain` 属于当前账号 `supported_domains` 的策略。如果从 GitHub Variables 动态读到了未登记 profile,先补进策略目录再切换。
@@ -163,6 +166,7 @@ wrangler secret put GITHUB_CLIENT_ID
163166
wrangler secret put GITHUB_CLIENT_SECRET
164167
wrangler secret put SESSION_SECRET
165168
wrangler secret put RUNTIME_SETTINGS_DISPATCH_TOKEN
169+
wrangler secret put STRATEGY_SWITCH_SYNC_TOKEN # 可选;默认复用 RUNTIME_SETTINGS_DISPATCH_TOKEN
166170
wrangler secret put ALLOWED_GITHUB_LOGINS
167171
wrangler secret put ALLOWED_GITHUB_ORGS
168172
wrangler secret put STRATEGY_SWITCH_ADMIN_LOGINS

0 commit comments

Comments
 (0)