Skip to content

Commit 9e8d1fe

Browse files
committed
Add live-enabled strategy catalog
1 parent d510739 commit 9e8d1fe

9 files changed

Lines changed: 208 additions & 31 deletions

File tree

.github/workflows/validate.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ jobs:
2323
run: |
2424
set -euo pipefail
2525
python3 scripts/sync_strategy_switch_page_asset.py
26-
git diff --exit-code -- web/strategy-switch-console/page_asset.js
26+
git diff --exit-code -- web/strategy-switch-console/page_asset.js web/strategy-switch-console/strategy_profiles_asset.js
27+
jq empty web/strategy-switch-console/strategy-profiles.example.json
2728
sed -n '/<script>/,/<\/script>/p' web/strategy-switch-console/index.html | sed '1d;$d' | node --check --input-type=commonjs
2829
node --check --input-type=module < web/strategy-switch-console/page_asset.js
30+
node --check --input-type=module < web/strategy-switch-console/strategy_profiles_asset.js
2931
node --check --input-type=module < web/strategy-switch-console/worker.js

scripts/sync_strategy_switch_page_asset.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,24 @@
1010
ROOT = Path(__file__).resolve().parents[1]
1111
SOURCE = ROOT / "web" / "strategy-switch-console" / "index.html"
1212
TARGET = ROOT / "web" / "strategy-switch-console" / "page_asset.js"
13+
PROFILE_SOURCE = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
14+
PROFILE_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js"
1315

1416

1517
def main() -> int:
1618
html = SOURCE.read_text(encoding="utf-8")
17-
payload = (
19+
page_payload = (
1820
"// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand.\n"
1921
f"export const PAGE_HTML = {json.dumps(html, ensure_ascii=False)};\n"
2022
)
21-
TARGET.write_text(payload, encoding="utf-8")
23+
TARGET.write_text(page_payload, encoding="utf-8")
24+
25+
profiles = json.loads(PROFILE_SOURCE.read_text(encoding="utf-8"))
26+
profile_payload = (
27+
"// Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand.\n"
28+
f"export const DEFAULT_STRATEGY_PROFILES = {json.dumps(profiles, ensure_ascii=False)};\n"
29+
)
30+
PROFILE_TARGET.write_text(profile_payload, encoding="utf-8")
2231
return 0
2332

2433

web/strategy-switch-console/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,23 @@ For editable admin settings, bind a Cloudflare KV namespace named `STRATEGY_SWIT
5757
```text
5858
auth_config
5959
account_options
60+
strategy_profiles
6061
audit_log
6162
```
6263

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

6566
## Page Asset
6667

67-
`worker.js` serves `web/strategy-switch-console/index.html` through `page_asset.js`.
68+
`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`.
6869

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

7172
```bash
7273
python3 scripts/sync_strategy_switch_page_asset.py
7374
```
7475

75-
Deploy `worker.js` and `page_asset.js` together.
76+
Deploy `worker.js`, `page_asset.js`, and `strategy_profiles_asset.js` together.
7677

7778
## Account Dropdowns
7879

@@ -108,6 +109,8 @@ Each account item supports:
108109

109110
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.
110111

112+
`/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`.
113+
111114
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`.
112115

113116
## Strategy Profile Alignment
@@ -116,8 +119,10 @@ Treat `strategy_profile` as the canonical strategy id across the switch console,
116119

117120
When adding or renaming a strategy profile:
118121

119-
- Add the profile id and display label to `web/strategy-switch-console/index.html`.
122+
- Add the runtime-enabled profile id and display label to `strategy-profiles.example.json`.
123+
- Run `python3 scripts/sync_strategy_switch_page_asset.py` so `strategy_profiles_asset.js` is regenerated.
120124
- Set each affected account's `default_strategy_profile` in `account-options.example.json` and the deployed KV account config.
125+
- Update the deployed KV `strategy_profiles` key from `strategy-profiles.example.json`.
121126
- Make sure the platform repository's current `RUNTIME_TARGET_JSON.strategy_profile` or account-specific `CLOUD_RUN_SERVICE_TARGETS_JSON` uses the same id.
122127
- Use lower-case ids with letters, numbers, dot, underscore, dash, or equals only. Do not encode account names or secrets in profile ids.
123128

web/strategy-switch-console/README.zh-CN.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ STRATEGY_SWITCH_ADMIN_LOGINS=your-github-login
5959
```text
6060
auth_config
6161
account_options
62+
strategy_profiles
6263
audit_log
6364
```
6465

@@ -73,13 +74,13 @@ page_asset.js
7374
wrangler.toml.example
7475
```
7576

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

7879
```bash
7980
python3 scripts/sync_strategy_switch_page_asset.py
8081
```
8182

82-
这会重新生成 `web/strategy-switch-console/page_asset.js`。部署 Worker 时需要同时带上 `worker.js``page_asset.js`
83+
这会重新生成 `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`
8384

8485
## 账号下拉配置
8586

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

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

119+
`/api/strategy-profiles` 会返回公开的 live-enabled 策略目录,用于生成策略下拉框。读取优先级是 KV `strategy_profiles``STRATEGY_SWITCH_STRATEGY_PROFILES_JSON``strategy-profiles.example.json`
120+
118121
登录用户访问 `/api/config` 时,Worker 还会读取目标平台仓库的当前 GitHub Variables。读取优先级是账号匹配的 `CLOUD_RUN_SERVICE_TARGETS_JSON`、匹配的 `RUNTIME_TARGET_JSON.strategy_profile``STRATEGY_PROFILE`;都读不到时,页面才回退到 `default_strategy_profile`
119122

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

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

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

web/strategy-switch-console/index.html

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -608,25 +608,20 @@ <h2 data-i18n="summary">切换摘要</h2>
608608
firstrade: { label: "Firstrade", code: "FT", accent: "var(--ft)" },
609609
};
610610

611-
const strategyOptions = [
612-
"tqqq_growth_income",
613-
"soxl_soxx_trend_income",
614-
"nasdaq_sp500_smart_dca",
615-
"hk_low_vol_dividend_quality_snapshot",
616-
"global_etf_rotation",
617-
"russell_1000_multi_factor_defensive",
618-
"mega_cap_leader_rotation_top50_balanced",
611+
const defaultStrategyProfiles = [
612+
{ profile: "tqqq_growth_income", label: "TQQQ Growth Income", domain: "us_equity", runtime_enabled: true },
613+
{ profile: "soxl_soxx_trend_income", label: "SOXL/SOXX Semiconductor Trend Income", domain: "us_equity", runtime_enabled: true },
614+
{ profile: "nasdaq_sp500_smart_dca", label: "Nasdaq/S&P 500 Smart DCA", domain: "us_equity", runtime_enabled: true },
615+
{ profile: "global_etf_rotation", label: "Global ETF Rotation", domain: "us_equity", runtime_enabled: true },
616+
{ profile: "russell_1000_multi_factor_defensive", label: "Russell 1000 Multi-Factor Defensive", domain: "us_equity", runtime_enabled: true },
617+
{ profile: "mega_cap_leader_rotation_top50_balanced", label: "Mega Cap Leader Rotation Top50 Balanced", domain: "us_equity", runtime_enabled: true },
618+
{ profile: "hk_dividend_gold_defensive_rotation", label: "HK Dividend-Gold Defensive Rotation", domain: "hk_equity", runtime_enabled: true },
619+
{ profile: "hk_global_etf_tactical_rotation", label: "HK Global ETF Tactical Rotation", domain: "hk_equity", runtime_enabled: true },
620+
{ profile: "hk_low_vol_dividend_quality_snapshot", label: "HK Low-Vol Dividend Quality Snapshot", domain: "hk_equity", runtime_enabled: true },
619621
];
620622

621-
const strategyLabels = {
622-
tqqq_growth_income: "TQQQ Growth Income",
623-
soxl_soxx_trend_income: "SOXL/SOXX Trend Income",
624-
nasdaq_sp500_smart_dca: "Nasdaq/S&P 500 Smart DCA",
625-
hk_low_vol_dividend_quality_snapshot: "HK Low Vol Dividend Quality",
626-
global_etf_rotation: "Global ETF Rotation",
627-
russell_1000_multi_factor_defensive: "Russell 1000 Defensive",
628-
mega_cap_leader_rotation_top50_balanced: "Mega Cap Top 50",
629-
};
623+
let strategyOptions = [];
624+
let strategyLabels = {};
630625

631626
const defaultAccountOptions = {
632627
longbridge: [
@@ -765,10 +760,10 @@ <h2 data-i18n="summary">切换摘要</h2>
765760
currentStrategies: {},
766761
configSource: "default",
767762
forms: {
768-
longbridge: { accountKey: "hk", strategy: "tqqq_growth_income", executionMode: "live" },
763+
longbridge: { accountKey: "hk", strategy: "hk_low_vol_dividend_quality_snapshot", executionMode: "live" },
769764
ibkr: { accountKey: "u15998061", strategy: "tqqq_growth_income", executionMode: "live" },
770-
schwab: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" },
771-
firstrade: { accountKey: "default", strategy: "tqqq_growth_income", executionMode: "live" },
765+
schwab: { accountKey: "default", strategy: "soxl_soxx_trend_income", executionMode: "live" },
766+
firstrade: { accountKey: "default", strategy: "mega_cap_leader_rotation_top50_balanced", executionMode: "live" },
772767
},
773768
};
774769

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

790+
function applyStrategyProfiles(rawProfiles) {
791+
const profiles = Array.isArray(rawProfiles) && rawProfiles.length
792+
? rawProfiles
793+
: defaultStrategyProfiles;
794+
const nextOptions = [];
795+
const nextLabels = {};
796+
for (const item of profiles) {
797+
const profile = cleanStrategyProfile(item?.profile || item?.strategy_profile);
798+
if (!profile || nextOptions.includes(profile)) continue;
799+
if (item?.runtime_enabled === false || item?.live_enabled === false) continue;
800+
nextOptions.push(profile);
801+
nextLabels[profile] = String(item?.label || item?.display_name || profile).trim() || profile;
802+
}
803+
if (!nextOptions.length && profiles !== defaultStrategyProfiles) return applyStrategyProfiles(defaultStrategyProfiles);
804+
strategyOptions = nextOptions;
805+
strategyLabels = nextLabels;
806+
}
807+
795808
function strategyChoices() {
796809
const choices = [...strategyOptions];
797810
const addChoice = (value) => {
@@ -1044,13 +1057,26 @@ <h2 data-i18n="summary">切换摘要</h2>
10441057
await refreshConfig();
10451058
}
10461059

1060+
async function refreshStrategyProfiles() {
1061+
try {
1062+
const response = await fetch("/api/strategy-profiles", { cache: "no-store" });
1063+
if (!response.ok) throw new Error("no strategy profiles");
1064+
const payload = await response.json();
1065+
applyStrategyProfiles(payload.strategyProfiles || []);
1066+
render();
1067+
} catch {
1068+
applyStrategyProfiles(defaultStrategyProfiles);
1069+
}
1070+
}
1071+
10471072
async function refreshConfig() {
10481073
if (!state.auth.available || !state.auth.allowed) return;
10491074
try {
10501075
const response = await fetch("/api/config", { cache: "no-store" });
10511076
if (!response.ok) throw new Error("no config");
10521077
const payload = await response.json();
10531078
if (payload.accountOptions) {
1079+
applyStrategyProfiles(payload.strategyProfiles || defaultStrategyProfiles);
10541080
state.accountOptions = normalizeAccountOptions(payload.accountOptions);
10551081
state.currentStrategies = normalizeCurrentStrategies(payload.currentStrategies || {});
10561082
state.configSource = "private";
@@ -1184,7 +1210,10 @@ <h2 data-i18n="summary">切换摘要</h2>
11841210
el("dispatch-button").addEventListener("click", dispatchSwitch);
11851211
el("logout-button").addEventListener("click", handleLogout);
11861212

1213+
applyStrategyProfiles(defaultStrategyProfiles);
1214+
for (const platform of Object.keys(platformMeta)) syncStrategyForAccount(platform);
11871215
render();
1216+
refreshStrategyProfiles();
11881217
refreshSession();
11891218
</script>
11901219
</body>

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.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[
2+
{
3+
"profile": "tqqq_growth_income",
4+
"label": "TQQQ Growth Income",
5+
"domain": "us_equity",
6+
"runtime_enabled": true
7+
},
8+
{
9+
"profile": "soxl_soxx_trend_income",
10+
"label": "SOXL/SOXX Semiconductor Trend Income",
11+
"domain": "us_equity",
12+
"runtime_enabled": true
13+
},
14+
{
15+
"profile": "nasdaq_sp500_smart_dca",
16+
"label": "Nasdaq/S&P 500 Smart DCA",
17+
"domain": "us_equity",
18+
"runtime_enabled": true
19+
},
20+
{
21+
"profile": "global_etf_rotation",
22+
"label": "Global ETF Rotation",
23+
"domain": "us_equity",
24+
"runtime_enabled": true
25+
},
26+
{
27+
"profile": "russell_1000_multi_factor_defensive",
28+
"label": "Russell 1000 Multi-Factor Defensive",
29+
"domain": "us_equity",
30+
"runtime_enabled": true
31+
},
32+
{
33+
"profile": "mega_cap_leader_rotation_top50_balanced",
34+
"label": "Mega Cap Leader Rotation Top50 Balanced",
35+
"domain": "us_equity",
36+
"runtime_enabled": true
37+
},
38+
{
39+
"profile": "hk_dividend_gold_defensive_rotation",
40+
"label": "HK Dividend-Gold Defensive Rotation",
41+
"domain": "hk_equity",
42+
"runtime_enabled": true
43+
},
44+
{
45+
"profile": "hk_global_etf_tactical_rotation",
46+
"label": "HK Global ETF Tactical Rotation",
47+
"domain": "hk_equity",
48+
"runtime_enabled": true
49+
},
50+
{
51+
"profile": "hk_low_vol_dividend_quality_snapshot",
52+
"label": "HK Low-Vol Dividend Quality Snapshot",
53+
"domain": "hk_equity",
54+
"runtime_enabled": true
55+
}
56+
]

web/strategy-switch-console/strategy_profiles_asset.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)