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
58 changes: 47 additions & 11 deletions .github/workflows/execution-report-heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ on:
default: disabled
options:
- disabled
- live-u16608560
- primary-live
- ingress-diagnostic
schedule:
- cron: "20 22 * * *"
Expand Down Expand Up @@ -122,20 +122,56 @@ jobs:
env:
EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }}

- name: Publish one fresh IBKR account-facts report
if: ${{ github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'live-u16608560' }}
env:
IBKR_ACCOUNT_FACTS_TARGET: ${{ inputs.account_facts_target }}
IBKR_ACCOUNT_FACTS_REPORT_PREFIX: gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560
IBKR_ACCOUNT_FACTS_RUNTIME_REVISION: ${{ vars.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION }}
IBKR_ACCOUNT_FACTS_SYNC_URL: ${{ vars.IBKR_ACCOUNT_FACTS_SYNC_URL }}
IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }}
run: uv run --no-sync python scripts/publish_account_facts_from_report.py

- 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

account-facts-publisher:
name: Publish latest fresh IBKR account-facts report
if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'primary-live') }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
env:
GCP_PROJECT_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_PROJECT_ID }}
GCP_WORKLOAD_IDENTITY_PROVIDER: projects/303168642265/locations/global/workloadIdentityPools/github-actions/providers/github-main
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ibkr-platform-deploy@interactivebrokersquant.iam.gserviceaccount.com
IBKR_ACCOUNT_FACTS_TARGET: primary-live
IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }}
IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }}
IBKR_ACCOUNT_FACTS_SERVICE_NAME: ${{ secrets.IBKR_ACCOUNT_FACTS_SERVICE_NAME }}
IBKR_ACCOUNT_FACTS_RUNTIME_REVISION: ${{ secrets.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION }}
IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE }}
IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON }}
IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR: ${{ secrets.IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR }}
IBKR_ACCOUNT_FACTS_SYNC_URL: ${{ vars.IBKR_ACCOUNT_FACTS_SYNC_URL }}
IBKR_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Setup uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78

- name: Install dependencies
run: |
set -euo pipefail
uv sync --frozen --no-dev

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}

- name: Set up gcloud
uses: google-github-actions/setup-gcloud@v3

- name: Publish one validated report
run: uv run --no-sync python scripts/publish_account_facts_from_report.py

account-facts-ingress-diagnostic:
name: Verify account-facts ingress authentication only
if: ${{ github.event_name == 'workflow_dispatch' && inputs.account_facts_target == 'ingress-diagnostic' }}
Expand Down
6 changes: 4 additions & 2 deletions scripts/README_account_facts_projection.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The projected record uses `schema_version: "ibkr_account_snapshot_history.v1"` a

`source_binding.id` is SHA-256 over sorted, compact canonical JSON containing only `project_id`, `service_name`, `runtime_revision`, `account_scope`, the original one-element `account_selector`, and `deployment_selector`. It excludes amounts, report URI, and token versions. The report URI is used only for GCS prefix validation and is never added to the history body. Native account IDs must match `U` or `DU` followed by digits.

The same script also has a workflow-only publisher entry point. It is disabled unless the manual workflow input explicitly selects `live-u16608560`. That path lists only the exact TQQQ/U16608560 prefix `gs://qsl-runtime-logs-shared/execution-reports/interactive_brokers/tqqq_growth_income/live-u16608560`, selects the newest timestamp-named report, reads that one object, and requires `finished_at` within the last 15 minutes or at most 5 minutes in the future. The expected Cloud Run revision comes from `vars.IBKR_ACCOUNT_FACTS_RUNTIME_REVISION`; it is never learned from the report being checked. An unset revision, missing account facts, stale report, or metadata mismatch stops before publishing.
The workflow publisher is disabled by default. A manual run must select the generic `primary-live` action; the existing daily heartbeat schedule invokes the same publisher in a separate job so its outcome is visible even if the heartbeat check fails. It lists only the report prefix supplied by protected configuration, selects the newest timestamp-named report across the current and preceding month, and never falls back to an older report if the newest report is stale or lacks account facts. Freshness is based on the actual aware account observation within the existing 36-hour display window (and at most five minutes in the future); the timestamp is not rewritten. The publisher does not run a broker query, Cloud Scheduler job, or Cloud Run request.

Publishing requires `vars.IBKR_ACCOUNT_FACTS_SYNC_URL` to exactly equal `https://qsl-strategy-switch-console.pigbibi.workers.dev/api/account-facts/sync` and the dedicated `secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN`. It makes one HTTPS POST attempt without redirects or retries, and prints fixed status/reason codes only. Scheduled heartbeat runs never call this path. This publisher does not invoke a broker, scheduler, or new runtime report, and does not use the legacy execution-evidence token.
All private target mapping is supplied by protected Secrets rather than public workflow values: `IBKR_ACCOUNT_FACTS_REPORT_PREFIX`, `IBKR_ACCOUNT_FACTS_TARGET_ID`, `IBKR_ACCOUNT_FACTS_PROJECT_ID`, `IBKR_ACCOUNT_FACTS_SERVICE_NAME`, `IBKR_ACCOUNT_FACTS_RUNTIME_REVISION`, `IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE`, `IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON`, and `IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR`. `IBKR_ACCOUNT_FACTS_SYNC_TOKEN` remains the dedicated publisher credential; `IBKR_ACCOUNT_FACTS_SYNC_URL` remains an exact-match protected variable. Missing or malformed mapping, an absent account-facts observation, stale observation, or any metadata mismatch stops without POST.

Publishing requires the exact configured sync endpoint and dedicated `secrets.IBKR_ACCOUNT_FACTS_SYNC_TOKEN`. It makes one HTTPS POST attempt without redirects or retries, and prints fixed status/reason codes only. The publisher reports only the transport/storage result; daily automatic refresh is accepted only after a later natural runtime report carries a newer original `account_facts.observed_at`. Replaying an unchanged report is not evidence of an automatic refresh. This publisher does not use the legacy execution-evidence token.
86 changes: 62 additions & 24 deletions scripts/publish_account_facts_from_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
IBKR_ACCOUNT_FACTS_USER_AGENT = "QSL-IBKR-AccountFacts/1.0"
IBKR_ACCOUNT_FACTS_MAX_AGE = timedelta(hours=36)
IBKR_ACCOUNT_FACTS_INGRESS_DIAGNOSTIC_TARGET = "ingress-diagnostic"
IBKR_ACCOUNT_FACTS_PRIMARY_TARGET = "primary-live"
_INGRESS_DIAGNOSTIC_BODY = b"{}"
_INGRESS_DIAGNOSTIC_ERROR = "invalid_account_facts_history"
_REPORT_RUN_ID = re.compile(r"^\d{8}T\d{6}Z\.json$")
Expand Down Expand Up @@ -99,6 +100,24 @@ def _exact_text(value: object) -> str:
return value if isinstance(value, str) and value and value == value.strip() else ""


def _required_private_setting(name: str) -> str:
value = _exact_text(os.environ.get(name))
if not value:
raise _ProjectionError("target_config_unavailable")
return value


def _expected_account_selector() -> tuple[str, ...]:
raw = _required_private_setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON")
try:
selector = _selector(json.loads(raw))
except (json.JSONDecodeError, TypeError):
raise _ProjectionError("target_config_invalid") from None
if len(selector) != 1 or selector[0].lower() == "default":
raise _ProjectionError("target_config_invalid")
return selector


def _observed_timestamp(value: object) -> datetime:
if not isinstance(value, str) or not value.strip():
raise _ProjectionError("observation_invalid")
Expand Down Expand Up @@ -303,6 +322,34 @@ def publish_ibkr_account_facts_history(
http_status=_numeric_http_status(response.status),
qrs_error_code="unknown",
)
response_body = response.read(_HTTP_ERROR_BODY_LIMIT + 1)
if not isinstance(response_body, bytes) or len(response_body) > _HTTP_ERROR_BODY_LIMIT:
return _publish_failed(
stage="http_response",
category="response_invalid",
http_status=_numeric_http_status(response.status),
)
try:
response_payload = json.loads(response_body)
except (json.JSONDecodeError, TypeError):
return _publish_failed(
stage="http_response",
category="response_invalid",
http_status=_numeric_http_status(response.status),
)
if (
not isinstance(response_payload, Mapping)
or response_payload.get("ok") is not True
or response_payload.get("stored") is not True
or not isinstance(response_payload.get("unchanged"), bool)
):
return _publish_failed(
stage="http_response",
category="response_invalid",
http_status=_numeric_http_status(response.status),
)
if response_payload["unchanged"]:
return {"status": "unchanged", "reason": "observation_unchanged"}
except HTTPError as exc:
return _publish_failed(
stage="http_response",
Expand Down Expand Up @@ -461,7 +508,7 @@ def diagnose_account_facts_ingress(*, sync_token: str) -> dict[str, Any]:


def _format_cli_result(result: Mapping[str, Any]) -> str:
status = result.get("status") if result.get("status") in {"published", "verified", "skipped"} else "skipped"
status = result.get("status") if result.get("status") in {"published", "unchanged", "verified", "skipped"} else "skipped"
reason = result.get("reason") if isinstance(result.get("reason"), str) else "unknown"
diagnostic = result.get("diagnostics")
if not isinstance(diagnostic, Mapping):
Expand All @@ -474,7 +521,7 @@ def _format_cli_result(result: Mapping[str, Any]) -> str:
category = (
diagnostic.get("category")
if diagnostic.get("category") in {
"http_status", "http_error", "timeout", "url_error", "transport_error", "unknown"
"http_status", "http_error", "response_invalid", "timeout", "url_error", "transport_error", "unknown"
}
else "unknown"
)
Expand Down Expand Up @@ -557,33 +604,24 @@ def main() -> int:
)
print(_format_cli_result(result))
return 0 if result.get("status") == "verified" else 1
if target != "live-u16608560":
if target != IBKR_ACCOUNT_FACTS_PRIMARY_TARGET:
print("skipped:target_disabled")
return 0
prefix = _text(os.environ.get("IBKR_ACCOUNT_FACTS_REPORT_PREFIX"))
expected_prefix = (
"gs://qsl-runtime-logs-shared/execution-reports/"
"interactive_brokers/tqqq_growth_income/live-u16608560"
)
if prefix != expected_prefix:
print("skipped:report_prefix_mismatch")
return 1
prefix = _required_private_setting("IBKR_ACCOUNT_FACTS_REPORT_PREFIX")
expected_prefix = prefix
expected = {
"target_id": "ibkr-u16608560",
"target_id": _required_private_setting("IBKR_ACCOUNT_FACTS_TARGET_ID"),
"expected_report_prefix": expected_prefix,
"expected_project_id": "interactivebrokersquant",
"expected_service_name": "interactive-brokers-quant-live-u16608560-service",
"expected_runtime_revision": _text(os.environ.get("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION")),
"expected_account_scope": "live-u16608560",
"expected_account_selector": ["U16608560"],
"expected_deployment_selector": "live-u16608560",
"expected_project_id": _required_private_setting("IBKR_ACCOUNT_FACTS_PROJECT_ID"),
"expected_service_name": _required_private_setting("IBKR_ACCOUNT_FACTS_SERVICE_NAME"),
"expected_runtime_revision": _required_private_setting("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION"),
"expected_account_scope": _required_private_setting("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE"),
"expected_account_selector": _expected_account_selector(),
"expected_deployment_selector": _required_private_setting("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR"),
}
if not expected["expected_runtime_revision"]:
print("skipped:expected_revision_unavailable")
return 1
now = datetime.now(timezone.utc)
uri = _latest_report_uri(prefix=expected_prefix, project_id="interactivebrokersquant", now=now)
report = _load_gcs_report(uri, project_id="interactivebrokersquant")
uri = _latest_report_uri(prefix=expected_prefix, project_id=expected["expected_project_id"], now=now)
report = _load_gcs_report(uri, project_id=expected["expected_project_id"])
result = publish_ibkr_account_facts_history(
report,
now=now,
Expand All @@ -593,7 +631,7 @@ def main() -> int:
**expected,
)
print(_format_cli_result(result))
return 0 if result["status"] == "published" else 1
return 0 if result["status"] in {"published", "unchanged"} else 1
except _ProjectionError as exc:
print(f"skipped:{exc.reason}")
return 1
Expand Down
Loading
Loading