diff --git a/.github/workflows/deploy-strategy-switch-console.yml b/.github/workflows/deploy-strategy-switch-console.yml index 22afdfb..0388d14 100644 --- a/.github/workflows/deploy-strategy-switch-console.yml +++ b/.github/workflows/deploy-strategy-switch-console.yml @@ -59,7 +59,6 @@ jobs: python3 python/scripts/sync_strategy_switch_page_asset.py - name: Validate Worker assets - continue-on-error: true run: | set -euo pipefail jq empty web/strategy-switch-console/strategy-profiles.example.json @@ -117,7 +116,7 @@ jobs: - name: Deploy Worker working-directory: web/strategy-switch-console - run: npx wrangler@latest deploy --config wrangler.toml + run: npx wrangler@4.106.0 deploy --config wrangler.toml - name: Sync bundled strategy profiles to KV if: github.event_name != 'workflow_dispatch' || inputs.sync_strategy_profiles diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 088ef2b..b8d025f 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -13,6 +13,7 @@ on: - schwab - firstrade - qmt + - binance target_name: description: "Target name, e.g. sg, live, live-u1599-tqqq." required: true @@ -172,6 +173,8 @@ jobs: RUNTIME_SETTINGS_IBKR_REPO: ${{ vars.RUNTIME_SETTINGS_IBKR_REPO }} RUNTIME_SETTINGS_SCHWAB_REPO: ${{ vars.RUNTIME_SETTINGS_SCHWAB_REPO }} RUNTIME_SETTINGS_FIRSTRADE_REPO: ${{ vars.RUNTIME_SETTINGS_FIRSTRADE_REPO }} + RUNTIME_SETTINGS_QMT_REPO: ${{ vars.RUNTIME_SETTINGS_QMT_REPO }} + RUNTIME_SETTINGS_BINANCE_REPO: ${{ vars.RUNTIME_SETTINGS_BINANCE_REPO }} steps: - name: Checkout uses: actions/checkout@v6 @@ -352,8 +355,9 @@ jobs: schwab|firstrade) gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main ;; - qmt) - echo "QMT platform Cloud Run sync workflow is not configured yet; skipped." + qmt|binance) + echo "${PLATFORM} platform Cloud Run sync workflow is not configured yet; skipped." + exit 0 ;; *) echo "No platform sync dispatch rule for ${PLATFORM}" >&2 diff --git a/docs/architecture_config_driven_20260629.md b/docs/architecture_config_driven_20260629.md index 5b7468f..fcca946 100644 --- a/docs/architecture_config_driven_20260629.md +++ b/docs/architecture_config_driven_20260629.md @@ -10,7 +10,7 @@ |------|------|------| | `index.html` | `platformMeta`(6), `defaultRepositories`(6), `defaultAccountOptions`(6), `state.forms`(6), `defaultStrategyProfiles`(18), `strategyDomains`(4), `platformSupportsMargin`(1), `platformSupportsReservedCash`(1), `platformSupportsDca`(1), `platformDryRunOnly`(1), `dcaProfileDefaults`(2), i18n domain labels(3) | 数据+逻辑 | | `worker.js` | `SUPPORTED_PLATFORMS`(6), `SUPPORTED_STRATEGY_DOMAINS`(4), `PLATFORM_META`(6), `DEFAULT_PLATFORM_REPOSITORIES`(6), `PLATFORM_REPOSITORY_ENV`(6), `DEFAULT_VARIABLE_SCOPE`(6), `PLATFORM_RESERVED_CASH_*`(4) | 数据 | -| `strategy-profiles.example.json` | 18个策略完整定义 | 数据 | +| `strategy-profiles.example.json` | 19个策略完整定义 | 数据 | | `examples/targets/*/` | 每个组合独立的 target JSON | 数据 | ### 核心问题 diff --git a/platform-config.json b/platform-config.json index 3c8fc10..8fde075 100644 --- a/platform-config.json +++ b/platform-config.json @@ -247,7 +247,7 @@ "label": "Binance", "target_name": "default", "cash_currency": "USD", - "default_strategy_profile": "crypto_live_pool_rotation", + "default_strategy_profile": "crypto_equity_combo", "supported_domains": [ "crypto" ] @@ -504,6 +504,18 @@ "combo_mode": "dynamic" } }, + "cn_industry_etf_rotation": { + "label": "A股行业ETF轮动", + "label_en": "CN Industry ETF Rotation", + "domain": "cn_equity", + "runtime_enabled": true, + "features": { + "income_layer": false, + "option_overlay": false, + "dca": false, + "combo": false + } + }, "cn_industry_etf_rotation_aggressive": { "label": "A股ETF轮动", "label_en": "CN ETF Rotation", @@ -595,4 +607,4 @@ } } } -} \ No newline at end of file +} diff --git a/python/scripts/build_config.py b/python/scripts/build_config.py index a05f3f8..0e9b383 100644 --- a/python/scripts/build_config.py +++ b/python/scripts/build_config.py @@ -117,7 +117,9 @@ def build_platform_meta_js(config: dict) -> str: lines = [] lines.append(" let platformMeta = {") for pid, pdata in sorted(platforms.items()): - lines.append(f' {pid}: {{ label: "{pdata["label"]}", code: "{pdata["code"]}", accent: "{pdata["accent_color"]}" }},') + lines.append( + f' {pid}: {{ label: "{pdata["label"]}", code: "{pdata["code"]}", accent: "{pdata["accent_color"]}" }},' + ) lines.append(" };") lines.append("") lines.append(" const platformRepositories = {") @@ -148,13 +150,13 @@ def build_platform_meta_js(config: dict) -> str: for pid, pdata in sorted(platforms.items()): caps = pdata.get("capabilities", {}) dep = pdata.get("deployment", {}) - lines.append(f' {pid}: {{') - lines.append(f' dry_run_only: {"true" if dep.get("dry_run_only") else "false"},') - lines.append(f' margin_policy: {"true" if caps.get("margin_policy") else "false"},') - lines.append(f' reserved_cash: {"true" if caps.get("reserved_cash") else "false"},') - lines.append(f' income_layer: {"true" if caps.get("income_layer") else "false"},') - lines.append(f' option_overlay: {"true" if caps.get("option_overlay") else "false"},') - lines.append(f' dca: {"true" if caps.get("dca") else "false"},') + lines.append(f" {pid}: {{") + lines.append(f" dry_run_only: {'true' if dep.get('dry_run_only') else 'false'},") + lines.append(f" margin_policy: {'true' if caps.get('margin_policy') else 'false'},") + lines.append(f" reserved_cash: {'true' if caps.get('reserved_cash') else 'false'},") + lines.append(f" income_layer: {'true' if caps.get('income_layer') else 'false'},") + lines.append(f" option_overlay: {'true' if caps.get('option_overlay') else 'false'},") + lines.append(f" dca: {'true' if caps.get('dca') else 'false'},") lines.append(f' execution_mode: "{dep.get("default_execution_mode", "live")}",') lines.append(f' service_name: "{dep.get("service_name", "")}",') lines.append(f' default_execution_mode: "{dep.get("default_execution_mode", "live")}"') diff --git a/python/scripts/build_platform_config.py b/python/scripts/build_platform_config.py index f9598c6..ed7d3cf 100644 --- a/python/scripts/build_platform_config.py +++ b/python/scripts/build_platform_config.py @@ -61,10 +61,19 @@ def build_config_module(config: dict) -> str: "supported_domains": acct.get("supported_domains", pdata.get("supported_domains", [])), "cash_currency": acct.get("cash_currency", "USD"), } - for fld in ("default_strategy_profile", "service_name", "account_scope", - "deployment_selector", "account_selector", "default_execution_mode", - "min_reserved_cash_usd", "reserved_cash_ratio", - "cash_only_execution_mode", "dca_mode", "dca_base_investment_usd"): + for fld in ( + "default_strategy_profile", + "service_name", + "account_scope", + "deployment_selector", + "account_selector", + "default_execution_mode", + "min_reserved_cash_usd", + "reserved_cash_ratio", + "cash_only_execution_mode", + "dca_mode", + "dca_base_investment_usd", + ): if fld in acct: entry[fld] = acct[fld] if "service_name" not in entry: @@ -100,21 +109,25 @@ def build_config_module(config: dict) -> str: if opt: families = [] if opt.get("growth_enabled"): - families.append({ - "family": "growth", - "recipe": opt["growth_recipe"], - "startUsd": opt["growth_start_usd"], - "ratio": str(opt.get("nav_budget_ratio", "")), - "ratioKind": "budget", - }) + families.append( + { + "family": "growth", + "recipe": opt["growth_recipe"], + "startUsd": opt["growth_start_usd"], + "ratio": str(opt.get("nav_budget_ratio", "")), + "ratioKind": "budget", + } + ) if opt.get("income_enabled"): - families.append({ - "family": "income", - "recipe": opt["income_recipe"], - "startUsd": opt["income_start_usd"], - "ratio": str(opt.get("nav_risk_ratio", "")), - "ratioKind": "risk", - }) + families.append( + { + "family": "income", + "recipe": opt["income_recipe"], + "startUsd": opt["income_start_usd"], + "ratio": str(opt.get("nav_risk_ratio", "")), + "ratioKind": "risk", + } + ) option_overlay_defaults[sid] = { "liveGate": opt.get("live_gate", ""), "liveStatus": opt.get("live_status", ""), @@ -152,7 +165,7 @@ def build_config_module(config: dict) -> str: # ── Generate JS module ── lines = [ "// Generated by python/scripts/build_platform_config.py; single source of truth.", - f"// Source: platform-config.json", + "// Source: platform-config.json", "", f"export const PLATFORM_CONFIG = {json.dumps(platform_config, indent=2, ensure_ascii=False)};", "", diff --git a/python/scripts/build_runtime_switch.py b/python/scripts/build_runtime_switch.py index e8c7c30..bc3c1cb 100644 --- a/python/scripts/build_runtime_switch.py +++ b/python/scripts/build_runtime_switch.py @@ -94,6 +94,7 @@ "ibkr": "IBKR_DRY_RUN_ONLY", "firstrade": "FIRSTRADE_DRY_RUN_ONLY", "qmt": "QMT_DRY_RUN_ONLY", + "binance": "BINANCE_DRY_RUN", } PLATFORM_RESERVED_CASH_RATIO_VARIABLES = { "schwab": "SCHWAB_RESERVED_CASH_RATIO", @@ -168,9 +169,7 @@ OPTION_OVERLAY_VARIABLES = tuple(field.upper() for field in OPTION_OVERLAY_CONTROL_FIELDS) OPTION_OVERLAY_MODES = frozenset({"current", "enabled", "disabled"}) OPTION_OVERLAY_PROFILE_PATH = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json" -RUNTIME_TARGET_VARIABLES = ( - "RUNTIME_TARGET_ENABLED", -) +RUNTIME_TARGET_VARIABLES = ("RUNTIME_TARGET_ENABLED",) DCA_PROFILES = frozenset( { "nasdaq_sp500_smart_dca", @@ -214,11 +213,13 @@ "schwab": "repository", "firstrade": "repository", "qmt": "repository", + "binance": "repository", } DEFAULT_SERVICE_NAME = { "schwab": "charles-schwab-quant-service", "firstrade": "firstrade-quant-service", "qmt": "qmt-quant-service", + "binance": "", } PLATFORM_ALIASES = { "firsttrade": "firstrade", @@ -536,9 +537,7 @@ def _option_overlay_extra_variables(args: argparse.Namespace, strategy_profile: defaults = _load_option_overlay_profile_defaults().get(strategy_profile) if not defaults: - raise ValueError( - "option_overlay_mode enabled is only supported for strategies with option overlay defaults" - ) + raise ValueError("option_overlay_mode enabled is only supported for strategies with option overlay defaults") return dict(defaults) @@ -594,10 +593,7 @@ def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, A ] if provided: names = ", ".join(provided) - raise ValueError( - "use ibit_zscore_exit_* control fields instead of extra_variables_json " - f"for {names}" - ) + raise ValueError(f"use ibit_zscore_exit_* control fields instead of extra_variables_json for {names}") def _reject_research_only_extra_variables(extra_variables: dict[str, Any]) -> None: @@ -813,9 +809,7 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: else _deployment_selector_default(platform, target_name) ) account_scope = ( - args.account_scope.strip() - if args.account_scope - else _account_scope_default(platform, deployment_selector) + args.account_scope.strip() if args.account_scope else _account_scope_default(platform, deployment_selector) ) account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope) service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name) @@ -948,9 +942,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: dca_controls = _extract_dca_control_fields(extra_variables) ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables) if cash_only_controls.get(CASH_ONLY_EXECUTION_CONTROL_FIELD): - args.cash_only_execution_mode = str( - cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD] - ).strip().lower() + args.cash_only_execution_mode = str(cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD]).strip().lower() _reject_direct_dca_extra_variables(extra_variables) _reject_direct_ibit_zscore_exit_extra_variables(extra_variables) _reject_research_only_extra_variables(extra_variables) diff --git a/python/scripts/gate_codex_app_review.py b/python/scripts/gate_codex_app_review.py index ac01b5c..4b71e9f 100644 --- a/python/scripts/gate_codex_app_review.py +++ b/python/scripts/gate_codex_app_review.py @@ -33,12 +33,13 @@ def env(name: str, default: str = "") -> str: def env_int(name: str, default: int) -> int: - try: return int(env(name, str(default))) - except ValueError: return default + try: + return int(env(name, str(default))) + except ValueError: + return default -def github_request(token: str, method: str, path: str, - payload: dict[str, Any] | None = None) -> Any: +def github_request(token: str, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: url = f"{API_BASE}{path}" if not path.startswith("https://") else path data = json.dumps(payload).encode() if payload else None headers = { @@ -47,7 +48,8 @@ def github_request(token: str, method: str, path: str, "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "codex-review-gate", } - if payload: headers["Content-Type"] = "application/json" + if payload: + headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=30) as resp: @@ -67,10 +69,13 @@ def step_summary(text: str) -> None: # ─── policy ────────────────────────────────────────────────────────────────── + def load_policy() -> dict[str, Any]: if POLICY_PATH.exists(): - try: return json.loads(POLICY_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): pass + try: + return json.loads(POLICY_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass return { "version": 1, "blocked_path_patterns": [ @@ -85,8 +90,10 @@ def compile_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]: pp: list[re.Pattern[str]] = [] for p in policy.get("blocked_path_patterns", []): if isinstance(p, str) and p.strip(): - try: pp.append(re.compile(p, re.IGNORECASE)) - except re.error: pass + try: + pp.append(re.compile(p, re.IGNORECASE)) + except re.error: + pass return pp @@ -111,8 +118,11 @@ def scan_diff(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str] violations.append(f"**Blocked file**: `{current}` matches `{pat.pattern}`") break continue - if line.startswith("+++ b/"): current = line[6:]; continue - if not line.startswith("+") or line.startswith("+++"): continue + if line.startswith("+++ b/"): + current = line[6:] + continue + if not line.startswith("+") or line.startswith("+++"): + continue m = _SENSITIVE.search(line[1:]) if m: violations.append(f"**Hardcoded secret** in `{current}`: `{m.group(0)[:100]}`") @@ -128,8 +138,10 @@ def check_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[ for f in files: fn = f.get("filename", "?") st = (f.get("status") or "").lower().strip() - if st == "removed": issues.append(f"**File deleted**: `{fn}` — verify intentional") - elif st == "renamed": issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`") + if st == "removed": + issues.append(f"**File deleted**: `{fn}` — verify intentional") + elif st == "renamed": + issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`") if len(files) > mx_f: issues.append(f"**Too many files**: {len(files)} changed (limit {mx_f})") if ta + td > mx_l: @@ -144,12 +156,14 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int: page = 1 while True: try: - batch = github_request(token, "GET", - f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") - except RuntimeError: break - if not isinstance(batch, list) or not batch: break + batch = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") + except RuntimeError: + break + if not isinstance(batch, list) or not batch: + break files.extend(batch) - if len(batch) < 100: break + if len(batch) < 100: + break page += 1 diff_text = "" @@ -165,23 +179,27 @@ def run_static_guard(token: str, repo: str, pr_number: int) -> int: ) with urllib.request.urlopen(req, timeout=30) as resp: diff_text = resp.read().decode("utf-8", errors="replace") - except Exception: pass + except Exception: + pass issues = check_metadata(files, policy) + scan_diff(diff_text, compile_patterns(policy)) - if not issues: return 0 + if not issues: + return 0 print(f"STATIC → BLOCKED: {len(issues)} issue(s)") - for i in issues: print(f" • {i}") - step_summary(f"## Merge blocked: {len(issues)} static issue(s)\n\n" + - "\n".join(f"- {i}" for i in issues)) + for i in issues: + print(f" • {i}") + step_summary(f"## Merge blocked: {len(issues)} static issue(s)\n\n" + "\n".join(f"- {i}" for i in issues)) return 1 # ─── app review ────────────────────────────────────────────────────────────── + def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None: reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100") - if not isinstance(reviews, list): return None + if not isinstance(reviews, list): + return None for r in reversed(reviews): if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN: return r @@ -191,8 +209,11 @@ def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]: """(exit_code, title, summary)""" if review is None: - return (0, "Codex: no review — passed through", - "Codex did not respond in time. Merge allowed to avoid blocking development.") + return ( + 0, + "Codex: no review — passed through", + "Codex did not respond in time. Merge allowed to avoid blocking development.", + ) state = (review.get("state") or "").strip().upper() url = review.get("html_url", "") body = (review.get("body") or "").strip() @@ -200,16 +221,23 @@ def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]: if state == "CHANGES_REQUESTED": snippet = (body[:500] + "...") if len(body) > 500 else body - return (1, "Codex: changes requested — MERGE BLOCKED", - f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})") + return ( + 1, + "Codex: changes requested — MERGE BLOCKED", + f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})", + ) if state == "APPROVED": return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})") - return (0, f"Codex: reviewed ({state.lower()})", - f"Codex state `{state}` at {at}. Not blocking. [View review]({url})") + return ( + 0, + f"Codex: reviewed ({state.lower()})", + f"Codex state `{state}` at {at}. Not blocking. [View review]({url})", + ) # ─── main ──────────────────────────────────────────────────────────────────── + def main() -> int: token = env("GH_TOKEN") or env("GITHUB_TOKEN") repo = env("GITHUB_REPOSITORY") @@ -228,16 +256,20 @@ def main() -> int: pr_number = pr.get("number") head_sha = (pr.get("head") or {}).get("sha") if not pr_number or not head_sha: - print(f"::warning::Cannot resolve PR context"); return 0 + print("::warning::Cannot resolve PR context") + return 0 print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}") # ── Phase 1: Static guard (skip on review-only events) ──────────── if event_name != "pull_request_review": - try: rc = run_static_guard(token, repo, pr_number) + try: + rc = run_static_guard(token, repo, pr_number) except RuntimeError as exc: - print(f"::warning::Static guard error: {exc}"); rc = 0 - if rc != 0: return 1 + print(f"::warning::Static guard error: {exc}") + rc = 0 + if rc != 0: + return 1 print("STATIC → clean") # ── Phase 2: App review ─────────────────────────────────────────── @@ -250,8 +282,10 @@ def main() -> int: return rc # WAIT: poll for existing or upcoming review - try: existing = get_codex_review(token, repo, pr_number) - except RuntimeError: existing = None + try: + existing = get_codex_review(token, repo, pr_number) + except RuntimeError: + existing = None if existing is not None: rc, title, summary = app_decision(existing) @@ -266,8 +300,10 @@ def main() -> int: while time.time() < deadline: time.sleep(poll_s) - try: review = get_codex_review(token, repo, pr_number) - except RuntimeError: continue + try: + review = get_codex_review(token, repo, pr_number) + except RuntimeError: + continue if review is not None: rc, title, summary = app_decision(review) print(f"WAIT → found review → exit={rc}: {title}") diff --git a/python/scripts/inject_platform_config.py b/python/scripts/inject_platform_config.py index e444428..7293fed 100644 --- a/python/scripts/inject_platform_config.py +++ b/python/scripts/inject_platform_config.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Inject platform-config globals into index.html before .""" + import json from pathlib import Path @@ -16,67 +17,117 @@ def main() -> int: pc, dao, dca, inc_layer, opt_overlay = {}, {}, {}, {}, {} for pid, pdata in platforms.items(): caps, depl = pdata["capabilities"], pdata["deployment"] - pc[pid] = dict(dry_run_only=depl.get("dry_run_only", False), - margin_policy=caps.get("margin_policy", False), - reserved_cash=caps.get("reserved_cash", False), - income_layer=caps.get("income_layer", False), - option_overlay=caps.get("option_overlay", False), - dca=caps.get("dca", False), - execution_mode=depl.get("default_execution_mode", "live"), - service_name=depl.get("service_name", ""), - default_execution_mode=depl.get("default_execution_mode", "live")) + pc[pid] = dict( + dry_run_only=depl.get("dry_run_only", False), + margin_policy=caps.get("margin_policy", False), + reserved_cash=caps.get("reserved_cash", False), + income_layer=caps.get("income_layer", False), + option_overlay=caps.get("option_overlay", False), + dca=caps.get("dca", False), + execution_mode=depl.get("default_execution_mode", "live"), + service_name=depl.get("service_name", ""), + default_execution_mode=depl.get("default_execution_mode", "live"), + ) acct = pdata.get("default_account", {}) - entry = dict(key=acct.get("key", pid), label=acct.get("label", pdata.get("label", pid)), - target_name=acct.get("target_name", acct.get("key", pid)), - supported_domains=acct.get("supported_domains", pdata.get("supported_domains", [])), - cash_currency=acct.get("cash_currency", "USD")) - for fld in ("default_strategy_profile", "service_name", "account_scope", - "deployment_selector", "account_selector", "default_execution_mode", - "min_reserved_cash_usd", "reserved_cash_ratio", - "cash_only_execution_mode", "dca_mode", "dca_base_investment_usd"): - if acct.get(fld): entry[fld] = acct[fld] - if "service_name" not in entry: entry["service_name"] = depl.get("service_name", "") - if "default_execution_mode" not in entry: entry["default_execution_mode"] = depl.get("default_execution_mode", "live") + entry = dict( + key=acct.get("key", pid), + label=acct.get("label", pdata.get("label", pid)), + target_name=acct.get("target_name", acct.get("key", pid)), + supported_domains=acct.get("supported_domains", pdata.get("supported_domains", [])), + cash_currency=acct.get("cash_currency", "USD"), + ) + for fld in ( + "default_strategy_profile", + "service_name", + "account_scope", + "deployment_selector", + "account_selector", + "default_execution_mode", + "min_reserved_cash_usd", + "reserved_cash_ratio", + "cash_only_execution_mode", + "dca_mode", + "dca_base_investment_usd", + ): + if acct.get(fld): + entry[fld] = acct[fld] + if "service_name" not in entry: + entry["service_name"] = depl.get("service_name", "") + if "default_execution_mode" not in entry: + entry["default_execution_mode"] = depl.get("default_execution_mode", "live") dao[pid] = [entry] for sid, sdata in strategies.items(): feat = sdata.get("features", {}) dd = sdata.get("dca_defaults") - if dd: dca[sid] = dict(defaultMode=dd.get("default_mode", "fixed"), - defaultBaseInvestmentUsd=str(dd.get("default_base_investment_usd", "1000"))) + if dd: + dca[sid] = dict( + defaultMode=dd.get("default_mode", "fixed"), + defaultBaseInvestmentUsd=str(dd.get("default_base_investment_usd", "1000")), + ) if feat.get("income_layer"): idl = sdata.get("income_layer_defaults", {}) - inc_layer[sid] = dict(startUsd=int(idl.get("start_usd", 0)), - maxRatio=str(idl.get("max_ratio", "")), - allocations=idl.get("allocations", {})) + inc_layer[sid] = dict( + startUsd=int(idl.get("start_usd", 0)), + maxRatio=str(idl.get("max_ratio", "")), + allocations=idl.get("allocations", {}), + ) if feat.get("option_overlay"): odl = sdata.get("option_overlay_defaults", {}) families = [] if odl.get("growth_enabled"): - families.append(dict(family="growth", recipe=odl["growth_recipe"], - startUsd=odl["growth_start_usd"], ratio=str(odl.get("nav_budget_ratio", "")), ratioKind="budget")) + families.append( + dict( + family="growth", + recipe=odl["growth_recipe"], + startUsd=odl["growth_start_usd"], + ratio=str(odl.get("nav_budget_ratio", "")), + ratioKind="budget", + ) + ) if odl.get("income_enabled"): - families.append(dict(family="income", recipe=odl["income_recipe"], - startUsd=odl["income_start_usd"], ratio=str(odl.get("nav_risk_ratio", "")), ratioKind="risk")) - opt_overlay[sid] = dict(liveGate=odl.get("live_gate", ""), liveStatus=odl.get("live_status", ""), families=families) + families.append( + dict( + family="income", + recipe=odl["income_recipe"], + startUsd=odl["income_start_usd"], + ratio=str(odl.get("nav_risk_ratio", "")), + ratioKind="risk", + ) + ) + opt_overlay[sid] = dict( + liveGate=odl.get("live_gate", ""), liveStatus=odl.get("live_status", ""), families=families + ) - block = "\n".join([ - "", - '", - ]) + block = "\n".join( + [ + "", + '", + ] + ) html = SOURCE.read_text(encoding="utf-8") + marker = "" existing = html.find('', existing) + 9 - html = html[:existing] + block + html[end:] + start = existing + while True: + prefix = html[:start].rstrip() + marker_start = prefix.rfind(marker) + if marker_start < 0: + break + if prefix[marker_start:].strip() != marker: + break + start = marker_start + end = html.find("", existing) + 9 + html = html[:start].rstrip() + "\n\n" + block + html[end:] else: - head_end = html.find('') + head_end = html.find("") html = html[:head_end] + "\n" + block + "\n" + html[head_end:] SOURCE.write_text(html, encoding="utf-8") diff --git a/python/scripts/run_codex_pr_review.py b/python/scripts/run_codex_pr_review.py index 15ad09d..2c4ddf0 100644 --- a/python/scripts/run_codex_pr_review.py +++ b/python/scripts/run_codex_pr_review.py @@ -46,9 +46,7 @@ class ReviewError(RuntimeError): # --------------------------------------------------------------------------- -def github_request( - token: str, method: str, path: str, payload: dict[str, Any] | None = None -) -> Any: +def github_request(token: str, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: url = path if path.startswith("https://") else f"{API_BASE}{path}" data = json.dumps(payload).encode("utf-8") if payload is not None else None req = urllib.request.Request( @@ -141,12 +139,8 @@ def _fail_closed(reason: str) -> dict[str, Any]: # --------------------------------------------------------------------------- -def classify_file_risk( - file_path: str, policy: dict[str, Any] -) -> tuple[str, str]: +def classify_file_risk(file_path: str, policy: dict[str, Any]) -> tuple[str, str]: """Return (risk_level, reason) for a single file path.""" - policy_errors = policy.get("policy_errors", []) - # Blocked patterns (secrets, credentials, etc.) blocked_patterns = policy.get("blocked_path_patterns", []) for pattern in blocked_patterns: @@ -160,9 +154,7 @@ def classify_file_risk( low = risk_policy.get("low", {}) low_prefixes = low.get("prefixes", []) low_exact = set(low.get("exact", [])) - medium_exact = set( - risk_policy.get("medium", {}).get("exact", []) - ) + medium_exact = set(risk_policy.get("medium", {}).get("exact", [])) # Normalize path normalized = file_path.strip() @@ -172,9 +164,7 @@ def classify_file_risk( if not normalized: return ("high", "empty path") - if normalized in low_exact or any( - normalized.startswith(prefix) for prefix in low_prefixes - ): + if normalized in low_exact or any(normalized.startswith(prefix) for prefix in low_prefixes): return ("low", "docs/test/readme change") if normalized in medium_exact: @@ -315,9 +305,7 @@ def request_github_oidc_token(audience: str) -> str: request_url = env_value("ACTIONS_ID_TOKEN_REQUEST_URL") request_token = env_value("ACTIONS_ID_TOKEN_REQUEST_TOKEN") if not request_url or not request_token: - raise ReviewError( - "GitHub OIDC environment unavailable. Set permissions: id-token: write." - ) + raise ReviewError("GitHub OIDC environment unavailable. Set permissions: id-token: write.") separator = "&" if "?" in request_url else "?" url = f"{request_url}{separator}audience={urllib.parse.quote(audience)}" req = urllib.request.Request( @@ -389,9 +377,7 @@ def run_codex_service_review(prompt: str, timeout_minutes: int) -> str: raise ReviewError("Codex service job timed out") -def _service_request( - method: str, url: str, oidc_token: str, payload: dict[str, Any] | None -) -> dict[str, Any]: +def _service_request(method: str, url: str, oidc_token: str, payload: dict[str, Any] | None) -> dict[str, Any]: data = json.dumps(payload).encode("utf-8") if payload is not None else None req = urllib.request.Request( url, @@ -432,8 +418,7 @@ def run_direct_api_review(prompt: str) -> str: return _run_openai_review(prompt, openai_key) raise ReviewError( - "No Codex service URL or API key configured. " - "Set CODEX_AUDIT_SERVICE_URL, ANTHROPIC_API_KEY, or OPENAI_API_KEY." + "No Codex service URL or API key configured. Set CODEX_AUDIT_SERVICE_URL, ANTHROPIC_API_KEY, or OPENAI_API_KEY." ) @@ -468,9 +453,7 @@ def _run_anthropic_review(prompt: str, api_key: str) -> str: if not isinstance(content, list): raise ReviewError("Unexpected Anthropic response format") text_parts = [ - str(block.get("text", "")) - for block in content - if isinstance(block, dict) and block.get("type") == "text" + str(block.get("text", "")) for block in content if isinstance(block, dict) and block.get("type") == "text" ] return "\n\n".join(text_parts) @@ -519,9 +502,7 @@ def parse_review_output(text: str) -> dict[str, Any]: stripped = text.strip() # Try to extract from markdown code fence - fence_match = re.fullmatch( - r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL | re.IGNORECASE - ) + fence_match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", stripped, flags=re.DOTALL | re.IGNORECASE) if fence_match: stripped = fence_match.group(1).strip() @@ -610,14 +591,10 @@ def evaluate_findings( all_findings = blocking + non_blocking summary_parts = [] if blocked: - summary_parts.append( - f"🚫 **Merge blocked**: {len(blocking)} serious issue(s) found in high-risk files" - ) + summary_parts.append(f"🚫 **Merge blocked**: {len(blocking)} serious issue(s) found in high-risk files") elif all_findings: total = len(all_findings) - summary_parts.append( - f"✅ **Merge allowed**: {total} finding(s) reported but none are blocking" - ) + summary_parts.append(f"✅ **Merge allowed**: {total} finding(s) reported but none are blocking") else: summary_parts.append("✅ **Merge allowed**: No issues found") @@ -647,28 +624,34 @@ def build_pr_comment(decision: dict[str, Any], pr_url: str) -> str: blocking = decision["blocking_findings"] if blocking: - lines.extend([ - "### 🚫 Blocking Issues", - "", - "These issues must be fixed before this PR can be merged:", - "", - ]) + lines.extend( + [ + "### 🚫 Blocking Issues", + "", + "These issues must be fixed before this PR can be merged:", + "", + ] + ) for i, f in enumerate(blocking, 1): lines.extend(_format_finding(i, f)) non_blocking = decision["non_blocking_findings"] if non_blocking: - lines.extend([ - "### ℹ️ Other Findings", - "", - ]) + lines.extend( + [ + "### ℹ️ Other Findings", + "", + ] + ) for i, f in enumerate(non_blocking, 1): lines.extend(_format_finding(i, f)) - lines.extend([ - "---", - f"*Review by Codex PR Review bot • [PR]({pr_url})*", - ]) + lines.extend( + [ + "---", + f"*Review by Codex PR Review bot • [PR]({pr_url})*", + ] + ) return "\n".join(lines) @@ -685,7 +668,7 @@ def _format_finding(index: int, finding: dict[str, Any]) -> list[str]: lines = [ f"#### {index}. {emoji} [{severity.upper()}] {category.title()} in `{file_path}`", - f"", + "", f"> {description}", ] if line: @@ -701,9 +684,7 @@ def _format_finding(index: int, finding: dict[str, Any]) -> list[str]: # --------------------------------------------------------------------------- -def find_existing_review_comment( - token: str, repo: str, pr_number: int -) -> int | None: +def find_existing_review_comment(token: str, repo: str, pr_number: int) -> int | None: """Find an existing Codex review comment on the PR.""" marker = "" page = 1 @@ -724,9 +705,7 @@ def find_existing_review_comment( return None -def upsert_pr_comment( - token: str, repo: str, pr_number: int, body: str -) -> None: +def upsert_pr_comment(token: str, repo: str, pr_number: int, body: str) -> None: """Create or update the Codex review comment on the PR.""" existing_id = find_existing_review_comment(token, repo, pr_number) if existing_id: @@ -785,8 +764,10 @@ def main() -> int: # Fetch changed files for risk classification changed_files = fetch_pr_files(token, repo, pr_number) changed_paths = [f.get("filename", "") for f in changed_files] - print(f"Changed files ({len(changed_paths)}): {', '.join(changed_paths[:10])}" - + (f" and {len(changed_paths) - 10} more..." if len(changed_paths) > 10 else "")) + print( + f"Changed files ({len(changed_paths)}): {', '.join(changed_paths[:10])}" + + (f" and {len(changed_paths) - 10} more..." if len(changed_paths) > 10 else "") + ) # Load policy policy = load_policy() @@ -794,9 +775,7 @@ def main() -> int: print(f"::warning::Policy errors: {policy['policy_errors']}") # First pass: classify files. If all files are low-risk, skip review. - all_low_risk = all( - classify_file_risk(p, policy)[0] == "low" for p in changed_paths - ) + all_low_risk = all(classify_file_risk(p, policy)[0] == "low" for p in changed_paths) if all_low_risk and changed_paths: print("All changed files are low-risk (docs/tests). Skipping Codex review.") decision = { diff --git a/python/scripts/runtime_settings.py b/python/scripts/runtime_settings.py index cac5f75..cf6e5d7 100644 --- a/python/scripts/runtime_settings.py +++ b/python/scripts/runtime_settings.py @@ -135,8 +135,7 @@ def gh_command(self, *, redact_body: bool = False, redact_metadata: bool = False def shell_command(self, *, redact_body: bool = False, redact_metadata: bool = False) -> str: return " ".join( - shlex.quote(part) - for part in self.gh_command(redact_body=redact_body, redact_metadata=redact_metadata) + shlex.quote(part) for part in self.gh_command(redact_body=redact_body, redact_metadata=redact_metadata) ) @@ -185,10 +184,7 @@ def is_repository_name(value: str) -> bool: def platform_repositories(env: dict[str, str] | None = None) -> dict[str, str]: env = env or os.environ - repositories = { - platform: config["repository"] - for platform, config in SUPPORTED_PLATFORMS.items() - } + repositories = {platform: config["repository"] for platform, config in SUPPORTED_PLATFORMS.items()} raw_json = str(env.get("RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON") or "").strip() if raw_json: try: @@ -347,13 +343,9 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: continue for field in window: if field not in {"enabled", "offset_minutes", "mode"}: - errors.append( - f"runtime_target.execution_windows.{window_name}.{field} is unsupported" - ) + errors.append(f"runtime_target.execution_windows.{window_name}.{field} is unsupported") if "enabled" in window and not isinstance(window["enabled"], bool): - errors.append( - f"runtime_target.execution_windows.{window_name}.enabled must be boolean" - ) + errors.append(f"runtime_target.execution_windows.{window_name}.enabled must be boolean") if "offset_minutes" in window: offset_minutes = window["offset_minutes"] if not isinstance(offset_minutes, int) or offset_minutes < 0: @@ -368,9 +360,7 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: ) for window_name in execution_windows: if window_name not in WINDOW_MODES: - errors.append( - "runtime_target.execution_windows only supports precheck and execution" - ) + errors.append("runtime_target.execution_windows only supports precheck and execution") break scheduler = runtime_target.get("scheduler") @@ -387,9 +377,7 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: for field in ("main_time", "probe_time", "precheck_time"): value = scheduler.get(field) if not isinstance(value, str) or len(value.split()) not in {2, 5}: - errors.append( - f"runtime_target.scheduler.{field} must have 2 time fields or 5 cron fields" - ) + errors.append(f"runtime_target.scheduler.{field} must have 2 time fields or 5 cron fields") def validate_plugin_mounts(target: dict[str, Any], errors: list[str]) -> None: @@ -541,9 +529,7 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None: if name in generated_names: errors.append(f"extra_variables.{name} duplicates a generated variable") if name in RESEARCH_ONLY_EXTRA_VARIABLES: - errors.append( - f"extra_variables.{name} is research-only and must not be stored in live switch settings" - ) + errors.append(f"extra_variables.{name} is research-only and must not be stored in live switch settings") if is_secret_variable_name(name): errors.append(f"extra_variables.{name} looks like a secret and must not be stored here") if isinstance(value, str) and "\n" in value: @@ -587,8 +573,7 @@ def validate_target(target: dict[str, Any], path: Path | None = None) -> list[st else: if github.get("repository") != expected_repository: errors.append( - "github.repository does not match platform " - f"{platform_id}: expected {expected_repository}" + f"github.repository does not match platform {platform_id}: expected {expected_repository}" ) return errors @@ -657,10 +642,7 @@ def command_render(args: argparse.Namespace) -> int: if args.format == "json": print( json.dumps( - [ - assignment_payload(assignment, redact_values=args.redact_values) - for assignment in all_assignments - ], + [assignment_payload(assignment, redact_values=args.redact_values) for assignment in all_assignments], ensure_ascii=False, indent=2, ) diff --git a/python/scripts/sync_strategy_switch_page_asset.py b/python/scripts/sync_strategy_switch_page_asset.py index e6b8303..c5d4499 100644 --- a/python/scripts/sync_strategy_switch_page_asset.py +++ b/python/scripts/sync_strategy_switch_page_asset.py @@ -35,15 +35,13 @@ def main() -> int: app_css = APP_CSS_SOURCE.read_text(encoding="utf-8") APP_CSS_TARGET.write_text( - "// Generated — CSS asset\n" - f"export const APP_CSS = {json.dumps(app_css, ensure_ascii=False)};\n", + f"// Generated — CSS asset\nexport const APP_CSS = {json.dumps(app_css, ensure_ascii=False)};\n", encoding="utf-8", ) app_js = APP_JS_SOURCE.read_text(encoding="utf-8") APP_JS_TARGET.write_text( - "// Generated — JS asset\n" - f"export const APP_JS = {json.dumps(app_js, ensure_ascii=False)};\n", + f"// Generated — JS asset\nexport const APP_JS = {json.dumps(app_js, ensure_ascii=False)};\n", encoding="utf-8", ) return 0 diff --git a/python/tests/test_internal_dependency_matrix.py b/python/tests/test_internal_dependency_matrix.py index eac185d..83bfccf 100644 --- a/python/tests/test_internal_dependency_matrix.py +++ b/python/tests/test_internal_dependency_matrix.py @@ -64,9 +64,7 @@ def test_check_matrix_reports_ref_drift_and_untracked_dependency(self): ) def test_current_matrix_matches_local_workspace(self): - matrix_pins = check_internal_dependency_matrix.load_matrix( - ROOT / "internal_dependency_matrix.json" - ) + matrix_pins = check_internal_dependency_matrix.load_matrix(ROOT / "internal_dependency_matrix.json") report = check_internal_dependency_matrix.check_matrix( matrix_pins=matrix_pins, @@ -75,9 +73,7 @@ def test_current_matrix_matches_local_workspace(self): if report.missing_files: missing_inside_checked_out_repos = [ - item - for item in report.missing_files - if (ROOT.parent / item.split("/", 1)[0]).exists() + item for item in report.missing_files if (ROOT.parent / item.split("/", 1)[0]).exists() ] self.assertEqual(missing_inside_checked_out_repos, []) self.assertEqual(report.issues, []) diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index 439c4b0..d63a1c7 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -47,6 +47,36 @@ def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self) self.assertNotIn("income_threshold_usd", input_names) self.assertNotIn("qqqi_income_ratio", input_names) + def test_manual_switch_platform_choices_cover_supported_platforms(self): + workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8") + platform_choices: list[str] = [] + in_platform_options = False + for line in workflow.splitlines(): + if line.strip() == "platform:": + in_platform_options = False + continue + if line.strip() == "options:" and not platform_choices: + in_platform_options = True + continue + if in_platform_options: + match = re.match(r"\s+- ([A-Za-z0-9_-]+)$", line) + if match: + platform_choices.append(match.group(1)) + continue + if platform_choices and line.strip() and not line.strip().startswith("-"): + break + + self.assertEqual(set(platform_choices), set(runtime_settings.SUPPORTED_PLATFORMS)) + + def test_platform_config_default_strategy_profiles_exist(self): + config = json.loads((ROOT / "platform-config.json").read_text(encoding="utf-8")) + profiles = set(config["strategies"]) + for platform, data in config["platforms"].items(): + default_profile = data.get("default_account", {}).get("default_strategy_profile") + if default_profile: + with self.subTest(platform=platform): + self.assertIn(default_profile, profiles) + def load_target(self, relative_path: str): path = ROOT / relative_path return path, runtime_settings.load_target(path) @@ -74,11 +104,7 @@ def test_example_targets_have_matching_plugin_mount(self): _, target = self.load_target(relative_path) profile = target["runtime_target"]["strategy_profile"] self.assertTrue( - any( - mount["strategy"] == profile - and mount["enabled"] is True - for mount in target["plugin_mounts"] - ) + any(mount["strategy"] == profile and mount["enabled"] is True for mount in target["plugin_mounts"]) ) def test_plugin_mount_schema_version_is_rendered_for_platform_parser(self): @@ -108,9 +134,7 @@ def test_auto_market_regime_control_profiles_cover_published_strategy_artifacts( def test_assignment_payload_can_redact_values(self): _, target = self.load_target("examples/targets/longbridge/sg.example.json") assignment = next( - item - for item in runtime_settings.build_assignments(target) - if item.name == "RUNTIME_TARGET_JSON" + item for item in runtime_settings.build_assignments(target) if item.name == "RUNTIME_TARGET_JSON" ) payload = runtime_settings.assignment_payload(assignment, redact_values=True) @@ -123,9 +147,7 @@ def test_assignment_payload_can_redact_values(self): def test_assignment_shell_command_can_redact_body_and_metadata(self): _, target = self.load_target("examples/targets/longbridge/sg.example.json") assignment = next( - item - for item in runtime_settings.build_assignments(target) - if item.name == "RUNTIME_TARGET_JSON" + item for item in runtime_settings.build_assignments(target) if item.name == "RUNTIME_TARGET_JSON" ) command = assignment.shell_command(redact_body=True, redact_metadata=True) @@ -165,9 +187,7 @@ def test_empty_assignment_deletes_variable_instead_of_setting_empty_body(self): self.assertEqual(runtime_settings.assignment_payload(assignment)["action"], "delete") def test_manual_switch_account_default_sync_is_warning_only(self): - workflow = (ROOT / ".github" / "workflows" / "manual-strategy-switch.yml").read_text( - encoding="utf-8" - ) + workflow = (ROOT / ".github" / "workflows" / "manual-strategy-switch.yml").read_text(encoding="utf-8") self.assertIn("Strategy switch account default sync failed", workflow) self.assertIn("::warning::", workflow) @@ -252,13 +272,12 @@ def test_extract_account_sync_controls_prefers_top_level_extra_variables(self): ) def test_strategy_switch_console_deploy_workflow_syncs_bundled_profiles(self): - workflow = (ROOT / ".github" / "workflows" / "deploy-strategy-switch-console.yml").read_text( - encoding="utf-8" - ) + workflow = (ROOT / ".github" / "workflows" / "deploy-strategy-switch-console.yml").read_text(encoding="utf-8") self.assertIn("environment: runtime-strategy-switch", workflow) - self.assertIn("npx wrangler@latest deploy --config wrangler.toml", workflow) + self.assertIn("npx wrangler@4.106.0 deploy --config wrangler.toml", workflow) self.assertIn("/api/internal/sync-strategy-profiles", workflow) + self.assertNotIn("continue-on-error: true", workflow) self.assertIn("STRATEGY_SWITCH_CONSOLE_URL", workflow) self.assertIn("STRATEGY_SWITCH_SYNC_TOKEN", workflow) self.assertIn("CLOUDFLARE_WRANGLER_CONFIG_TOML", workflow) @@ -537,6 +556,29 @@ def test_build_switch_target_defaults_qmt_repository_scope(self): }, ) + def test_build_switch_target_defaults_binance_repository_scope(self): + parser = build_runtime_switch.build_parser() + args = parser.parse_args( + [ + "--platform", + "binance", + "--target-name", + "default", + "--strategy-profile", + "crypto_equity_combo", + "--plugin-mode", + "none", + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + + self.assertEqual(target["github"]["repository"], "QuantStrategyLab/BinancePlatform") + self.assertEqual(target["github"]["variable_scope"], "repository") + self.assertEqual(target["runtime_target"]["platform_id"], "binance") + self.assertEqual(assignments["BINANCE_DRY_RUN"], "false") + def test_build_switch_target_uses_dca_monthly_scheduler_window(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( diff --git a/tests/strategy_switch_worker_validation.mjs b/tests/strategy_switch_worker_validation.mjs index 54fb5c5..6be7ecc 100644 --- a/tests/strategy_switch_worker_validation.mjs +++ b/tests/strategy_switch_worker_validation.mjs @@ -21,6 +21,7 @@ assert.ok(indexHtml.includes('id="app-shell"')); assert.ok(indexHtml.includes(".switch-surface.summary-hidden")); assert.ok(indexHtml.includes('summaryPanel.hidden = !showSummary')); assert.ok(indexHtml.includes('switchSurface.classList.toggle("summary-hidden", !showSummary)')); +assert.equal(indexHtml.match(/Generated by inject_platform_config\.py/g)?.length, 1); assert.equal(indexHtml.includes("publicSummary"), false); assert.ok(indexHtml.includes("function hasPrivateConfig()")); assert.ok(indexHtml.includes('el("quick-form").hidden = !showPrivateControls')); diff --git a/web/strategy-switch-console/config.js b/web/strategy-switch-console/config.js index 10fe572..f5940ed 100644 --- a/web/strategy-switch-console/config.js +++ b/web/strategy-switch-console/config.js @@ -1,4 +1,4 @@ -// Generated by scripts/build_platform_config.py; single source of truth. +// Generated by python/scripts/build_platform_config.py; single source of truth. // Source: platform-config.json export const PLATFORM_CONFIG = { @@ -160,7 +160,7 @@ export const DEFAULT_ACCOUNT_OPTIONS = { "crypto" ], "cash_currency": "USD", - "default_strategy_profile": "crypto_live_pool_rotation", + "default_strategy_profile": "crypto_equity_combo", "service_name": "", "default_execution_mode": "live" } @@ -411,6 +411,12 @@ export const STRATEGY_FEATURES = { "dca": false, "combo": true }, + "cn_industry_etf_rotation": { + "income_layer": false, + "option_overlay": false, + "dca": false, + "combo": false + }, "cn_industry_etf_rotation_aggressive": { "income_layer": false, "option_overlay": false, diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index c0a0a88..6ef504a 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -8,18 +8,10 @@ - - - - - - - - \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 当前平台\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\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\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 初始化控制台\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\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\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 index 2e1d76a..da76c38 100644 --- a/web/strategy-switch-console/strategy-profiles.example.json +++ b/web/strategy-switch-console/strategy-profiles.example.json @@ -205,6 +205,14 @@ "domain": "cn_equity", "runtime_enabled": true }, + { + "profile": "cn_industry_etf_rotation", + "label": "CN Industry ETF Rotation", + "label_en": "CN Industry ETF Rotation", + "label_zh": "A股行业ETF轮动", + "domain": "cn_equity", + "runtime_enabled": true + }, { "profile": "cn_equity_combo", "label": "CN Alpha Combo", diff --git a/web/strategy-switch-console/strategy_profiles_asset.js b/web/strategy-switch-console/strategy_profiles_asset.js index b5816ea..459c7b1 100644 --- a/web/strategy-switch-console/strategy_profiles_asset.js +++ b/web/strategy-switch-console/strategy_profiles_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const DEFAULT_STRATEGY_PROFILES = [{"profile": "ibit_smart_dca", "label": "IBIT Bitcoin DCA", "label_en": "IBIT Bitcoin DCA", "label_zh": "IBIT比特币定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "global_etf_rotation", "label": "Global ETF Rotation", "label_en": "Global ETF Rotation", "label_zh": "全球ETF轮动", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "500000", "income_layer_max_ratio": "0.15", "income_layer_allocations": {"SCHD": 0.4, "DGRO": 0.25, "SGOV": 0.3, "SPYI": 0.05}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "500000", "option_growth_overlay_nav_budget_ratio": 0.015}, {"profile": "soxl_soxx_trend_income", "label": "Semiconductor Trend Income", "label_en": "Semiconductor Trend Income", "label_zh": "半导体趋势收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "150000", "income_layer_max_ratio": "0.95", "income_layer_allocations": {"SCHD": 0.15, "DGRO": 0.1, "SGOV": 0.7, "SPYI": 0.04, "QQQI": 0.01}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_income_overlay_enabled": true, "option_income_overlay_recipe": "soxx_put_credit_spread_income_v1", "option_income_overlay_start_usd": "150000", "option_income_overlay_nav_risk_ratio": 0.01}, {"profile": "nasdaq_sp500_smart_dca", "label": "NASDAQ/S&P 500 DCA", "label_en": "NASDAQ/S&P 500 DCA", "label_zh": "纳指标普定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "tqqq_growth_income", "label": "NASDAQ Growth Income", "label_en": "NASDAQ Growth Income", "label_zh": "纳斯达克增长收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "250000", "income_layer_max_ratio": "0.55", "income_layer_allocations": {"SCHD": 0.3, "DGRO": 0.2, "SGOV": 0.4, "SPYI": 0.08, "QQQI": 0.02}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "tqqq_leaps_growth_v1", "option_growth_overlay_start_usd": "250000", "option_growth_overlay_nav_budget_ratio": 0.03}, {"profile": "russell_top50_leader_rotation", "label": "Russell Top50 Leaders", "label_en": "Russell Top50 Leaders", "label_zh": "罗素Top50领涨", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "300000", "income_layer_max_ratio": "0.25", "income_layer_allocations": {"SCHD": 0.45, "DGRO": 0.3, "SGOV": 0.25}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "300000", "option_growth_overlay_nav_budget_ratio": 0.015}, {"profile": "us_equity_combo_leveraged", "label": "US Alpha Combo", "label_en": "US Alpha Combo", "label_zh": "美股加速组合", "domain": "us_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo", "label": "US Core Combo", "label_en": "US Core Combo", "label_zh": "美股核心组合", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "300000", "income_layer_max_ratio": "0.25", "income_layer_allocations": {"SCHD": 0.25, "DGRO": 0.25, "SGOV": 0.2, "SPYI": 0.15, "QQQI": 0.15}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "300000", "option_growth_overlay_nav_budget_ratio": 0.015, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_global_etf_tactical_rotation", "label": "HK ETF Tactical Rotation", "label_en": "HK ETF Tactical Rotation", "label_zh": "港股ETF战术轮动", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_equity_combo", "label": "HK Core Combo", "label_en": "HK Core Combo", "label_zh": "港股恒生组合", "domain": "hk_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "HK Dividend Quality", "label_en": "HK Dividend Quality", "label_zh": "港股红利质量", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "cn_industry_etf_rotation_aggressive", "label": "CN ETF Rotation", "label_en": "CN ETF Rotation", "label_zh": "A股ETF轮动", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_stock_momentum_rotation", "label": "CN Stock Momentum", "label_en": "CN Stock Momentum", "label_zh": "A股个股动量", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_dividend_quality_snapshot", "label": "CN Dividend Quality", "label_en": "CN Dividend Quality", "label_zh": "A股红利质量", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_equity_combo", "label": "CN Alpha Combo", "label_en": "CN Alpha Combo", "label_zh": "A股进取组合", "domain": "cn_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_btc_dca", "label": "BTC DCA", "label_en": "BTC DCA", "label_zh": "BTC定投", "domain": "crypto", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "100"}, {"profile": "crypto_equity_combo", "label": "Crypto Core Combo", "label_en": "Crypto Core Combo", "label_zh": "加密动量组合", "domain": "crypto", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_trend_rotation", "label": "Altcoin Trend", "label_en": "Altcoin Trend", "label_zh": "山寨趋势轮动", "domain": "crypto", "runtime_enabled": true}]; +export const DEFAULT_STRATEGY_PROFILES = [{"profile": "ibit_smart_dca", "label": "IBIT Bitcoin DCA", "label_en": "IBIT Bitcoin DCA", "label_zh": "IBIT比特币定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "global_etf_rotation", "label": "Global ETF Rotation", "label_en": "Global ETF Rotation", "label_zh": "全球ETF轮动", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "500000", "income_layer_max_ratio": "0.15", "income_layer_allocations": {"SCHD": 0.4, "DGRO": 0.25, "SGOV": 0.3, "SPYI": 0.05}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "500000", "option_growth_overlay_nav_budget_ratio": 0.015}, {"profile": "soxl_soxx_trend_income", "label": "Semiconductor Trend Income", "label_en": "Semiconductor Trend Income", "label_zh": "半导体趋势收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "150000", "income_layer_max_ratio": "0.95", "income_layer_allocations": {"SCHD": 0.15, "DGRO": 0.1, "SGOV": 0.7, "SPYI": 0.04, "QQQI": 0.01}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_income_overlay_enabled": true, "option_income_overlay_recipe": "soxx_put_credit_spread_income_v1", "option_income_overlay_start_usd": "150000", "option_income_overlay_nav_risk_ratio": 0.01}, {"profile": "nasdaq_sp500_smart_dca", "label": "NASDAQ/S&P 500 DCA", "label_en": "NASDAQ/S&P 500 DCA", "label_zh": "纳指标普定投", "domain": "us_equity", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "1000"}, {"profile": "tqqq_growth_income", "label": "NASDAQ Growth Income", "label_en": "NASDAQ Growth Income", "label_zh": "纳斯达克增长收益", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "250000", "income_layer_max_ratio": "0.55", "income_layer_allocations": {"SCHD": 0.3, "DGRO": 0.2, "SGOV": 0.4, "SPYI": 0.08, "QQQI": 0.02}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "tqqq_leaps_growth_v1", "option_growth_overlay_start_usd": "250000", "option_growth_overlay_nav_budget_ratio": 0.03}, {"profile": "russell_top50_leader_rotation", "label": "Russell Top50 Leaders", "label_en": "Russell Top50 Leaders", "label_zh": "罗素Top50领涨", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "300000", "income_layer_max_ratio": "0.25", "income_layer_allocations": {"SCHD": 0.45, "DGRO": 0.3, "SGOV": 0.25}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "300000", "option_growth_overlay_nav_budget_ratio": 0.015}, {"profile": "us_equity_combo_leveraged", "label": "US Alpha Combo", "label_en": "US Alpha Combo", "label_zh": "美股加速组合", "domain": "us_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo", "label": "US Core Combo", "label_en": "US Core Combo", "label_zh": "美股核心组合", "domain": "us_equity", "runtime_enabled": true, "income_layer_enabled": true, "income_layer_start_usd": "300000", "income_layer_max_ratio": "0.25", "income_layer_allocations": {"SCHD": 0.25, "DGRO": 0.25, "SGOV": 0.2, "SPYI": 0.15, "QQQI": 0.15}, "option_overlay_enabled": true, "option_overlay_live_gate": "promotion_required", "option_overlay_live_status": "research_only", "option_growth_overlay_enabled": true, "option_growth_overlay_recipe": "spy_leaps_growth_v1", "option_growth_overlay_start_usd": "300000", "option_growth_overlay_nav_budget_ratio": 0.015, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_global_etf_tactical_rotation", "label": "HK ETF Tactical Rotation", "label_en": "HK ETF Tactical Rotation", "label_zh": "港股ETF战术轮动", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "hk_equity_combo", "label": "HK Core Combo", "label_en": "HK Core Combo", "label_zh": "港股恒生组合", "domain": "hk_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "HK Dividend Quality", "label_en": "HK Dividend Quality", "label_zh": "港股红利质量", "domain": "hk_equity", "runtime_enabled": true}, {"profile": "cn_industry_etf_rotation_aggressive", "label": "CN ETF Rotation", "label_en": "CN ETF Rotation", "label_zh": "A股ETF轮动", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_stock_momentum_rotation", "label": "CN Stock Momentum", "label_en": "CN Stock Momentum", "label_zh": "A股个股动量", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_dividend_quality_snapshot", "label": "CN Dividend Quality", "label_en": "CN Dividend Quality", "label_zh": "A股红利质量", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_industry_etf_rotation", "label": "CN Industry ETF Rotation", "label_en": "CN Industry ETF Rotation", "label_zh": "A股行业ETF轮动", "domain": "cn_equity", "runtime_enabled": true}, {"profile": "cn_equity_combo", "label": "CN Alpha Combo", "label_en": "CN Alpha Combo", "label_zh": "A股进取组合", "domain": "cn_equity", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_btc_dca", "label": "BTC DCA", "label_en": "BTC DCA", "label_zh": "BTC定投", "domain": "crypto", "runtime_enabled": true, "dca_enabled": true, "dca_default_mode": "fixed", "dca_default_base_investment_usd": "100"}, {"profile": "crypto_equity_combo", "label": "Crypto Core Combo", "label_en": "Crypto Core Combo", "label_zh": "加密动量组合", "domain": "crypto", "runtime_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_trend_rotation", "label": "Altcoin Trend", "label_en": "Altcoin Trend", "label_zh": "山寨趋势轮动", "domain": "crypto", "runtime_enabled": true}];