diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 75535fa..5a55355 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -134,16 +134,7 @@ def _should_add_local_src(candidate: Path) -> bool: "probe_time": "CLOUD_SCHEDULER_PROBE_TIME", "precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME", } -NEAR_RUN_WARMUP_SCHEDULE = "43 9,15 * * 1-5" RUN_SCHEDULER_ATTEMPT_DEADLINE = "330s" -NEAR_RUN_WARMUP_SERVICES = frozenset( - { - "interactive-brokers-quant-live-u15998061-service", - "interactive-brokers-quant-live-u16608560-service", - "interactive-brokers-quant-live-u18336562-service", - "interactive-brokers-quant-live-u18308207-service", - } -) # Strategy-derived vars: auto-populated from platform-config.json defaults. def _derive_strategy_env_defaults(strategy_config: dict) -> dict[str, str]: @@ -468,9 +459,8 @@ def _build_target_plan( env_values=env_values, per_service_mode=per_service_mode, ) - if service_name in NEAR_RUN_WARMUP_SERVICES: + if _requires_extended_run_deadline(runtime_target, env_values): scheduler["attempt_deadline"] = RUN_SCHEDULER_ATTEMPT_DEADLINE - _validate_near_run_warmup_schedule(service_name, scheduler) return { "service_name": service_name, @@ -512,18 +502,18 @@ def _build_scheduler_plan( return scheduler -def _validate_near_run_warmup_schedule( - service_name: str, - scheduler: Mapping[str, str], -) -> None: - if service_name not in NEAR_RUN_WARMUP_SERVICES: - return - if scheduler.get("timezone") != "America/New_York": - raise ValueError(f"{service_name} warmup timezone must be America/New_York") - if scheduler.get("probe_time") != NEAR_RUN_WARMUP_SCHEDULE: - raise ValueError( - f"{service_name} warmup schedule must be {NEAR_RUN_WARMUP_SCHEDULE!r}" - ) +def _requires_extended_run_deadline( + runtime_target: Mapping[str, object], + env_values: Mapping[str, str], +) -> bool: + """Reserve enough scheduler time for any live target backed by IB Gateway.""" + backend = str(env_values.get("IBKR_EXECUTION_BACKEND") or "gateway").strip().lower() + raw_dry_run = runtime_target.get("dry_run_only") + if raw_dry_run is None: + raw_dry_run = env_values.get("IBKR_DRY_RUN_ONLY") + dry_run_only = str(raw_dry_run or "").strip().lower() in {"1", "true", "yes", "on"} + execution_mode = str(runtime_target.get("execution_mode") or "").strip().lower() + return backend == "gateway" and execution_mode != "paper" and not dry_run_only def _validate_profile_inputs( diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 141b41f..a709008 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1136,6 +1136,7 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): "main_time": "10 16", "probe_time": "40 9,15", "precheck_time": "45 9", + "attempt_deadline": "330s", } assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in slot_a["remove_env_vars"] @@ -1151,6 +1152,7 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): "main_time": "45 15 1-7 * *", "probe_time": "35 9,15 1-7 * *", "precheck_time": "45 9 1-7 * *", + "attempt_deadline": "330s", } assert u7654_mega["env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "gs://runtime/mega/snapshot.csv" assert ( @@ -1225,7 +1227,7 @@ def _four_gateway_warmup_payload(probe_time: str) -> dict[str, object]: "runtime_target": { **json.loads( runtime_target_json( - "us_equity_combo_leveraged", + "tqqq_growth_income", dry_run_only=True, deployment_selector="us-combo-shadow", account_scope="us-combo-shadow", @@ -1252,7 +1254,7 @@ def _four_gateway_warmup_payload(probe_time: str) -> dict[str, object]: } -def test_build_cloud_run_env_sync_plan_generates_four_gateway_near_run_warmups() -> None: +def test_build_cloud_run_env_sync_plan_generates_live_gateway_deadlines() -> None: payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") result = subprocess.run( [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], @@ -1284,19 +1286,36 @@ def test_build_cloud_run_env_sync_plan_generates_four_gateway_near_run_warmups() assert "attempt_deadline" not in by_service["interactive-brokers-us-combo-shadow-service"]["scheduler"] -def test_build_cloud_run_env_sync_plan_rejects_stale_gateway_warmup_schedule() -> None: +def test_build_cloud_run_env_sync_plan_accepts_strategy_defined_gateway_schedule() -> None: payload = _four_gateway_warmup_payload("35 9,15 * * 1-5") + first_target = payload["targets"][0] + first_target["runtime_target"]["scheduler"] = { + "timezone": "Asia/Hong_Kong", + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5", + } result = subprocess.run( [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], - check=False, + check=True, capture_output=True, text=True, env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, ) - assert result.returncode != 0 - assert "interactive-brokers-quant-live-u15998061-service" in result.stderr - assert "43 9,15 * * 1-5" in result.stderr + plan = json.loads(result.stdout) + first_plan = next( + target + for target in plan["targets"] + if target["service_name"] == "interactive-brokers-quant-live-u15998061-service" + ) + assert first_plan["scheduler"] == { + "timezone": "Asia/Hong_Kong", + "main_time": "45 15 * * 1-5", + "probe_time": "35 9,15 * * 1-5", + "precheck_time": "45 9 * * 1-5", + "attempt_deadline": "330s", + } def test_build_cloud_run_env_sync_plan_requires_target_snapshot_in_per_service_mode(): diff --git a/tests/test_scheduler_deadline_contract.py b/tests/test_scheduler_deadline_contract.py index 003a181..284aa7f 100644 --- a/tests/test_scheduler_deadline_contract.py +++ b/tests/test_scheduler_deadline_contract.py @@ -10,16 +10,22 @@ WORKFLOW = ROOT / ".github" / "workflows" / "sync-cloud-run-env.yml" -def test_four_gateway_run_deadline_exceeds_cloud_run_timeout() -> None: - expected_services = { - "interactive-brokers-quant-live-u15998061-service", - "interactive-brokers-quant-live-u16608560-service", - "interactive-brokers-quant-live-u18336562-service", - "interactive-brokers-quant-live-u18308207-service", - } - assert sync_plan.NEAR_RUN_WARMUP_SERVICES == expected_services +def test_live_gateway_run_deadline_exceeds_cloud_run_timeout() -> None: assert sync_plan.RUN_SCHEDULER_ATTEMPT_DEADLINE == "330s" + assert sync_plan._requires_extended_run_deadline( + {"execution_mode": "live", "dry_run_only": False}, + {"IBKR_EXECUTION_BACKEND": "gateway"}, + ) + assert not sync_plan._requires_extended_run_deadline( + {"execution_mode": "paper", "dry_run_only": True}, + {"IBKR_EXECUTION_BACKEND": "gateway"}, + ) + assert not sync_plan._requires_extended_run_deadline( + {"execution_mode": "live", "dry_run_only": False}, + {"IBKR_EXECUTION_BACKEND": "quantconnect"}, + ) + workflow = WORKFLOW.read_text(encoding="utf-8") cloud_run_timeout = re.search(r"--timeout=(\d+)s", workflow) assert cloud_run_timeout is not None