Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

### 架构

```
Expand Down
24 changes: 24 additions & 0 deletions docs/hk_equity_runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
28 changes: 28 additions & 0 deletions scripts/build_cloud_run_env_sync_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions scripts/print_strategy_profile_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
123 changes: 117 additions & 6 deletions scripts/print_strategy_switch_env_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,43 @@
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)

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,
Expand All @@ -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(
Expand All @@ -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",
Expand All @@ -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"] = "<required>"
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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"]:
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down