diff --git a/README.md b/README.md index 2977c8b..cb3d9e7 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ Snapshot-backed profiles use upstream artifacts from `UsEquitySnapshotPipelines` For the HK-equity runtime scope, platform matrix, and env defaults, see [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md). +For HK verify-only rollout planning, print the switch plan first instead of changing Cloud Run directly: + +```bash +python scripts/print_strategy_switch_env_plan.py --profile hk_listed_global_etf_rotation --dry-run-only --deployment-selector hk-verify --account-scope hk-verify --account-group hk-verify --service-name interactive-brokers-hk-verify-service --json +``` + Example runtime pointer: ```bash @@ -427,6 +433,12 @@ feature-snapshot 类策略使用 `UsEquitySnapshotPipelines` 或 `HkEquitySnapsh 港股运行时范围、平台矩阵和环境变量默认值见 [`docs/hk_equity_runtime.md`](docs/hk_equity_runtime.md)。 +港股 verify-only 接入先打印切换计划,不直接改 Cloud Run: + +```bash +python scripts/print_strategy_switch_env_plan.py --profile hk_listed_global_etf_rotation --dry-run-only --deployment-selector hk-verify --account-scope hk-verify --account-group hk-verify --service-name interactive-brokers-hk-verify-service --json +``` + ### 架构 ``` diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md index 4d09c9b..e5487f5 100644 --- a/docs/hk_equity_runtime.md +++ b/docs/hk_equity_runtime.md @@ -68,6 +68,30 @@ IBKR_MARKET_CURRENCY=HKD IBKR_MARKET_DATA_SYMBOL_SUFFIX=.HK ``` +## Dry-run 切换计划 + +先只生成 verify-only 环境计划,不部署生产 Cloud Run: + +```bash +python scripts/print_strategy_switch_env_plan.py \ + --profile hk_listed_global_etf_rotation \ + --dry-run-only \ + --deployment-selector hk-verify \ + --account-scope hk-verify \ + --account-group hk-verify \ + --service-name interactive-brokers-hk-verify-service \ + --json +``` + +这个命令只打印计划。输出会显式包含: + +- `RUNTIME_TARGET_JSON`:`strategy_profile=hk_listed_global_etf_rotation`、`dry_run_only=true`、`execution_mode=paper`。 +- `IBKR_DRY_RUN_ONLY=true` 和 `IBKR_MARKET=HK` / `XHKG` / `SEHK` / `HKD` / `.HK`。 +- `remove_if_present`:清理 snapshot/config 相关环境变量,因为该 profile 直接使用 `market_history`。 +- `dry_run_plan`:检查 HK 行情权限、SEHK/HKD 映射、整数股和 lot-size、HKD 现金口径、通知和 runtime report。 + +合并代码或打印计划不会触发生产部署;只有单独执行 Cloud Run env 更新/部署命令才会改变服务配置。 + ## 订单、组合和行情口径 - 股票订单通过 `Stock(symbol, IBKR_MARKET_EXCHANGE, IBKR_MARKET_CURRENCY)` 构造;港股默认是 `SEHK/HKD`。 diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 2092f6d..e0cd04a 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -13,7 +13,35 @@ UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" + +def _has_catalog_marker(candidate: Path, package_name: str, marker: str) -> bool: + catalog_path = candidate / package_name / "catalog.py" + if not catalog_path.exists(): + return False + return marker in catalog_path.read_text(encoding="utf-8") + + +def _should_add_local_src(candidate: Path) -> bool: + if candidate == QPK_SRC: + return (candidate / "quant_platform_kit" / "common" / "runtime_target.py").exists() + if candidate == UES_SRC: + return _has_catalog_marker( + candidate, + "us_equity_strategies", + "mega_cap_leader_rotation_top50_balanced", + ) + if candidate == HES_SRC: + return _has_catalog_marker( + candidate, + "hk_equity_strategies", + "hk_listed_global_etf_rotation", + ) + return True + + for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): + if not _should_add_local_src(candidate): + continue candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) diff --git a/scripts/print_strategy_profile_status.py b/scripts/print_strategy_profile_status.py index 4b6c7d8..613f8a1 100644 --- a/scripts/print_strategy_profile_status.py +++ b/scripts/print_strategy_profile_status.py @@ -10,7 +10,35 @@ UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" + +def _has_catalog_marker(candidate: Path, package_name: str, marker: str) -> bool: + catalog_path = candidate / package_name / "catalog.py" + if not catalog_path.exists(): + return False + return marker in catalog_path.read_text(encoding="utf-8") + + +def _should_add_local_src(candidate: Path) -> bool: + if candidate == QPK_SRC: + return (candidate / "quant_platform_kit" / "common" / "runtime_target.py").exists() + if candidate == UES_SRC: + return _has_catalog_marker( + candidate, + "us_equity_strategies", + "mega_cap_leader_rotation_top50_balanced", + ) + if candidate == HES_SRC: + return _has_catalog_marker( + candidate, + "hk_equity_strategies", + "hk_listed_global_etf_rotation", + ) + return True + + for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): + if not _should_add_local_src(candidate): + continue candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 983ae78..c50a03f 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -11,7 +11,35 @@ UES_SRC = ROOT.parent / "UsEquityStrategies" / "src" HES_SRC = ROOT.parent / "HkEquityStrategies" / "src" + +def _has_catalog_marker(candidate: Path, package_name: str, marker: str) -> bool: + catalog_path = candidate / package_name / "catalog.py" + if not catalog_path.exists(): + return False + return marker in catalog_path.read_text(encoding="utf-8") + + +def _should_add_local_src(candidate: Path) -> bool: + if candidate == QPK_SRC: + return (candidate / "quant_platform_kit" / "common" / "runtime_target.py").exists() + if candidate == UES_SRC: + return _has_catalog_marker( + candidate, + "us_equity_strategies", + "mega_cap_leader_rotation_top50_balanced", + ) + if candidate == HES_SRC: + return _has_catalog_marker( + candidate, + "hk_equity_strategies", + "hk_listed_global_etf_rotation", + ) + return True + + for candidate in (ROOT, QPK_SRC, UES_SRC, HES_SRC): + if not _should_add_local_src(candidate): + continue candidate_str = str(candidate) if candidate_str not in sys.path: sys.path.insert(0, candidate_str) @@ -19,6 +47,7 @@ from quant_platform_kit.common.runtime_target import build_runtime_target # noqa: E402 from quant_platform_kit.common.strategies import derive_strategy_artifact_paths # noqa: E402 from strategy_registry import ( # noqa: E402 + HK_EQUITY_DOMAIN, IBKR_PLATFORM, STRATEGY_CATALOG, describe_platform_runtime_requirements, @@ -28,7 +57,40 @@ ) -def build_switch_plan(profile: str) -> dict[str, object]: +IBKR_HK_MARKET_ENV: dict[str, str] = { + "IBKR_MARKET": "HK", + "IBKR_MARKET_CALENDAR": "XHKG", + "IBKR_MARKET_CURRENCY": "HKD", + "IBKR_MARKET_DATA_SYMBOL_SUFFIX": ".HK", + "IBKR_MARKET_EXCHANGE": "SEHK", + "IBKR_MARKET_TIMEZONE": "Asia/Hong_Kong", +} + +HK_DRY_RUN_CHECKS = [ + "Confirm IBKR account group uses the intended HK/SEHK paper or verify-only service identity.", + "Confirm HK/SEHK market-data and trading permissions before evaluating the strategy.", + "Load market_history for all HK managed symbols with .HK/SEHK/HKD mapping.", + "Preview orders only; keep IBKR_DRY_RUN_ONLY=true until operator approval.", + "Verify integer-share sizing and broker lot-size behavior before any live order submission.", + "Verify HKD cash, reserved-cash policy, fees, notifications, and runtime report output.", +] + +HK_BLOCKED_DRY_RUN_ACTIONS = [ + "Do not deploy or update the production Cloud Run service from this plan.", + "Do not submit live IBKR orders while dry_run_only=true.", + "Do not remove HK market overrides when testing hk_equity profiles.", +] + + +def build_switch_plan( + profile: str, + *, + dry_run_only: bool = False, + deployment_selector: str | None = None, + account_scope: str | None = None, + account_group: str | None = None, + service_name: str | None = "interactive-brokers-quant-service", +) -> dict[str, object]: definition = resolve_strategy_definition(profile, platform_id=IBKR_PLATFORM) metadata = resolve_strategy_metadata(definition.profile, platform_id=IBKR_PLATFORM) status_row = next( @@ -55,15 +117,17 @@ def build_switch_plan(profile: str) -> dict[str, object]: runtime_target = build_runtime_target( platform_id=IBKR_PLATFORM, strategy_profile=definition.profile, - dry_run_only=False, - deployment_selector=None, - account_scope=None, - service_name="interactive-brokers-quant-service", + dry_run_only=dry_run_only, + deployment_selector=deployment_selector, + account_scope=account_scope, + service_name=service_name, ) set_env: dict[str, str] = { "RUNTIME_TARGET_JSON": json.dumps(runtime_target.to_dict(), separators=(",", ":")) } + if account_group: + set_env["ACCOUNT_GROUP"] = account_group keep_env = [ "ACCOUNT_GROUP", "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", @@ -86,6 +150,37 @@ def build_switch_plan(profile: str) -> dict[str, object]: "Keep ACCOUNT_GROUP and IB account-group config aligned with the current service identity.", "For HK-equity deployments set IBKR_MARKET=HK, or use an ACCOUNT_GROUP containing hk to derive SEHK/HKD/XHKG defaults.", ] + dry_run_plan: dict[str, object] = {} + if dry_run_only: + set_env["IBKR_DRY_RUN_ONLY"] = "true" + dry_run_plan = { + "dry_run_only": True, + "verify_only": True, + "checks": [ + "Confirm runtime_target.execution_mode is paper before applying any env plan.", + "Review broker order preview and notifications; do not submit live orders.", + ], + "blocked_actions": [ + "Do not deploy or update production Cloud Run from a switch-plan printout alone.", + "Do not submit live orders while dry_run_only=true.", + ], + } + + if definition.domain == HK_EQUITY_DOMAIN: + set_env.update(IBKR_HK_MARKET_ENV) + set_env["IBKR_DRY_RUN_ONLY"] = "true" if dry_run_only else "false" + dry_run_plan = { + "dry_run_only": dry_run_only, + "verify_only": dry_run_only, + "market": "HK", + "checks": HK_DRY_RUN_CHECKS, + "blocked_actions": HK_BLOCKED_DRY_RUN_ACTIONS if dry_run_only else [], + } + notes.append( + "HK-equity switch plans are environment plans only; merge alone must not change production Cloud Run." + ) + if not dry_run_only: + notes.append("Use --dry-run-only for first HK runtime validation; live mode requires separate operator approval.") if requires_feature_snapshot: set_env["IBKR_FEATURE_SNAPSHOT_PATH"] = "" @@ -145,6 +240,7 @@ def build_switch_plan(profile: str) -> dict[str, object]: "optional_env": sorted(optional_env), "remove_if_present": sorted(set(remove_if_present)), "hints": hints, + "dry_run_plan": dry_run_plan, "notes": notes, } @@ -176,6 +272,9 @@ def _print_plan(plan: dict[str, object]) -> None: print("\nhints:") for key, value in plan["hints"].items(): print(f" {key}: {value}") + if plan["dry_run_plan"]: + print("\ndry_run_plan:") + print(json.dumps(plan["dry_run_plan"], indent=2, sort_keys=True)) if plan["notes"]: print("\nnotes:") for note in plan["notes"]: @@ -185,10 +284,22 @@ def _print_plan(plan: dict[str, object]) -> None: def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--profile", required=True) + parser.add_argument("--dry-run-only", action="store_true") + parser.add_argument("--deployment-selector") + parser.add_argument("--account-scope") + parser.add_argument("--account-group") + parser.add_argument("--service-name", default="interactive-brokers-quant-service") parser.add_argument("--json", action="store_true") args = parser.parse_args() - plan = build_switch_plan(args.profile) + plan = build_switch_plan( + args.profile, + dry_run_only=args.dry_run_only, + deployment_selector=args.deployment_selector, + account_scope=args.account_scope, + account_group=args.account_group, + service_name=args.service_name, + ) if args.json: print(json.dumps(plan, indent=2, sort_keys=True)) return 0 diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 51424f2..e109245 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -849,6 +849,59 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income(): assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] +def test_print_strategy_switch_env_plan_for_hk_global_etf_dry_run(): + result = subprocess.run( + [ + sys.executable, + str(SWITCH_PLAN_SCRIPT_PATH), + "--profile", + "hk_listed_global_etf_rotation", + "--dry-run-only", + "--deployment-selector", + "hk-verify", + "--account-scope", + "hk-verify", + "--account-group", + "hk-verify", + "--service-name", + "interactive-brokers-hk-verify-service", + "--json", + ], + check=True, + capture_output=True, + text=True, + ) + + plan = json.loads(result.stdout) + assert plan["platform"] == "ibkr" + assert plan["canonical_profile"] == "hk_listed_global_etf_rotation" + assert plan["domain"] == HK_EQUITY_DOMAIN + assert plan["runtime_target"]["dry_run_only"] is True + assert plan["runtime_target"]["execution_mode"] == "paper" + assert plan["runtime_target"]["deployment_selector"] == "hk-verify" + assert plan["runtime_target"]["account_scope"] == "hk-verify" + assert plan["runtime_target"]["service_name"] == "interactive-brokers-hk-verify-service" + runtime_target_env = json.loads(plan["set_env"]["RUNTIME_TARGET_JSON"]) + assert runtime_target_env["dry_run_only"] is True + assert runtime_target_env["execution_mode"] == "paper" + assert plan["set_env"]["ACCOUNT_GROUP"] == "hk-verify" + assert plan["set_env"]["IBKR_DRY_RUN_ONLY"] == "true" + assert plan["set_env"]["IBKR_MARKET"] == HK_MARKET + assert plan["set_env"]["IBKR_MARKET_CALENDAR"] == HK_MARKET_CALENDAR + assert plan["set_env"]["IBKR_MARKET_CURRENCY"] == HK_MARKET_CURRENCY + assert plan["set_env"]["IBKR_MARKET_DATA_SYMBOL_SUFFIX"] == HK_MARKET_DATA_SYMBOL_SUFFIX + assert plan["set_env"]["IBKR_MARKET_EXCHANGE"] == HK_MARKET_EXCHANGE + assert plan["set_env"]["IBKR_MARKET_TIMEZONE"] == HK_MARKET_TIMEZONE + assert plan["profile_group"] == "direct_runtime_inputs" + assert plan["input_mode"] == "market_history" + assert plan["requires_snapshot_artifacts"] is False + assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"] + assert plan["dry_run_plan"]["dry_run_only"] is True + assert plan["dry_run_plan"]["verify_only"] is True + assert any("lot-size" in check for check in plan["dry_run_plan"]["checks"]) + assert any("production Cloud Run" in action for action in plan["dry_run_plan"]["blocked_actions"]) + + def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): payload = { "defaults": {