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
8 changes: 7 additions & 1 deletion .github/workflows/execution-report-heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ on:
- disabled
- primary-live
- ingress-diagnostic
account_facts_report_name:
description: "Optional exact UTC runtime report filename for account-facts publishing."
required: false
type: string
default: ""
schedule:
- cron: "20 22 * * *"

Expand All @@ -40,7 +45,7 @@ concurrency:
jobs:
heartbeat:
name: Check execution report heartbeat
if: ${{ github.event_name != 'workflow_dispatch' || inputs.account_facts_target != 'ingress-diagnostic' }}
if: ${{ github.event_name != 'workflow_dispatch' || (inputs.account_facts_target != 'ingress-diagnostic' && (inputs.account_facts_target != 'primary-live' || inputs.account_facts_report_name == '')) }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
Expand Down Expand Up @@ -139,6 +144,7 @@ jobs:
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_NAME: ${{ inputs.account_facts_report_name }}
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 }}
Expand Down
16 changes: 11 additions & 5 deletions application/http_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
from google.api_core.exceptions import PreconditionFailed
from google.cloud import storage

from application.account_facts import build_ibkr_account_facts


class _MainModuleProxy:
"""Resolve ``main.<name>`` against ``sys.modules["main"]`` on every access.
Expand Down Expand Up @@ -97,14 +99,18 @@ def _handle_probe(*, response_body: str = "Probe OK"):
positions = tuple(getattr(snapshot, "positions", ()) or ())
buying_power = float(getattr(snapshot, "buying_power", 0.0) or 0.0)
total_equity = float(getattr(snapshot, "total_equity", 0.0) or 0.0)
summary = {
"buying_power": buying_power,
"total_equity": total_equity,
"positions_count": len(positions),
}
account_facts = build_ibkr_account_facts(snapshot)
if account_facts and main._account_facts_match_runtime_target(account_facts):
summary["account_facts"] = account_facts
main.finalize_runtime_report(
report,
status="ok",
summary={
"buying_power": buying_power,
"total_equity": total_equity,
"positions_count": len(positions),
},
summary=summary,
)
main.log_runtime_event(
log_context,
Expand Down
31 changes: 30 additions & 1 deletion scripts/publish_account_facts_from_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,23 @@ def _latest_report_uri(*, prefix: str, project_id: str, now: datetime) -> str:
return max(candidates, key=lambda row: row[0])[1]


def _named_report_uri(*, prefix: str, report_name: str, now: datetime) -> str:
"""Resolve one strictly named report beneath the protected prefix."""
if not isinstance(report_name, str) or _REPORT_RUN_ID.fullmatch(report_name) is None:
raise _ProjectionError("report_name_invalid")
try:
report_time = datetime.strptime(report_name[:-5], "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc
)
except ValueError:
raise _ProjectionError("report_name_invalid") from None
if report_time.strftime("%Y%m%dT%H%M%SZ.json") != report_name:
raise _ProjectionError("report_name_invalid")
if report_time > now.astimezone(timezone.utc):
raise _ProjectionError("report_name_future")
return f"{prefix.rstrip('/')}/{report_time:%Y-%m}/{report_name}"


def _load_gcs_report(uri: str, *, project_id: str) -> dict[str, Any]:
result = subprocess.run(
("gcloud", "storage", "cat", uri, "--project", project_id),
Expand Down Expand Up @@ -620,7 +637,19 @@ def main() -> int:
"expected_deployment_selector": _required_private_setting("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR"),
}
now = datetime.now(timezone.utc)
uri = _latest_report_uri(prefix=expected_prefix, project_id=expected["expected_project_id"], now=now)
report_name = os.environ.get("IBKR_ACCOUNT_FACTS_REPORT_NAME", "")
if report_name:
uri = _named_report_uri(
prefix=expected_prefix,
report_name=report_name,
now=now,
)
else:
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,
Expand Down
156 changes: 156 additions & 0 deletions tests/test_publish_ibkr_account_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,21 @@ def test_workflow_scheduled_publisher_is_independent_and_single_target():
assert "IBKR_ACCOUNT_FACTS_REPORT_PREFIX: ${{ secrets.IBKR_ACCOUNT_FACTS_REPORT_PREFIX }}" in publisher_job
assert "IBKR_ACCOUNT_FACTS_TARGET_ID: ${{ secrets.IBKR_ACCOUNT_FACTS_TARGET_ID }}" in publisher_job
assert "id-token: write" in publisher_job
assert "IBKR_ACCOUNT_FACTS_REPORT_NAME: ${{ inputs.account_facts_report_name }}" in publisher_job


def test_workflow_explicit_report_name_skips_only_manual_heartbeat():
workflow = Path(__file__).parents[1] / ".github/workflows/execution-report-heartbeat.yml"
source = workflow.read_text()
assert "account_facts_report_name:" in source
assert 'type: string\n default: ""' in source
heartbeat_if = source.split(" heartbeat:", 1)[1].split(" account-facts-publisher:", 1)[0]
assert "github.event_name != 'workflow_dispatch'" in heartbeat_if
assert "inputs.account_facts_target != 'primary-live'" in heartbeat_if
assert "inputs.account_facts_report_name == ''" in heartbeat_if
publisher_job = source.split(" account-facts-publisher:", 1)[1].split(" account-facts-ingress-diagnostic:", 1)[0]
assert "github.event_name == 'schedule'" in publisher_job
assert "inputs.account_facts_target == 'primary-live'" in publisher_job


def test_latest_report_listing_is_confined_to_exact_prefix(monkeypatch):
Expand Down Expand Up @@ -774,3 +789,144 @@ def run(argv, **_kwargs):
)
assert uri.endswith("/2026-09/20260930T010000Z.json")
assert all("live-primary/" in value for value in seen)


def test_named_report_uri_uses_strict_utc_filename_and_protected_prefix():
now = datetime(2026, 10, 1, 13, 0, tzinfo=timezone.utc)
assert publisher._named_report_uri(
prefix="gs://example-private/reports/ibkr",
report_name="20261001T125959Z.json",
now=now,
) == "gs://example-private/reports/ibkr/2026-10/20261001T125959Z.json"

for report_name in (
"20260230T120000Z.json",
"2026101T125959Z.json",
"20261001T130001Z.json",
"gs://attacker.example/20261001T125959Z.json",
"../20261001T125959Z.json",
"20261001T125959Z.json/extra",
):
with pytest.raises(publisher._ProjectionError):
publisher._named_report_uri(
prefix="gs://example-private/reports/ibkr",
report_name=report_name,
now=now,
)


def test_named_report_cli_skips_listing_and_rejects_bad_or_future_names_before_post(
monkeypatch, capsys
):
class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 10, 1, 13, 0, tzinfo=timezone.utc)

monkeypatch.setattr(publisher, "datetime", FrozenDateTime)
for name, reason in (
("20260230T120000Z.json", "report_name_invalid"),
("20261001T130001Z.json", "report_name_future"),
("gs://attacker.example/report.json", "report_name_invalid"),
):
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_PRIMARY_TARGET)
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://example-private/reports/ibkr")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "ibkr-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_PROJECT_ID", "example-project")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SERVICE_NAME", "ibkr-primary-service")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "runtime-revision-001")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["U00000001"]')
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_NAME", name)
monkeypatch.setattr(
publisher,
"_latest_report_uri",
lambda **_kwargs: pytest.fail("explicit report selection must not list latest reports"),
)
monkeypatch.setattr(
publisher,
"_load_gcs_report",
lambda *_args, **_kwargs: pytest.fail("invalid report name must stop before GCS read"),
)
monkeypatch.setattr(
publisher,
"publish_ibkr_account_facts_history",
lambda *_args, **_kwargs: pytest.fail("invalid report name must stop before POST"),
)

assert publisher.main() == 1
assert capsys.readouterr().out.strip() == f"skipped:{reason}"


def test_named_report_cli_loads_only_exact_prefix_derived_object(monkeypatch, capsys):
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_PRIMARY_TARGET)
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://example-private/reports/ibkr")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "ibkr-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_PROJECT_ID", "example-project")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SERVICE_NAME", "ibkr-primary-service")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "runtime-revision-001")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["U00000001"]')
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_NAME", "20261001T125959Z.json")
selected = []
monkeypatch.setattr(
publisher,
"_latest_report_uri",
lambda **_kwargs: pytest.fail("explicit report selection must not list latest reports"),
)
monkeypatch.setattr(
publisher,
"_load_gcs_report",
lambda uri, **_kwargs: selected.append(uri) or _report(),
)
monkeypatch.setattr(
publisher,
"publish_ibkr_account_facts_history",
lambda _report, **kwargs: {"status": "published"},
)

class FrozenDateTime(datetime):
@classmethod
def now(cls, tz=None):
return datetime(2026, 10, 1, 13, 0, tzinfo=timezone.utc)

monkeypatch.setattr(publisher, "datetime", FrozenDateTime)
assert publisher.main() == 0
assert selected == ["gs://example-private/reports/ibkr/2026-10/20261001T125959Z.json"]
assert capsys.readouterr().out.strip() == "published:unknown"


def test_empty_named_report_preserves_latest_report_selection(monkeypatch, capsys):
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET", publisher.IBKR_ACCOUNT_FACTS_PRIMARY_TARGET)
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_PREFIX", "gs://example-private/reports/ibkr")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_TARGET_ID", "ibkr-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_PROJECT_ID", "example-project")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_SERVICE_NAME", "ibkr-primary-service")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_RUNTIME_REVISION", "runtime-revision-001")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SCOPE", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON", '["U00000001"]')
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_DEPLOYMENT_SELECTOR", "live-primary")
monkeypatch.setenv("IBKR_ACCOUNT_FACTS_REPORT_NAME", "")
latest = []
loaded = []
monkeypatch.setattr(
publisher,
"_latest_report_uri",
lambda **_kwargs: latest.append(True) or "gs://example-private/reports/ibkr/2026-10/20261001T120000Z.json",
)
monkeypatch.setattr(
publisher,
"_load_gcs_report",
lambda uri, **_kwargs: loaded.append(uri) or _report(),
)
monkeypatch.setattr(
publisher,
"publish_ibkr_account_facts_history",
lambda _report, **_kwargs: {"status": "published"},
)
assert publisher.main() == 0
assert latest == [True]
assert loaded == ["gs://example-private/reports/ibkr/2026-10/20261001T120000Z.json"]
assert capsys.readouterr().out.strip() == "published:unknown"
Loading
Loading