Skip to content

Commit 6d3dc6b

Browse files
Pigbibicursoragent
andauthored
Fix strategy switch KV sync timing and account matching edge cases. (#78)
Defer console KV updates until workflow success, normalize variable_scope matching, extract IBKR DCA controls from service targets for workflow sync, and default option overlay UI to current instead of enabled. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0513886 commit 6d3dc6b

7 files changed

Lines changed: 162 additions & 13 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,10 @@ jobs:
369369
import urllib.error
370370
import urllib.request
371371
372+
root = os.environ.get("GITHUB_WORKSPACE") or os.getcwd()
373+
sys.path.insert(0, os.path.join(root, "scripts"))
374+
import runtime_settings
375+
372376
base_url = os.environ["STRATEGY_SWITCH_CONSOLE_URL"].rstrip("/")
373377
token = os.environ["STRATEGY_SWITCH_SYNC_TOKEN"]
374378
if not token:
@@ -383,19 +387,15 @@ jobs:
383387
"target_name": target["target_id"].split("/", 1)[1],
384388
"strategy_profile": runtime_target["strategy_profile"],
385389
"execution_mode": runtime_target["execution_mode"],
386-
"variable_scope": github["variable_scope"],
390+
"variable_scope": "default",
387391
"plugin_mode": os.environ["PLUGIN_MODE"],
388392
"option_overlay_mode": os.environ.get("OPTION_OVERLAY_MODE", "current"),
389393
"deployment_selector": runtime_target["deployment_selector"],
390394
"account_selector": ",".join(runtime_target["account_selector"]),
391395
"account_scope": runtime_target["account_scope"],
392396
"service_name": runtime_target["service_name"],
393397
}
394-
extra_variables = target.get("extra_variables") or {}
395-
if extra_variables.get("DCA_MODE"):
396-
payload["dca_mode"] = extra_variables["DCA_MODE"]
397-
if extra_variables.get("DCA_BASE_INVESTMENT_USD"):
398-
payload["dca_base_investment_usd"] = extra_variables["DCA_BASE_INVESTMENT_USD"]
398+
payload.update(runtime_settings.extract_account_sync_controls(target))
399399
if github.get("environment"):
400400
payload["github_environment"] = github["environment"]
401401

scripts/runtime_settings.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,63 @@ def command_repository(args: argparse.Namespace) -> int:
724724
return 0
725725

726726

727+
ACCOUNT_SYNC_CONTROL_FIELDS = {
728+
"DCA_MODE": "dca_mode",
729+
"DCA_BASE_INVESTMENT_USD": "dca_base_investment_usd",
730+
"IBIT_ZSCORE_EXIT_MODE": "ibit_zscore_exit_mode",
731+
}
732+
733+
734+
def _service_target_entry_matches(runtime_target: dict[str, Any], entry: dict[str, Any]) -> bool:
735+
service_name = str(runtime_target.get("service_name") or "").strip()
736+
account_scope = str(runtime_target.get("account_scope") or "").strip()
737+
entry_runtime = entry.get("runtime_target") if isinstance(entry.get("runtime_target"), dict) else {}
738+
candidates = {
739+
str(entry.get("service") or "").strip(),
740+
str(entry.get("service_name") or "").strip(),
741+
str(entry_runtime.get("service_name") or "").strip(),
742+
str(entry_runtime.get("account_scope") or "").strip(),
743+
str(entry.get("ACCOUNT_GROUP") or "").strip(),
744+
}
745+
return service_name in candidates or account_scope in candidates
746+
747+
748+
def extract_account_sync_controls(target: dict[str, Any]) -> dict[str, str]:
749+
extra_variables = dict(target.get("extra_variables") or {})
750+
controls: dict[str, str] = {}
751+
for source_key, payload_key in ACCOUNT_SYNC_CONTROL_FIELDS.items():
752+
value = extra_variables.get(source_key)
753+
if value not in (None, ""):
754+
controls[payload_key] = str(value).strip()
755+
756+
service_targets = extra_variables.get("CLOUD_RUN_SERVICE_TARGETS_JSON")
757+
if isinstance(service_targets, str):
758+
try:
759+
service_targets = json.loads(service_targets)
760+
except json.JSONDecodeError:
761+
service_targets = None
762+
763+
runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {}
764+
if isinstance(service_targets, dict):
765+
entries = service_targets.get("targets") if isinstance(service_targets.get("targets"), list) else []
766+
matched = next(
767+
(
768+
entry
769+
for entry in entries
770+
if isinstance(entry, dict) and _service_target_entry_matches(runtime_target, entry)
771+
),
772+
None,
773+
)
774+
if matched:
775+
for source_key, payload_key in ACCOUNT_SYNC_CONTROL_FIELDS.items():
776+
if payload_key in controls:
777+
continue
778+
value = matched.get(source_key)
779+
if value not in (None, ""):
780+
controls[payload_key] = str(value).strip()
781+
return controls
782+
783+
727784
def build_parser() -> argparse.ArgumentParser:
728785
parser = argparse.ArgumentParser(description=__doc__)
729786
subparsers = parser.add_subparsers(dest="command", required=True)

tests/strategy_switch_worker_validation.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,24 @@ const updatedAccountOptions = __test.updateAccountOptionsDefaultStrategy(
610610
assert.equal(updatedAccountOptions.changed, true);
611611
assert.equal(updatedAccountOptions.options.longbridge[1].default_strategy_profile, "soxl_soxx_trend_income");
612612

613+
assert.equal(
614+
__test.accountOptionMatchesInputs(
615+
{ target_name: "sg", variable_scope: "default" },
616+
{
617+
target_name: "sg",
618+
platform: "longbridge",
619+
variable_scope: "environment",
620+
github_environment: "longbridge-sg",
621+
},
622+
),
623+
true,
624+
);
625+
626+
assert.equal(
627+
__test.resolvedVariableScope("default", { platform: "longbridge", target_name: "sg" }),
628+
"environment",
629+
);
630+
613631
const updatedPluginModeOptions = __test.updateAccountOptionsDefaultStrategy(
614632
accountOptions,
615633
{

tests/test_runtime_settings.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,63 @@ def test_manual_switch_account_default_sync_is_warning_only(self):
172172
self.assertIn("Strategy switch account default sync failed", workflow)
173173
self.assertIn("::warning::", workflow)
174174
self.assertIn("raise SystemExit(0)", workflow)
175+
self.assertIn('"variable_scope": "default"', workflow)
176+
self.assertIn("runtime_settings.extract_account_sync_controls(target)", workflow)
177+
178+
def test_extract_account_sync_controls_reads_ibkr_service_targets(self):
179+
target = {
180+
"target_id": "ibkr/demo-ibkr-dca",
181+
"runtime_target": {
182+
"platform_id": "ibkr",
183+
"strategy_profile": "nasdaq_sp500_smart_dca",
184+
"service_name": "interactive-brokers-demo-ibkr-dca-service",
185+
"account_scope": "demo-ibkr-dca",
186+
},
187+
"extra_variables": {
188+
"CLOUD_RUN_SERVICE_TARGETS_JSON": {
189+
"targets": [
190+
{
191+
"service": "interactive-brokers-demo-ibkr-dca-service",
192+
"ACCOUNT_GROUP": "demo-ibkr-dca",
193+
"DCA_MODE": "smart",
194+
"DCA_BASE_INVESTMENT_USD": "500",
195+
"IBIT_ZSCORE_EXIT_MODE": "paper",
196+
}
197+
]
198+
}
199+
},
200+
}
201+
202+
controls = runtime_settings.extract_account_sync_controls(target)
203+
204+
self.assertEqual(
205+
controls,
206+
{
207+
"dca_mode": "smart",
208+
"dca_base_investment_usd": "500",
209+
"ibit_zscore_exit_mode": "paper",
210+
},
211+
)
212+
213+
def test_extract_account_sync_controls_prefers_top_level_extra_variables(self):
214+
target = {
215+
"target_id": "firstrade/default",
216+
"runtime_target": {
217+
"platform_id": "firstrade",
218+
"strategy_profile": "ibit_smart_dca",
219+
"service_name": "firstrade-quant-service",
220+
"account_scope": "US",
221+
},
222+
"extra_variables": {
223+
"DCA_MODE": "fixed",
224+
"DCA_BASE_INVESTMENT_USD": "50",
225+
},
226+
}
227+
228+
self.assertEqual(
229+
runtime_settings.extract_account_sync_controls(target),
230+
{"dca_mode": "fixed", "dca_base_investment_usd": "50"},
231+
)
175232

176233
def test_strategy_switch_console_deploy_workflow_syncs_bundled_profiles(self):
177234
workflow = (ROOT / ".github" / "workflows" / "deploy-strategy-switch-console.yml").read_text(

web/strategy-switch-console/index.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,7 +2512,7 @@ <h2 data-i18n="summary">切换摘要</h2>
25122512
form.optionOverlayMode = configured;
25132513
return;
25142514
}
2515-
form.optionOverlayMode = optionOverlaySupported(form.strategy) ? "enabled" : "disabled";
2515+
form.optionOverlayMode = optionOverlaySupported(form.strategy) ? "current" : "disabled";
25162516
}
25172517

25182518
function syncDcaForAccount(platform) {
@@ -3069,8 +3069,8 @@ <h2 data-i18n="summary">切换摘要</h2>
30693069
currentPluginMode,
30703070
nextPluginMode,
30713071
strategyChanged: Boolean(nextProfile && (!currentProfile || currentProfile !== nextProfile)),
3072-
modeChanged: Boolean(currentMode && inputs.execution_mode && currentMode !== inputs.execution_mode),
3073-
pluginModeChanged: Boolean(currentPluginMode && nextPluginMode && currentPluginMode !== nextPluginMode),
3072+
modeChanged: Boolean(inputs.execution_mode && (!currentMode || currentMode !== inputs.execution_mode)),
3073+
pluginModeChanged: Boolean(nextPluginMode && (!currentPluginMode || currentPluginMode !== nextPluginMode)),
30743074
runtimeTargetChanged: runtimeTarget.changed,
30753075
reserveCashChanged: reserve.changed,
30763076
incomeLayerChanged: income.changed,
@@ -3766,6 +3766,7 @@ <h2 data-i18n="summary">切换摘要</h2>
37663766
if (!response.ok || !payload.ok) throw new Error(payload.error || t("dispatchFailed"));
37673767
showToast(t("dispatched"), { duration: 4000 });
37683768
if (payload.actions_url) window.open(payload.actions_url, "_blank", "noopener,noreferrer");
3769+
await refreshConfig();
37693770
} catch (error) {
37703771
showToast(`${t("dispatchFailed")}: ${error.message}`, { duration: 12000 });
37713772
}

web/strategy-switch-console/page_asset.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/strategy-switch-console/worker.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -830,14 +830,16 @@ async function dispatchSwitch(request, env) {
830830
return json({ ok: false, error: `GitHub dispatch failed: ${text.slice(0, 600)}` }, 502);
831831
}
832832

833-
const accountOptionsSync = await syncDefaultStrategyForAccount(env, accountConfig.options, inputs, session);
834833
return json({
835834
ok: true,
836835
repository,
837836
workflow,
838837
actions_url: `https://github.com/${repository}/actions/workflows/${workflow}`,
839-
account_options_synced: accountOptionsSync.synced,
840-
account_options_sync: accountOptionsSync,
838+
account_options_sync: {
839+
synced: false,
840+
deferred: true,
841+
reason: "workflow_success_required",
842+
},
841843
inputs,
842844
});
843845
}
@@ -1146,6 +1148,12 @@ function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles
11461148
}
11471149
}
11481150

1151+
function resolvedVariableScope(value, inputs) {
1152+
const text = String(value || "").trim();
1153+
if (!text || text === "default") return defaultInputValue("variable_scope", inputs);
1154+
return text;
1155+
}
1156+
11491157
function accountOptionMatchesInputs(option, inputs) {
11501158
if (option.target_name !== inputs.target_name) return false;
11511159
const fields = [
@@ -1157,6 +1165,12 @@ function accountOptionMatchesInputs(option, inputs) {
11571165
"variable_scope",
11581166
];
11591167
for (const field of fields) {
1168+
if (field === "variable_scope") {
1169+
if (resolvedVariableScope(option[field], inputs) !== resolvedVariableScope(inputs[field], inputs)) {
1170+
return false;
1171+
}
1172+
continue;
1173+
}
11601174
const expected = option[field] || "";
11611175
const actual = inputs[field] || "";
11621176
if (expected && actual !== expected) return false;
@@ -2453,6 +2467,8 @@ function escapeHtml(value) {
24532467

24542468
export const __test = {
24552469
assertConfiguredAccount,
2470+
accountOptionMatchesInputs,
2471+
resolvedVariableScope,
24562472
currentStrategiesTimeoutMs: CURRENT_STRATEGIES_TIMEOUT_MS,
24572473
assertStrategyAllowedForAccount,
24582474
inferAccountSupportedDomains,

0 commit comments

Comments
 (0)