From f597b50be0b06a507caadc3ead826a2104385c47 Mon Sep 17 00:00:00 2001 From: codex-maintenance <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:27:06 +0800 Subject: [PATCH 1/2] Move drill and reconciliation bindings to protected inventory Co-Authored-By: Codex --- .../collect-reconciliation-evidence.yml | 44 +++- .../workflows/execution-report-heartbeat.yml | 4 +- .github/workflows/sync-cloud-run-env.yml | 27 ++- config/runtime_targets.manifest.json | 18 +- docs/runtime_target_manifest.md | 45 ++-- scripts/build_cloud_run_env_sync_plan.py | 20 ++ scripts/daily_dry_run_digest.py | 227 ++++++++++-------- scripts/render_runtime_target_matrix.py | 35 ++- tests/test_execution_service.py | 6 +- tests/test_historical_execution_cloud.py | 10 +- tests/test_ibkr_portfolio.py | 28 +-- .../test_reconciliation_evidence_workflow.py | 14 +- tests/test_runtime_config_support.py | 26 +- tests/test_runtime_target_manifest.py | 20 +- tests/test_runtime_target_matrix.py | 10 +- tests/test_scheduler_deadline_contract.py | 2 +- tests/test_strategy_runtime.py | 8 +- tests/test_sync_cloud_run_env_workflow.sh | 6 +- 18 files changed, 332 insertions(+), 218 deletions(-) diff --git a/.github/workflows/collect-reconciliation-evidence.yml b/.github/workflows/collect-reconciliation-evidence.yml index 2f6ca13..71f7cb9 100644 --- a/.github/workflows/collect-reconciliation-evidence.yml +++ b/.github/workflows/collect-reconciliation-evidence.yml @@ -46,11 +46,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - name: Render validated matrix from public manifest + - name: Resolve reconciliation profiles from private inventory id: render + env: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} run: | set -euo pipefail - python3 scripts/render_runtime_target_matrix.py --profile reconciliation --github-output + python3 scripts/render_runtime_target_matrix.py --profile reconciliation --private-config --github-output collect: needs: resolve-matrix @@ -78,6 +80,36 @@ jobs: if: ${{ steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} uses: actions/checkout@v6 + - name: Resolve selected service from private inventory + id: runtime_target + if: ${{ steps.selection.outputs.selected == 'true' }} + env: + CLOUD_RUN_SERVICE_TARGETS_JSON: ${{ vars.CLOUD_RUN_SERVICE_TARGETS_JSON || secrets.CLOUD_RUN_SERVICE_TARGETS_JSON }} + PROFILE: ${{ matrix.profile }} + run: | + python3 - <<'PY' + import json + import os + + payload = json.loads(os.environ["CLOUD_RUN_SERVICE_TARGETS_JSON"]) + entries = payload.get("targets") if isinstance(payload, dict) else payload + matches = [] + for item in entries: + if not isinstance(item, dict) or item.get("include_reconciliation") is not True: + continue + runtime = item.get("runtime_target") or {} + if isinstance(runtime, str): + runtime = json.loads(runtime) + if runtime.get("strategy_profile") == os.environ["PROFILE"]: + service = str(item.get("service") or item.get("service_name") or "").strip() + if service and runtime.get("service_name") == service: + matches.append(service) + if len(matches) != 1: + raise SystemExit("expected exactly one private reconciliation service") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as handle: + handle.write(f"service={matches[0]}\n") + PY + - name: Authenticate to Google Cloud if: ${{ steps.selection.outputs.selected == 'true' }} uses: google-github-actions/auth@v3 @@ -93,7 +125,7 @@ jobs: id: audience if: ${{ steps.selection.outputs.selected == 'true' }} env: - SERVICE: ${{ matrix.service }} + SERVICE: ${{ steps.runtime_target.outputs.service }} run: | set -euo pipefail service_url="$(gcloud run services describe "$SERVICE" --project "$GCP_PROJECT_ID" --region "$GCP_REGION" --format='value(status.url)')" @@ -161,7 +193,7 @@ jobs: if: ${{ steps.selection.outputs.selected == 'true' }} env: PROFILE: ${{ matrix.profile }} - SERVICE: ${{ matrix.service }} + SERVICE: ${{ steps.runtime_target.outputs.service }} SCHEDULER_JOB_SHA256: ${{ steps.scheduler.outputs.scheduler_job_sha256 }} REQUESTED_AT: ${{ steps.scheduler.outputs.requested_at }} run: | @@ -282,7 +314,7 @@ jobs: WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} WORKFLOW_HEAD_SHA: ${{ github.sha }} PROFILE: ${{ matrix.profile }} - SERVICE: ${{ matrix.service }} + SERVICE: ${{ steps.runtime_target.outputs.service }} SERVICE_REVISION: ${{ steps.receipt.outputs.serving_revision }} SERVICE_REVISION_COMMIT_SHA: ${{ steps.receipt.outputs.service_revision_commit_sha }} SERVICE_DEPLOY_RUN_ID: ${{ steps.receipt.outputs.service_deploy_run_id }} @@ -373,7 +405,7 @@ jobs: - name: Summarize scoped execution record types if: ${{ success() && steps.selection.outputs.selected == 'true' && inputs.inspect_execution_ledger }} env: - SERVICE: ${{ matrix.service }} + SERVICE: ${{ steps.runtime_target.outputs.service }} run: | set -euo pipefail python3 scripts/summarize_execution_ledger_cloud.py \ diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index 85ab130..9ac5f0f 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -17,7 +17,7 @@ on: - "true" - "false" send_daily_dry_run_digest: - description: "Send the U183 daily drill digest on a manual run." + description: "Send the configured daily drill digest on a manual run." required: false type: boolean default: false @@ -114,6 +114,6 @@ jobs: env: EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} - - name: Send U183 daily dry-run digest + - name: Send configured daily dry-run digest if: ${{ always() && (github.event_name == 'schedule' || inputs.send_daily_dry_run_digest) }} run: uv run --no-sync python scripts/daily_dry_run_digest.py diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index c6fd945..129e12c 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -1169,6 +1169,7 @@ jobs: 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(bool(target.get("standard_execution_enabled", False))).lower(), + str(bool(target.get("drill_precheck_enabled", False))).lower(), str(scheduler.get("attempt_deadline") or ""), ] ) @@ -1177,7 +1178,7 @@ jobs: ) for update in "${scheduler_updates[@]}"; do - IFS=$'\t' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled main_attempt_deadline <<< "${update}" + IFS=$'\t' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled drill_precheck_enabled main_attempt_deadline <<< "${update}" if [ -z "${cloud_run_service}" ] || [ -z "${market_timezone}" ]; then continue fi @@ -1358,6 +1359,12 @@ jobs: precheck_job_name="${cloud_run_service%-service}-precheck-scheduler" precheck_uri="${service_url}/dry-run" + precheck_retry_attempts=3 + precheck_retry_duration=900s + if [ "${drill_precheck_enabled}" = "true" ]; then + precheck_retry_attempts=0 + precheck_retry_duration=0s + fi precheck_state="$(gcloud scheduler jobs describe "${precheck_job_name}" \ --project="${GCP_PROJECT_ID}" \ --location="${scheduler_location}" \ @@ -1374,10 +1381,10 @@ jobs: --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ --oidc-token-audience="${service_url}" \ --attempt-deadline=180s \ - --max-retry-attempts=3 \ + --max-retry-attempts="${precheck_retry_attempts}" \ --min-backoff=120s \ --max-backoff=300s \ - --max-retry-duration=900s \ + --max-retry-duration="${precheck_retry_duration}" \ --quiet else echo "Creating Cloud Scheduler precheck ${precheck_job_name} at ${desired_precheck_schedule}." @@ -1391,10 +1398,10 @@ jobs: --oidc-service-account-email="${GCP_SCHEDULER_SERVICE_ACCOUNT}" \ --oidc-token-audience="${service_url}" \ --attempt-deadline=180s \ - --max-retry-attempts=3 \ + --max-retry-attempts="${precheck_retry_attempts}" \ --min-backoff=120s \ --max-backoff=300s \ - --max-retry-duration=900s \ + --max-retry-duration="${precheck_retry_duration}" \ --quiet fi managed_scheduler_jobs=("${job_name}" "${warmup_job_name}" "${precheck_job_name}") @@ -1403,10 +1410,14 @@ jobs: --project="${GCP_PROJECT_ID}" \ --location="${scheduler_location}" \ --format='value(state)')" - case "${standard_execution_enabled}" in + job_enabled="${standard_execution_enabled}" + if [ "${managed_job_name}" = "${precheck_job_name}" ] && [ "${drill_precheck_enabled}" = "true" ]; then + job_enabled=true + fi + case "${job_enabled}" in 1|true|yes|on) if [ "${managed_job_state}" = "PAUSED" ]; then - echo "Resuming Cloud Scheduler job ${managed_job_name} because ${cloud_run_service} permits standard execution." + echo "Resuming approved Cloud Scheduler job ${managed_job_name}." gcloud scheduler jobs resume "${managed_job_name}" \ --project="${GCP_PROJECT_ID}" \ --location="${scheduler_location}" \ @@ -1416,7 +1427,7 @@ jobs: ;; *) if [ "${managed_job_state}" != "PAUSED" ]; then - echo "Pausing Cloud Scheduler job ${managed_job_name} because ${cloud_run_service} does not permit standard execution." + echo "Pausing Cloud Scheduler job ${managed_job_name} because it is not approved for this target." gcloud scheduler jobs pause "${managed_job_name}" \ --project="${GCP_PROJECT_ID}" \ --location="${scheduler_location}" \ diff --git a/config/runtime_targets.manifest.json b/config/runtime_targets.manifest.json index d01c57a..cb1342a 100644 --- a/config/runtime_targets.manifest.json +++ b/config/runtime_targets.manifest.json @@ -1,14 +1,14 @@ { "schema_version": 1, "platform_id": "ibkr", - "description": "Public non-sensitive runtime-target inventory for InteractiveBrokersPlatform. Account credentials, Gateway host/port/client id, tokens, and continuity fingerprints stay in Secret Manager / Environment; this file is not yet consumed by workflows.", + "description": "Synthetic public examples only. Production service and account bindings live in protected deployment inventory; credentials, Gateway coordinates, tokens, and continuity fingerprints stay outside this repository.", "targets": [ { "id": "soxl_soxx_trend_income", "label": "SOXL/SOXX trend income", - "service": "interactive-brokers-quant-live-u15998061-service", + "service": "interactive-brokers-quant-live-u00000001-service", "region": "us-central1", - "account_group": "live-u15998061", + "account_group": "live-u00000001", "strategy_profile": "soxl_soxx_trend_income", "execution_mode": "live", "lifecycle_role": "live", @@ -19,9 +19,9 @@ { "id": "tqqq_growth_income", "label": "TQQQ growth income", - "service": "interactive-brokers-quant-live-u16608560-service", + "service": "interactive-brokers-quant-live-u00000002-service", "region": "us-central1", - "account_group": "live-u16608560", + "account_group": "live-u00000002", "strategy_profile": "tqqq_growth_income", "execution_mode": "live", "lifecycle_role": "live", @@ -32,9 +32,9 @@ { "id": "global_etf_rotation", "label": "Global ETF rotation", - "service": "interactive-brokers-quant-live-u18308207-service", + "service": "interactive-brokers-quant-live-u00000003-service", "region": "us-central1", - "account_group": "live-u18308207", + "account_group": "live-u00000003", "strategy_profile": "global_etf_rotation", "execution_mode": "live", "lifecycle_role": "live", @@ -45,9 +45,9 @@ { "id": "russell_top50_leader_rotation", "label": "Russell top-50 leader rotation", - "service": "interactive-brokers-quant-live-u18336562-service", + "service": "interactive-brokers-quant-live-u00000004-service", "region": "us-central1", - "account_group": "live-u18336562", + "account_group": "live-u00000004", "strategy_profile": "russell_top50_leader_rotation", "execution_mode": "live", "lifecycle_role": "live", diff --git a/docs/runtime_target_manifest.md b/docs/runtime_target_manifest.md index 0baeafe..187b83a 100644 --- a/docs/runtime_target_manifest.md +++ b/docs/runtime_target_manifest.md @@ -2,9 +2,9 @@ ## 结论 -`config/runtime_targets.manifest.json` 是 InteractiveBrokersPlatform 的公开、非敏感 runtime-target 清单。它用标准库 JSON 表达现有 4 个 live 目标与 1 个 `us_combo_shadow` 的必要字段,并提供严格校验。 +`config/runtime_targets.manifest.json` 是公开的**合成示例**,用标准库 JSON 展示 4 个 live 目标与 1 个 shadow 目标的字段契约;其中服务名和账户组编号均为虚构值。 -本文件**不是**当前生产启停真相源。Cloud Run / GitHub Environment 变量(尤其 `RUNTIME_TARGET_ENABLED`)、Secret Manager 内容和实际部署保持不变;`collect-reconciliation-evidence.yml` 已按 `include_reconciliation` 从校验后的 manifest 生成与既有硬编码一致的 4 个 live 矩阵行(不使用 `enabled`),其余 workflow 仍硬编码。 +本文件**不是**当前生产启停或账户映射真相源。生产目标由受保护的 `CLOUD_RUN_SERVICE_TARGETS_JSON` 配置,Cloud Run 的 `RUNTIME_TARGET_ENABLED`、Secret Manager 和实际调度状态须分别读回;`collect-reconciliation-evidence.yml` 从受保护配置选择对账目标,公开矩阵只包含策略 profile,不携带私有服务名。 ## 字段契约 @@ -43,57 +43,44 @@ - 把长串 opaque 密钥值直接写进 manifest - 未知顶层或目标字段 -## 现有 4 live + 1 shadow 示例 +## 合成的 4 live + 1 shadow 示例 -仓库内示例已表达当前 workflow / env-sync 使用的公开结构: +仓库内示例只表达字段结构,编号不对应真实账户或生产 Cloud Run 服务: | id | service | account_group | execution_mode | include_lifecycle | include_reconciliation | | --- | --- | --- | --- | --- | --- | -| `soxl_soxx_trend_income` | `interactive-brokers-quant-live-u15998061-service` | `live-u15998061` | `live` | true | true | -| `tqqq_growth_income` | `interactive-brokers-quant-live-u16608560-service` | `live-u16608560` | `live` | true | true | -| `global_etf_rotation` | `interactive-brokers-quant-live-u18308207-service` | `live-u18308207` | `live` | true | true | -| `russell_top50_leader_rotation` | `interactive-brokers-quant-live-u18336562-service` | `live-u18336562` | `live` | true | true | +| `soxl_soxx_trend_income` | `interactive-brokers-quant-live-u00000001-service` | `live-u00000001` | `live` | true | true | +| `tqqq_growth_income` | `interactive-brokers-quant-live-u00000002-service` | `live-u00000002` | `live` | true | true | +| `global_etf_rotation` | `interactive-brokers-quant-live-u00000003-service` | `live-u00000003` | `live` | true | true | +| `russell_top50_leader_rotation` | `interactive-brokers-quant-live-u00000004-service` | `live-u00000004` | `live` | true | true | | `us_combo_shadow` | `interactive-brokers-us-combo-shadow-service` | `us-combo-shadow` | `shadow` | false | false | 示例中五个目标的 `enabled` 均为 `false`。这表示公开清单的安全默认值,**不**覆盖 Environment / Cloud Run 里现有的启停状态,也不授权交易。 -## 如何增减目标(本批之后的操作顺序) +## 如何增减目标 新增目标: -1. 在 `config/runtime_targets.manifest.json` 增加一条目标;`enabled` 保持 `false`。 -2. 为该目标准备受保护的 Environment / Secret Manager 名称引用;密钥值、Gateway 主机端口、client id、`account_ids` 与 continuity 指纹一律不进仓库。 -3. 本地运行: +1. 在受保护的 `CLOUD_RUN_SERVICE_TARGETS_JSON` 中增加目标,先保持 `RUNTIME_TARGET_ENABLED=false`;不要把真实账户编号、服务名或项目映射写进公开 manifest、测试与文档。 +2. 为该目标准备受保护的 Environment / Secret Manager 引用;密钥值、Gateway 主机端口、client id、`account_ids` 与 continuity 指纹一律不进仓库。 +3. 公开示例格式变更时,本地运行: ```bash uv run --no-sync python scripts/validate_runtime_target_manifest.py uv run --no-sync python -m pytest -q tests/test_runtime_target_manifest.py ``` -4. 在**后续 wiring 批次**再考虑让 Guard / Lifecycle / Reconciliation / Deploy workflow 读取该清单;在那之前不要假设改 manifest 就会改变运行矩阵。 +4. 按目标核对部署清单、只读对账矩阵和实际 Cloud Run / Scheduler 状态;公开 manifest 的增减不会改变生产运行矩阵。 减少目标: 1. 先确认对应服务已停用、Scheduler / Cloud Run / reconciliation 不再需要该目标。 -2. 从 manifest 删除该条目并保持校验通过。 +2. 从受保护配置移除该条目;公开示例无需与生产目标逐一对应。 3. Environment / Secret / Cloud Run 的实际清理另授权,不由本文件自动执行。 -## 下一阶段 dynamic matrix 边界(明确未做) +## 运行边界 -本批完成:schema、示例、校验、parity tests、文档。 - -本批**不**做: - -- 把 `runtime-guard.yml`、`runtime-target-lifecycle.yml`、`collect-reconciliation-evidence.yml`、`execution-report-heartbeat.yml`、`sync-cloud-run-env.yml` 的硬编码 matrix / 变量改成动态读取 manifest -- 修改任何 GitHub Secret / Environment 内容、生产开关、部署流程或交易逻辑 -- 云端写入、交易、Scheduler pause/resume、流量切换 - -后续若要接线,建议最小边界: - -1. 先让只读 workflow(Guard / Lifecycle / Heartbeat / Reconciliation)从 manifest 的 `include_*` 标志生成 matrix,但仍以 Environment / Cloud Run 的 `RUNTIME_TARGET_ENABLED` 为启停真相。 -2. Deploy / env sync 再单独迁移;新建目标默认 `enabled=false`,不会自动部署或启用。 -3. `include_reconciliation` 继续只允许 live;shadow 不得进入 reconciliation 矩阵。 -4. 任何把 manifest `enabled` 提升为生产权威的改动,必须另开有授权的批次,并保留 fail-closed 读回。 +公开 manifest 仅用于 schema、示例和离线校验。生产部署、每日演练与只读对账从受保护配置取目标,并以 Cloud Run / Scheduler 读回为运行事实。`include_reconciliation` 只用于 live 目标;shadow 不进入对账矩阵。新增目标默认停用,配置清单本身不授予交易权限。 ## 本地校验 diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 1c38d3e..40c4634 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -575,6 +575,25 @@ def _build_target_plan( if _requires_extended_run_deadline(runtime_target, env_values): scheduler["attempt_deadline"] = RUN_SCHEDULER_ATTEMPT_DEADLINE + drill_precheck_enabled = target.get("drill_precheck_enabled") is True + if drill_precheck_enabled: + continuity = runtime_target.get("live_continuity") or {} + if not isinstance(continuity, Mapping): + continuity = {} + precheck_fields = scheduler["precheck_time"].split() + if ( + _runtime_target_enabled(env_values) + or continuity.get("state") != "RECONCILE_ONLY" + or str(env_values.get("IBKR_FORCE_RUN") or "").lower() == "true" + or len(precheck_fields) != 5 + or precheck_fields[2] != "*" + or precheck_fields[4] != "*" + ): + raise ValueError( + "daily drill requires disabled RECONCILE_ONLY runtime, " + "force_run=false, and an every-day precheck schedule" + ) + return { "service_name": service_name, "strategy_profile": canonical_profile, @@ -586,6 +605,7 @@ def _build_target_plan( # not receive normal execution schedules. "standard_execution_enabled": _runtime_target_enabled(env_values) and runtime_target_permits_standard_execution(runtime_target), + "drill_precheck_enabled": drill_precheck_enabled, "remove_env_vars": sorted(set(remove_env_vars) - set(env_values)), "_recovery_state_ledger_applied": recovery_expected_digests is not None, } diff --git a/scripts/daily_dry_run_digest.py b/scripts/daily_dry_run_digest.py index 6c303cd..2864452 100644 --- a/scripts/daily_dry_run_digest.py +++ b/scripts/daily_dry_run_digest.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 -"""Send one daily, read-only summary for the two IBKR drill targets.""" +"""Send a daily summary for privately configured read-only IBKR drills.""" from __future__ import annotations import datetime as dt import json import os +import re import subprocess from dataclasses import dataclass from zoneinfo import ZoneInfo @@ -16,18 +17,14 @@ from execution_report_heartbeat import _send_telegram -PROJECT = "interactivebrokersquant" -LOCATION = "us-central1" -REPORT_ROOT = "gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers" -TIMEZONE = ZoneInfo("America/New_York") - - @dataclass(frozen=True) class DrillTarget: - account: str + label: str profile: str scope: str service: str + schedule: str + timezone: str @property def precheck_job(self) -> str: @@ -37,15 +34,55 @@ def precheck_job(self) -> str: def live_job(self) -> str: return f"{self.service.removesuffix('-service')}-scheduler" - @property - def report_prefix(self) -> str: - return f"{REPORT_ROOT}/{self.profile}/{self.scope}" - -TARGETS = ( - DrillTarget("U18308207", "global_etf_rotation", "live-u18308207", "interactive-brokers-quant-live-u18308207-service"), - DrillTarget("U18336562", "russell_top50_leader_rotation", "live-u18336562", "interactive-brokers-quant-live-u18336562-service"), -) +def _setting(name: str) -> str: + value = str(os.environ.get(name) or "").strip() + if not value: + raise ValueError(f"{name} is required for daily drill reporting") + return value + + +def _targets() -> list[DrillTarget]: + payload = json.loads(_setting("CLOUD_RUN_SERVICE_TARGETS_JSON")) + entries = payload.get("targets") if isinstance(payload, dict) else payload + if not isinstance(entries, list): + raise ValueError("drill target inventory must be a list") + result: list[DrillTarget] = [] + for item in entries: + if not isinstance(item, dict) or item.get("drill_precheck_enabled") is not True: + continue + runtime = item.get("runtime_target") or item.get("runtime_target_json") or {} + if isinstance(runtime, str): + runtime = json.loads(runtime) + if not isinstance(runtime, dict): + raise ValueError("drill runtime target must be an object") + scheduler = runtime.get("scheduler") or {} + service = str(item.get("service") or item.get("service_name") or "").strip() + profile = str(runtime.get("strategy_profile") or "").strip() + scope = str(runtime.get("account_scope") or "").strip() + schedule = str(scheduler.get("precheck_time") or "").strip() + timezone = str(scheduler.get("timezone") or "").strip() + if ( + not re.fullmatch(r"[a-z][a-z0-9-]{0,62}", service) + or runtime.get("service_name") != service + or not re.fullmatch(r"[a-z][a-z0-9_]*", profile) + or not re.fullmatch(r"[A-Za-z0-9_-]+", scope) + or not timezone + ): + raise ValueError("drill target identity is incomplete or invalid") + fields = schedule.split() + if len(fields) != 5 or fields[2:] != ["*", "*", "*"]: + raise ValueError("drill precheck must run every day") + int(fields[0]), int(fields[1]) + result.append(DrillTarget( + label=str(item.get("drill_label") or f"演练目标 {len(result) + 1}"), + profile=profile, + scope=scope, + service=service, + schedule=schedule, + timezone=timezone, + )) + return result def _gcloud(*args: str) -> str: @@ -56,154 +93,152 @@ def _gcloud(*args: str) -> str: def _job(job_name: str) -> dict: - output = _gcloud( + return json.loads(_gcloud( "scheduler", "jobs", "describe", job_name, - "--project", PROJECT, "--location", LOCATION, "--format=json", - ) - return json.loads(output) - - -def _report_time(report: dict) -> dt.datetime | None: - return _timestamp(report.get("started_at")) + "--project", _setting("GCP_PROJECT_ID"), + "--location", _setting("RUNTIME_HEARTBEAT_SCHEDULER_LOCATION"), "--format=json", + )) def _timestamp(raw_value: object) -> dt.datetime | None: - raw = str(raw_value or "").strip() try: - value = dt.datetime.fromisoformat(raw.replace("Z", "+00:00")) + value = dt.datetime.fromisoformat(str(raw_value or "").replace("Z", "+00:00")) except ValueError: return None return value if value.tzinfo else None +def _report_time(report: dict) -> dt.datetime | None: + return _timestamp(report.get("started_at")) + + def _today_reports(target: DrillTarget, day: dt.date) -> list[dict]: - # A New York calendar day can span two UTC dates and, at a month boundary, - # two UTC month directories. List only those exact month prefixes. - start = dt.datetime.combine(day, dt.time.min, TIMEZONE).astimezone(dt.timezone.utc) - end = dt.datetime.combine(day + dt.timedelta(days=1), dt.time.min, TIMEZONE).astimezone(dt.timezone.utc) + timezone = ZoneInfo(target.timezone) + start = dt.datetime.combine(day, dt.time.min, timezone).astimezone(dt.timezone.utc) + end = dt.datetime.combine(day + dt.timedelta(days=1), dt.time.min, timezone).astimezone(dt.timezone.utc) months = {start.strftime("%Y-%m"), end.strftime("%Y-%m")} utc_dates = {start.strftime("%Y%m%d"), end.strftime("%Y%m%d")} + report_root = _setting("RUNTIME_HEARTBEAT_GCS_URIS").split(",", 1)[0].rstrip("/") + if not report_root.startswith("gs://"): + raise ValueError("drill report root must be a GCS URI") + prefix = f"{report_root}/interactive_brokers/{target.profile}/{target.scope}" reports: list[dict] = [] for month in sorted(months): - pattern = f"{target.report_prefix}/{month}/*" listing = subprocess.run( - ("gcloud", "storage", "ls", pattern, "--project", PROJECT), + ("gcloud", "storage", "ls", f"{prefix}/{month}/*", "--project", _setting("GCP_PROJECT_ID")), capture_output=True, text=True, check=False, ) if listing.returncode != 0: - # An empty month is normal, but an access failure must be visible. if "matched no objects" in listing.stderr.lower() or "not found" in listing.stderr.lower(): continue - raise RuntimeError(f"GCS report listing failed for {target.account}") + raise RuntimeError("GCS drill report listing failed") for uri in listing.stdout.splitlines(): - if not uri.endswith(".json") or not uri.startswith(target.report_prefix + "/"): + if not uri.startswith(prefix + "/") or not uri.endswith(".json"): continue if uri.rsplit("/", 1)[-1][:8] not in utc_dates: continue - report = json.loads(_gcloud("storage", "cat", uri, "--project", PROJECT)) + report = json.loads(_gcloud("storage", "cat", uri, "--project", _setting("GCP_PROJECT_ID"))) when = _report_time(report) - if when is None or when.astimezone(TIMEZONE).date() != day: + if when is None or when.astimezone(timezone).date() != day: continue if ( - report.get("service_name") != target.service - or report.get("strategy_profile") != target.profile - or report.get("account_scope") != target.scope + report.get("service_name") == target.service + and report.get("strategy_profile") == target.profile + and report.get("account_scope") == target.scope ): - continue - reports.append(report) + reports.append(report) return sorted(reports, key=lambda item: _report_time(item) or start, reverse=True) -def _service_is_drill_only(target: DrillTarget) -> bool: +def _service_is_drill_only(target: DrillTarget) -> tuple[bool, str]: service = json.loads(_gcloud( "run", "services", "describe", target.service, - "--project", PROJECT, "--region", LOCATION, "--format=json", + "--project", _setting("GCP_PROJECT_ID"), + "--region", _setting("RUNTIME_HEARTBEAT_SCHEDULER_LOCATION"), "--format=json", )) containers = service.get("spec", {}).get("template", {}).get("spec", {}).get("containers") or [] env = {item.get("name"): item.get("value") for item in (containers[0].get("env") or [])} if containers else {} - try: - runtime_target = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}") - except json.JSONDecodeError: - return False - return ( + runtime = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}") + safe = ( env.get("RUNTIME_TARGET_ENABLED") == "false" and env.get("IBKR_DRY_RUN_ONLY") == "false" - and runtime_target.get("dry_run_only") is False - and runtime_target.get("live_continuity", {}).get("state") == "RECONCILE_ONLY" + and runtime.get("dry_run_only") is False + and runtime.get("live_continuity", {}).get("state") == "RECONCILE_ONLY" ) + return safe, str(service.get("status", {}).get("url") or "") -def _target_status(target: DrillTarget, day: dt.date) -> str: +def _target_status(target: DrillTarget, now: dt.datetime) -> tuple[dt.date, str]: + timezone = ZoneInfo(target.timezone) + day = now.astimezone(timezone).date() try: - if not _service_is_drill_only(target): - return "⚠️ 云端禁单配置不符;需立即检查" - precheck = _job(target.precheck_job) - live = _job(target.live_job) + safe, service_url = _service_is_drill_only(target) + if not safe: + return day, "⚠️ 云端禁单配置不符;需立即检查" + precheck, live = _job(target.precheck_job), _job(target.live_job) if live.get("state") != "PAUSED": - return "⚠️ 实盘任务未暂停;需立即检查" + return day, "⚠️ 实盘任务未暂停;需立即检查" if precheck.get("state") != "ENABLED": - return "⚠️ 演练任务未启用" - if precheck.get("schedule") != "45 9 * * *" or precheck.get("timeZone") != "America/New_York": - return "⚠️ 每日演练时间配置不符" + return day, "⚠️ 演练任务未启用" + if precheck.get("schedule") != target.schedule or precheck.get("timeZone") != target.timezone: + return day, "⚠️ 每日演练时间配置不符" http_target = precheck.get("httpTarget") or {} + oidc = http_target.get("oidcToken") or {} if ( - not str(http_target.get("uri") or "").endswith("/dry-run") + http_target.get("uri") != f"{service_url}/dry-run" or http_target.get("httpMethod") != "POST" - or http_target.get("oidcToken", {}).get("serviceAccountEmail") - != "ibkr-platform-scheduler@interactivebrokersquant.iam.gserviceaccount.com" + or not oidc.get("serviceAccountEmail") + or oidc.get("audience") != service_url ): - return "⚠️ 演练任务未指向 /dry-run" + return day, "⚠️ 演练任务路由或认证不符" attempted_at = _timestamp(precheck.get("lastAttemptTime")) - if attempted_at is None or attempted_at.astimezone(TIMEZONE).date() != day: - return "⚠️ 今日定时演练尚未触发" - scheduled_at = dt.datetime.combine(day, dt.time(hour=9, minute=45), TIMEZONE) - if attempted_at < scheduled_at.astimezone(dt.timezone.utc) - dt.timedelta(minutes=5): - return "⚠️ 今日定时演练尚未触发" + minute, hour = (int(value) for value in target.schedule.split()[:2]) + scheduled_at = dt.datetime.combine(day, dt.time(hour=hour, minute=minute), timezone) + if attempted_at is None or attempted_at < scheduled_at.astimezone(dt.timezone.utc) - dt.timedelta(minutes=5): + return day, "⚠️ 今日定时演练尚未触发" + if attempted_at.astimezone(timezone).date() != day: + return day, "⚠️ 今日定时演练尚未触发" if (precheck.get("status") or {}).get("code") not in (None, 0): - return "⚠️ 今日定时演练请求失败" + return day, "⚠️ 今日定时演练请求失败" reports = _today_reports(target, day) - if not reports: - return "⚠️ 今日未找到演练报告,结果未验证" - report = next( - ( - item for item in reports - if (started := _report_time(item)) is not None - and abs((started - attempted_at).total_seconds()) <= 600 - ), - None, - ) + report = next((item for item in reports if (started := _report_time(item)) is not None + and abs((started - attempted_at).total_seconds()) <= 600), None) if report is None: - return "⚠️ 定时请求与演练报告无法对应,结果未验证" + return day, "⚠️ 定时请求与演练报告无法对应,结果未验证" if report.get("dry_run") is not True: - return "⚠️ 最新报告不是 dry run,结果未验证" + return day, "⚠️ 最新报告不是 dry run,结果未验证" status = str(report.get("status") or "").lower() diagnostics = report.get("diagnostics") or {} if status == "skipped" and diagnostics.get("skip_reason") == "market_closed": - return "🗓️ 休市,演练按规则跳过;未下单" + return day, "🗓️ 休市,演练按规则跳过;未下单" summary = report.get("summary") or {} if status == "ok" and summary.get("execution_status") and summary.get("orders_submitted_count") == 0: - return "✅ 今日模拟周期完成;实际下单 0 笔" - return f"⚠️ 演练未通过完整零下单核验(状态:{status or '未知'})" - except (RuntimeError, ValueError, TypeError, json.JSONDecodeError): - return "⚠️ 无法读取演练证据,结果未验证" + return day, "✅ 今日模拟周期完成;实际下单 0 笔" + return day, f"⚠️ 演练未通过完整零下单核验(状态:{status or '未知'})" + except (RuntimeError, ValueError, TypeError, KeyError, json.JSONDecodeError): + return day, "⚠️ 无法读取演练证据,结果未验证" def main(now: dt.datetime | None = None) -> int: now = now or dt.datetime.now(dt.timezone.utc) - day = now.astimezone(TIMEZONE).date() - lines = [f"🧪 IBKR 每日模拟演练 · {day.isoformat()}", "仅检查 U183 两账户;实盘下单任务保持关闭。"] + targets = _targets() + if not targets: + print("No daily drill targets configured") + return 0 + lines = ["🧪 IBKR 每日模拟演练", "仅检查已配置的只读目标;实盘下单任务应保持关闭。"] statuses = [] - for target in TARGETS: - status = _target_status(target, day) + for target in targets: + day, status = _target_status(target, now) statuses.append(status) - lines.append(f"{target.account}:{status}") + lines.append(f"{target.label} · {day.isoformat()}:{status}") message = "\n".join(lines) - print(message) + alerts = sum(not value.startswith(("✅", "🗓️")) for value in statuses) if os.environ.get("DRILL_DIGEST_PREVIEW") == "true": + print(f"Daily drill preview: targets={len(targets)}, alerts={alerts}") return 0 - if not _send_telegram(message): - return 1 - return 0 if all(value.startswith(("✅", "🗓️")) for value in statuses) else 1 + sent = _send_telegram(message) + print(f"Daily drill digest sent={sent}; targets={len(targets)}; alerts={alerts}") + return 0 if sent and alerts == 0 else 1 if __name__ == "__main__": diff --git a/scripts/render_runtime_target_matrix.py b/scripts/render_runtime_target_matrix.py index dbf74fa..be2e383 100644 --- a/scripts/render_runtime_target_matrix.py +++ b/scripts/render_runtime_target_matrix.py @@ -46,11 +46,39 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="Append compact matrix=... to $GITHUB_OUTPUT for workflow jobs.", ) + parser.add_argument( + "--private-config", + action="store_true", + help="Resolve profile names from the protected runtime inventory; omit service identities.", + ) args = parser.parse_args(argv) try: - manifest = load_runtime_target_manifest(args.path or default_manifest_path()) - matrix = build_github_actions_matrix(manifest, profile=args.profile) + if args.private_config: + raw = os.environ.get("CLOUD_RUN_SERVICE_TARGETS_JSON") or "" + payload = json.loads(raw) + entries = payload.get("targets") if isinstance(payload, dict) else payload + if not isinstance(entries, list): + raise RuntimeTargetManifestError("private runtime inventory is invalid") + profiles = [] + for item in entries: + if not isinstance(item, dict) or item.get("include_reconciliation") is not True: + continue + runtime = item.get("runtime_target") or {} + if isinstance(runtime, str): + runtime = json.loads(runtime) + if not isinstance(runtime, dict) or runtime.get("execution_mode") != "live": + raise RuntimeTargetManifestError("reconciliation target must be live") + profile = str(runtime.get("strategy_profile") or "").strip() + if not profile or profile in profiles: + raise RuntimeTargetManifestError("reconciliation profile is missing or duplicated") + profiles.append(profile) + if not profiles: + raise RuntimeTargetManifestError("no private reconciliation targets are configured") + matrix = {"include": [{"profile": profile} for profile in profiles]} + else: + manifest = load_runtime_target_manifest(args.path or default_manifest_path()) + matrix = build_github_actions_matrix(manifest, profile=args.profile) except RuntimeTargetManifestError as exc: print(f"runtime-target matrix render failed: {exc}", file=sys.stderr) return 1 @@ -75,7 +103,8 @@ def main(argv: list[str] | None = None) -> int: return 1 with open(github_output, "a", encoding="utf-8") as handle: handle.write(f"matrix={payload}\n") - print(payload) + if not args.private_config: + print(payload) return 0 diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 7ed290f..28560a3 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -195,7 +195,7 @@ def test_live_rebalance_never_submits_without_matching_riskengine_authority( risk_flags=("risk_gate:passed",), risk_authority={ "strategy_profile": "tqqq_growth_income", - "account_ids": ("U16608560",), + "account_ids": ("U00000002",), "trade_date": "2026-09-25", "signal_date": None, "effective_date": effective_date, @@ -214,7 +214,7 @@ def test_live_rebalance_never_submits_without_matching_riskengine_authority( elif invalid_authority == "changed_profile": metadata["risk_authority"]["strategy_profile"] = "other_profile" elif invalid_authority == "changed_account": - metadata["risk_authority"]["account_ids"] = ("U15998061",) + metadata["risk_authority"]["account_ids"] = ("U00000001",) elif invalid_authority == "changed_date": metadata["risk_authority"]["trade_date"] = "2026-09-24" elif invalid_authority == "changed_cap": @@ -240,7 +240,7 @@ def test_live_rebalance_never_submits_without_matching_riskengine_authority( translator=translate, acquire_execution_claim=lambda: True, strategy_profile="tqqq_growth_income", - account_ids=("U16608560",), + account_ids=("U00000002",), signal_metadata=metadata, dry_run_only=dry_run_only, cash_reserve_ratio=0.0, diff --git a/tests/test_historical_execution_cloud.py b/tests/test_historical_execution_cloud.py index 383c5d4..7ff1ffd 100644 --- a/tests/test_historical_execution_cloud.py +++ b/tests/test_historical_execution_cloud.py @@ -7,18 +7,18 @@ def test_summarize_selected_private_reports_without_order_details(monkeypatch): - root = "gs://private/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560/2026-08/" + root = "gs://private/execution-reports/interactive_brokers/tqqq_growth_income/live-u00000002/2026-08/" service = { "spec": {"template": {"spec": {"containers": [{"env": [ {"name": "EXECUTION_REPORT_GCS_URI", "value": "gs://private/execution-reports"}, {"name": "STRATEGY_PROFILE", "value": "tqqq_growth_income"}, - {"name": "ACCOUNT_GROUP", "value": "live-u16608560"}, + {"name": "ACCOUNT_GROUP", "value": "live-u00000002"}, ]}]}}}, } report = { "platform": "interactive_brokers", "strategy_profile": "tqqq_growth_income", - "account_scope": "live-u16608560", + "account_scope": "live-u00000002", "started_at": "2026-08-04T19:45:00Z", "dry_run": False, "status": "ok", @@ -51,11 +51,11 @@ def fake_gcloud(*args): def test_summarize_rejects_wrong_account_report(monkeypatch): - root = "gs://private/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560/2026-08/" + root = "gs://private/execution-reports/interactive_brokers/tqqq_growth_income/live-u00000002/2026-08/" service = {"spec": {"template": {"spec": {"containers": [{"env": [ {"name": "EXECUTION_REPORT_GCS_URI", "value": "gs://private/execution-reports"}, {"name": "STRATEGY_PROFILE", "value": "tqqq_growth_income"}, - {"name": "ACCOUNT_GROUP", "value": "live-u16608560"}, + {"name": "ACCOUNT_GROUP", "value": "live-u00000002"}, ]}]}}}} report = { "platform": "interactive_brokers", diff --git a/tests/test_ibkr_portfolio.py b/tests/test_ibkr_portfolio.py index f994a57..cb67ade 100644 --- a/tests/test_ibkr_portfolio.py +++ b/tests/test_ibkr_portfolio.py @@ -165,14 +165,14 @@ def positions(self): def accountValues(self): return [ - SimpleNamespace(account="U15998061", currency="BASE", tag="NetLiquidation", value="371.93"), - SimpleNamespace(account="U15998061", currency="USD", tag="CashBalance", value="371.93"), - SimpleNamespace(account="U15998061", currency="USD", tag="AvailableFunds", value="371.93"), + SimpleNamespace(account="U00000001", currency="BASE", tag="NetLiquidation", value="371.93"), + SimpleNamespace(account="U00000001", currency="USD", tag="CashBalance", value="371.93"), + SimpleNamespace(account="U00000001", currency="USD", tag="AvailableFunds", value="371.93"), ] snapshot = fetch_portfolio_snapshot( BaseNetLiquidationIB(), - account_ids=("U15998061",), + account_ids=("U00000001",), wait_seconds=0, currency="USD", ) @@ -180,7 +180,7 @@ def accountValues(self): assert snapshot.metadata["total_equity_source"] == "broker_net_liquidation" assert snapshot.metadata["broker_net_liquidation"] == 371.93 assert snapshot.total_equity == 371.93 - assert snapshot.metadata["account_hash"] == "U15998061" + assert snapshot.metadata["account_hash"] == "U00000001" assert isinstance(snapshot.metadata["source_digest_sha256"], str) assert len(snapshot.metadata["source_digest_sha256"]) == 64 @@ -190,7 +190,7 @@ class DriftedMarksIB(FakeIB): def positions(self): return [ SimpleNamespace( - account="U15998061", + account="U00000001", contract=SimpleNamespace(secType="STK", symbol="SOXL", currency="USD"), position=3, avgCost=150.0, @@ -199,14 +199,14 @@ def positions(self): def accountValues(self): return [ - SimpleNamespace(account="U15998061", currency="USD", tag="NetLiquidation", value="472.0"), - SimpleNamespace(account="U15998061", currency="USD", tag="CashBalance", value="40.0"), - SimpleNamespace(account="U15998061", currency="USD", tag="AvailableFunds", value="40.0"), + SimpleNamespace(account="U00000001", currency="USD", tag="NetLiquidation", value="472.0"), + SimpleNamespace(account="U00000001", currency="USD", tag="CashBalance", value="40.0"), + SimpleNamespace(account="U00000001", currency="USD", tag="AvailableFunds", value="40.0"), ] snapshot = fetch_portfolio_snapshot( DriftedMarksIB(), - account_ids=("U15998061",), + account_ids=("U00000001",), wait_seconds=0, currency="USD", cash_only_execution=True, @@ -227,14 +227,14 @@ def positions(self): def accountValues(self): return [ - SimpleNamespace(account="U15998061", currency="BASE", tag="NetLiquidation", value="999.0"), - SimpleNamespace(account="U15998061", currency="USD", tag="NetLiquidation", value="371.93"), - SimpleNamespace(account="U15998061", currency="USD", tag="CashBalance", value="371.93"), + SimpleNamespace(account="U00000001", currency="BASE", tag="NetLiquidation", value="999.0"), + SimpleNamespace(account="U00000001", currency="USD", tag="NetLiquidation", value="371.93"), + SimpleNamespace(account="U00000001", currency="USD", tag="CashBalance", value="371.93"), ] snapshot = fetch_portfolio_snapshot( DualNetLiquidationIB(), - account_ids=("U15998061",), + account_ids=("U00000001",), wait_seconds=0, currency="USD", ) diff --git a/tests/test_reconciliation_evidence_workflow.py b/tests/test_reconciliation_evidence_workflow.py index 52d9657..a14d5a8 100644 --- a/tests/test_reconciliation_evidence_workflow.py +++ b/tests/test_reconciliation_evidence_workflow.py @@ -9,18 +9,18 @@ ) -def test_reconciliation_evidence_matrix_comes_from_validated_manifest() -> None: +def test_reconciliation_evidence_matrix_comes_from_private_inventory() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert "resolve-matrix:" in workflow - assert "render_runtime_target_matrix.py --profile reconciliation --github-output" in workflow + assert "render_runtime_target_matrix.py --profile reconciliation --private-config --github-output" in workflow assert "needs: resolve-matrix" in workflow assert "matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }}" in workflow - # Hardcoded live inventory must live in the public manifest, not the workflow. - assert "interactive-brokers-quant-live-u15998061-service" not in workflow - assert "interactive-brokers-quant-live-u16608560-service" not in workflow - assert "interactive-brokers-quant-live-u18308207-service" not in workflow - assert "interactive-brokers-quant-live-u18336562-service" not in workflow + # Neither public examples nor real account bindings belong in the workflow. + assert "interactive-brokers-quant-live-u00000001-service" not in workflow + assert "interactive-brokers-quant-live-u00000002-service" not in workflow + assert "interactive-brokers-quant-live-u00000003-service" not in workflow + assert "interactive-brokers-quant-live-u00000004-service" not in workflow assert "- profile: soxl_soxx_trend_income\n service:" not in workflow diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index ec5d41c..8a3485e 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -399,7 +399,7 @@ def test_runtime_target_service_name_overrides_shared_account_group_service(monk "IB_ACCOUNT_GROUP_CONFIG_JSON", '{"groups":{"shared-live":{"ib_gateway_instance_name":"ib-gateway",' '"ib_gateway_mode":"live","ib_client_id":1,' - '"service_name":"interactive-brokers-quant-live-u18336562-service",' + '"service_name":"interactive-brokers-quant-live-u00000004-service",' '"account_ids":["U123456"]}}}', ) @@ -1239,26 +1239,26 @@ def test_build_cloud_run_env_sync_plan_supports_per_service_targets(): def _four_gateway_warmup_payload(probe_time: str) -> dict[str, object]: gateway_targets = ( ( - "interactive-brokers-quant-live-u15998061-service", - "live-u15998061", + "interactive-brokers-quant-live-u00000001-service", + "live-u00000001", "soxl_soxx_trend_income", True, ), ( - "interactive-brokers-quant-live-u16608560-service", - "live-u16608560", + "interactive-brokers-quant-live-u00000002-service", + "live-u00000002", "tqqq_growth_income", True, ), ( - "interactive-brokers-quant-live-u18336562-service", - "live-u18336562", + "interactive-brokers-quant-live-u00000004-service", + "live-u00000004", "russell_top50_leader_rotation", False, ), ( - "interactive-brokers-quant-live-u18308207-service", - "live-u18308207", + "interactive-brokers-quant-live-u00000003-service", + "live-u00000003", "global_etf_rotation", False, ), @@ -1341,7 +1341,7 @@ def test_build_cloud_run_env_sync_plan_generates_live_gateway_deadlines() -> Non plan = json.loads(result.stdout) by_service = {target["service_name"]: target for target in plan["targets"]} - gateway_accounts = ("u15998061", "u16608560", "u18336562", "u18308207") + gateway_accounts = ("u00000001", "u00000002", "u00000004", "u00000003") for account in gateway_accounts: service_name = f"interactive-brokers-quant-live-{account}-service" assert by_service[service_name]["env"]["IBKR_EXECUTION_DEDUP_ENABLED"] == "true" @@ -1378,7 +1378,7 @@ def test_build_cloud_run_env_sync_plan_pauses_reconcile_only_execution_schedule( plan = json.loads(result.stdout) by_service = {target["service_name"]: target for target in plan["targets"]} assert by_service[protected["service"]]["standard_execution_enabled"] is False - assert by_service["interactive-brokers-quant-live-u16608560-service"][ + assert by_service["interactive-brokers-quant-live-u00000002-service"][ "standard_execution_enabled" ] is True @@ -1718,7 +1718,7 @@ def test_build_cloud_run_env_sync_plan_honors_explicit_dedup_override() -> None: plan = json.loads(result.stdout) by_service = {target["service_name"]: target for target in plan["targets"]} assert ( - by_service["interactive-brokers-quant-live-u15998061-service"]["env"][ + by_service["interactive-brokers-quant-live-u00000001-service"]["env"][ "IBKR_EXECUTION_DEDUP_ENABLED" ] == "false" @@ -1768,7 +1768,7 @@ def test_build_cloud_run_env_sync_plan_accepts_strategy_defined_gateway_schedule first_plan = next( target for target in plan["targets"] - if target["service_name"] == "interactive-brokers-quant-live-u15998061-service" + if target["service_name"] == "interactive-brokers-quant-live-u00000001-service" ) assert first_plan["scheduler"] == { "timezone": "Asia/Hong_Kong", diff --git a/tests/test_runtime_target_manifest.py b/tests/test_runtime_target_manifest.py index 8043780..43358a2 100644 --- a/tests/test_runtime_target_manifest.py +++ b/tests/test_runtime_target_manifest.py @@ -20,26 +20,26 @@ EXPECTED_LIVE_TARGETS = { "soxl_soxx_trend_income": { "label": "SOXL/SOXX trend income", - "service": "interactive-brokers-quant-live-u15998061-service", - "account_group": "live-u15998061", + "service": "interactive-brokers-quant-live-u00000001-service", + "account_group": "live-u00000001", "strategy_profile": "soxl_soxx_trend_income", }, "tqqq_growth_income": { "label": "TQQQ growth income", - "service": "interactive-brokers-quant-live-u16608560-service", - "account_group": "live-u16608560", + "service": "interactive-brokers-quant-live-u00000002-service", + "account_group": "live-u00000002", "strategy_profile": "tqqq_growth_income", }, "global_etf_rotation": { "label": "Global ETF rotation", - "service": "interactive-brokers-quant-live-u18308207-service", - "account_group": "live-u18308207", + "service": "interactive-brokers-quant-live-u00000003-service", + "account_group": "live-u00000003", "strategy_profile": "global_etf_rotation", }, "russell_top50_leader_rotation": { "label": "Russell top-50 leader rotation", - "service": "interactive-brokers-quant-live-u18336562-service", - "account_group": "live-u18336562", + "service": "interactive-brokers-quant-live-u00000004-service", + "account_group": "live-u00000004", "strategy_profile": "russell_top50_leader_rotation", }, } @@ -53,9 +53,9 @@ def _valid_payload() -> dict: { "id": "soxl_soxx_trend_income", "label": "SOXL/SOXX trend income", - "service": "interactive-brokers-quant-live-u15998061-service", + "service": "interactive-brokers-quant-live-u00000001-service", "region": "us-central1", - "account_group": "live-u15998061", + "account_group": "live-u00000001", "strategy_profile": "soxl_soxx_trend_income", "execution_mode": "live", "lifecycle_role": "live", diff --git a/tests/test_runtime_target_matrix.py b/tests/test_runtime_target_matrix.py index c1279b4..236cc3b 100644 --- a/tests/test_runtime_target_matrix.py +++ b/tests/test_runtime_target_matrix.py @@ -15,23 +15,23 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -# Frozen parity with the pre-manifest hardcoded collect-reconciliation-evidence matrix. +# Synthetic public matrix examples; production bindings come from protected inventory. EXPECTED_RECONCILIATION = [ { "profile": "soxl_soxx_trend_income", - "service": "interactive-brokers-quant-live-u15998061-service", + "service": "interactive-brokers-quant-live-u00000001-service", }, { "profile": "tqqq_growth_income", - "service": "interactive-brokers-quant-live-u16608560-service", + "service": "interactive-brokers-quant-live-u00000002-service", }, { "profile": "global_etf_rotation", - "service": "interactive-brokers-quant-live-u18308207-service", + "service": "interactive-brokers-quant-live-u00000003-service", }, { "profile": "russell_top50_leader_rotation", - "service": "interactive-brokers-quant-live-u18336562-service", + "service": "interactive-brokers-quant-live-u00000004-service", }, ] diff --git a/tests/test_scheduler_deadline_contract.py b/tests/test_scheduler_deadline_contract.py index 0095648..a73f751 100644 --- a/tests/test_scheduler_deadline_contract.py +++ b/tests/test_scheduler_deadline_contract.py @@ -71,7 +71,7 @@ def test_disabled_target_gets_a_paused_canonical_precheck_before_legacy_cleanup( workflow = WORKFLOW.read_text(encoding="utf-8") ensure_precheck = workflow.index('if [ -n "${precheck_state}" ]; then') - enabled_state = workflow.index('case "${standard_execution_enabled}" in') + enabled_state = workflow.index('case "${job_enabled}" in') pause_precheck = workflow.index('gcloud scheduler jobs pause "${managed_job_name}"') retire_legacy = workflow.index('python3 scripts/reconcile_cloud_runtime.py "${reconcile_args[@]}"') diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 01cf4b6..13ba881 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -1600,14 +1600,14 @@ def fake_fetch(ib, **kwargs): return PortfolioSnapshot( as_of=strategy_runtime_module.pd.Timestamp("2026-09-17", tz="UTC").to_pydatetime(), total_equity=500.0, - metadata={"account_hash": "U15998061"}, + metadata={"account_hash": "U00000001"}, ) runtime = strategy_runtime_module.LoadedStrategyRuntime( entrypoint=SimpleNamespace(manifest=SimpleNamespace(profile="soxl_soxx_trend_income")), runtime_settings=replace( _build_runtime_settings(profile="soxl_soxx_trend_income"), - account_ids=("U15998061",), + account_ids=("U00000001",), market_currency="USD", cash_only_execution=True, ), @@ -1616,8 +1616,8 @@ def fake_fetch(ib, **kwargs): ) monkeypatch.setattr(strategy_runtime_module, "fetch_portfolio_snapshot", fake_fetch) snapshot = runtime._fetch_portfolio_snapshot_for_context(object(), required=True) - assert snapshot.metadata["account_hash"] == "U15998061" - assert observed["kwargs"]["account_ids"] == ("U15998061",) + assert snapshot.metadata["account_hash"] == "U00000001" + assert observed["kwargs"]["account_ids"] == ("U00000001",) assert observed["kwargs"]["currency"] == "USD" assert observed["kwargs"]["cash_only_execution"] is True diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index dc66ca5..85a5253 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -160,7 +160,7 @@ grep -Fq 'configured_time("CLOUD_SCHEDULER_MAIN_TIME", "45 15 * * 1-5")' "$workf 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 'str(bool(target.get("standard_execution_enabled", False))).lower()' "$workflow_file" -grep -Fq 'IFS=$'\''\t'\'' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled main_attempt_deadline <<< "${update}"' "$workflow_file" +grep -Fq 'IFS=$'\''\t'\'' read -r cloud_run_service market_timezone main_time warmup_time precheck_time standard_execution_enabled drill_precheck_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" grep -Fq 'for candidate_job in "${scheduler_job_candidates[@]}"; do' "$workflow_file" @@ -188,10 +188,10 @@ grep -Fq 'gcloud scheduler jobs create http "${precheck_job_name}"' "$workflow_f # Precheck may collide with other dry-runs on maxScale=1; retry transient 429s. # Keep /run without these retries to avoid duplicate live submits. test "$(grep -Fc -- '--attempt-deadline=180s' "$workflow_file")" -eq 2 -test "$(grep -Fc -- '--max-retry-attempts=3' "$workflow_file")" -eq 2 +test "$(grep -Fc -- '--max-retry-attempts="${precheck_retry_attempts}"' "$workflow_file")" -eq 2 test "$(grep -Fc -- '--min-backoff=120s' "$workflow_file")" -eq 2 test "$(grep -Fc -- '--max-backoff=300s' "$workflow_file")" -eq 2 -test "$(grep -Fc -- '--max-retry-duration=900s' "$workflow_file")" -eq 2 +test "$(grep -Fc -- '--max-retry-duration="${precheck_retry_duration}"' "$workflow_file")" -eq 2 test "$(grep -Fc -- '--max-retry-attempts=0' "$workflow_file")" -eq 0 grep -Fq 'managed_scheduler_jobs=("${job_name}" "${warmup_job_name}" "${precheck_job_name}")' "$workflow_file" grep -Fq 'for managed_job_name in "${managed_scheduler_jobs[@]}"; do' "$workflow_file" From f9f06783bdc62bec3fe6c4b452ed8fcb706eb47c Mon Sep 17 00:00:00 2001 From: codex-maintenance <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:28:53 +0800 Subject: [PATCH 2/2] Align scheduler contract check with configured retry policy Co-Authored-By: Codex --- tests/test_scheduler_deadline_contract.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_scheduler_deadline_contract.py b/tests/test_scheduler_deadline_contract.py index a73f751..2f7f1c2 100644 --- a/tests/test_scheduler_deadline_contract.py +++ b/tests/test_scheduler_deadline_contract.py @@ -51,10 +51,14 @@ def test_precheck_uses_per_service_scheduler_with_bounded_deadline() -> None: assert 'precheck_job_name="${cloud_run_service%-service}-precheck-scheduler"' in workflow assert 'precheck_uri="${service_url}/dry-run"' in workflow assert workflow.count("--attempt-deadline=180s") == 2 - assert workflow.count("--max-retry-attempts=3") == 2 + assert "precheck_retry_attempts=3" in workflow + assert "precheck_retry_attempts=0" in workflow + assert workflow.count('--max-retry-attempts="${precheck_retry_attempts}"') == 2 assert workflow.count("--min-backoff=120s") == 2 assert workflow.count("--max-backoff=300s") == 2 - assert workflow.count("--max-retry-duration=900s") == 2 + assert "precheck_retry_duration=900s" in workflow + assert "precheck_retry_duration=0s" in workflow + assert workflow.count('--max-retry-duration="${precheck_retry_duration}"') == 2 assert workflow.count("--max-retry-attempts=0") == 0 assert 'managed_scheduler_jobs=("${job_name}" "${warmup_job_name}" "${precheck_job_name}")' in workflow assert 'monitor_job_name="interactive-brokers-monitor-dispatcher-scheduler"' not in workflow