diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index d8f9b64..795f74b 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -108,6 +108,7 @@ jobs: IBKR_RECONCILIATION_OUTPUT_PATH: ${{ vars.IBKR_RECONCILIATION_OUTPUT_PATH }} IBKR_DRY_RUN_ONLY: ${{ vars.IBKR_DRY_RUN_ONLY }} 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 }} @@ -1014,7 +1015,7 @@ jobs: [ service_name, timezone, - str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15")), + str(scheduler.get("main_time") or configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")), ] ) ) diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index acbe19b..5437996 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", @@ -98,6 +99,7 @@ def _should_add_local_src(candidate: Path) -> bool: "IBKR_RECONCILIATION_OUTPUT_PATH", "IBKR_DRY_RUN_ONLY", "IBKR_PAPER_LIQUIDATE_ONLY", + "IBKR_FORCE_RUN", "IBKR_MIN_RESERVED_CASH_USD", "IBKR_RESERVED_CASH_RATIO", "IBKR_CASH_ONLY_EXECUTION", @@ -125,15 +127,59 @@ 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", "probe_time": "CLOUD_SCHEDULER_PROBE_TIME", "precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME", } +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]: @@ -450,6 +496,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") + return { "service_name": service_name, "strategy_profile": canonical_profile, @@ -494,6 +546,24 @@ 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: {scheduler[key]!r}" + ) return scheduler diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 91b0c97..5f67f5e 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -635,6 +635,7 @@ def fail_if_called(): raise AssertionError("Closed market should not execute strategy") monkeypatch.setattr(strategy_module, "is_market_open_now", lambda **_kwargs: False) + monkeypatch.delenv("IBKR_FORCE_RUN", raising=False) monkeypatch.setattr(strategy_module, "run_strategy_core", fail_if_called) monkeypatch.setattr( strategy_module, diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index a42551f..c5e5ebe 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -1091,9 +1091,9 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): ), "scheduler": { "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", }, }, "ibkr_feature_snapshot_path": "gs://runtime/mega/snapshot.csv", @@ -1135,10 +1135,11 @@ 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", } assert "IBKR_FEATURE_SNAPSHOT_PATH" not in slot_a["env"] assert "IBKR_FEATURE_SNAPSHOT_PATH" in slot_a["remove_env_vars"] + assert "IBKR_FORCE_RUN" in slot_a["remove_env_vars"] assert "gs://stale-paper/snapshot.csv" not in json.dumps(slot_a) assert json.loads(slot_a["env"]["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"])["strategy_plugins"][0][ "strategy" @@ -1148,9 +1149,9 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): assert u7654_mega["env"]["STRATEGY_PROFILE"] == "russell_top50_leader_rotation" assert u7654_mega["scheduler"] == { "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", } assert u7654_mega["env"]["IBKR_FEATURE_SNAPSHOT_PATH"] == "gs://runtime/mega/snapshot.csv" assert ( @@ -1162,6 +1163,176 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): assert u7654_mega["env"]["INCOME_LAYER_MAX_RATIO"] == "0.25" assert u7654_mega["env"]["DCA_MODE"] == "smart" assert u7654_mega["env"]["DCA_BASE_INVESTMENT_USD"] == "500" + assert "IBKR_FORCE_RUN" in u7654_mega["remove_env_vars"] + + +def test_build_cloud_run_env_sync_plan_keeps_four_live_account_targets_weekday_only(): + enabled_by_account = { + "U1000001": "true", + "U1000002": "true", + "U1000003": "false", + "U1000004": "false", + } + targets = [] + for account, enabled in enabled_by_account.items(): + service_name = f"interactive-brokers-live-{account.lower()}-service" + runtime_target = json.loads( + runtime_target_json( + "tqqq_growth_income", + deployment_selector=f"live-{account.lower()}", + account_selector=[account], + account_scope=f"live-{account.lower()}", + service_name=service_name, + ) + ) + runtime_target["execution_mode"] = "live" + runtime_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", + } + if account == "U1000001": + runtime_target["scheduler"].update( + { + "main_time": "45 15", + "probe_time": "35 9,15", + "precheck_time": "45 9", + } + ) + targets.append( + { + "service": service_name, + "account_group": f"live-{account.lower()}", + "RUNTIME_TARGET_ENABLED": enabled, + "runtime_target": runtime_target, + } + ) + payload = { + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", + "IBKR_MARKET": "US", + "IBKR_MARKET_CALENDAR": "NYSE", + "IBKR_MARKET_CURRENCY": "USD", + "IBKR_MARKET_EXCHANGE": "SMART", + "IBKR_MARKET_TIMEZONE": "America/New_York", + }, + "targets": targets, + } + env = { + **os.environ, + "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload), + "PLATFORM_CONFIG_JSON": "{}", + } + env.pop("IBKR_FORCE_RUN", None) + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + check=True, + capture_output=True, + text=True, + env=env, + ) + + plan = json.loads(result.stdout) + assert len(plan["targets"]) == 4 + for target in plan["targets"]: + account = json.loads(target["env"]["RUNTIME_TARGET_JSON"])["account_selector"][0] + assert target["env"]["RUNTIME_TARGET_ENABLED"] == enabled_by_account[account] + assert 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", + } + 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_weekend_schedule_and_force_run_for_live_account(): + service_name = "interactive-brokers-live-u1000001-service" + runtime_target = json.loads( + runtime_target_json( + "tqqq_growth_income", + deployment_selector="live-u1000001", + account_selector=["U1000001"], + account_scope="live-u1000001", + service_name=service_name, + ) + ) + runtime_target["execution_mode"] = "live" + runtime_target["scheduler"] = { + "timezone": "America/New_York", + "main_time": "45 15 * * *", + "probe_time": "35 9,15 * * *", + "precheck_time": "45 9 * * *", + } + payload = { + "defaults": { + "GLOBAL_TELEGRAM_CHAT_ID": "5992562050", + "NOTIFY_LANG": "zh", + "IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": "ibkr-account-groups", + "IBKR_MARKET": "US", + "IBKR_MARKET_CALENDAR": "NYSE", + "IBKR_MARKET_CURRENCY": "USD", + "IBKR_MARKET_EXCHANGE": "SMART", + "IBKR_MARKET_TIMEZONE": "America/New_York", + }, + "targets": [ + { + "service": service_name, + "account_group": "live-u1000001", + "runtime_target": runtime_target, + } + ], + } + env = { + **os.environ, + "CLOUD_RUN_SERVICE_TARGETS_JSON": json.dumps(payload), + "PLATFORM_CONFIG_JSON": "{}", + } + + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "US live account scheduler main_time must be Mon-Fri cron" in result.stderr + + runtime_target["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", + } + payload["targets"][0]["runtime_target"] = runtime_target + env["CLOUD_RUN_SERVICE_TARGETS_JSON"] = json.dumps(payload) + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0 + assert json.loads(result.stdout)["targets"][0]["scheduler"] == runtime_target["scheduler"] + + payload["targets"][0]["IBKR_FORCE_RUN"] = "true" + env["CLOUD_RUN_SERVICE_TARGETS_JSON"] = json.dumps(payload) + result = subprocess.run( + [sys.executable, str(SYNC_PLAN_SCRIPT_PATH), "--json"], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets" in result.stderr def test_build_cloud_run_env_sync_plan_requires_target_snapshot_in_per_service_mode(): diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 005a71f..1ec81ec 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" @@ -131,7 +132,7 @@ 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 'configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")' "$workflow_file" grep -Fq 'IFS=$'\''\t'\'' read -r cloud_run_service market_timezone main_time <<< "${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"