读取策略配置
\n正在读取登录状态、账号配置和当前状态。
\n \ndiff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index 7dbfef0..b00f030 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -407,6 +407,31 @@ jobs: print(f"- `{assignment['name']}` = `{assignment['value']}`") PY + - name: Preflight IBKR deployment plan + if: env.PLATFORM == 'ibkr' && env.SERVICE_TARGETS_MODE != 'off' + env: + TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} + run: | + set -euo pipefail + platform_root="${RUNNER_TEMP}/interactive-brokers-platform" + repository_variables_file="${RUNNER_TEMP}/ibkr-repository-variables.json" + trap 'rm -f "${repository_variables_file}"' EXIT + + gh auth setup-git + git clone --quiet --depth 1 --branch main \ + "https://github.com/${TARGET_REPOSITORY}.git" \ + "${platform_root}" + python3 -m pip install --quiet uv + uv sync --frozen --no-dev --quiet --project "${platform_root}" + gh variable list \ + --repo "${TARGET_REPOSITORY}" \ + --json name,value \ + > "${repository_variables_file}" + python3 python/scripts/preflight_ibkr_switch.py \ + --target-file "${TARGET_FILE}" \ + --platform-root "${platform_root}" \ + --repository-variables-file "${repository_variables_file}" + - name: Apply GitHub variable updates if: env.APPLY_SWITCH == 'true' run: python3 python/scripts/runtime_settings.py apply "${TARGET_FILE}" --yes diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index 77976bb..a722361 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -62,28 +62,42 @@ "path": "pyproject.toml", "package": "cn-equity-strategies", "source_repo": "CnEquityStrategies", - "ref": "73844e92a8570a61e5a9dc6c245809d0b27b89bc" + "ref": "00fa762466617d0961bb8b03821f56b1b9c2b866" + }, + { + "consumer_repo": "CnEquitySnapshotPipelines", + "path": "pyproject.toml", + "package": "quant-platform-kit", + "source_repo": "QuantPlatformKit", + "ref": "8ba8276948ff71a8cc0a810f98b7437a1311c671" }, { "consumer_repo": "CnEquitySnapshotPipelines", "path": "uv.lock", "package": "cn-equity-strategies", "source_repo": "CnEquityStrategies", - "ref": "73844e92a8570a61e5a9dc6c245809d0b27b89bc" + "ref": "00fa762466617d0961bb8b03821f56b1b9c2b866" + }, + { + "consumer_repo": "CnEquitySnapshotPipelines", + "path": "uv.lock", + "package": "quant-platform-kit", + "source_repo": "QuantPlatformKit", + "ref": "8ba8276948ff71a8cc0a810f98b7437a1311c671" }, { "consumer_repo": "CnEquityStrategies", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "CnEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "CryptoLivePoolPipelines", @@ -118,14 +132,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "CryptoStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "FirstradePlatform", @@ -174,14 +188,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "HkEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -195,7 +209,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -216,7 +230,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "92458590a463e7219f0369a3505031ee74414135" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -342,14 +356,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "8ba8276948ff71a8cc0a810f98b7437a1311c671" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" }, { "consumer_repo": "UsEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "8ba8276948ff71a8cc0a810f98b7437a1311c671" + "ref": "776fe71e57e2924fcd1c73126f41d244242240bb" } ] } diff --git a/platform-config.json b/platform-config.json index d0f532a..6798f04 100644 --- a/platform-config.json +++ b/platform-config.json @@ -62,27 +62,27 @@ "profiles": { "us_daily": { "timezone": "America/New_York", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *" + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5" }, "us_dca_month_end": { "timezone": "America/New_York", - "main_time": "45 15 25-29 * *", - "probe_time": "35 9,15 25-29 * *", - "precheck_time": "45 9 25-29 * *" + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5" }, "us_snapshot_month_start": { "timezone": "America/New_York", - "main_time": "45 15 1-7 * *", - "probe_time": "35 9,15 1-7 * *", - "precheck_time": "45 9 1-7 * *" + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5" }, "hk_daily": { "timezone": "Asia/Hong_Kong", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *" + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5" }, "hk_snapshot_month_start": { "timezone": "Asia/Hong_Kong", @@ -501,6 +501,13 @@ "dry_run" ], "blocked_live_reason": "", + "runtime_artifacts": { + "feature_snapshot": { + "required": true, + "path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv", + "manifest_path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv.manifest.json" + } + }, "features": { "income_layer": true, "option_overlay": true, @@ -541,6 +548,13 @@ "dry_run" ], "blocked_live_reason": "", + "runtime_artifacts": { + "feature_snapshot": { + "required": true, + "path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/russell_top50_leader_rotation_staging/russell_top50_leader_rotation_feature_snapshot_latest.csv", + "manifest_path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/russell_top50_leader_rotation_staging/russell_top50_leader_rotation_feature_snapshot_latest.csv.manifest.json" + } + }, "features": { "income_layer": true, "option_overlay": true, @@ -691,13 +705,17 @@ "scheduler_profile": "hk_snapshot_month_start", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", - "can_switch_live": true, + "can_switch_live": false, "allowed_execution_modes": [ - "live", "paper", "dry_run" ], - "blocked_live_reason": "", + "blocked_live_reason": "production_snapshot_artifact_route_not_configured", + "runtime_artifacts": { + "feature_snapshot": { + "required": true + } + }, "features": { "income_layer": false, "option_overlay": false, diff --git a/python/scripts/build_config.py b/python/scripts/build_config.py index da237f4..7955b58 100644 --- a/python/scripts/build_config.py +++ b/python/scripts/build_config.py @@ -39,6 +39,7 @@ } SCHEDULER_FIELDS = {"timezone", "main_time", "probe_time", "precheck_time"} MARKET_FIELDS = {"market", "market_calendar", "market_timezone"} +FEATURE_SNAPSHOT_FIELDS = {"required", "path", "manifest_path"} RUNTIME_MODELS = {"cloud_run", "oracle_vps_self_hosted", "not_configured"} SETTINGS_ACTIVATION_MODES = { "cloud_run_sync_workflow", @@ -79,6 +80,13 @@ def validate(config: dict) -> list[str]: errors.append( f"scheduler profile {profile}: {field} must have 2 time fields or 5 cron fields" ) + continue + if profile.startswith("us_"): + cron = value.split() + if len(cron) != 5 or cron[2] != "*" or cron[4] != "1-5": + errors.append( + f"scheduler profile {profile}: {field} must be Mon-Fri cron with day-of-month '*'" + ) for pid, pdata in config.get("platforms", {}).items(): if "capabilities" not in pdata: errors.append(f"platform {pid}: missing capabilities") @@ -220,6 +228,56 @@ def validate(config: dict) -> list[str]: f"{scheduler_timezone!r} must match market_timezone " f"{market_timezone!r}" ) + runtime_artifacts = sdata.get("runtime_artifacts") + if runtime_artifacts is None: + continue + if not isinstance(runtime_artifacts, dict): + errors.append(f"strategy {sid}: runtime_artifacts must be an object") + continue + unsupported_artifacts = sorted(set(runtime_artifacts) - {"feature_snapshot"}) + if unsupported_artifacts: + errors.append( + f"strategy {sid}: unsupported runtime_artifacts {unsupported_artifacts}" + ) + feature_snapshot = runtime_artifacts.get("feature_snapshot") + if feature_snapshot is None: + continue + if not isinstance(feature_snapshot, dict): + errors.append( + f"strategy {sid}: runtime_artifacts.feature_snapshot must be an object" + ) + continue + unsupported_fields = sorted(set(feature_snapshot) - FEATURE_SNAPSHOT_FIELDS) + if unsupported_fields: + errors.append( + f"strategy {sid}: unsupported feature snapshot fields {unsupported_fields}" + ) + required = feature_snapshot.get("required") + if not isinstance(required, bool): + errors.append( + f"strategy {sid}: runtime_artifacts.feature_snapshot.required must be boolean" + ) + snapshot_path = feature_snapshot.get("path") + manifest_path = feature_snapshot.get("manifest_path") + for field, value in (("path", snapshot_path), ("manifest_path", manifest_path)): + if value is not None and ( + not isinstance(value, str) or not value.startswith("gs://") + ): + errors.append( + f"strategy {sid}: runtime_artifacts.feature_snapshot.{field} must be a gs:// URI" + ) + has_snapshot_path = isinstance(snapshot_path, str) and bool(snapshot_path.strip()) + has_manifest_path = isinstance(manifest_path, str) and bool(manifest_path.strip()) + if has_snapshot_path != has_manifest_path: + errors.append( + f"strategy {sid}: feature snapshot path and manifest_path must be configured together" + ) + if required is True and sdata.get("can_switch_live") is True and not ( + has_snapshot_path and has_manifest_path + ): + errors.append( + f"strategy {sid}: live feature snapshot requires path and manifest_path" + ) return errors diff --git a/python/scripts/build_runtime_switch.py b/python/scripts/build_runtime_switch.py index 6ba7eeb..54c5a60 100644 --- a/python/scripts/build_runtime_switch.py +++ b/python/scripts/build_runtime_switch.py @@ -64,6 +64,18 @@ "ibkr": "IBKR_CASH_ONLY_EXECUTION", "firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION", } +PLATFORM_FEATURE_SNAPSHOT_VARIABLES = { + "schwab": ("SCHWAB_FEATURE_SNAPSHOT_PATH", "SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH"), + "longbridge": ( + "LONGBRIDGE_FEATURE_SNAPSHOT_PATH", + "LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH", + ), + "ibkr": ("IBKR_FEATURE_SNAPSHOT_PATH", "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"), + "firstrade": ( + "FIRSTRADE_FEATURE_SNAPSHOT_PATH", + "FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH", + ), +} INCOME_LAYER_VARIABLES = ( "INCOME_LAYER_ENABLED", "INCOME_LAYER_START_USD", @@ -747,6 +759,57 @@ def _market_plan_for_strategy(strategy_profile: str) -> dict[str, str]: return market +def _feature_snapshot_extra_variables( + platform: str, + strategy_profile: str, + extra_variables: dict[str, Any], +) -> dict[str, str]: + variable_names = PLATFORM_FEATURE_SNAPSHOT_VARIABLES.get(platform) + if not variable_names: + return {} + snapshot_variable, manifest_variable = variable_names + + config = _load_platform_config() + strategies = config.get("strategies") + strategy = strategies.get(strategy_profile) if isinstance(strategies, dict) else None + if not isinstance(strategy, dict): + raise ValueError(f"strategy {strategy_profile!r} is missing from the runtime artifact catalog") + runtime_artifacts = strategy.get("runtime_artifacts") or {} + if not isinstance(runtime_artifacts, dict): + raise ValueError(f"strategy {strategy_profile!r} runtime_artifacts must be an object") + feature_snapshot = runtime_artifacts.get("feature_snapshot") or {} + if not isinstance(feature_snapshot, dict): + raise ValueError( + f"strategy {strategy_profile!r} runtime_artifacts.feature_snapshot must be an object" + ) + + explicit = snapshot_variable in extra_variables or manifest_variable in extra_variables + if explicit: + snapshot_path = extra_variables.get(snapshot_variable) + manifest_path = extra_variables.get(manifest_variable) + else: + snapshot_path = feature_snapshot.get("path") + manifest_path = feature_snapshot.get("manifest_path") + snapshot_path = snapshot_path.strip() if isinstance(snapshot_path, str) else "" + manifest_path = manifest_path.strip() if isinstance(manifest_path, str) else "" + if explicit and not feature_snapshot and (snapshot_path or manifest_path): + raise ValueError( + f"strategy {strategy_profile!r} does not accept feature snapshot artifacts" + ) + if bool(snapshot_path) != bool(manifest_path): + raise ValueError( + f"strategy {strategy_profile!r} feature snapshot path and manifest path must be configured together" + ) + if feature_snapshot.get("required") is True and not (snapshot_path and manifest_path): + raise ValueError( + f"strategy {strategy_profile!r} requires feature snapshot path and manifest path" + ) + return { + snapshot_variable: snapshot_path, + manifest_variable: manifest_path, + } + + def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]: platform = _normalize_platform(args.platform) target_name = _normalize_target_name(args.target_name) @@ -903,6 +966,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]: _reject_direct_dca_extra_variables(extra_variables) _reject_direct_ibit_zscore_exit_extra_variables(extra_variables) _reject_research_only_extra_variables(extra_variables) + extra_variables.update( + _feature_snapshot_extra_variables( + platform, + runtime_target["strategy_profile"], + extra_variables, + ) + ) if args.set_platform_dry_run_variable: extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"]) diff --git a/python/scripts/preflight_ibkr_switch.py b/python/scripts/preflight_ibkr_switch.py new file mode 100644 index 0000000..a13478e --- /dev/null +++ b/python/scripts/preflight_ibkr_switch.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Validate an IBKR switch with the target platform's deployment planner.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from runtime_settings import build_assignments, load_target # noqa: E402 + + +PROTECTED_ENV_NAMES = { + "GH_TOKEN", + "HOME", + "PATH", + "PYTHONHOME", + "PYTHONPATH", + "VIRTUAL_ENV", +} + + +def _load_repository_variables(path: Path) -> dict[str, str]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, list): + raise ValueError("repository variables file must contain a list") + variables: dict[str, str] = {} + for item in payload: + if not isinstance(item, dict): + raise ValueError("repository variable entries must be objects") + name = str(item.get("name") or "").strip() + if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name): + raise ValueError("repository variable names must use uppercase environment syntax") + if name in PROTECTED_ENV_NAMES or name.startswith(("LD_", "DYLD_")): + continue + value = item.get("value") + variables[name] = value if isinstance(value, str) else str(value or "") + return variables + + +def _candidate_environment(target: dict[str, Any], variables: dict[str, str]) -> dict[str, str]: + environment = os.environ.copy() + environment.update(variables) + candidate_inventory = False + for assignment in build_assignments(target): + if assignment.name == "CLOUD_RUN_SERVICE_TARGETS_JSON": + candidate_inventory = True + if assignment.deletes_variable: + environment.pop(assignment.name, None) + else: + environment[assignment.name] = assignment.value + if not candidate_inventory: + raise ValueError("candidate CLOUD_RUN_SERVICE_TARGETS_JSON assignment is required") + return environment + + +def preflight(target_file: Path, platform_root: Path, repository_variables_file: Path) -> None: + target = load_target(target_file) + runtime_target = target.get("runtime_target") + if not isinstance(runtime_target, dict) or runtime_target.get("platform_id") != "ibkr": + raise ValueError("preflight_ibkr_switch.py only accepts IBKR targets") + + variables = _load_repository_variables(repository_variables_file) + environment = _candidate_environment(target, variables) + python_path = platform_root / ".venv" / "bin" / "python" + planner_path = platform_root / "scripts" / "build_cloud_run_env_sync_plan.py" + if not python_path.is_file() or not planner_path.is_file(): + raise ValueError("IBKR planner runtime is incomplete") + + result = subprocess.run( + [str(python_path), str(planner_path), "--json"], + cwd=platform_root, + env=environment, + capture_output=True, + text=True, + timeout=90, + check=False, + ) + if result.returncode != 0: + raise ValueError("IBKR deployment planner rejected the candidate runtime settings") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target-file", required=True, type=Path) + parser.add_argument("--platform-root", required=True, type=Path) + parser.add_argument("--repository-variables-file", required=True, type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + preflight(args.target_file, args.platform_root, args.repository_variables_file) + except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print("IBKR deployment plan preflight passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/scripts/runtime_settings.py b/python/scripts/runtime_settings.py index 53d213f..63115aa 100644 --- a/python/scripts/runtime_settings.py +++ b/python/scripts/runtime_settings.py @@ -405,6 +405,7 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: 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") + validate_live_ibkr_us_scheduler(runtime_target, scheduler, errors) configured_market_fields = [] for field in MARKET_FIELDS: @@ -426,6 +427,49 @@ def validate_runtime_target(target: dict[str, Any], errors: list[str]) -> None: errors.append(f"runtime_target.market_timezone is invalid: {market_timezone!r}") +def validate_live_ibkr_us_scheduler( + runtime_target: dict[str, Any], + scheduler: dict[str, Any], + errors: list[str], +) -> None: + if ( + runtime_target.get("platform_id") != "ibkr" + or runtime_target.get("execution_mode") != "live" + or runtime_target.get("dry_run_only") is not False + ): + return + + market = str(runtime_target.get("market") or "").strip().upper() + if not market: + config = load_platform_config() + strategies = config.get("strategies", {}) + domains = config.get("domains", {}) + profile = str(runtime_target.get("strategy_profile") or "").strip() + strategy = strategies.get(profile) if isinstance(strategies, dict) else None + domain = ( + domains.get(strategy.get("domain")) + if isinstance(strategy, dict) and isinstance(domains, dict) + else None + ) + market = str(domain.get("market") or "").strip().upper() if isinstance(domain, dict) else "" + if market != "US": + return + + for field in ("main_time", "probe_time", "precheck_time"): + value = scheduler.get(field) + if not isinstance(value, str): + continue + cron = value.split() + if len(cron) == 2: + continue + if len(cron) == 5 and cron[2] == "*" and cron[4] == "1-5": + continue + errors.append( + f"runtime_target.scheduler.{field} must be a two-field time or Mon-Fri cron " + "for live IBKR US targets" + ) + + def validate_runtime_target_strategy_policy(runtime_target: dict[str, Any], errors: list[str]) -> None: config = load_platform_config() strategies = config.get("strategies", {}) diff --git a/python/tests/test_internal_dependency_matrix.py b/python/tests/test_internal_dependency_matrix.py index a5e39b3..cbc58bf 100644 --- a/python/tests/test_internal_dependency_matrix.py +++ b/python/tests/test_internal_dependency_matrix.py @@ -103,13 +103,13 @@ def test_qpk_migrated_consumers_use_current_baseline_pins(self): expected_pins_by_consumer = { "BinancePlatform": "92458590a463e7219f0369a3505031ee74414135", "CharlesSchwabPlatform": "92458590a463e7219f0369a3505031ee74414135", - "CnEquityStrategies": "92458590a463e7219f0369a3505031ee74414135", - "CryptoStrategies": "92458590a463e7219f0369a3505031ee74414135", + "CnEquityStrategies": "776fe71e57e2924fcd1c73126f41d244242240bb", + "CryptoStrategies": "776fe71e57e2924fcd1c73126f41d244242240bb", "FirstradePlatform": "92458590a463e7219f0369a3505031ee74414135", - "HkEquityStrategies": "92458590a463e7219f0369a3505031ee74414135", - "InteractiveBrokersPlatform": "92458590a463e7219f0369a3505031ee74414135", + "HkEquityStrategies": "776fe71e57e2924fcd1c73126f41d244242240bb", + "InteractiveBrokersPlatform": "776fe71e57e2924fcd1c73126f41d244242240bb", "LongBridgePlatform": "92458590a463e7219f0369a3505031ee74414135", - "UsEquityStrategies": "8ba8276948ff71a8cc0a810f98b7437a1311c671", + "UsEquityStrategies": "776fe71e57e2924fcd1c73126f41d244242240bb", } matrix_pins = check_internal_dependency_matrix.load_matrix(ROOT / "internal_dependency_matrix.json") refs = { diff --git a/python/tests/test_runtime_settings.py b/python/tests/test_runtime_settings.py index a933b91..a359df2 100644 --- a/python/tests/test_runtime_settings.py +++ b/python/tests/test_runtime_settings.py @@ -4,7 +4,9 @@ import json import os import re +import subprocess import sys +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -140,6 +142,130 @@ def test_manual_switch_rejects_inventory_bypass_before_variable_write(self): workflow.index("Apply GitHub variable updates"), ) + def test_manual_switch_preflights_ibkr_plan_before_variable_write(self): + workflow = (ROOT / ".github" / "workflows" / "manual-strategy-switch.yml").read_text(encoding="utf-8") + + self.assertIn("Preflight IBKR deployment plan", workflow) + self.assertIn("python/scripts/preflight_ibkr_switch.py", workflow) + self.assertIn("uv sync --frozen --no-dev", workflow) + self.assertLess( + workflow.index("Preflight IBKR deployment plan"), + workflow.index("Apply GitHub variable updates"), + ) + + def test_preflight_ibkr_switch_uses_candidate_inventory_without_printing_plan(self): + with tempfile.TemporaryDirectory(dir=ROOT) as temp_dir: + temp = Path(temp_dir) + existing_path = temp / "existing.json" + existing_path.write_text( + runtime_settings.compact_json( + { + "targets": [ + { + "service": "interactive-brokers-live-service", + "ACCOUNT_GROUP": "live", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "soxl_soxx_trend_income", + "dry_run_only": False, + "deployment_selector": "live", + "account_selector": ["LIVE"], + "account_scope": "live", + "service_name": "interactive-brokers-live-service", + "execution_mode": "live", + }, + } + ] + } + ), + encoding="utf-8", + ) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--account-selector", + "LIVE", + "--service-name", + "interactive-brokers-live-service", + "--plugin-mode", + "none", + "--existing-service-targets-json-file", + str(existing_path), + ] + ) + target_path = temp / "target.json" + target_path.write_text( + runtime_settings.compact_json(build_runtime_switch.build_switch_target(args)), + encoding="utf-8", + ) + variables_path = temp / "variables.json" + variables_path.write_text( + json.dumps( + [ + {"name": "CLOUD_RUN_SERVICE_TARGETS_JSON", "value": '{"targets":[]}'}, + {"name": "UNCHANGED_SETTING", "value": "preserved"}, + ] + ), + encoding="utf-8", + ) + platform_root = temp / "platform" + planner_path = platform_root / "scripts" / "build_cloud_run_env_sync_plan.py" + planner_path.parent.mkdir(parents=True) + capture_path = temp / "capture.json" + planner_path.write_text( + """ +import json +import os +from pathlib import Path + +inventory = json.loads(os.environ["CLOUD_RUN_SERVICE_TARGETS_JSON"]) +Path(os.environ["CAPTURE_PATH"]).write_text( + json.dumps( + { + "profile": inventory["targets"][0]["runtime_target"]["strategy_profile"], + "unchanged": os.environ.get("UNCHANGED_SETTING"), + } + ), + encoding="utf-8", +) +print('{"candidate_inventory":"must-not-be-forwarded"}') +""".strip(), + encoding="utf-8", + ) + python_path = platform_root / ".venv" / "bin" / "python" + python_path.parent.mkdir(parents=True) + python_path.symlink_to(sys.executable) + + result = subprocess.run( + [ + sys.executable, + str(ROOT / "python" / "scripts" / "preflight_ibkr_switch.py"), + "--target-file", + str(target_path), + "--platform-root", + str(platform_root), + "--repository-variables-file", + str(variables_path), + ], + capture_output=True, + text=True, + env={**os.environ, "CAPTURE_PATH": str(capture_path)}, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "IBKR deployment plan preflight passed.\n") + self.assertNotIn("candidate_inventory", result.stdout + result.stderr) + self.assertEqual( + json.loads(capture_path.read_text(encoding="utf-8")), + {"profile": "tqqq_growth_income", "unchanged": "preserved"}, + ) + def test_settings_activation_comes_from_platform_config(self): self.assertEqual( runtime_settings.platform_settings_activation("binance"), @@ -706,9 +832,9 @@ def test_build_switch_target_defaults_longbridge_sg_tqqq(self): target["runtime_target"]["scheduler"], { "timezone": "America/New_York", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *", + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5", }, ) self.assertEqual(assignments["STRATEGY_PROFILE"], "tqqq_growth_income") @@ -737,6 +863,18 @@ def test_market_plan_covers_every_catalog_strategy(self): }, ) + def test_live_us_scheduler_profiles_are_weekday_only(self): + config = build_runtime_switch._load_platform_config() + + for profile, scheduler in config["scheduling"]["profiles"].items(): + if not profile.startswith("us_"): + continue + for field in ("main_time", "probe_time", "precheck_time"): + cron = scheduler[field].split() + self.assertEqual(len(cron), 5, (profile, field)) + self.assertEqual(cron[2], "*", (profile, field)) + self.assertEqual(cron[4], "1-5", (profile, field)) + def test_build_switch_target_uses_fork_repository_overrides(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( @@ -936,7 +1074,7 @@ def test_build_switch_target_defaults_binance_repository_scope(self): }, ) - def test_build_switch_target_uses_dca_monthly_scheduler_window(self): + def test_build_switch_target_uses_weekday_trigger_for_monthly_dca(self): parser = build_runtime_switch.build_parser() args = parser.parse_args( [ @@ -957,9 +1095,9 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self): target["runtime_target"]["scheduler"], { "timezone": "America/New_York", - "main_time": "45 15 25-29 * *", - "probe_time": "35 9,15 25-29 * *", - "precheck_time": "45 9 25-29 * *", + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5", }, ) @@ -986,9 +1124,9 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_smart_dca_is_smart(s target["runtime_target"]["scheduler"], { "timezone": "America/New_York", - "main_time": "45 15 * * *", - "probe_time": "35 9,15 * * *", - "precheck_time": "45 9 * * *", + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5", }, ) self.assertEqual(plugin_payload["strategy_plugins"][0]["plugin"], "ibit_zscore_exit") @@ -1442,8 +1580,19 @@ def test_build_switch_target_uses_snapshot_scheduler_window(self): "hk", "--strategy-profile", "hk_low_vol_dividend_quality_snapshot", + "--execution-mode", + "paper", "--plugin-mode", "none", + "--extra-variables-json", + json.dumps( + { + "LONGBRIDGE_FEATURE_SNAPSHOT_PATH": "gs://manual/snapshot.csv", + "LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH": ( + "gs://manual/snapshot.csv.manifest.json" + ), + } + ), ] ) @@ -1459,6 +1608,169 @@ def test_build_switch_target_uses_snapshot_scheduler_window(self): }, ) + def test_build_switch_target_auto_configures_ibkr_snapshot_artifacts(self): + cases = { + "global_etf_rotation": ( + "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/" + "global_etf_rotation_feature_snapshot_latest.csv" + ), + "russell_top50_leader_rotation": ( + "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/" + "russell_top50_leader_rotation_staging/" + "russell_top50_leader_rotation_feature_snapshot_latest.csv" + ), + } + for profile, snapshot_path in cases.items(): + with self.subTest(profile=profile): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + profile, + "--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(assignments["IBKR_FEATURE_SNAPSHOT_PATH"], snapshot_path) + self.assertEqual( + assignments["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"], + f"{snapshot_path}.manifest.json", + ) + + def test_build_switch_target_clears_stale_ibkr_snapshot_artifacts(self): + existing = { + "targets": [ + { + "service": "interactive-brokers-live-service", + "ACCOUNT_GROUP": "live", + "IBKR_FEATURE_SNAPSHOT_PATH": "gs://stale/snapshot.csv", + "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH": "gs://stale/snapshot.csv.manifest.json", + "runtime_target": { + "platform_id": "ibkr", + "strategy_profile": "global_etf_rotation", + "dry_run_only": False, + "deployment_selector": "live", + "account_selector": ["LIVE"], + "account_scope": "live", + "service_name": "interactive-brokers-live-service", + "execution_mode": "live", + }, + } + ] + } + path = ROOT / ".pytest_runtime_service_targets_snapshot.json" + path.write_text(runtime_settings.compact_json(existing), encoding="utf-8") + self.addCleanup(lambda: path.unlink(missing_ok=True)) + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--account-selector", + "LIVE", + "--service-name", + "interactive-brokers-live-service", + "--plugin-mode", + "none", + "--existing-service-targets-json-file", + str(path), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)} + selected = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])["targets"][0] + + self.assertEqual(selected["IBKR_FEATURE_SNAPSHOT_PATH"], "") + self.assertEqual(selected["IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"], "") + + def test_snapshot_strategy_without_catalog_artifacts_requires_explicit_pair(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "hk", + "--strategy-profile", + "hk_low_vol_dividend_quality_snapshot", + "--execution-mode", + "paper", + "--plugin-mode", + "none", + ] + ) + + with self.assertRaisesRegex(ValueError, "requires feature snapshot path and manifest path"): + build_runtime_switch.build_switch_target(args) + + def test_snapshot_strategy_accepts_explicit_platform_artifact_pair(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "longbridge", + "--target-name", + "hk", + "--strategy-profile", + "hk_low_vol_dividend_quality_snapshot", + "--execution-mode", + "paper", + "--plugin-mode", + "none", + "--extra-variables-json", + json.dumps( + { + "LONGBRIDGE_FEATURE_SNAPSHOT_PATH": "gs://manual/snapshot.csv", + "LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH": ( + "gs://manual/snapshot.csv.manifest.json" + ), + } + ), + ] + ) + + target = build_runtime_switch.build_switch_target(args) + + self.assertEqual( + target["extra_variables"]["LONGBRIDGE_FEATURE_SNAPSHOT_PATH"], + "gs://manual/snapshot.csv", + ) + + def test_non_snapshot_strategy_rejects_snapshot_artifact_override(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + "--extra-variables-json", + json.dumps( + { + "IBKR_FEATURE_SNAPSHOT_PATH": "gs://unexpected/snapshot.csv", + "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH": ( + "gs://unexpected/snapshot.csv.manifest.json" + ), + } + ), + ] + ) + + with self.assertRaisesRegex(ValueError, "does not accept feature snapshot artifacts"): + build_runtime_switch.build_switch_target(args) + def test_scheduler_plan_uses_catalog_strategy_override_without_code_mapping(self): scheduler = { "timezone": "Europe/London", @@ -1509,6 +1821,28 @@ def test_build_config_rejects_unknown_strategy_scheduler_profile(self): build_config.validate(config), ) + def test_build_config_requires_live_snapshot_artifact_pair(self): + config = build_config.load_config() + config["strategies"]["global_etf_rotation"]["runtime_artifacts"][ + "feature_snapshot" + ].pop("manifest_path") + + self.assertIn( + "strategy global_etf_rotation: live feature snapshot requires path and manifest_path", + build_config.validate(config), + ) + + def test_build_config_rejects_non_boolean_snapshot_requirement(self): + config = build_config.load_config() + config["strategies"]["global_etf_rotation"]["runtime_artifacts"][ + "feature_snapshot" + ]["required"] = "true" + + self.assertIn( + "strategy global_etf_rotation: runtime_artifacts.feature_snapshot.required must be boolean", + build_config.validate(config), + ) + def test_build_config_reports_malformed_scheduler_timezone(self): config = build_config.load_config() config["scheduling"]["profiles"]["us_daily"]["timezone"] = "../UTC" @@ -1601,6 +1935,25 @@ def test_runtime_target_scheduler_rejects_invalid_cron_shape(self): runtime_settings.validate_target(target), ) + def test_live_ibkr_us_scheduler_rejects_weekend_cron(self): + args = build_runtime_switch.build_parser().parse_args( + [ + "--platform", + "ibkr", + "--target-name", + "live", + "--strategy-profile", + "tqqq_growth_income", + ] + ) + target = build_runtime_switch.build_switch_target(args) + target["runtime_target"]["scheduler"]["main_time"] = "45 15 * * *" + + self.assertIn( + "runtime_target.scheduler.main_time must be a two-field time or Mon-Fri cron for live IBKR US targets", + runtime_settings.validate_target(target), + ) + def test_runtime_target_scheduler_rejects_extra_fields(self): _, target = self.load_target("examples/targets/longbridge/hk_combo.example.json") target["runtime_target"]["scheduler"]["offset_minutes"] = 10 diff --git a/web/strategy-switch-console/index.html b/web/strategy-switch-console/index.html index fbd1c89..5c15242 100644 --- a/web/strategy-switch-console/index.html +++ b/web/strategy-switch-console/index.html @@ -13,7 +13,7 @@ window.__PLATFORM_CONFIG__ = {"longbridge": {"dry_run_only": false, "margin_policy": true, "reserved_cash": true, "income_layer": true, "option_overlay": true, "dca": true, "execution_mode": "live", "service_name": "", "default_execution_mode": "live"}, "ibkr": {"dry_run_only": false, "margin_policy": true, "reserved_cash": true, "income_layer": true, "option_overlay": true, "dca": true, "execution_mode": "live", "service_name": "", "default_execution_mode": "live"}, "schwab": {"dry_run_only": false, "margin_policy": true, "reserved_cash": true, "income_layer": true, "option_overlay": true, "dca": true, "execution_mode": "live", "service_name": "charles-schwab-quant-service", "default_execution_mode": "live"}, "firstrade": {"dry_run_only": false, "margin_policy": true, "reserved_cash": true, "income_layer": true, "option_overlay": true, "dca": true, "execution_mode": "live", "service_name": "firstrade-quant-service", "default_execution_mode": "live"}, "qmt": {"dry_run_only": true, "margin_policy": false, "reserved_cash": false, "income_layer": false, "option_overlay": false, "dca": false, "execution_mode": "paper", "service_name": "qmt-quant-service", "default_execution_mode": "paper"}, "binance": {"dry_run_only": false, "margin_policy": false, "reserved_cash": false, "income_layer": false, "option_overlay": false, "dca": false, "execution_mode": "live", "service_name": "binance-platform", "default_execution_mode": "live"}}; window.__DEFAULT_ACCOUNT_OPTIONS__ = {"longbridge": [{"key": "preview", "label": "LongBridge", "target_name": "preview", "supported_domains": ["us_equity", "hk_equity"], "cash_currency": "USD", "default_execution_mode": "live", "service_name": ""}], "ibkr": [{"key": "preview", "label": "IBKR", "target_name": "preview", "supported_domains": ["us_equity", "hk_equity"], "cash_currency": "USD", "default_execution_mode": "live", "service_name": ""}], "schwab": [{"key": "preview", "label": "Schwab", "target_name": "preview", "supported_domains": ["us_equity"], "cash_currency": "USD", "default_execution_mode": "live", "service_name": "charles-schwab-quant-service"}], "firstrade": [{"key": "preview", "label": "Firstrade", "target_name": "preview", "supported_domains": ["us_equity"], "cash_currency": "USD", "default_execution_mode": "live", "service_name": "firstrade-quant-service"}], "qmt": [{"key": "default", "label": "QMT", "target_name": "default", "supported_domains": ["cn_equity"], "cash_currency": "CNY", "service_name": "qmt-quant-service", "default_execution_mode": "paper"}], "binance": [{"key": "default", "label": "Binance", "target_name": "default", "supported_domains": ["crypto"], "cash_currency": "USD", "service_name": "binance-platform", "default_execution_mode": "live"}]}; window.__DOMAIN_LABELS__ = {"us_equity": {"zh": "美股", "en": "US Equity"}, "hk_equity": {"zh": "港股", "en": "HK Equity"}, "cn_equity": {"zh": "A股", "en": "CN A-share"}, "crypto": {"zh": "加密", "en": "Crypto"}}; -window.__DEFAULT_STRATEGY_PROFILES__ = [{"profile": "tqqq_growth_income", "label": "纳斯达克增长收益", "label_en": "NASDAQ Growth Income", "label_zh": "纳斯达克增长收益", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "soxl_soxx_trend_income", "label": "半导体趋势收益", "label_en": "Semiconductor Trend Income", "label_zh": "半导体趋势收益", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "nasdaq_sp500_smart_dca", "label": "纳指标普定投", "label_en": "NASDAQ/S&P 500 DCA", "label_zh": "纳指标普定投", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "ibit_smart_dca", "label": "IBIT比特币定投", "label_en": "IBIT Bitcoin DCA", "label_zh": "IBIT比特币定投", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "global_etf_rotation", "label": "全球ETF轮动", "label_en": "Global ETF Rotation", "label_zh": "全球ETF轮动", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "russell_top50_leader_rotation", "label": "罗素Top50领涨", "label_en": "Russell Top50 Leaders", "label_zh": "罗素Top50领涨", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "tecl_xlk_trend_income", "label": "TECL/XLK趋势收益", "label_en": "TECL/XLK Trend Income", "label_zh": "TECL/XLK趋势收益", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "failed_promotion_vs_live_profiles", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "us_equity_combo", "label": "美股核心组合", "label_en": "US Core Combo", "label_zh": "美股核心组合", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo_core", "label": "美股核心组合影子", "label_en": "US Core Combo Shadow", "label_zh": "美股核心组合影子", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo_leveraged", "label": "美股加速组合", "label_en": "US Alpha Combo", "label_zh": "美股加速组合", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_global_etf_tactical_rotation", "label": "港股ETF战术轮动", "label_en": "HK ETF Tactical Rotation", "label_zh": "港股ETF战术轮动", "domain": "hk_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "港股红利质量", "label_en": "HK Dividend Quality", "label_zh": "港股红利质量", "domain": "hk_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "hk_equity_combo", "label": "港股恒生组合", "label_en": "HK Core Combo", "label_zh": "港股恒生组合", "domain": "hk_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "cn_industry_etf_rotation", "label": "A股行业ETF轮动", "label_en": "CN Industry ETF Rotation", "label_zh": "A股行业ETF轮动", "domain": "cn_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_industry_etf_rotation_aggressive", "label": "A股ETF轮动", "label_en": "CN ETF Rotation", "label_zh": "A股ETF轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "live_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "live_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_index_etf_tactical_rotation", "label": "A股宽基ETF战术轮动", "label_en": "CN Index ETF Tactical Rotation", "label_zh": "A股宽基ETF战术轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_tactical_rotation", "label": "创业板战术轮动", "label_en": "CN ChiNext Tactical Rotation", "label_zh": "创业板战术轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_growth_momentum_quality", "label": "创业板成长动量质量", "label_en": "CN ChiNext Growth Momentum Quality", "label_zh": "创业板成长动量质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_dividend_quality_snapshot", "label": "A股红利质量", "label_en": "CN Dividend Quality", "label_zh": "A股红利质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_growth_momentum_quality_snapshot", "label": "创业板成长质量快照", "label_en": "CN ChiNext Growth Quality Snapshot", "label_zh": "创业板成长质量快照", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_star_growth_momentum_quality", "label": "科创板成长动量质量", "label_en": "CN STAR Growth Momentum Quality", "label_zh": "科创板成长动量质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_equity_combo", "label": "A股进取组合", "label_en": "CN Alpha Combo", "label_zh": "A股进取组合", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_live_pool_rotation", "label": "加密实时池轮动", "label_en": "Crypto Live Pool Rotation", "label_zh": "加密实时池轮动", "domain": "crypto", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_btc_dca", "label": "BTC定投", "label_en": "BTC DCA", "label_zh": "BTC定投", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_trend_rotation", "label": "山寨趋势轮动", "label_en": "Altcoin Trend", "label_zh": "山寨趋势轮动", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_equity_combo", "label": "加密动量组合", "label_en": "Crypto Core Combo", "label_zh": "加密动量组合", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}]; +window.__DEFAULT_STRATEGY_PROFILES__ = [{"profile": "tqqq_growth_income", "label": "纳斯达克增长收益", "label_en": "NASDAQ Growth Income", "label_zh": "纳斯达克增长收益", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "soxl_soxx_trend_income", "label": "半导体趋势收益", "label_en": "Semiconductor Trend Income", "label_zh": "半导体趋势收益", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "nasdaq_sp500_smart_dca", "label": "纳指标普定投", "label_en": "NASDAQ/S&P 500 DCA", "label_zh": "纳指标普定投", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "ibit_smart_dca", "label": "IBIT比特币定投", "label_en": "IBIT Bitcoin DCA", "label_zh": "IBIT比特币定投", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "global_etf_rotation", "label": "全球ETF轮动", "label_en": "Global ETF Rotation", "label_zh": "全球ETF轮动", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "russell_top50_leader_rotation", "label": "罗素Top50领涨", "label_en": "Russell Top50 Leaders", "label_zh": "罗素Top50领涨", "domain": "us_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": false}, {"profile": "tecl_xlk_trend_income", "label": "TECL/XLK趋势收益", "label_en": "TECL/XLK Trend Income", "label_zh": "TECL/XLK趋势收益", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "failed_promotion_vs_live_profiles", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "us_equity_combo", "label": "美股核心组合", "label_en": "US Core Combo", "label_zh": "美股核心组合", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": true, "option_overlay_enabled": true, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo_core", "label": "美股核心组合影子", "label_en": "US Core Combo Shadow", "label_zh": "美股核心组合影子", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "us_equity_combo_leveraged", "label": "美股加速组合", "label_en": "US Alpha Combo", "label_zh": "美股加速组合", "domain": "us_equity", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "hk_global_etf_tactical_rotation", "label": "港股ETF战术轮动", "label_en": "HK ETF Tactical Rotation", "label_zh": "港股ETF战术轮动", "domain": "hk_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "hk_low_vol_dividend_quality_snapshot", "label": "港股红利质量", "label_en": "HK Dividend Quality", "label_zh": "港股红利质量", "domain": "hk_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "production_snapshot_artifact_route_not_configured", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "hk_equity_combo", "label": "港股恒生组合", "label_en": "HK Core Combo", "label_zh": "港股恒生组合", "domain": "hk_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "cn_industry_etf_rotation", "label": "A股行业ETF轮动", "label_en": "CN Industry ETF Rotation", "label_zh": "A股行业ETF轮动", "domain": "cn_equity", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_industry_etf_rotation_aggressive", "label": "A股ETF轮动", "label_en": "CN ETF Rotation", "label_zh": "A股ETF轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "live_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "live_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_index_etf_tactical_rotation", "label": "A股宽基ETF战术轮动", "label_en": "CN Index ETF Tactical Rotation", "label_zh": "A股宽基ETF战术轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_tactical_rotation", "label": "创业板战术轮动", "label_en": "CN ChiNext Tactical Rotation", "label_zh": "创业板战术轮动", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_growth_momentum_quality", "label": "创业板成长动量质量", "label_en": "CN ChiNext Growth Momentum Quality", "label_zh": "创业板成长动量质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_dividend_quality_snapshot", "label": "A股红利质量", "label_en": "CN Dividend Quality", "label_zh": "A股红利质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_chinext_growth_momentum_quality_snapshot", "label": "创业板成长质量快照", "label_en": "CN ChiNext Growth Quality Snapshot", "label_zh": "创业板成长质量快照", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_star_growth_momentum_quality", "label": "科创板成长动量质量", "label_en": "CN STAR Growth Momentum Quality", "label_zh": "科创板成长动量质量", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "cn_equity_combo", "label": "A股进取组合", "label_en": "CN Alpha Combo", "label_zh": "A股进取组合", "domain": "cn_equity", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}, {"profile": "crypto_live_pool_rotation", "label": "加密实时池轮动", "label_en": "Crypto Live Pool Rotation", "label_zh": "加密实时池轮动", "domain": "crypto", "runtime_enabled": true, "lifecycle_stage": "runtime_enabled", "can_switch_live": true, "allowed_execution_modes": ["live", "paper", "dry_run"], "blocked_live_reason": "", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_btc_dca", "label": "BTC定投", "label_en": "BTC DCA", "label_zh": "BTC定投", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "shadow_candidate", "can_switch_live": false, "allowed_execution_modes": ["paper", "dry_run"], "blocked_live_reason": "shadow_candidate_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_trend_rotation", "label": "山寨趋势轮动", "label_en": "Altcoin Trend", "label_zh": "山寨趋势轮动", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": false}, {"profile": "crypto_equity_combo", "label": "加密动量组合", "label_en": "Crypto Core Combo", "label_zh": "加密动量组合", "domain": "crypto", "runtime_enabled": false, "lifecycle_stage": "research_backtest_only", "can_switch_live": false, "allowed_execution_modes": ["dry_run"], "blocked_live_reason": "research_backtest_only_requires_evidence_package", "income_layer_enabled": false, "option_overlay_enabled": false, "combo_enabled": true, "combo_mode": "dynamic"}]; window.__DCA_PROFILE_DEFAULTS__ = {"nasdaq_sp500_smart_dca": {"defaultMode": "fixed", "defaultBaseInvestmentUsd": "1000"}, "ibit_smart_dca": {"defaultMode": "fixed", "defaultBaseInvestmentUsd": "1000"}}; window.__INCOME_LAYER_DEFAULTS__ = {"tqqq_growth_income": {"startUsd": 250000, "maxRatio": "0.55", "allocations": {"SCHD": 0.3, "DGRO": 0.2, "SGOV": 0.4, "SPYI": 0.08, "QQQI": 0.02}}, "soxl_soxx_trend_income": {"startUsd": 150000, "maxRatio": "0.95", "allocations": {"SCHD": 0.15, "DGRO": 0.1, "SGOV": 0.7, "SPYI": 0.04, "QQQI": 0.01}}, "global_etf_rotation": {"startUsd": 500000, "maxRatio": "0.15", "allocations": {"SCHD": 0.4, "DGRO": 0.25, "SGOV": 0.3, "SPYI": 0.05}}, "russell_top50_leader_rotation": {"startUsd": 300000, "maxRatio": "0.25", "allocations": {"SCHD": 0.45, "DGRO": 0.3, "SGOV": 0.25}}, "us_equity_combo": {"startUsd": 300000, "maxRatio": "0.25", "allocations": {"SCHD": 0.25, "DGRO": 0.25, "SGOV": 0.2, "SPYI": 0.15, "QQQI": 0.15}}}; window.__OPTION_OVERLAY_DEFAULTS__ = {"tqqq_growth_income": {"liveGate": "promotion_required", "liveStatus": "research_only", "families": [{"family": "growth", "recipe": "tqqq_leaps_growth_v1", "startUsd": "250000", "ratio": "0.03", "ratioKind": "budget"}]}, "soxl_soxx_trend_income": {"liveGate": "promotion_required", "liveStatus": "research_only", "families": [{"family": "income", "recipe": "soxx_put_credit_spread_income_v1", "startUsd": "150000", "ratio": "0.01", "ratioKind": "risk"}]}, "global_etf_rotation": {"liveGate": "promotion_required", "liveStatus": "research_only", "families": [{"family": "growth", "recipe": "spy_leaps_growth_v1", "startUsd": "500000", "ratio": "0.015", "ratioKind": "budget"}]}, "russell_top50_leader_rotation": {"liveGate": "promotion_required", "liveStatus": "research_only", "families": [{"family": "growth", "recipe": "spy_leaps_growth_v1", "startUsd": "300000", "ratio": "0.015", "ratioKind": "budget"}]}, "us_equity_combo": {"liveGate": "promotion_required", "liveStatus": "research_only", "families": [{"family": "growth", "recipe": "spy_leaps_growth_v1", "startUsd": "300000", "ratio": "0.015", "ratioKind": "budget"}]}}; diff --git a/web/strategy-switch-console/page_asset.js b/web/strategy-switch-console/page_asset.js index 3a2732c..f5a7ac4 100644 --- a/web/strategy-switch-console/page_asset.js +++ b/web/strategy-switch-console/page_asset.js @@ -1,2 +1,2 @@ // Generated by scripts/sync_strategy_switch_page_asset.py; do not edit by hand. -export const PAGE_HTML = "\n\n
\n \n \n \n选平台、目标账号和策略,一次执行完成切换。
\n正在读取登录状态、账号配置和当前状态。
\n \n健康不等于已批准 live;正常实盘、资金和杠杆变更仍需人工确认。
\n登录后查看账号配置。
\n\n允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可执行切换。
\n \n选平台、目标账号和策略,一次执行完成切换。
\n正在读取登录状态、账号配置和当前状态。
\n \n健康不等于已批准 live;正常实盘、资金和杠杆变更仍需人工确认。
\n登录后查看账号配置。
\n\n允许融资与预留现金覆盖不能同时生效。
\nA 股 QMT 不使用 margin / 平台预留现金;现金约束在策略参数 execution_cash_reserve_ratio 内配置。
\n登录后才可执行切换。
\n \n