From 85ff885685e2d664c8facd7e963588e7350094ec Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:02:57 +0800 Subject: [PATCH 1/5] fix(runtime): block weekend IBKR live schedules Co-Authored-By: Codex --- .github/workflows/sync-cloud-run-env.yml | 7 +- scripts/build_cloud_run_env_sync_plan.py | 86 +++++++++++++++- tests/test_runtime_config_support.py | 117 +++++++++++++++++++++- tests/test_sync_cloud_run_env_workflow.sh | 6 +- 4 files changed, 206 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index f738db2..a549b2c 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -109,6 +109,7 @@ jobs: IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} IBKR_EXECUTION_DEDUP_ENABLED: ${{ vars.IBKR_EXECUTION_DEDUP_ENABLED }} IBKR_PAPER_LIQUIDATE_ONLY: ${{ vars.IBKR_PAPER_LIQUIDATE_ONLY }} + IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }} IBKR_MARKET: ${{ vars.IBKR_MARKET }} IBKR_MARKET_CALENDAR: ${{ vars.IBKR_MARKET_CALENDAR }} IBKR_MARKET_CURRENCY: ${{ vars.IBKR_MARKET_CURRENCY }} @@ -994,9 +995,9 @@ jobs: [ service_name, timezone, - str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15")), - str(scheduler.get("probe_time") or configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15")), - str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9")), + str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")), + str(scheduler.get("probe_time") or configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15 * * 1-5")), + str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9 * * 1-5")), str(env.get("RUNTIME_TARGET_ENABLED", "true")).strip().lower() or "true", str(scheduler.get("attempt_deadline") or ""), ] diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index dbf63f8..3a2074b 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -61,6 +61,7 @@ def _should_add_local_src(candidate: Path) -> bool: "NOTIFY_LANG", "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME", "IBKR_EXECUTION_BACKEND", + "IBKR_FORCE_RUN", "IBKR_MARKET", "IBKR_MARKET_CALENDAR", "IBKR_MARKET_CURRENCY", @@ -100,6 +101,7 @@ def _should_add_local_src(candidate: Path) -> bool: "IBKR_DRY_RUN_ONLY", "IBKR_EXECUTION_DEDUP_ENABLED", "IBKR_PAPER_LIQUIDATE_ONLY", + "IBKR_FORCE_RUN", "IBKR_MIN_RESERVED_CASH_USD", "IBKR_RESERVED_CASH_RATIO", "IBKR_CASH_ONLY_EXECUTION", @@ -127,9 +129,9 @@ def _should_add_local_src(candidate: Path) -> bool: "EXECUTION_REPORT_GCS_URI", ) SCHEDULER_TIME_DEFAULTS = { - "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", } SCHEDULER_TIME_ENV = { "main_time": "CLOUD_SCHEDULER_MAIN_TIME", @@ -137,6 +139,50 @@ def _should_add_local_src(candidate: Path) -> bool: "precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME", } RUN_SCHEDULER_ATTEMPT_DEADLINE = "330s" +WEEKDAY_CRON_DAYS = frozenset({1, 2, 3, 4, 5}) +CRON_DAY_NAMES = { + "SUN": 0, + "MON": 1, + "TUE": 2, + "WED": 3, + "THU": 4, + "FRI": 5, + "SAT": 6, +} + + +def _cron_day_value(raw: str) -> int: + value = raw.strip().upper() + if value in CRON_DAY_NAMES: + return CRON_DAY_NAMES[value] + numeric = int(value) + if not 0 <= numeric <= 7: + raise ValueError(f"Invalid cron day-of-week value: {raw!r}") + return numeric % 7 + + +def _cron_days_of_week(raw: str) -> set[int]: + days: set[int] = set() + for item in raw.split(","): + base, separator, raw_step = item.strip().partition("/") + step = int(raw_step) if separator else 1 + if step <= 0: + raise ValueError(f"Invalid cron day-of-week step: {item!r}") + if base == "*": + values = list(range(7)) + elif "-" in base: + raw_start, raw_end = base.split("-", 1) + start = _cron_day_value(raw_start) + end = _cron_day_value(raw_end) + values = [start] + while values[-1] != end: + values.append((values[-1] + 1) % 7) + if len(values) > 7: + raise ValueError(f"Invalid cron day-of-week range: {item!r}") + else: + values = [_cron_day_value(base)] + days.update(values[::step]) + return days # Strategy-derived vars: auto-populated from platform-config.json defaults. def _derive_strategy_env_defaults(strategy_config: dict) -> dict[str, str]: @@ -456,6 +502,12 @@ def _build_target_plan( + "\n".join(f" - {item}" for item in missing) ) + if ( + str(runtime_target.get("execution_mode") or "").strip().lower() == "live" + and str(env_values.get("IBKR_FORCE_RUN") or "").strip().lower() == "true" + ): + raise ValueError("IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets") + scheduler = _build_scheduler_plan( runtime_target=runtime_target, target=target, @@ -488,7 +540,9 @@ def _build_scheduler_plan( runtime_scheduler = runtime_target.get("scheduler") if isinstance(runtime_target, Mapping) else {} if not isinstance(runtime_scheduler, Mapping): runtime_scheduler = {} - market = str(env_values.get("IBKR_MARKET") or "").strip().upper() + market = str( + runtime_target.get("market") or env_values.get("IBKR_MARKET") or "" + ).strip().upper() timezone = str(runtime_scheduler.get("timezone") or env_values.get("IBKR_MARKET_TIMEZONE") or "").strip() if not timezone: timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York" @@ -504,6 +558,30 @@ def _build_scheduler_plan( allow_shared_fallback=True, ) scheduler[key] = str(runtime_scheduler.get(key) or configured_value or SCHEDULER_TIME_DEFAULTS[key]) + if ( + market == "US" + and str(runtime_target.get("execution_mode") or "").strip().lower() == "live" + and runtime_target.get("account_selector") + ): + for key in SCHEDULER_TIME_ENV: + fields = scheduler[key].split() + if len(fields) == 2: + scheduler[key] = " ".join([*fields, "*", "*", "1-5"]) + fields = scheduler[key].split() + try: + scheduled_days = _cron_days_of_week(fields[4]) if len(fields) == 5 else set() + except (TypeError, ValueError): + scheduled_days = set() + if ( + len(fields) != 5 + or fields[2] != "*" + or not scheduled_days + or not scheduled_days <= WEEKDAY_CRON_DAYS + ): + raise ValueError( + f"US live account scheduler {key} must be Mon-Fri cron: " + f"{scheduler[key]!r}" + ) return scheduler diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index c9c61a5..0d21b74 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1190,7 +1190,7 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): "timezone": "Asia/Hong_Kong", "main_time": "10 16", "probe_time": "40 9,15", - "precheck_time": "45 9", + "precheck_time": "45 9 * * 1-5", "attempt_deadline": "330s", } assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"] @@ -1260,6 +1260,7 @@ def _four_gateway_warmup_payload(probe_time: str) -> dict[str, object]: runtime_target_json( strategy_profile, deployment_selector=account_group, + account_selector=[account_group.removeprefix("live-").upper()], account_scope=account_group, service_name=service_name, ) @@ -1362,6 +1363,120 @@ def test_build_cloud_run_env_sync_plan_does_not_enable_live_dedup_implicitly() - assert "IBKR_EXECUTION_DEDUP_ENABLED" in target["remove_env_vars"] +def test_build_cloud_run_env_sync_plan_removes_unset_force_run() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + plan = json.loads(result.stdout) + for target in plan["targets"]: + assert "IBKR_FORCE_RUN" not in target["env"] + assert "IBKR_FORCE_RUN" in target["remove_env_vars"] + + +def test_build_cloud_run_env_sync_plan_rejects_force_run_for_live_account() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["IBKR_FORCE_RUN"] = True + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr + + +@pytest.mark.parametrize("schedule_key", ("main_time", "probe_time", "precheck_time")) +def test_build_cloud_run_env_sync_plan_rejects_weekend_schedule_for_live_account( + schedule_key: str, +) -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"]["scheduler"][schedule_key] = "45 15 * * *" + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert f"US live account scheduler {schedule_key} must be Mon-Fri cron" in result.stderr + + +def test_build_cloud_run_env_sync_plan_normalizes_legacy_live_account_schedule() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"]["scheduler"].update( + { + "main_time": "45 15", + "probe_time": "35 9,15", + "precheck_time": "45 9", + } + ) + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + plan = json.loads(result.stdout) + first_target = plan["targets"][0] + assert first_target["scheduler"] == { + "timezone": "America/New_York", + "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_accepts_weekday_only_cron_variants() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = " us " + target["runtime_target"]["scheduler"].update( + { + "main_time": "45 15 * * 1,3,5", + "probe_time": "35 9,15 * * MON-FRI", + "precheck_time": "45 9 * * 2-5", + } + ) + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + plan = json.loads(result.stdout) + assert plan["targets"][0]["scheduler"] == { + "timezone": "America/New_York", + "main_time": "45 15 * * 1,3,5", + "probe_time": "35 9,15 * * MON-FRI", + "precheck_time": "45 9 * * 2-5", + "attempt_deadline": "330s", + } + + def test_build_cloud_run_env_sync_plan_honors_explicit_dedup_override() -> None: payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") payload["targets"][0]["runtime_target"]["overrides"] = { diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index cec2359..0b5ebc4 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -40,6 +40,7 @@ grep -Fq 'IBKR_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.IBKR_STRATEGY_PLUGIN_MOUNTS grep -Fq 'IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_MODE }}' "$workflow_file" grep -Fq 'IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR: ${{ vars.IBKR_FEATURE_SNAPSHOT_FALLBACK_CACHE_DIR }}' "$workflow_file" grep -Fq 'IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS: ${{ vars.IBKR_FEATURE_SNAPSHOT_MAX_STALE_DAYS }}' "$workflow_file" +grep -Fq 'IBKR_FORCE_RUN: ${{ vars.IBKR_FORCE_RUN }}' "$workflow_file" grep -Fq 'INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}' "$workflow_file" grep -Fq 'INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}' "$workflow_file" grep -Fq 'INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}' "$workflow_file" @@ -138,8 +139,9 @@ grep -Fq -- '--role="roles/run.invoker"' "$workflow_file" grep -Fq 'scheduler = target.get("scheduler") or {}' "$workflow_file" grep -Fq 'timezone = str(scheduler.get("timezone") or env.get("IBKR_MARKET_TIMEZONE") or "").strip()' "$workflow_file" grep -Fq 'timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York"' "$workflow_file" -grep -Fq 'configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15")' "$workflow_file" -grep -Fq 'str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9"))' "$workflow_file" +grep -Fq 'configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")' "$workflow_file" +grep -Fq 'configured_time("CLOUD_SCHEDULER_PROBE_TIME", "35 9,15 * * 1-5")' "$workflow_file" +grep -Fq 'str(scheduler.get("precheck_time") or configured_time("CLOUD_SCHEDULER_PRECHECK_TIME", "45 9 * * 1-5"))' "$workflow_file" grep -Fq 'IFS=$'\''\t'\'' read -r cloud_run_service market_timezone main_time warmup_time precheck_time runtime_target_enabled main_attempt_deadline <<< "${update}"' "$workflow_file" grep -Fq 'scheduler_job_candidates+=("${cloud_run_service%-service}-scheduler")' "$workflow_file" grep -Fq 'scheduler_job_candidates+=("${cloud_run_service}-scheduler")' "$workflow_file" From 5609124b8f23808d3c6bd7de81dd6e28281a71ef Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:15:31 +0800 Subject: [PATCH 2/5] fix(runtime): align live schedule guard with runtime Co-Authored-By: Codex --- scripts/build_cloud_run_env_sync_plan.py | 13 +++- tests/test_runtime_config_support.py | 80 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 3a2074b..42ea951 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -140,6 +140,7 @@ def _should_add_local_src(candidate: Path) -> bool: } RUN_SCHEDULER_ATTEMPT_DEADLINE = "330s" WEEKDAY_CRON_DAYS = frozenset({1, 2, 3, 4, 5}) +US_MARKET_TIMEZONE = "America/New_York" CRON_DAY_NAMES = { "SUN": 0, "MON": 1, @@ -503,7 +504,8 @@ def _build_target_plan( ) if ( - str(runtime_target.get("execution_mode") or "").strip().lower() == "live" + _runtime_target_enabled(env_values) + and str(runtime_target.get("execution_mode") or "").strip().lower() == "live" and str(env_values.get("IBKR_FORCE_RUN") or "").strip().lower() == "true" ): raise ValueError("IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets") @@ -541,7 +543,7 @@ def _build_scheduler_plan( if not isinstance(runtime_scheduler, Mapping): runtime_scheduler = {} market = str( - runtime_target.get("market") or env_values.get("IBKR_MARKET") or "" + env_values.get("IBKR_MARKET") or runtime_target.get("market") or "" ).strip().upper() timezone = str(runtime_scheduler.get("timezone") or env_values.get("IBKR_MARKET_TIMEZONE") or "").strip() if not timezone: @@ -561,8 +563,13 @@ def _build_scheduler_plan( if ( market == "US" and str(runtime_target.get("execution_mode") or "").strip().lower() == "live" - and runtime_target.get("account_selector") + and _runtime_target_enabled(env_values) ): + if timezone != US_MARKET_TIMEZONE: + raise ValueError( + f"US live account scheduler timezone must be {US_MARKET_TIMEZONE}: " + f"{timezone!r}" + ) for key in SCHEDULER_TIME_ENV: fields = scheduler[key].split() if len(fields) == 2: diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 0d21b74..28ad1e8 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1416,6 +1416,86 @@ def test_build_cloud_run_env_sync_plan_rejects_weekend_schedule_for_live_account assert f"US live account scheduler {schedule_key} must be Mon-Fri cron" in result.stderr +def test_build_cloud_run_env_sync_plan_uses_deployed_market_precedence() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["IBKR_MARKET"] = "US" + target["runtime_target"]["market"] = "HK" + target["runtime_target"]["scheduler"]["main_time"] = "45 15 * * *" + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "US live account scheduler main_time must be Mon-Fri cron" in result.stderr + + +def test_build_cloud_run_env_sync_plan_rejects_weekend_without_account_selector() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"].pop("account_selector") + target["runtime_target"]["scheduler"]["main_time"] = "45 15 * * *" + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "US live account scheduler main_time must be Mon-Fri cron" in result.stderr + + +def test_build_cloud_run_env_sync_plan_allows_disabling_unsafe_live_target() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target_enabled"] = False + target["runtime_target"]["market"] = "US" + target["IBKR_FORCE_RUN"] = True + target["runtime_target"]["scheduler"].update( + { + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *", + } + ) + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + plan = json.loads(result.stdout) + assert plan["targets"][0]["env"]["RUNTIME_TARGET_ENABLED"] == "false" + assert plan["targets"][0]["env"]["IBKR_FORCE_RUN"] == "true" + + +def test_build_cloud_run_env_sync_plan_rejects_non_us_timezone_for_live_us_target() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"]["scheduler"]["timezone"] = "Asia/Hong_Kong" + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "US live account scheduler timezone must be America/New_York" in result.stderr + + def test_build_cloud_run_env_sync_plan_normalizes_legacy_live_account_schedule() -> None: payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") target = payload["targets"][0] From bfb49a069f3a9ce23e9ea4b99612114a35038f7d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:21:22 +0800 Subject: [PATCH 3/5] fix(runtime): classify live risk by dry-run guard Co-Authored-By: Codex --- scripts/build_cloud_run_env_sync_plan.py | 24 +++++++++++++++++------- tests/test_runtime_config_support.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 42ea951..462356f 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -505,7 +505,7 @@ def _build_target_plan( if ( _runtime_target_enabled(env_values) - and str(runtime_target.get("execution_mode") or "").strip().lower() == "live" + and not _runtime_target_is_dry_run_only(runtime_target, env_values) and str(env_values.get("IBKR_FORCE_RUN") or "").strip().lower() == "true" ): raise ValueError("IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets") @@ -562,8 +562,8 @@ def _build_scheduler_plan( scheduler[key] = str(runtime_scheduler.get(key) or configured_value or SCHEDULER_TIME_DEFAULTS[key]) if ( market == "US" - and str(runtime_target.get("execution_mode") or "").strip().lower() == "live" and _runtime_target_enabled(env_values) + and not _runtime_target_is_dry_run_only(runtime_target, env_values) ): if timezone != US_MARKET_TIMEZONE: raise ValueError( @@ -592,18 +592,28 @@ def _build_scheduler_plan( return scheduler -def _requires_extended_run_deadline( +def _runtime_target_is_dry_run_only( 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"} + return str(raw_dry_run or "").strip().lower() in {"1", "true", "yes", "on"} + + +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() execution_mode = str(runtime_target.get("execution_mode") or "").strip().lower() - return backend == "gateway" and execution_mode != "paper" and not dry_run_only + return ( + backend == "gateway" + and execution_mode != "paper" + and not _runtime_target_is_dry_run_only(runtime_target, env_values) + ) def _validate_profile_inputs( diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 28ad1e8..01b25e1 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1396,6 +1396,25 @@ def test_build_cloud_run_env_sync_plan_rejects_force_run_for_live_account() -> N assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr +def test_build_cloud_run_env_sync_plan_rejects_force_run_for_non_dry_run_target() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"]["execution_mode"] = "paper" + target["runtime_target"]["dry_run_only"] = False + target["IBKR_FORCE_RUN"] = True + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr + + @pytest.mark.parametrize("schedule_key", ("main_time", "probe_time", "precheck_time")) def test_build_cloud_run_env_sync_plan_rejects_weekend_schedule_for_live_account( schedule_key: str, From ba4dd8dc3ca9cebefa63a646113fdb5904c6793f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:26:57 +0800 Subject: [PATCH 4/5] fix(runtime): normalize market before schedule guard Co-Authored-By: Codex --- scripts/build_cloud_run_env_sync_plan.py | 19 ++++++++++++------- tests/test_runtime_config_support.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 462356f..de39b21 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -52,6 +52,11 @@ def _should_add_local_src(candidate: Path) -> bool: get_platform_profile_status_matrix, resolve_strategy_definition, ) +from runtime_config_support import ( # noqa: E402 + DEFAULT_MARKET, + DEFAULT_MARKET_TIMEZONE, + resolve_market, +) TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON" @@ -140,7 +145,6 @@ def _should_add_local_src(candidate: Path) -> bool: } RUN_SCHEDULER_ATTEMPT_DEADLINE = "330s" WEEKDAY_CRON_DAYS = frozenset({1, 2, 3, 4, 5}) -US_MARKET_TIMEZONE = "America/New_York" CRON_DAY_NAMES = { "SUN": 0, "MON": 1, @@ -542,9 +546,10 @@ def _build_scheduler_plan( runtime_scheduler = runtime_target.get("scheduler") if isinstance(runtime_target, Mapping) else {} if not isinstance(runtime_scheduler, Mapping): runtime_scheduler = {} - market = str( - env_values.get("IBKR_MARKET") or runtime_target.get("market") or "" - ).strip().upper() + market = resolve_market( + env_values.get("IBKR_MARKET") or runtime_target.get("market"), + account_group=str(env_values.get("ACCOUNT_GROUP") or ""), + ) timezone = str(runtime_scheduler.get("timezone") or env_values.get("IBKR_MARKET_TIMEZONE") or "").strip() if not timezone: timezone = "Asia/Hong_Kong" if market == "HK" else "America/New_York" @@ -561,13 +566,13 @@ def _build_scheduler_plan( ) scheduler[key] = str(runtime_scheduler.get(key) or configured_value or SCHEDULER_TIME_DEFAULTS[key]) if ( - market == "US" + market == DEFAULT_MARKET and _runtime_target_enabled(env_values) and not _runtime_target_is_dry_run_only(runtime_target, env_values) ): - if timezone != US_MARKET_TIMEZONE: + if timezone != DEFAULT_MARKET_TIMEZONE: raise ValueError( - f"US live account scheduler timezone must be {US_MARKET_TIMEZONE}: " + f"US live account scheduler timezone must be {DEFAULT_MARKET_TIMEZONE}: " f"{timezone!r}" ) for key in SCHEDULER_TIME_ENV: diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 01b25e1..d777aeb 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1453,6 +1453,28 @@ def test_build_cloud_run_env_sync_plan_uses_deployed_market_precedence() -> None assert "US live account scheduler main_time must be Mon-Fri cron" in result.stderr +@pytest.mark.parametrize("market", (None, "NYSE", "NASDAQ", "USA")) +def test_build_cloud_run_env_sync_plan_normalizes_us_market_aliases( + market: str | None, +) -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"].pop("market", None) + if market is not None: + target["IBKR_MARKET"] = market + target["runtime_target"]["scheduler"]["main_time"] = "45 15 * * *" + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "US live account scheduler main_time must be Mon-Fri cron" in result.stderr + + def test_build_cloud_run_env_sync_plan_rejects_weekend_without_account_selector() -> None: payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") target = payload["targets"][0] @@ -1623,6 +1645,7 @@ def test_build_cloud_run_env_sync_plan_honors_global_dedup_env() -> 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"]["market"] = "HK" first_target["runtime_target"]["scheduler"] = { "timezone": "Asia/Hong_Kong", "main_time": "45 15 * * 1-5", From 33cd90e0e19e7d821703f4ff93cd0cc7621c90c2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:31:51 +0800 Subject: [PATCH 5/5] fix(runtime): honor emitted dry-run mode Co-Authored-By: Codex --- scripts/build_cloud_run_env_sync_plan.py | 4 ++-- tests/test_runtime_config_support.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index de39b21..0df7806 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -601,9 +601,9 @@ def _runtime_target_is_dry_run_only( runtime_target: Mapping[str, object], env_values: Mapping[str, str], ) -> bool: - raw_dry_run = runtime_target.get("dry_run_only") + raw_dry_run = env_values.get("IBKR_DRY_RUN_ONLY") if raw_dry_run is None: - raw_dry_run = env_values.get("IBKR_DRY_RUN_ONLY") + raw_dry_run = runtime_target.get("dry_run_only") return str(raw_dry_run or "").strip().lower() in {"1", "true", "yes", "on"} diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index d777aeb..b0a7dd4 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1415,6 +1415,26 @@ def test_build_cloud_run_env_sync_plan_rejects_force_run_for_non_dry_run_target( assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr +def test_build_cloud_run_env_sync_plan_uses_emitted_dry_run_value() -> None: + payload = _four_gateway_warmup_payload("43 9,15 * * 1-5") + target = payload["targets"][0] + target["runtime_target"]["market"] = "US" + target["runtime_target"]["execution_mode"] = "paper" + target["runtime_target"]["dry_run_only"] = True + target["IBKR_DRY_RUN_ONLY"] = False + target["IBKR_FORCE_RUN"] = True + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env={**os.environ, "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload)}, + ) + + assert result.returncode != 0 + assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr + + @pytest.mark.parametrize("schedule_key", ("main_time", "probe_time", "precheck_time")) def test_build_cloud_run_env_sync_plan_rejects_weekend_schedule_for_live_account( schedule_key: str,