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
4 changes: 3 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs
node --check --input-type=module < web/strategy-switch-console/page_asset.js
node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js
node --check --input-type=module < web/strategy-switch-console/worker.js
13 changes: 11 additions & 2 deletions scripts/sync_strategy_switch_page_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,24 @@
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "web" / "strategy-switch-console" / "index.html"
TARGET = ROOT / "web" / "strategy-switch-console" / "page_asset.js"
PROFILE_SOURCE = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
PROFILE_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js"


def main() -> int:
html = SOURCE.read_text(encoding="utf-8")
payload = (
page_payload = (
"// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand.\n"
f"export const PAGE_HTML = {json.dumps(html, ensure_ascii=False)};\n"
)
TARGET.write_text(payload, encoding="utf-8")
TARGET.write_text(page_payload, encoding="utf-8")

profiles = json.loads(PROFILE_SOURCE.read_text(encoding="utf-8"))
profile_payload = (
"// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand.\n"
f"export const DEFAULT_STRATEGY_PROFILES = {json.dumps(profiles, ensure_ascii=False)};\n"
)
PROFILE_TARGET.write_text(profile_payload, encoding="utf-8")
return 0


Expand Down
13 changes: 9 additions & 4 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,23 @@ For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWIT
```text
auth_config
account_options
strategy_profiles
audit_log
```

Without the KV binding, `/admin` is read-only and the Worker falls back to `ALLOWED_GITHUB_LOGINS`, `ALLOWED_GITHUB_ORGS`, `STRATEGY_SWITCH_ADMIN_LOGINS`, `STRATEGY_SWITCH_ADMIN_ORGS`, and `STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON`.

## Page Asset

`worker.js` serves `web/strategy-switch-console/index.html` through `page_asset.js`.
`worker.js` serves `web/strategy-switch-console/index.html` through `page_asset.js` and the fallback live-enabled strategy catalog through `strategy_profiles_asset.js`.

After editing `web/strategy-switch-console/index.html`, regenerate the asset:
After editing `web/strategy-switch-console/index.html` or `strategy-profiles.example.json`, regenerate the assets:

```bash
python3 scripts/sync_strategy_switch_page_asset.py
```

Deploy `worker.js` and `page_asset.js` together.
Deploy `worker.js`, `page_asset.js`, and `strategy_profiles_asset.js` together.

## Account Dropdowns

Expand Down Expand Up @@ -108,6 +109,8 @@ 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.

`/api/strategy-profiles` returns the public live-enabled strategy catalog for the dropdown. It reads the KV `strategy_profiles` key first, then `STRATEGY_SWITCH_STRATEGY_PROFILES_JSON`, then `strategy-profiles.example.json`.

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
Expand All @@ -116,8 +119,10 @@ Treat `strategy_profile` as the canonical strategy id across the switch console,

When adding or renaming a strategy profile:

- Add the profile id and display label to `web/strategy-switch-console/index.html`.
- Add the runtime-enabled profile id and display label to `strategy-profiles.example.json`.
- Run `python3 scripts/sync_strategy_switch_page_asset.py` so `strategy_profiles_asset.js` is regenerated.
- Set each affected account's `default_strategy_profile` in `account-options.example.json` and the deployed KV account config.
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
- 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.

Expand Down
11 changes: 8 additions & 3 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login
```text
auth_config
account_options
strategy_profiles
audit_log
```

Expand All @@ -73,13 +74,13 @@ page_asset.js
wrangler.toml.example
```

`worker.js` 会通过 `page_asset.js` 发布 `web/strategy-switch-console/index.html`。改完页面后运行
`worker.js` 会通过 `page_asset.js` 发布 `web/strategy-switch-console/index.html`,并通过 `strategy_profiles_asset.js` 提供兜底 live-enabled 策略目录。改完页面或 `strategy-profiles.example.json` 后运行

```bash
python3 scripts/sync_strategy_switch_page_asset.py
```

这会重新生成 `web/strategy-switch-console/page_asset.js`。部署 Worker 时需要同时带上 `worker.js` 和 `page_asset.js`。
这会重新生成 `web/strategy-switch-console/page_asset.js` 和 `web/strategy-switch-console/strategy_profiles_asset.js`。部署 Worker 时需要同时带上 `worker.js`、`page_asset.js` 和 `strategy_profiles_asset.js`。

## 账号下拉配置

Expand Down Expand Up @@ -115,6 +116,8 @@ wrangler secret put STRATEGY_SWITCH_ACCOUNT_OPTIONS_JSON < /tmp/strategy-switch-

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

`/api/strategy-profiles` 会返回公开的 live-enabled 策略目录,用于生成策略下拉框。读取优先级是 KV `strategy_profiles`、`STRATEGY_SWITCH_STRATEGY_PROFILES_JSON`、`strategy-profiles.example.json`。

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

## 策略 Profile 对齐规范
Expand All @@ -123,8 +126,10 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项。只放路

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

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

Expand Down
69 changes: 49 additions & 20 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -608,25 +608,20 @@ <h2 data-i18n="summary">切换摘要</h2>
firstrade: { label: "Firstrade", code: "FT", accent: "var(--ft)" },
};

const strategyOptions = [
"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",
const defaultStrategyProfiles = [
{ 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 },
];

const strategyLabels = {
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",
};
let strategyOptions = [];
let strategyLabels = {};

const defaultAccountOptions = {
longbridge: [
Expand Down Expand Up @@ -765,10 +760,10 @@ <h2 data-i18n="summary">切换摘要</h2>
currentStrategies: {},
configSource: "default",
forms: {
longbridge: { accountKey: "hk", strategy: "tqqq_growth_income", executionMode: "live" },
longbridge: { accountKey: "hk", strategy: "hk_low_vol_dividend_quality_snapshot", executionMode: "live" },
ibkr: { accountKey: "u15998061", strategy: "tqqq_growth_income", executionMode: "live" },
schwab: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" },
firstrade: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" },
schwab: { accountKey: "default", strategy: "soxl_soxx_trend_income", executionMode: "live" },
firstrade: { accountKey: "default", strategy: "mega_cap_leader_rotation_top50_balanced", executionMode: "live" },
},
};

Expand All @@ -792,6 +787,24 @@ <h2 data-i18n="summary">切换摘要</h2>
return /^[a-z0-9._=-]{1,120}$/.test(profile) ? profile : "";
}

function applyStrategyProfiles(rawProfiles) {
const profiles = Array.isArray(rawProfiles) && rawProfiles.length
? rawProfiles
: defaultStrategyProfiles;
const nextOptions = [];
const nextLabels = {};
for (const item of profiles) {
const profile = cleanStrategyProfile(item?.profile || item?.strategy_profile);
if (!profile || nextOptions.includes(profile)) continue;
if (item?.runtime_enabled === false || item?.live_enabled === false) continue;
nextOptions.push(profile);
nextLabels[profile] = String(item?.label || item?.display_name || profile).trim() || profile;
}
if (!nextOptions.length && profiles !== defaultStrategyProfiles) return applyStrategyProfiles(defaultStrategyProfiles);
strategyOptions = nextOptions;
strategyLabels = nextLabels;
}

function strategyChoices() {
const choices = [...strategyOptions];
const addChoice = (value) => {
Expand Down Expand Up @@ -1044,13 +1057,26 @@ <h2 data-i18n="summary">切换摘要</h2>
await refreshConfig();
}

async function refreshStrategyProfiles() {
try {
const response = await fetch("/api/strategy-profiles", { cache: "no-store" });
if (!response.ok) throw new Error("no strategy profiles");
const payload = await response.json();
applyStrategyProfiles(payload.strategyProfiles || []);
render();
} catch {
applyStrategyProfiles(defaultStrategyProfiles);
}
}

async function refreshConfig() {
if (!state.auth.available || !state.auth.allowed) return;
try {
const response = await fetch("/api/config", { cache: "no-store" });
if (!response.ok) throw new Error("no config");
const payload = await response.json();
if (payload.accountOptions) {
applyStrategyProfiles(payload.strategyProfiles || defaultStrategyProfiles);
state.accountOptions = normalizeAccountOptions(payload.accountOptions);
state.currentStrategies = normalizeCurrentStrategies(payload.currentStrategies || {});
state.configSource = "private";
Expand Down Expand Up @@ -1184,7 +1210,10 @@ <h2 data-i18n="summary">切换摘要</h2>
el("dispatch-button").addEventListener("click", dispatchSwitch);
el("logout-button").addEventListener("click", handleLogout);

applyStrategyProfiles(defaultStrategyProfiles);
for (const platform of Object.keys(platformMeta)) syncStrategyForAccount(platform);
render();
refreshStrategyProfiles();
refreshSession();
</script>
</body>
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions web/strategy-switch-console/strategy-profiles.example.json
Original file line number Diff line number Diff line change
@@ -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
}
]
2 changes: 2 additions & 0 deletions web/strategy-switch-console/strategy_profiles_asset.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading