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
44 changes: 38 additions & 6 deletions .github/workflows/collect-reconciliation-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)')"
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/execution-report-heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
27 changes: 19 additions & 8 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""),
]
)
Expand All @@ -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
Expand Down Expand Up @@ -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}" \
Expand All @@ -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}."
Expand All @@ -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}")
Expand All @@ -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}" \
Expand All @@ -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}" \
Expand Down
18 changes: 9 additions & 9 deletions config/runtime_targets.manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
45 changes: 16 additions & 29 deletions docs/runtime_target_manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,不携带私有服务名。

## 字段契约

Expand Down Expand Up @@ -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 不进入对账矩阵。新增目标默认停用,配置清单本身不授予交易权限。

## 本地校验

Expand Down
20 changes: 20 additions & 0 deletions scripts/build_cloud_run_env_sync_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down
Loading
Loading