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
7 changes: 4 additions & 3 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 ""),
]
Expand Down
118 changes: 109 additions & 9 deletions scripts/build_cloud_run_env_sync_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -61,6 +66,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 @@ -100,6 +106,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",
Expand Down Expand Up @@ -127,16 +134,60 @@ 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",
}
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]:
Expand Down Expand Up @@ -456,6 +507,13 @@ def _build_target_plan(
+ "\n".join(f" - {item}" for item in missing)
)

if (
_runtime_target_enabled(env_values)
and not _runtime_target_is_dry_run_only(runtime_target, env_values)
and str(env_values.get("IBKR_FORCE_RUN") or "").strip().lower() == "true"
):
Comment thread
Pigbibi marked this conversation as resolved.
raise ValueError("IBKR_FORCE_RUN=true is not allowed for live Cloud Run targets")

scheduler = _build_scheduler_plan(
runtime_target=runtime_target,
target=target,
Expand Down Expand Up @@ -488,7 +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 "").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"
Expand All @@ -504,21 +565,60 @@ 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 == DEFAULT_MARKET
and _runtime_target_enabled(env_values)
and not _runtime_target_is_dry_run_only(runtime_target, env_values)
):
Comment thread
Pigbibi marked this conversation as resolved.
Comment thread
Pigbibi marked this conversation as resolved.
if timezone != DEFAULT_MARKET_TIMEZONE:
raise ValueError(
f"US live account scheduler timezone must be {DEFAULT_MARKET_TIMEZONE}: "
f"{timezone!r}"
Comment on lines +573 to +576

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 Validate the emitted market timezone as well

When runtime_target.scheduler.timezone is America/New_York but the target explicitly emits IBKR_MARKET_TIMEZONE=Asia/Hong_Kong, this check passes because it examines only the scheduler timezone. The workflow then schedules /run in New York, while runtime_config_support.py:427-430 gives the emitted environment value precedence for the runtime market-open check; consequently, a Friday 15:45 New York invocation is evaluated as Saturday in Hong Kong and skipped, losing the Friday live cycle. The fresh evidence in the final tree is that the canonical-timezone guard still never compares against env_values["IBKR_MARKET_TIMEZONE"]; reject conflicting values or validate both effective timezones.

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: "
f"{scheduler[key]!r}"
)
return scheduler


def _runtime_target_is_dry_run_only(
runtime_target: Mapping[str, object],
env_values: Mapping[str, str],
) -> bool:
raw_dry_run = env_values.get("IBKR_DRY_RUN_ONLY")
if raw_dry_run is None:
raw_dry_run = runtime_target.get("dry_run_only")
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()
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
return (
backend == "gateway"
and execution_mode != "paper"
and not _runtime_target_is_dry_run_only(runtime_target, env_values)
)


def _validate_profile_inputs(
Expand Down
Loading
Loading