读取策略配置
\n正在读取登录状态、账号配置和当前状态。
\n \nFrom 8252da14d0091fa8a865f3883e1323ec363c5bfe Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:02:00 +0800 Subject: [PATCH] fix: harden runtime switch config and deployment validation --- .../deploy-strategy-switch-console.yml | 3 +- .github/workflows/manual-strategy-switch.yml | 8 +- docs/architecture_config_driven_20260629.md | 2 +- platform-config.json | 16 +- python/scripts/build_config.py | 18 ++- python/scripts/build_platform_config.py | 51 ++++--- python/scripts/build_runtime_switch.py | 24 +-- python/scripts/gate_codex_app_review.py | 112 +++++++++----- python/scripts/inject_platform_config.py | 137 ++++++++++++------ python/scripts/run_codex_pr_review.py | 99 +++++-------- python/scripts/runtime_settings.py | 36 ++--- .../sync_strategy_switch_page_asset.py | 6 +- .../tests/test_internal_dependency_matrix.py | 8 +- python/tests/test_runtime_settings.py | 78 +++++++--- tests/strategy_switch_worker_validation.mjs | 1 + web/strategy-switch-console/config.js | 10 +- web/strategy-switch-console/index.html | 10 +- web/strategy-switch-console/page_asset.js | 2 +- .../strategy-profiles.example.json | 8 + .../strategy_profiles_asset.js | 2 +- 20 files changed, 372 insertions(+), 259 deletions(-) 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允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可执行切换。
\n \n