diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 039959b..d8196c7 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -574,19 +574,27 @@ jobs: target = json.load(handle) runtime_target = target["runtime_target"] github = target["github"] + continuity = runtime_target.get("live_continuity") + if not isinstance(continuity, dict): + continuity = {} 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"], + "live_continuity_state": continuity.get("state", "NONE"), "variable_scope": "default", "plugin_mode": os.environ["PLUGIN_MODE"], "option_overlay_mode": os.environ.get("OPTION_OVERLAY_MODE", "current"), + "cash_only_execution_mode": "current", "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 continuity.get("state") and continuity.get("state") != "NONE": + payload["live_continuity_baseline_id"] = continuity.get("baseline_id", "") + payload["live_continuity_captured_at"] = continuity.get("captured_at", "") extra_variables_json = os.environ.get("EXTRA_VARIABLES_JSON", "").strip() if extra_variables_json: try: diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 39bda39..a3d9462 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -405,6 +405,20 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload( can_switch_live: true, allowed_execution_modes: ["live", "dry_run"], }, + { + profile: "legacy_continuity_profile", + label: "Legacy continuity profile", + domain: "us_equity", + runtime_enabled: false, + lifecycle_stage: "research_active", + can_switch_live: false, + allowed_execution_modes: ["dry_run"], + blocked_live_reason: "candidate_gate_remains_closed", + live_continuity: { + eligible: true, + allowed_platforms: ["ibkr"], + }, + }, { profile: "us_equity_combo_leveraged", label: "US Alpha Combo", @@ -470,17 +484,19 @@ assert.equal(strategyProfiles[0].option_growth_overlay_nav_budget_ratio, "0.03") assert.equal(strategyProfiles[0].option_income_overlay_enabled, false); assert.equal(strategyProfiles[0].latest_evidence_status, "live_allowed"); assert.equal(strategyProfiles[0].plugin_gate_status, "live_allowed"); -assert.equal(strategyProfiles[2].lifecycle_stage, "research_active"); -assert.equal(strategyProfiles[2].can_switch_live, false); -assert.deepEqual(strategyProfiles[2].allowed_execution_modes, ["dry_run"]); -assert.equal(strategyProfiles[2].blocked_live_reason, "promotion_required"); -assert.equal(strategyProfiles[2].latest_evidence_status, "research_only"); -assert.equal(strategyProfiles[2].plugin_gate_status, "blocked"); -assert.equal(strategyProfiles[3].dca_enabled, true); -assert.equal(strategyProfiles[3].dca_default_mode, "fixed"); -assert.equal(strategyProfiles[3].dca_default_base_investment_usd, "1000"); -assert.equal(strategyProfiles[4].lifecycle_stage, "shadow_active"); -assert.equal(strategyProfiles[5].lifecycle_stage, "live_enabled"); +const legacyContinuityProfile = strategyProfiles.find((item) => item.profile === "legacy_continuity_profile"); +assert.deepEqual(legacyContinuityProfile.live_continuity, { eligible: true, allowed_platforms: ["ibkr"] }); +assert.equal(strategyProfiles[3].lifecycle_stage, "research_active"); +assert.equal(strategyProfiles[3].can_switch_live, false); +assert.deepEqual(strategyProfiles[3].allowed_execution_modes, ["dry_run"]); +assert.equal(strategyProfiles[3].blocked_live_reason, "promotion_required"); +assert.equal(strategyProfiles[3].latest_evidence_status, "research_only"); +assert.equal(strategyProfiles[3].plugin_gate_status, "blocked"); +assert.equal(strategyProfiles[4].dca_enabled, true); +assert.equal(strategyProfiles[4].dca_default_mode, "fixed"); +assert.equal(strategyProfiles[4].dca_default_base_investment_usd, "1000"); +assert.equal(strategyProfiles[5].lifecycle_stage, "shadow_active"); +assert.equal(strategyProfiles[6].lifecycle_stage, "live_enabled"); assert.doesNotThrow(() => __test.assertStrategyAllowedForAccount( @@ -644,6 +660,92 @@ assert.deepEqual(kvUnboundSyncBody.account_options_sync, { skipped: true, }); +const legacyContinuityKv = new Map([ + ["account_options", JSON.stringify(accountOptions)], + ["strategy_profiles", JSON.stringify(strategyProfiles)], +]); +const legacyContinuityEnv = { + STRATEGY_SWITCH_SYNC_TOKEN: "test-sync-token", + STRATEGY_SWITCH_CONFIG: { + get: async (key) => legacyContinuityKv.get(key) || null, + put: async (key, value) => legacyContinuityKv.set(key, value), + }, +}; +const legacyContinuityPayload = { + platform: "ibkr", + target_name: "legacy-ibkr-route", + account_selector: "LEGACY_IBKR", + deployment_selector: "legacy-ibkr-route", + account_scope: "legacy-ibkr-route", + service_name: "interactive-brokers-legacy-ibkr-route-service", + strategy_profile: "legacy_continuity_profile", + execution_mode: "live", + live_continuity_state: "RECONCILE_ONLY", + live_continuity_baseline_id: "legacy-ibkr-lkg-20260830", + live_continuity_captured_at: "2026-08-30", + variable_scope: "default", + plugin_mode: "current", + option_overlay_mode: "current", + cash_only_execution_mode: "current", +}; +const legacyContinuitySyncResponse = await worker.fetch( + new Request("https://switch.example/api/internal/sync-account-default", { + method: "POST", + headers: { + Authorization: "Bearer test-sync-token", + "Content-Type": "application/json", + }, + body: JSON.stringify(legacyContinuityPayload), + }), + legacyContinuityEnv, +); +assert.equal(legacyContinuitySyncResponse.status, 200); +const legacyContinuitySyncBody = await legacyContinuitySyncResponse.json(); +assert.equal(legacyContinuitySyncBody.ok, true); +assert.equal(legacyContinuitySyncBody.legacy_continuity_account_registered, true); +const registeredLegacyAccount = JSON.parse(legacyContinuityKv.get("account_options")).ibkr.find( + (option) => option.target_name === "legacy-ibkr-route", +); +assert.equal(registeredLegacyAccount.service_name, "interactive-brokers-legacy-ibkr-route-service"); +assert.deepEqual(registeredLegacyAccount.supported_domains, ["us_equity"]); +assert.equal("plugin_mode" in registeredLegacyAccount, false); +const repeatedLegacyContinuitySyncResponse = await worker.fetch( + new Request("https://switch.example/api/internal/sync-account-default", { + method: "POST", + headers: { + Authorization: "Bearer test-sync-token", + "Content-Type": "application/json", + }, + body: JSON.stringify(legacyContinuityPayload), + }), + legacyContinuityEnv, +); +assert.equal(repeatedLegacyContinuitySyncResponse.status, 200); +assert.equal((await repeatedLegacyContinuitySyncResponse.json()).legacy_continuity_account_registered, false); +assert.equal( + JSON.parse(legacyContinuityKv.get("account_options")).ibkr.filter( + (option) => option.target_name === "legacy-ibkr-route", + ).length, + 1, +); +const normalizedLegacyContinuityInputs = __test.normalizeSwitchInputs(legacyContinuityPayload); +assert.doesNotThrow(() => + __test.assertStrategyAllowedForAccount( + normalizedLegacyContinuityInputs, + registeredLegacyAccount, + strategyProfiles, + ), +); +assert.throws( + () => + __test.assertStrategyAllowedForAccount( + { ...normalizedLegacyContinuityInputs, live_continuity_state: "NONE" }, + registeredLegacyAccount, + strategyProfiles, + ), + /not live-enabled/, +); + const kvUnboundProfileSyncResponse = await worker.fetch( new Request("https://switch.example/api/internal/sync-strategy-profiles", { method: "POST", @@ -722,6 +824,9 @@ assert.deepEqual(JSON.parse(normalizedCashOnlyInputs.extra_variables_json), { assert.equal("cash_only_execution_mode" in normalizedCashOnlyInputs, false); const workflowYaml = readFileSync(resolve(root, ".github/workflows/manual-strategy-switch.yml"), "utf8"); +assert.ok(workflowYaml.includes('"live_continuity_state": continuity.get("state", "NONE")')); +assert.ok(workflowYaml.includes('payload["live_continuity_baseline_id"]')); +assert.ok(workflowYaml.includes('"cash_only_execution_mode": "current"')); const workflowInputs = [...workflowYaml.matchAll(/^ ([A-Za-z0-9_]+):\n description:/gm)].map((match) => match[1]); const dispatchInputs = __test.normalizeSwitchInputs({ platform: "ibkr", diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index 911b960..dc29147 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -172,6 +172,15 @@ const RESEARCH_TASK_OBJECTIVES = ["diagnose_degradation", "test_hypothesis", "ch const SUPPORTED_PLATFORMS = ["longbridge", "ibkr", "schwab", "firstrade", "qmt", "binance"]; const SUPPORTED_STRATEGY_DOMAINS = ["us_equity", "hk_equity", "cn_equity", "crypto"]; +const LIVE_CONTINUITY_STATES = [ + "NONE", + "ACTIVE_LKG", + "ACTIVE_REDUCED", + "RECONCILE_ONLY", + "RISK_REDUCTION_ONLY", + "PAUSED", + "ROLLBACK_LKG", +]; const DEFAULT_PLATFORM_REPOSITORIES = { longbridge: "QuantStrategyLab/LongBridgePlatform", ibkr: "QuantStrategyLab/InteractiveBrokersPlatform", @@ -1366,15 +1375,49 @@ async function syncAccountDefaultResponse(request, env) { } const inputs = normalizeSwitchInputs(rawInput); const accountConfig = await loadAccountOptionsConfig(env); - const accountOption = assertConfiguredAccount(inputs, accountConfig.options); - assertStrategyAllowedForAccount(inputs, accountOption, await loadStrategyProfilesConfig(env)); - const result = await syncDefaultStrategyForAccount(env, accountConfig.options, inputs, { + const strategyProfiles = await loadStrategyProfilesConfig(env); + const strategy = strategyProfiles.find((item) => item.profile === inputs.strategy_profile); + if (!strategy) throw new Error(`strategy ${inputs.strategy_profile} is not configured`); + + let accountOptions = accountConfig.options; + let accountOption = configuredAccountForInputs(inputs, accountOptions); + let registeredLegacyContinuityAccount = false; + if (!accountOption) { + const registration = registerLegacyContinuityAccount(env, accountOptions, inputs, strategy); + accountOptions = registration.options; + accountOption = registration.account; + registeredLegacyContinuityAccount = registration.registered; + if (registeredLegacyContinuityAccount) { + await writeConfigJson(env, ACCOUNT_OPTIONS_KEY, accountOptions); + try { + await appendAuditLog(env, { + ts: new Date().toISOString(), + login: "github-actions", + action: "register_legacy_continuity_account", + platform: inputs.platform, + target_name: inputs.target_name, + strategy_profile: inputs.strategy_profile, + live_continuity_state: inputs.live_continuity_state, + }); + } catch { + // The routing registration is still valid if its non-critical audit + // append fails; callers receive the registration result below. + } + } + } + if (!accountOption) throw new Error("switch inputs do not match configured account options"); + assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles); + const result = await syncDefaultStrategyForAccount(env, accountOptions, inputs, { login: "github-actions", }); const kvSyncSkipped = result.reason === "kv_not_bound"; const accountOptionsSync = kvSyncSkipped ? { ...result, skipped: true } : result; return json( - { ok: result.synced || kvSyncSkipped, account_options_sync: accountOptionsSync }, + { + ok: result.synced || kvSyncSkipped, + account_options_sync: accountOptionsSync, + legacy_continuity_account_registered: registeredLegacyContinuityAccount, + }, result.synced || kvSyncSkipped ? 200 : 500, ); } @@ -4447,6 +4490,7 @@ function normalizeSwitchInputs(raw) { if (!supportedExecutionModesForPlatform(platform).includes(executionMode)) { throw new Error(`${platform} does not support ${executionMode} control execution`); } + const liveContinuity = normalizeLiveContinuityInputs(raw, executionMode); // "current" is used only by internal deployment reconciliation to retain // a service's existing plugin mount. It is deliberately not exposed as a // console editing mode, where operators can still select only "none". @@ -4505,6 +4549,7 @@ function normalizeSwitchInputs(raw) { target_name: targetName, strategy_profile: strategyProfile, execution_mode: executionMode, + live_continuity_state: liveContinuity.state, variable_scope: variableScope, plugin_mode: pluginMode, option_overlay_mode: optionOverlayMode, @@ -4515,6 +4560,11 @@ function normalizeSwitchInputs(raw) { platform_sync_workflow: "sync-cloud-run-env.yml", }; + if (liveContinuity.state !== "NONE") { + inputs.live_continuity_baseline_id = liveContinuity.baseline_id; + inputs.live_continuity_captured_at = liveContinuity.captured_at; + } + addOptional(inputs, "github_environment", raw.github_environment, cleanSlug); addOptional(inputs, "deployment_selector", raw.deployment_selector, cleanSlug); addOptional(inputs, "account_selector", raw.account_selector, cleanCsv); @@ -4556,6 +4606,29 @@ function normalizeSwitchInputs(raw) { return inputs; } +function normalizeLiveContinuityInputs(raw, executionMode) { + const state = String(raw.live_continuity_state || "NONE").trim().toUpperCase(); + if (!LIVE_CONTINUITY_STATES.includes(state)) { + throw new Error(`live_continuity_state must be one of ${LIVE_CONTINUITY_STATES.join(", ")}`); + } + const rawBaselineId = String(raw.live_continuity_baseline_id || "").trim(); + const rawCapturedAt = String(raw.live_continuity_captured_at || "").trim(); + if (state === "NONE") { + if (rawBaselineId || rawCapturedAt) { + throw new Error("live continuity baseline fields require a non-NONE live_continuity_state"); + } + return { state }; + } + if (executionMode !== "live") { + throw new Error("live continuity is only valid for live execution_mode"); + } + return { + state, + baseline_id: cleanSlug(rawBaselineId, "live_continuity_baseline_id"), + captured_at: normalizeM0ResearchDate(rawCapturedAt, "live_continuity_captured_at"), + }; +} + function assertSwitchIntent(inputs) { if ( inputs.apply !== "true" || @@ -4568,13 +4641,55 @@ function assertSwitchIntent(inputs) { function assertConfiguredAccount(inputs, accountOptions) { if (!accountOptions) throw new Error("account options are not configured"); - const options = accountOptions[inputs.platform] || []; - if (!options.length) throw new Error(`no account options configured for ${inputs.platform}`); - const matched = options.find((option) => accountOptionMatchesInputs(option, inputs)); + const matched = configuredAccountForInputs(inputs, accountOptions); if (!matched) throw new Error("switch inputs do not match configured account options"); return matched; } +function configuredAccountForInputs(inputs, accountOptions) { + if (!accountOptions) return null; + const options = accountOptions[inputs.platform] || []; + return options.find((option) => accountOptionMatchesInputs(option, inputs)) || null; +} + +function registerLegacyContinuityAccount(env, accountOptions, inputs, strategy) { + if (!hasConfigStore(env)) { + throw new Error("switch inputs do not match configured account options"); + } + if (!isEligibleLegacyContinuityInput(inputs, strategy)) { + throw new Error("switch inputs do not match configured account options"); + } + const platformOptions = accountOptions[inputs.platform] || []; + if (platformOptions.some((option) => option.target_name === inputs.target_name)) { + throw new Error("legacy continuity account target conflicts with configured account options"); + } + if (platformOptions.length >= 20) { + throw new Error(`account options for ${inputs.platform} have reached the maximum`); + } + + const account = cleanAccountOption( + { + key: inputs.target_name, + label: `Legacy continuity ${inputs.target_name}`, + target_name: inputs.target_name, + account_selector: inputs.account_selector, + deployment_selector: inputs.deployment_selector, + account_scope: inputs.account_scope, + service_name: inputs.service_name, + github_environment: inputs.github_environment, + variable_scope: resolvedVariableScope(inputs.variable_scope, inputs), + supported_domains: [strategy.domain], + }, + inputs.platform, + platformOptions.length, + ); + const options = normalizeAccountOptionsPayload( + { ...accountOptions, [inputs.platform]: [...platformOptions, account] }, + ACCOUNT_OPTIONS_KEY, + ); + return { options, account: options[inputs.platform].at(-1), registered: true }; +} + function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles) { const strategy = strategyProfiles.find((item) => item.profile === inputs.strategy_profile); if (!strategy) { @@ -4592,6 +4707,10 @@ function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles } const allowedModes = strategy.allowed_execution_modes || []; if (executionMode === "live") { + if (isEligibleLegacyContinuityInput(inputs, strategy)) { + assertDcaPlatform(inputs.platform, inputs.strategy_profile); + return; + } const lifecycleStage = cleanLifecycleStage(strategy.lifecycle_stage || "research_active"); if ( strategy.runtime_enabled !== true || @@ -4615,6 +4734,37 @@ function assertStrategyAllowedForAccount(inputs, accountOption, strategyProfiles assertDcaPlatform(inputs.platform, inputs.strategy_profile); } +function isEligibleLegacyContinuityInput(inputs, strategy) { + if ( + inputs.execution_mode !== "live" || + !inputs.live_continuity_state || + inputs.live_continuity_state === "NONE" || + !inputs.live_continuity_baseline_id || + !inputs.live_continuity_captured_at || + inputs.plugin_mode !== "current" || + inputs.option_overlay_mode !== "current" || + inputs.reserved_cash_ratio || + inputs.min_reserved_cash_usd || + inputs.income_layer_start_usd || + inputs.income_layer_max_ratio + ) { + return false; + } + const extraVariables = inputs.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {}; + const extraVariableNames = Object.keys(extraVariables); + if ( + extraVariableNames.length > 1 || + (extraVariableNames.length === 1 && ( + extraVariableNames[0] !== "RUNTIME_TARGET_ENABLED" || + extraVariables.RUNTIME_TARGET_ENABLED !== "true" + )) + ) { + return false; + } + const policy = strategy?.live_continuity; + return policy?.eligible === true && Array.isArray(policy.allowed_platforms) && policy.allowed_platforms.includes(inputs.platform); +} + function resolvedVariableScope(value, inputs) { const text = String(value || "").trim(); if (!text || text === "default") return defaultInputValue("variable_scope", inputs); @@ -4758,6 +4908,12 @@ function normalizeStrategyProfilesPayload(payload, fieldName = "strategy profile }); } addConfigOptional(entry, "blocked_live_reason", item.blocked_live_reason, cleanLabel); + if (item.live_continuity !== undefined && item.live_continuity !== null) { + entry.live_continuity = normalizeLiveContinuityPolicy( + item.live_continuity, + `${fieldName}[${index}].live_continuity`, + ); + } addConfigOptional(entry, "latest_evidence_status", item.latest_evidence_status, cleanLifecycleStage); addConfigOptional(entry, "plugin_gate_status", item.plugin_gate_status, cleanLifecycleStage); // DCA detection: accept from item payload OR hardcoded DCA_PROFILE_CONFIG @@ -4788,6 +4944,32 @@ function normalizeStrategyProfilesPayload(payload, fieldName = "strategy profile return result; } +function normalizeLiveContinuityPolicy(value, fieldName) { + if (!value || Array.isArray(value) || typeof value !== "object") { + throw new Error(`${fieldName} must be an object`); + } + const unsupported = Object.keys(value).filter((key) => !["eligible", "allowed_platforms"].includes(key)); + if (unsupported.length) { + throw new Error(`${fieldName} contains unsupported fields: ${unsupported.sort().join(", ")}`); + } + if (typeof value.eligible !== "boolean") { + throw new Error(`${fieldName}.eligible must be boolean`); + } + if (!Array.isArray(value.allowed_platforms)) { + throw new Error(`${fieldName}.allowed_platforms must be an array`); + } + const allowedPlatforms = value.allowed_platforms.map((platform) => + cleanChoice(platform, SUPPORTED_PLATFORMS, `${fieldName}.allowed_platforms`), + ); + if (new Set(allowedPlatforms).size !== allowedPlatforms.length) { + throw new Error(`${fieldName}.allowed_platforms must not contain duplicates`); + } + if (value.eligible && !allowedPlatforms.length) { + throw new Error(`${fieldName}.eligible requires allowed_platforms`); + } + return { eligible: value.eligible, allowed_platforms: allowedPlatforms }; +} + function rejectResearchOnlyExtraVariables(extraVariables) { const blocked = [ ...LEGACY_INCOME_LAYER_CONTROL_FIELDS,