|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate config.js from platform-config.json — single source of truth. |
| 3 | +
|
| 4 | +Reads platform-config.json and produces: |
| 5 | + web/strategy-switch-console/config.js |
| 6 | +
|
| 7 | +This file is imported by BOTH index.html (frontend) and worker.js (backend), |
| 8 | +replacing the previously hardcoded platformConfig, defaultAccountOptions, |
| 9 | +fallbackIncomeLayerDefaults, fallbackOptionOverlayDefaults, and DCA_SUPPORTED_PLATFORMS. |
| 10 | +""" |
| 11 | + |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import json |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +ROOT = Path(__file__).resolve().parents[1] |
| 18 | +SOURCE = ROOT / "platform-config.json" |
| 19 | +TARGET = ROOT / "web" / "strategy-switch-console" / "config.js" |
| 20 | +STRATEGY_TARGET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js" |
| 21 | +STRATEGY_SOURCE = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json" |
| 22 | + |
| 23 | + |
| 24 | +def build_config_module(config: dict) -> str: |
| 25 | + platforms = config["platforms"] |
| 26 | + strategies = config["strategies"] |
| 27 | + domains = config.get("domains", {}) |
| 28 | + |
| 29 | + # ── platformConfig (replaces hardcoded in index.html) ── |
| 30 | + platform_config = {} |
| 31 | + default_accounts = {} |
| 32 | + repositories = {} |
| 33 | + dcaplat = [] |
| 34 | + variable_scopes = {} |
| 35 | + |
| 36 | + for pid, pdata in platforms.items(): |
| 37 | + caps = pdata["capabilities"] |
| 38 | + depl = pdata["deployment"] |
| 39 | + platform_config[pid] = { |
| 40 | + "dry_run_only": depl.get("dry_run_only", False), |
| 41 | + "margin_policy": caps.get("margin_policy", False), |
| 42 | + "reserved_cash": caps.get("reserved_cash", False), |
| 43 | + "income_layer": caps.get("income_layer", False), |
| 44 | + "option_overlay": caps.get("option_overlay", False), |
| 45 | + "dca": caps.get("dca", False), |
| 46 | + "execution_mode": depl.get("default_execution_mode", "live"), |
| 47 | + "service_name": depl.get("service_name", ""), |
| 48 | + "default_execution_mode": depl.get("default_execution_mode", "live"), |
| 49 | + } |
| 50 | + if caps.get("dca"): |
| 51 | + dcaplat.append(pid) |
| 52 | + repositories[pid] = pdata["repository"] |
| 53 | + variable_scopes[pid] = pdata.get("variable_scope", "repository") |
| 54 | + |
| 55 | + # default account options |
| 56 | + acct = pdata.get("default_account", {}) |
| 57 | + entry = { |
| 58 | + "key": acct.get("key", pid), |
| 59 | + "label": acct.get("label", pdata.get("label", pid)), |
| 60 | + "target_name": acct.get("target_name", acct.get("key", pid)), |
| 61 | + "supported_domains": acct.get("supported_domains", pdata.get("supported_domains", [])), |
| 62 | + "cash_currency": acct.get("cash_currency", "USD"), |
| 63 | + } |
| 64 | + for fld in ("default_strategy_profile", "service_name", "account_scope", |
| 65 | + "deployment_selector", "account_selector", "default_execution_mode", |
| 66 | + "min_reserved_cash_usd", "reserved_cash_ratio", |
| 67 | + "cash_only_execution_mode", "dca_mode", "dca_base_investment_usd"): |
| 68 | + if fld in acct: |
| 69 | + entry[fld] = acct[fld] |
| 70 | + if "service_name" not in entry: |
| 71 | + entry["service_name"] = depl.get("service_name", "") |
| 72 | + if "default_execution_mode" not in entry: |
| 73 | + entry["default_execution_mode"] = depl.get("default_execution_mode", "live") |
| 74 | + default_accounts[pid] = [entry] |
| 75 | + |
| 76 | + # ── strategy features ── |
| 77 | + income_layer_defaults = {} |
| 78 | + option_overlay_defaults = {} |
| 79 | + strategy_features = {} |
| 80 | + dca_profile_defaults = {} |
| 81 | + |
| 82 | + for sid, sdata in strategies.items(): |
| 83 | + feat = sdata.get("features", {}) |
| 84 | + strategy_features[sid] = { |
| 85 | + "income_layer": feat.get("income_layer", False), |
| 86 | + "option_overlay": feat.get("option_overlay", False), |
| 87 | + "dca": feat.get("dca", False), |
| 88 | + "combo": feat.get("combo", False), |
| 89 | + } |
| 90 | + |
| 91 | + inc = sdata.get("income_layer_defaults") |
| 92 | + if inc: |
| 93 | + income_layer_defaults[sid] = { |
| 94 | + "startUsd": int(inc.get("start_usd", 0)), |
| 95 | + "maxRatio": str(inc.get("max_ratio", "")), |
| 96 | + "allocations": inc.get("allocations", {}), |
| 97 | + } |
| 98 | + |
| 99 | + opt = sdata.get("option_overlay_defaults") |
| 100 | + if opt: |
| 101 | + families = [] |
| 102 | + if opt.get("growth_enabled"): |
| 103 | + families.append({ |
| 104 | + "family": "growth", |
| 105 | + "recipe": opt["growth_recipe"], |
| 106 | + "startUsd": opt["growth_start_usd"], |
| 107 | + "ratio": str(opt.get("nav_budget_ratio", "")), |
| 108 | + "ratioKind": "budget", |
| 109 | + }) |
| 110 | + if opt.get("income_enabled"): |
| 111 | + families.append({ |
| 112 | + "family": "income", |
| 113 | + "recipe": opt["income_recipe"], |
| 114 | + "startUsd": opt["income_start_usd"], |
| 115 | + "ratio": str(opt.get("nav_risk_ratio", "")), |
| 116 | + "ratioKind": "risk", |
| 117 | + }) |
| 118 | + option_overlay_defaults[sid] = { |
| 119 | + "liveGate": opt.get("live_gate", ""), |
| 120 | + "liveStatus": opt.get("live_status", ""), |
| 121 | + "families": families, |
| 122 | + } |
| 123 | + |
| 124 | + dca = sdata.get("dca_defaults") |
| 125 | + if dca: |
| 126 | + dca_profile_defaults[sid] = { |
| 127 | + "defaultMode": dca.get("default_mode", "fixed"), |
| 128 | + "defaultBaseInvestmentUsd": str(dca.get("default_base_investment_usd", "1000")), |
| 129 | + } |
| 130 | + |
| 131 | + # ── domain labels ── |
| 132 | + domain_labels = {} |
| 133 | + for did, ddata in domains.items(): |
| 134 | + domain_labels[did] = { |
| 135 | + "zh": ddata.get("label_zh", did), |
| 136 | + "en": ddata.get("label_en", did), |
| 137 | + } |
| 138 | + |
| 139 | + # ── reserved cash variable names ── |
| 140 | + min_cash_vars = {} |
| 141 | + ratio_vars = {} |
| 142 | + var_prefixes = { |
| 143 | + "longbridge": "LONGBRIDGE", |
| 144 | + "ibkr": "IBKR", |
| 145 | + "schwab": "SCHWAB", |
| 146 | + "firstrade": "FIRSTRADE", |
| 147 | + } |
| 148 | + for pid, prefix in var_prefixes.items(): |
| 149 | + min_cash_vars[pid] = f"{prefix}_MIN_RESERVED_CASH_USD" |
| 150 | + ratio_vars[pid] = f"{prefix}_RESERVED_CASH_RATIO" |
| 151 | + |
| 152 | + # ── Generate JS module ── |
| 153 | + lines = [ |
| 154 | + "// Generated by scripts/build_platform_config.py; single source of truth.", |
| 155 | + f"// Source: platform-config.json", |
| 156 | + "", |
| 157 | + f"export const PLATFORM_CONFIG = {json.dumps(platform_config, indent=2, ensure_ascii=False)};", |
| 158 | + "", |
| 159 | + f"export const DEFAULT_ACCOUNT_OPTIONS = {json.dumps(default_accounts, indent=2, ensure_ascii=False)};", |
| 160 | + "", |
| 161 | + f"export const PLATFORM_REPOSITORIES = {json.dumps(repositories, indent=2, ensure_ascii=False)};", |
| 162 | + "", |
| 163 | + f"export const DCA_SUPPORTED_PLATFORMS = new Set({json.dumps(dcaplat)});", |
| 164 | + "", |
| 165 | + f"export const DEFAULT_VARIABLE_SCOPES = {json.dumps(variable_scopes, indent=2, ensure_ascii=False)};", |
| 166 | + "", |
| 167 | + f"export const DOMAIN_LABELS = {json.dumps(domain_labels, indent=2, ensure_ascii=False)};", |
| 168 | + "", |
| 169 | + f"export const FALLBACK_INCOME_LAYER_DEFAULTS = {json.dumps(income_layer_defaults, indent=2, ensure_ascii=False)};", |
| 170 | + "", |
| 171 | + f"export const FALLBACK_OPTION_OVERLAY_DEFAULTS = {json.dumps(option_overlay_defaults, indent=2, ensure_ascii=False)};", |
| 172 | + "", |
| 173 | + f"export const DCA_PROFILE_DEFAULTS = {json.dumps(dca_profile_defaults, indent=2, ensure_ascii=False)};", |
| 174 | + "", |
| 175 | + f"export const STRATEGY_FEATURES = {json.dumps(strategy_features, indent=2, ensure_ascii=False)};", |
| 176 | + "", |
| 177 | + f"export const PLATFORM_MIN_RESERVED_CASH_VARIABLES = {json.dumps(min_cash_vars, indent=2, ensure_ascii=False)};", |
| 178 | + "", |
| 179 | + f"export const PLATFORM_RESERVED_CASH_RATIO_VARIABLES = {json.dumps(ratio_vars, indent=2, ensure_ascii=False)};", |
| 180 | + "", |
| 181 | + ] |
| 182 | + return "\n".join(lines) |
| 183 | + |
| 184 | + |
| 185 | +def build_strategy_profiles(config: dict) -> str: |
| 186 | + """Generate strategy_profiles_asset.js — the strategy catalog.""" |
| 187 | + strategies = config["strategies"] |
| 188 | + profiles = [] |
| 189 | + for sid, sdata in strategies.items(): |
| 190 | + feat = sdata.get("features", {}) |
| 191 | + entry = { |
| 192 | + "profile": sid, |
| 193 | + "label": sdata.get("label", sid), |
| 194 | + "label_en": sdata.get("label_en", sid), |
| 195 | + "label_zh": sdata.get("label", sid), |
| 196 | + "domain": sdata.get("domain", ""), |
| 197 | + "runtime_enabled": sdata.get("runtime_enabled", False), |
| 198 | + "income_layer_enabled": feat.get("income_layer", False), |
| 199 | + "option_overlay_enabled": feat.get("option_overlay", False), |
| 200 | + "combo_enabled": feat.get("combo", False), |
| 201 | + } |
| 202 | + if feat.get("combo"): |
| 203 | + entry["combo_mode"] = feat.get("combo_mode", "dynamic") |
| 204 | + inc = sdata.get("income_layer_defaults") |
| 205 | + if inc: |
| 206 | + entry["income_layer_start_usd"] = str(inc.get("start_usd", "")) |
| 207 | + entry["income_layer_max_ratio"] = str(inc.get("max_ratio", "")) |
| 208 | + entry["income_layer_allocations"] = inc.get("allocations", {}) |
| 209 | + opt = sdata.get("option_overlay_defaults") |
| 210 | + if opt: |
| 211 | + entry["option_overlay_live_gate"] = opt.get("live_gate", "") |
| 212 | + entry["option_overlay_live_status"] = opt.get("live_status", "") |
| 213 | + if opt.get("growth_enabled"): |
| 214 | + entry["option_growth_overlay_enabled"] = True |
| 215 | + entry["option_growth_overlay_recipe"] = opt["growth_recipe"] |
| 216 | + entry["option_growth_overlay_start_usd"] = opt["growth_start_usd"] |
| 217 | + entry["option_growth_overlay_nav_budget_ratio"] = str(opt.get("nav_budget_ratio", "")) |
| 218 | + dca = sdata.get("dca_defaults") |
| 219 | + if dca or feat.get("dca"): |
| 220 | + entry["dca_enabled"] = True |
| 221 | + entry["dca_default_mode"] = (dca or {}).get("default_mode", "fixed") |
| 222 | + entry["dca_default_base_investment_usd"] = str((dca or {}).get("default_base_investment_usd", "1000")) |
| 223 | + profiles.append(entry) |
| 224 | + |
| 225 | + payload = ( |
| 226 | + "// Generated by scripts/build_platform_config.py from platform-config.json\n" |
| 227 | + f"export const DEFAULT_STRATEGY_PROFILES = {json.dumps(profiles, indent=2, ensure_ascii=False)};\n" |
| 228 | + ) |
| 229 | + return payload |
| 230 | + |
| 231 | + |
| 232 | +def main() -> int: |
| 233 | + config = json.loads(SOURCE.read_text(encoding="utf-8")) |
| 234 | + |
| 235 | + # Generate config.js |
| 236 | + module = build_config_module(config) |
| 237 | + TARGET.write_text(module, encoding="utf-8") |
| 238 | + |
| 239 | + # Generate strategy_profiles_asset.js |
| 240 | + profiles = build_strategy_profiles(config) |
| 241 | + STRATEGY_TARGET.write_text(profiles, encoding="utf-8") |
| 242 | + |
| 243 | + print(f"Generated: {TARGET}") |
| 244 | + print(f"Generated: {STRATEGY_TARGET}") |
| 245 | + return 0 |
| 246 | + |
| 247 | + |
| 248 | +if __name__ == "__main__": |
| 249 | + raise SystemExit(main()) |
0 commit comments