diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index b639234..ac74a9a 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -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 * * *" @@ -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: @@ -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 }} diff --git a/application/http_routes.py b/application/http_routes.py index 341ac9f..b8d547a 100644 --- a/application/http_routes.py +++ b/application/http_routes.py @@ -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.`` against ``sys.modules["main"]`` on every access. @@ -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, diff --git a/scripts/publish_account_facts_from_report.py b/scripts/publish_account_facts_from_report.py index 3d434d8..cb5a0cd 100644 --- a/scripts/publish_account_facts_from_report.py +++ b/scripts/publish_account_facts_from_report.py @@ -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), @@ -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, diff --git a/tests/test_publish_ibkr_account_facts.py b/tests/test_publish_ibkr_account_facts.py index 0d1e003..b20a30f 100644 --- a/tests/test_publish_ibkr_account_facts.py +++ b/tests/test_publish_ibkr_account_facts.py @@ -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): @@ -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" diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 268f18a..9b2cdeb 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -687,6 +687,128 @@ def fake_connect_ib(*, read_only=False, validate_trading_permissions=True): ] +@pytest.mark.parametrize( + ("selector", "account_ids", "publishes_facts"), + [ + (("U00000001",), ("U00000001",), True), + (("U00000002",), ("U00000001",), False), + (("U00000001",), ("U00000001", "U00000002"), False), + (("default",), ("U00000001",), False), + ], +) +def test_probe_projects_only_target_bound_facts_without_market_or_strategy_gate( + strategy_module_factory, monkeypatch, selector, account_ids, publishes_facts +): + strategy_module = strategy_module_factory() + monkeypatch.setattr( + strategy_module, + "RUNTIME_SETTINGS", + replace( + strategy_module.RUNTIME_SETTINGS, + runtime_target=replace( + strategy_module.RUNTIME_SETTINGS.runtime_target, + account_selector=selector, + ), + ), + ) + snapshot = types.SimpleNamespace( + as_of=datetime(2026, 9, 30, 20, tzinfo=timezone(timedelta(hours=8))), + metadata={ + "account_ids": account_ids, + "total_equity_source": "broker_net_liquidation", + "broker_net_liquidation": "1234.50", + "cash_balances": ( + { + "account_id": "U00000001", + "currency": "USD", + "NetLiquidation": "1234.50", + "CashBalance": "0", + }, + { + "account_id": "U00000001", + "currency": "HKD", + "$LEDGER-TotalCashBalance": "-4.25", + }, + ), + }, + positions=(), + buying_power=9999.0, + total_equity=9998.0, + ) + observed = { + "snapshot_reads": 0, + "disconnects": 0, + "facts": None, + "scope": None, + "connection_options": [], + } + + class FakeIB: + def disconnect(self): + observed["disconnects"] += 1 + + monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: object()) + monkeypatch.setattr( + strategy_module, + "build_execution_report", + lambda *_args, **_kwargs: {"status": "pending", "account_scope": "scope-fixture"}, + ) + monkeypatch.setattr( + strategy_module, + "persist_execution_report", + lambda report, **_kwargs: observed.update( + facts=report.get("summary", {}).get("account_facts"), + scope=report.get("account_scope"), + ) or "/tmp/runtime-report.json", + ) + monkeypatch.setattr(strategy_module, "log_runtime_event", lambda *_args, **_kwargs: None) + def connect_read_only(**kwargs): + observed["connection_options"].append(kwargs) + return FakeIB() + + monkeypatch.setattr(strategy_module, "connect_ib", connect_read_only) + + def read_snapshot(_ib): + observed["snapshot_reads"] += 1 + return snapshot + + monkeypatch.setattr(strategy_module, "build_portfolio_snapshot", read_snapshot) + monkeypatch.setattr( + strategy_module, + "is_market_open_now", + lambda **_kwargs: pytest.fail("read-only probe must not consult the strategy market gate"), + ) + monkeypatch.setattr( + strategy_module, + "run_strategy_core", + lambda **_kwargs: pytest.fail("read-only probe must not run strategy execution"), + ) + + with strategy_module.app.test_request_context("/probe", method="POST"): + body, status = strategy_module.handle_probe() + + assert (body, status) == ("Probe OK", 200) + assert observed["snapshot_reads"] == 1 + assert observed["disconnects"] == 1 + assert observed["scope"] == "scope-fixture" + assert observed["connection_options"] == [ + {"read_only": True, "validate_trading_permissions": False} + ] + if publishes_facts: + assert observed["facts"]["account_ids"] == ["U00000001"] + assert observed["facts"]["net_assets"] == "1234.50" + assert observed["facts"]["cash"] == [ + {"currency": "USD", "cash_balance": "0", "source_tag": "CashBalance"}, + { + "currency": "HKD", + "cash_balance": "-4.25", + "source_tag": "$LEDGER-TotalCashBalance", + }, + ] + else: + assert observed["facts"] is None + + def test_handle_probe_connect_timeout_sends_concise_connection_notification(strategy_module, monkeypatch): observed = {"events": [], "notifications": []} timeout_message = (