Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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")),
]
)
)
Expand Down
76 changes: 73 additions & 3 deletions scripts/build_cloud_run_env_sync_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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")
Comment on lines +500 to +503

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject force-run based on actual dry-run state

For a target whose runtime_target.execution_mode is missing or stale as paper while IBKR_DRY_RUN_ONLY is unset/false and the account group points at a live gateway, this check allows IBKR_FORCE_RUN=true into Cloud Run even though the request path uses that variable to bypass the market-hours gate and the order path is controlled by the dry-run/gateway env, not this metadata field. The guard should also require the resolved target to be dry-run/paper from the env values before allowing force-run, otherwise a config mismatch can still force live execution outside market hours.

Useful? React with 👍 / 👎.


return {
"service_name": service_name,
"strategy_profile": canonical_profile,
Expand Down Expand Up @@ -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")
Comment on lines +550 to +552

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce weekday schedules for default-US live targets

When a live service relies on the runtime defaults for US market (IBKR_MARKET unset) or on ACCOUNT_GROUP/account-group config instead of embedding account_selector, this condition is false, so a weekend cron like 45 15 * * * is accepted even though the application defaults the market to US and resolves live accounts from the account group. This leaves a common live Cloud Run target shape outside the new weekend-schedule guard, so those services can still be synced with Saturday/Sunday schedules.

Useful? React with 👍 / 👎.

):
Comment on lines +549 to +553

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip schedule validation for disabled targets

When a live target is already disabled with RUNTIME_TARGET_ENABLED=false but still has an old daily cron such as 45 15 * * *, this guard now aborts the entire sync plan before the workflow can keep the service disabled or clean up its env. Disabled targets do not reach the market-hours/order path in main._handle_request(), and this script already skips other required-input validation for disabled targets via _runtime_target_enabled(env_values), so applying the new weekend schedule rejection to disabled services makes safe cleanup/sync operations fail unnecessarily.

Useful? React with 👍 / 👎.

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


Expand Down
1 change: 1 addition & 0 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
185 changes: 178 additions & 7 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand All @@ -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 (
Expand All @@ -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():
Expand Down
3 changes: 2 additions & 1 deletion tests/test_sync_cloud_run_env_workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading