From 9e8d1fe7d06ffc470310fac27df50b43ef2e8b80 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:20:50 +0800 Subject: [PATCH] Add live-enabled strategy catalog --- .github/workflows/validate.yml | 4 +- scripts/sync_strategy_switch_page_asset.py | 13 +++- web/strategy-switch-console/README.md | 13 ++-- web/strategy-switch-console/README.zh-CN.md | 11 ++- web/strategy-switch-console/index.html | 69 +++++++++++++------ web/strategy-switch-console/page_asset.js | 2 +- .../strategy-profiles.example.json | 56 +++++++++++++++ .../strategy_profiles_asset.js | 2 + web/strategy-switch-console/worker.js | 69 +++++++++++++++++++ 9 files changed, 208 insertions(+), 31 deletions(-) create mode 100644 web/strategy-switch-console/strategy-profiles.example.json create mode 100644 web/strategy-switch-console/strategy_profiles_asset.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 98ae4d0..605a4d2 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,7 +23,9 @@ jobs: run: | set -euo pipefail python3 scripts/sync_strategy_switch_page_asset.py - git diff --exit-code -- web/strategy-switch-console/page_asset.js + git diff --exit-code -- web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js + jq empty web/strategy-switch-console/strategy-profiles.example.json sed -n '/ diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index fc1dba6..f44e9e8 100644 --- a/web/strategy-switch-console/page_asset.js +++ b/web/strategy-switch-console/page_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -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

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"; +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

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"; diff --git a/web/strategy-switch-console/strategy-profiles.example.json b/web/strategy-switch-console/strategy-profiles.example.json new file mode 100644 index 0000000..1b8c0e6 --- /dev/null +++ b/web/strategy-switch-console/strategy-profiles.example.json @@ -0,0 +1,56 @@ +[ + { + "profile": "tqqq_growth_income", + "label": "TQQQ Growth Income", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "soxl_soxx_trend_income", + "label": "SOXL/SOXX Semiconductor Trend Income", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "nasdaq_sp500_smart_dca", + "label": "Nasdaq/S&P 500 Smart DCA", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "global_etf_rotation", + "label": "Global ETF Rotation", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "russell_1000_multi_factor_defensive", + "label": "Russell 1000 Multi-Factor Defensive", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "mega_cap_leader_rotation_top50_balanced", + "label": "Mega Cap Leader Rotation Top50 Balanced", + "domain": "us_equity", + "runtime_enabled": true + }, + { + "profile": "hk_dividend_gold_defensive_rotation", + "label": "HK Dividend-Gold Defensive Rotation", + "domain": "hk_equity", + "runtime_enabled": true + }, + { + "profile": "hk_global_etf_tactical_rotation", + "label": "HK Global ETF Tactical Rotation", + "domain": "hk_equity", + "runtime_enabled": true + }, + { + "profile": "hk_low_vol_dividend_quality_snapshot", + "label": "HK Low-Vol Dividend Quality Snapshot", + "domain": "hk_equity", + "runtime_enabled": true + } +] diff --git a/web/strategy-switch-console/strategy_profiles_asset.js b/web/strategy-switch-console/strategy_profiles_asset.js new file mode 100644 index 0000000..a3c3fba --- /dev/null +++ b/web/strategy-switch-console/strategy_profiles_asset.js @@ -0,0 +1,2 @@ +// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. +export const DEFAULT_STRATEGY_PROFILES = [{"profile": "tqqq_growth_income", "label": "TQQQ Growth Income", "domain": "us_equity", "runtime_enabled": true}, {"profile": "soxl_soxx_trend_income", "label": "SOXL/SOXX Semiconductor Trend Income", "domain": "us_equity", "runtime_enabled": true}, {"profile": "nasdaq_sp500_smart_dca", "label": "Nasdaq/S&P 500 Smart DCA", "domain": "us_equity", "runtime_enabled": true}, {"profile": "global_etf_rotation", "label": "Global ETF Rotation", "domain": "us_equity", "runtime_enabled": true}, {"profile": "russell_1000_multi_factor_defensive", "label": "Russell 1000 Multi-Factor Defensive", "domain": "us_equity", "runtime_enabled": true}, {"profile": "mega_cap_leader_rotation_top50_balanced", "label": "Mega Cap Leader Rotation Top50 Balanced", "domain": "us_equity", "runtime_enabled": true}, {"profile": "hk_dividend_gold_defensive_rotation", "label": "HK Dividend-Gold Defensive Rotation", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_global_etf_tactical_rotation", "label": "HK Global ETF Tactical Rotation", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "HK Low-Vol Dividend Quality Snapshot", "domain": "hk_equity", "runtime_enabled": true}]; diff --git a/web/strategy-switch-console/worker.js b/web/strategy-switch-console/worker.js index b12d88b..e13ef38 100644 --- a/web/strategy-switch-console/worker.js +++ b/web/strategy-switch-console/worker.js @@ -1,4 +1,5 @@ import { PAGE_HTML } from "./page_asset.js"; +import { DEFAULT_STRATEGY_PROFILES } from "./strategy_profiles_asset.js"; const DEFAULT_REPOSITORY = "QuantStrategyLab/QuantRuntimeSettings"; const DEFAULT_WORKFLOW = "manual-strategy-switch.yml"; @@ -7,6 +8,7 @@ const OAUTH_STATE_COOKIE = "qsl_switch_oauth_state"; const SESSION_TTL_SECONDS = 8 * 60 * 60; const AUTH_CONFIG_KEY = "auth_config"; const ACCOUNT_OPTIONS_KEY = "account_options"; +const STRATEGY_PROFILES_KEY = "strategy_profiles"; const AUDIT_LOG_KEY = "audit_log"; const AUDIT_LOG_LIMIT = 50; @@ -32,6 +34,7 @@ export default { if (url.pathname === "/callback") return finishLogin(request, env); if (url.pathname === "/admin") return adminPage(request, env); if (url.pathname === "/api/session") return json(await sessionPayload(request, env)); + if (url.pathname === "/api/strategy-profiles") return json(await strategyProfilesPayload(env)); if (url.pathname === "/api/config") return json(await configPayload(request, env)); if (url.pathname === "/api/admin/config" && request.method === "GET") return adminConfigResponse(request, env); if (url.pathname === "/api/admin/config" && request.method === "POST") { @@ -423,13 +426,21 @@ async function configPayload(request, env) { const session = await readSession(request, env); if (!session?.allowed) return { accountOptions: null }; const accountConfig = await loadAccountOptionsConfig(env); + const strategyProfiles = await loadStrategyProfilesConfig(env); const currentStrategies = await loadCurrentStrategies(accountConfig.options, env); return { accountOptions: accountConfig.options, + strategyProfiles, currentStrategies, }; } +async function strategyProfilesPayload(env) { + return { + strategyProfiles: await loadStrategyProfilesConfig(env), + }; +} + async function loadCurrentStrategies(accountOptions, env) { const token = env.RUNTIME_SETTINGS_DISPATCH_TOKEN; if (!token || !accountOptions) return {}; @@ -647,6 +658,51 @@ function normalizeAccountOptionsInput(value, fieldName) { return normalizeAccountOptionsPayload(value, fieldName); } +function parseStrategyProfiles(raw, fieldName = "strategy profiles") { + const text = String(raw || "").trim(); + if (!text) return null; + let payload; + try { + payload = JSON.parse(text); + } catch (error) { + throw new Error(`${fieldName} must be valid JSON`); + } + return normalizeStrategyProfilesPayload(payload, fieldName); +} + +function normalizeStrategyProfilesPayload(payload, fieldName = "strategy profiles") { + if (!Array.isArray(payload) || payload.length > 100) { + throw new Error(`${fieldName} must be an array with at most 100 items`); + } + + const result = []; + const seen = new Set(); + for (const [index, item] of payload.entries()) { + if (!item || Array.isArray(item) || typeof item !== "object") { + throw new Error(`${fieldName}[${index}] must be an object`); + } + const profile = cleanCurrentStrategy(item.profile || item.strategy_profile); + if (!profile) throw new Error(`${fieldName}[${index}].profile is invalid`); + if (seen.has(profile)) continue; + seen.add(profile); + const entry = { + profile, + label: cleanLabel(item.label || item.display_name || profile, `${fieldName}[${index}].label`), + runtime_enabled: cleanProfileBoolean(item.runtime_enabled ?? item.live_enabled ?? true), + }; + const domain = String(item.domain || "").trim(); + if (domain) entry.domain = cleanChoice(domain, ["us_equity", "hk_equity"], `${fieldName}[${index}].domain`); + result.push(entry); + } + return result; +} + +function cleanProfileBoolean(value) { + if (value === true || value === "true" || value === "1" || value === 1) return true; + if (value === false || value === "false" || value === "0" || value === 0) return false; + throw new Error("runtime_enabled must be boolean"); +} + function normalizeAccountOptionsPayload(payload, fieldName = "account options") { if (!payload || Array.isArray(payload) || typeof payload !== "object") { throw new Error(`${fieldName} must be an object`); @@ -1082,6 +1138,19 @@ async function loadAccountOptionsConfig(env) { }; } +async function loadStrategyProfilesConfig(env) { + if (hasConfigStore(env)) { + const stored = await readConfigJson(env, STRATEGY_PROFILES_KEY); + if (stored) return normalizeStrategyProfilesPayload(stored, STRATEGY_PROFILES_KEY); + } + const configured = parseStrategyProfiles( + env.STRATEGY_SWITCH_STRATEGY_PROFILES_JSON || "", + "STRATEGY_SWITCH_STRATEGY_PROFILES_JSON", + ); + if (configured) return configured; + return normalizeStrategyProfilesPayload(DEFAULT_STRATEGY_PROFILES, "DEFAULT_STRATEGY_PROFILES"); +} + function hasConfigStore(env) { return Boolean(configStore(env)); }