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
15 changes: 15 additions & 0 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@ Each account item supports:

The Worker validates dispatch inputs against this config. Keep only routing metadata here. Do not store broker passwords, tokens, or API keys in this config.

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

## Strategy Profile Alignment

Treat `strategy_profile` as the canonical strategy id across the switch console, runtime settings, and platform repositories.

When adding or renaming a strategy profile:

- Add the profile id and display label to `web/strategy-switch-console/index.html`.
- Set each affected account's `default_strategy_profile` in `account-options.example.json` and the deployed KV account config.
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
- 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 can display a dynamically read unknown profile, but the profile should still be added to the catalog so the UI and docs stay aligned.

## GitHub OAuth App

Create a GitHub OAuth App:
Expand Down
15 changes: 15 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-

Worker 会校验 dispatch 参数必须匹配这里的某个账号项。只放路由信息,不放 broker 密码、token、API key。

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

## 策略 Profile 对齐规范

`strategy_profile` 是切换页、runtime settings 和各平台仓库之间的统一策略 ID。

新增或重命名策略 profile 时,需要同时做这些事:

- 在 `web/strategy-switch-console/index.html` 增加 profile id 和显示名称。
- 在 `account-options.example.json` 和已部署的 KV 账号配置里更新对应账号的 `default_strategy_profile`。
- 确认平台仓库当前的 `RUNTIME_TARGET_JSON.strategy_profile` 或账号级 `CLOUD_RUN_SERVICE_TARGETS_JSON` 使用同一个 id。
- profile id 只使用小写字母、数字、点、下划线、短横线或等号。不要把账号名、密码、token、密钥信息写进 profile id。

切换页可以临时显示动态读取到但未登记的 profile,但后续仍应补进策略目录,保持 UI 和文档一致。

## GitHub OAuth App

创建 GitHub OAuth App:
Expand Down
8 changes: 4 additions & 4 deletions web/strategy-switch-console/account-options.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"label": "hk",
"target_name": "hk",
"account_selector": "HK",
"default_strategy_profile": "tqqq_growth_income"
"default_strategy_profile": "hk_low_vol_dividend_quality_snapshot"
},
{
"key": "sg",
Expand All @@ -19,7 +19,7 @@
"label": "paper",
"target_name": "paper",
"account_selector": "PAPER",
"default_strategy_profile": "tqqq_growth_income"
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced"
}
],
"ibkr": [
Expand Down Expand Up @@ -59,15 +59,15 @@
"key": "default",
"label": "default",
"target_name": "default",
"default_strategy_profile": "tqqq_growth_income"
"default_strategy_profile": "soxl_soxx_trend_income"
}
],
"firstrade": [
{
"key": "default",
"label": "default",
"target_name": "default",
"default_strategy_profile": "tqqq_growth_income"
"default_strategy_profile": "mega_cap_leader_rotation_top50_balanced"
}
]
}
104 changes: 87 additions & 17 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ <h2 data-i18n="summary">切换摘要</h2>
"tqqq_growth_income",
"soxl_soxx_trend_income",
"nasdaq_sp500_smart_dca",
"hk_low_vol_dividend_quality_snapshot",
"global_etf_rotation",
"russell_1000_multi_factor_defensive",
"mega_cap_leader_rotation_top50_balanced",
Expand All @@ -621,16 +622,17 @@ <h2 data-i18n="summary">切换摘要</h2>
tqqq_growth_income: "TQQQ Growth Income",
soxl_soxx_trend_income: "SOXL/SOXX Trend Income",
nasdaq_sp500_smart_dca: "Nasdaq/S&P 500 Smart DCA",
hk_low_vol_dividend_quality_snapshot: "HK Low Vol Dividend Quality",
global_etf_rotation: "Global ETF Rotation",
russell_1000_multi_factor_defensive: "Russell 1000 Defensive",
mega_cap_leader_rotation_top50_balanced: "Mega Cap Top 50",
};

const defaultAccountOptions = {
longbridge: [
{ key: "hk", label: "hk", target_name: "hk", account_selector: "HK", default_strategy_profile: "tqqq_growth_income" },
{ key: "hk", label: "hk", target_name: "hk", account_selector: "HK", default_strategy_profile: "hk_low_vol_dividend_quality_snapshot" },
{ key: "sg", label: "sg", target_name: "sg", account_selector: "SG", default_strategy_profile: "tqqq_growth_income" },
{ key: "paper", label: "paper", target_name: "paper", account_selector: "PAPER", default_strategy_profile: "tqqq_growth_income" },
{ key: "paper", label: "paper", target_name: "paper", account_selector: "PAPER", default_strategy_profile: "mega_cap_leader_rotation_top50_balanced" },
],
ibkr: [
{
Expand Down Expand Up @@ -665,10 +667,10 @@ <h2 data-i18n="summary">切换摘要</h2>
},
],
schwab: [
{ key: "default", label: "default", target_name: "default", default_strategy_profile: "tqqq_growth_income" },
{ key: "default", label: "default", target_name: "default", default_strategy_profile: "soxl_soxx_trend_income" },
],
firstrade: [
{ key: "default", label: "default", target_name: "default", default_strategy_profile: "tqqq_growth_income" },
{ key: "default", label: "default", target_name: "default", default_strategy_profile: "mega_cap_leader_rotation_top50_balanced" },
],
};

Expand Down Expand Up @@ -760,6 +762,7 @@ <h2 data-i18n="summary">切换摘要</h2>
lang: initialLang,
auth: { available: false, allowed: false, admin: false, login: null },
accountOptions: clone(defaultAccountOptions),
currentStrategies: {},
configSource: "default",
forms: {
longbridge: { accountKey: "hk", strategy: "tqqq_growth_income", executionMode: "live" },
Expand All @@ -784,9 +787,50 @@ <h2 data-i18n="summary">切换摘要</h2>
return options.find((option) => option.key === form.accountKey) || options[0];
}

function defaultStrategyForAccount(account, fallback = "tqqq_growth_income") {
const profile = String(account?.default_strategy_profile || account?.strategy_profile || "").trim();
if (strategyOptions.includes(profile)) return profile;
function cleanStrategyProfile(value) {
const profile = String(value || "").trim();
return /^[a-z0-9._=-]{1,120}$/.test(profile) ? profile : "";
}

function strategyChoices() {
const choices = [...strategyOptions];
const addChoice = (value) => {
const profile = cleanStrategyProfile(value);
if (profile && !choices.includes(profile)) choices.push(profile);
};
for (const platform of Object.keys(platformMeta)) {
for (const account of optionsFor(platform)) {
addChoice(account.default_strategy_profile || account.strategy_profile);
}
for (const entry of Object.values(state.currentStrategies[platform] || {})) {
addChoice(entry.strategy_profile);
}
addChoice(state.forms[platform].strategy);
}
return choices;
}

function strategyLabel(profile) {
return strategyLabels[profile] || profile;
}

function currentStrategyForAccount(platform, account) {
const byPlatform = state.currentStrategies[platform] || {};
const keys = [account?.key, account?.target_name, account?.label]
.filter(Boolean)
.map((value) => String(value));
for (const key of keys) {
const profile = cleanStrategyProfile(byPlatform[key]?.strategy_profile);
if (profile) return profile;
}
return "";
}

function defaultStrategyForAccount(platform, account, fallback = "tqqq_growth_income") {
const currentProfile = currentStrategyForAccount(platform, account);
if (currentProfile) return currentProfile;
const profile = cleanStrategyProfile(account?.default_strategy_profile || account?.strategy_profile);
if (profile) return profile;
const hint = [
account?.key,
account?.label,
Expand All @@ -801,12 +845,18 @@ <h2 data-i18n="summary">切换摘要</h2>
return fallback;
}

function syncStrategyForAccount(platform) {
const account = selectedAccount(platform);
if (!account) return;
state.forms[platform].strategy = defaultStrategyForAccount(platform, account, state.forms[platform].strategy);
}

function ensureAccountSelection(platform) {
const options = optionsFor(platform);
if (!options.length) return;
if (!options.some((option) => option.key === state.forms[platform].accountKey)) {
state.forms[platform].accountKey = options[0].key;
state.forms[platform].strategy = defaultStrategyForAccount(options[0], state.forms[platform].strategy);
state.forms[platform].strategy = defaultStrategyForAccount(platform, options[0], state.forms[platform].strategy);
}
}

Expand Down Expand Up @@ -859,7 +909,7 @@ <h2 data-i18n="summary">切换摘要</h2>
return [
[t("repository"), repositories[state.selected]],
[t("selectedAccount"), account.label],
[t("selectedStrategy"), strategyLabels[inputs.strategy_profile] || inputs.strategy_profile],
[t("selectedStrategy"), strategyLabel(inputs.strategy_profile)],
[t("selectedMode"), inputs.execution_mode],
[t("target"), inputs.target_name],
[t("accountSelector"), inputs.account_selector || "auto"],
Expand Down Expand Up @@ -894,7 +944,7 @@ <h2 data-i18n="summary">切换摘要</h2>
<span class="platform-copy">
<strong>${meta.label}</strong>
<span>${escapeHtml(account.label)}</span>
<small>${escapeHtml(strategyLabels[form.strategy] || form.strategy)}</small>
<small>${escapeHtml(strategyLabel(form.strategy))}</small>
</span>
`;
strip.appendChild(button);
Expand All @@ -919,8 +969,8 @@ <h2 data-i18n="summary">切换摘要</h2>
: `<option value="">${t("noAccount")}</option>`;
el("account-meta").textContent = accounts.length ? accountMetaText(platform) : "";

strategySelect.innerHTML = strategyOptions.map((strategy) => (
`<option value="${strategy}" ${strategy === form.strategy ? "selected" : ""}>${escapeHtml(strategyLabels[strategy] || strategy)}</option>`
strategySelect.innerHTML = strategyChoices().map((strategy) => (
`<option value="${strategy}" ${strategy === form.strategy ? "selected" : ""}>${escapeHtml(strategyLabel(strategy))}</option>`
)).join("");

document.querySelectorAll("[data-mode]").forEach((button) => {
Expand Down Expand Up @@ -1002,12 +1052,17 @@ <h2 data-i18n="summary">切换摘要</h2>
const payload = await response.json();
if (payload.accountOptions) {
state.accountOptions = normalizeAccountOptions(payload.accountOptions);
state.currentStrategies = normalizeCurrentStrategies(payload.currentStrategies || {});
state.configSource = "private";
for (const platform of Object.keys(platformMeta)) ensureAccountSelection(platform);
for (const platform of Object.keys(platformMeta)) {
ensureAccountSelection(platform);
syncStrategyForAccount(platform);
}
render();
}
} catch {
state.configSource = "default";
state.currentStrategies = {};
}
}

Expand All @@ -1034,6 +1089,24 @@ <h2 data-i18n="summary">切换摘要</h2>
return normalized;
}

function normalizeCurrentStrategies(raw) {
const normalized = {};
for (const platform of Object.keys(platformMeta)) {
if (!raw[platform] || typeof raw[platform] !== "object" || Array.isArray(raw[platform])) continue;
normalized[platform] = {};
for (const [key, entry] of Object.entries(raw[platform])) {
const profile = cleanStrategyProfile(entry?.strategy_profile);
if (!profile) continue;
normalized[platform][String(key)] = {
strategy_profile: profile,
source: entry?.source ? String(entry.source) : "",
};
}
if (!Object.keys(normalized[platform]).length) delete normalized[platform];
}
return normalized;
}

async function dispatchSwitch() {
if (!state.auth.allowed) return;
el("toast").textContent = t("dispatching");
Expand Down Expand Up @@ -1083,10 +1156,7 @@ <h2 data-i18n="summary">切换摘要</h2>

el("account-select").addEventListener("change", () => {
state.forms[state.selected].accountKey = el("account-select").value;
state.forms[state.selected].strategy = defaultStrategyForAccount(
selectedAccount(),
state.forms[state.selected].strategy,
);
syncStrategyForAccount(state.selected);
render();
});

Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

Loading