diff --git a/.github/workflows/publish-ibkr-digest-cash.yml b/.github/workflows/publish-ibkr-digest-cash.yml new file mode 100644 index 00000000..27598612 --- /dev/null +++ b/.github/workflows/publish-ibkr-digest-cash.yml @@ -0,0 +1,62 @@ +name: Publish IBKR Digest Settled Cash + +on: + workflow_dispatch: + inputs: + read_once: + description: Read one configured Flex report and publish private period-end cash + type: boolean + default: false + schedule: + # Saturday UTC also collects Friday's completed statement if it was not ready on Friday. + - cron: "5 22 * * 1-6" + +permissions: {} +concurrency: + group: ibkr-flex-period-return + cancel-in-progress: false + +jobs: + publish: + if: >- + github.repository == 'QuantStrategyLab/InteractiveBrokersPlatform' && + github.ref == 'refs/heads/main' && github.run_attempt == 1 && + (github.event_name == 'schedule' || inputs.read_once) && + vars.IBKR_FLEX_DIGEST_JOB_REFS != '' && vars.IBKR_FLEX_DIGEST_WIF_PROVIDER != '' && + vars.DIGEST_CANDIDATES_GCS_URI != '' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - name: Require infrastructure variables + env: + IBKR_DEPLOY_SERVICE_ACCOUNT: ${{ vars.IBKR_DEPLOY_SERVICE_ACCOUNT }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${IBKR_DEPLOY_SERVICE_ACCOUNT:-}" ]]; then + echo "::error::IBKR_DEPLOY_SERVICE_ACCOUNT is required" >&2 + exit 2 + fi + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 + - run: uv sync --frozen --no-dev + - name: Authenticate to the existing digest candidate store + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 + with: + workload_identity_provider: ${{ vars.IBKR_FLEX_DIGEST_WIF_PROVIDER }} + service_account: ${{ vars.IBKR_DEPLOY_SERVICE_ACCOUNT }} + - name: Generate one Flex report and write bound cash objects + run: uv run --no-sync python -m scripts.publish_ibkr_digest_cash + env: + IBKR_FLEX_TOKEN: ${{ secrets.IBKR_FLEX_TOKEN }} + IBKR_FLEX_QUERY_ID: ${{ secrets.IBKR_FLEX_QUERY_ID }} + IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON: ${{ secrets.IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON }} + IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON }} + IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON: ${{ secrets.IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON }} + IBKR_FLEX_DIGEST_JOB_REFS: ${{ vars.IBKR_FLEX_DIGEST_JOB_REFS }} + DIGEST_CANDIDATES_GCS_URI: ${{ vars.DIGEST_CANDIDATES_GCS_URI }} diff --git a/docs/ibkr-flex-ledger.zh-CN.md b/docs/ibkr-flex-ledger.zh-CN.md index 55cdd896..e4a7074d 100644 --- a/docs/ibkr-flex-ledger.zh-CN.md +++ b/docs/ibkr-flex-ledger.zh-CN.md @@ -162,6 +162,12 @@ TWR,不是账户收费计划、当前已交收资金、证券/碎股权限、 ## 原生期间收益的一次发布 +配置 `IBKR_FLEX_DIGEST_JOB_REFS` 和现有摘要存储的云身份后,定时任务从一份独立的 +Flex 报表取四个日报账户的 USD 期末已结算现金。每户通过现有 Cloud Run 日报任务的 +账号和身份核对,金额与报表日期保存到各自的私有对象。完整收益报表仍按原规则校验。 +手动运行 `Publish IBKR Digest Settled Cash` 并选择 `read_once` 时只写现金摘要。 +现金和收益任务共用查询并发组,避免同时生成报表。 + `application.ibkr_period_return.build_ibkr_period_return` 从已复核账户的账本提取 `ibkr_account_period_return.v1`。它只接受唯一受保护预期账户、有效且一致的报表/原生收益 期间、`return_assessment.computable=True`、`native_ibkr_twr` 方法与 `ChangeInNAV.twr` diff --git a/scripts/publish_ibkr_digest_cash.py b/scripts/publish_ibkr_digest_cash.py new file mode 100644 index 00000000..d01bd7b1 --- /dev/null +++ b/scripts/publish_ibkr_digest_cash.py @@ -0,0 +1,161 @@ +"""Publish only account-bound IBKR Flex period-end cash for the private digest.""" + +from __future__ import annotations + +import json +import os +import re +import time +from datetime import date, datetime +from urllib.parse import urlsplit +from zoneinfo import ZoneInfo + +from application.ibkr_flex_ledger import build_flex_period_cash_facts +from application.account_facts_target_config import account_selector, parse_additional_target_configs +from application.ibkr_flex_source import ( + FlexReportPending, _xml_root, collect_activity_flex_xml, fetch_activity_flex_xml, verify_report_accounts, +) + +_JOB = re.compile(r"projects/[a-z0-9-]+/locations/[a-z0-9-]+/jobs/[a-z0-9-]+") +_SLOT = re.compile(r"[a-z0-9][a-z0-9_-]{0,40}") +_AMOUNT = re.compile(r"-?[0-9]+(?:\.[0-9]+)?") +_SCHEMA = "ibkr_digest_settled_cash.v1" + + +def _bindings(job_refs, destination, session): + refs = json.loads(job_refs) + if (not isinstance(refs, list) or len(refs) != 4 + or any(not isinstance(ref, str) or not _JOB.fullmatch(ref) for ref in refs) + or len(set(refs)) != 4): + raise ValueError("digest_cash_jobs_invalid") + bindings = {} + for ref in refs: + response = session.get("https://run.googleapis.com/v2/" + ref, timeout=10, allow_redirects=False) + response.raise_for_status() + job = response.json() + container, = job["template"]["template"]["containers"] + env = {row["name"]: row.get("value") for row in container["env"]} + runtime = json.loads(env["RUNTIME_TARGET_JSON"]) + account, = runtime["account_selector"] + slot = env["DIGEST_STORE_TARGET"] + uid, target = env["DIGEST_OPAQUE_ACCOUNT_UID"], env["DIGEST_TARGET_ID"] + if (job["name"] != ref or env["DIGEST_CANDIDATES_GCS_URI"].rstrip("/") != destination + or runtime["platform_id"] != "ibkr" or runtime["dry_run_only"] is not False + or not re.fullmatch(r"U[0-9]+", account) or account in bindings or not _SLOT.fullmatch(slot) + or not all(isinstance(value, str) and 0 < len(value) <= 128 for value in (uid, target))): + raise ValueError("digest_cash_binding_invalid") + bindings[account] = (slot, uid, target) + if any(len(set(values)) != 4 for values in zip(*bindings.values(), strict=True)): + raise ValueError("digest_cash_binding_duplicate") + return bindings + + +def project_digest_cash(report, *, expected_primary, allowed_accounts, bindings, latest_day: date): + """Project verified Cash Report values without reading other statement sections.""" + if len(bindings) != 4 or set(bindings) != set(allowed_accounts): + raise ValueError("digest_cash_account_scope_mismatch") + verify_report_accounts(report, expected_account_ids=expected_primary, allowed_report_account_ids=allowed_accounts) + root = _xml_root(report.content, endpoint="GetStatement") + if (root.get("type") or "").strip() not in {"", "AF"}: + raise ValueError("digest_cash_activity_report_required") + from application.ibkr_flex_executions import _day + + payloads = {} + for statement in root.findall("./FlexStatements/FlexStatement"): + account = statement.get("accountId", "").strip() + slot, uid, target = bindings[account] + period = {"from": _day(statement.get("fromDate")), "to": _day(statement.get("toDate"))} + start, day = period["from"], period["to"] + if not start or not day or start > day or date.fromisoformat(day) > latest_day: + continue + if any(row.get("accountId", "").strip() not in {"", account} + for section in statement.findall("CashReport") for row in section.iter()): + continue + facts = build_flex_period_cash_facts(statement, source_ref=report.content_sha256, period=period) + if facts["status"] != "available_period_end": + continue + amount = facts["values"]["endingSettledCash"] + if len(amount) > 64 or not _AMOUNT.fullmatch(amount): + continue + payloads[slot] = {"schema_version": _SCHEMA, "target_id": target, "opaque_account_uid": uid, + "statement_date": day, "currency": "USD", "amount": amount} + return payloads + + +def publish_digest_cash(report, *, expected_primary, allowed_accounts, job_refs, destination): + import google.auth + from google.api_core.exceptions import NotFound + from google.auth.transport.requests import AuthorizedSession + from google.cloud import storage + + destination = destination.rstrip("/") + uri = urlsplit(destination) + if uri.scheme != "gs" or not uri.netloc or not uri.path.strip("/") or uri.query or uri.fragment: + raise ValueError("digest_cash_store_invalid") + credentials, project = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) + with AuthorizedSession(credentials) as session: + bindings = _bindings(job_refs, destination, session) + payloads = project_digest_cash(report, expected_primary=expected_primary, allowed_accounts=allowed_accounts, bindings=bindings, + latest_day=datetime.now(ZoneInfo("America/New_York")).date()) + bucket = storage.Client(credentials=credentials, project=project).bucket(uri.netloc) + count = 0 + for slot, payload in payloads.items(): + blob = bucket.blob(f"{uri.path.strip('/')}/ibkr/flex-cash/{slot}.json") + try: + blob.reload(timeout=10, retry=None) + except NotFound: + generation = 0 + else: + generation = int(blob.generation) + current = json.loads(blob.download_as_bytes(if_generation_match=generation, timeout=10, retry=None)) + if (current["target_id"], current["opaque_account_uid"]) != (payload["target_id"], payload["opaque_account_uid"]): + raise ValueError("digest_cash_stored_binding_mismatch") + if current["statement_date"] > payload["statement_date"]: + continue + if current == payload: + count += 1 + continue + blob.upload_from_string(json.dumps(payload, sort_keys=True), content_type="application/json", + if_generation_match=generation, timeout=10, retry=None) + count += 1 + return count + + +def main(): + try: + env = os.environ + primary = account_selector(json.loads(env["IBKR_FLEX_EXPECTED_ACCOUNT_IDS_JSON"])) + if len(primary) != 1 or primary != account_selector(json.loads(env["IBKR_ACCOUNT_FACTS_ACCOUNT_SELECTOR_JSON"])): + raise ValueError("digest_cash_primary_invalid") + configs = parse_additional_target_configs(env["IBKR_ACCOUNT_FACTS_ADDITIONAL_TARGETS_JSON"]) + allowed = (*primary, *(value["account_selector"][0] for value in configs.values())) + if len(set(allowed)) != 4: + raise ValueError("digest_cash_account_scope_mismatch") + try: + report = fetch_activity_flex_xml(token=env["IBKR_FLEX_TOKEN"], query_id=env["IBKR_FLEX_QUERY_ID"]) + except FlexReportPending as pending: + handle = pending.request + for _ in range(5): + time.sleep(3) + try: + report = collect_activity_flex_xml(handle) + break + except FlexReportPending as error: + if error.request != handle: + raise + else: + raise FlexReportPending(handle) from None + count = publish_digest_cash(report, expected_primary=primary, allowed_accounts=allowed, + job_refs=env["IBKR_FLEX_DIGEST_JOB_REFS"], + destination=env["DIGEST_CANDIDATES_GCS_URI"]) + receipt = {"settled_cash_status": "published" if count == 4 else "partial", "settled_cash_accounts": count} + except FlexReportPending: + receipt = {"settled_cash_status": "flex_pending"} + except Exception: + receipt = {"settled_cash_status": "read_or_publish_failed"} + print(json.dumps(receipt, sort_keys=True)) + return 0 if receipt["settled_cash_status"] == "published" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_publish_ibkr_digest_cash.py b/tests/test_publish_ibkr_digest_cash.py new file mode 100644 index 00000000..fa93039f --- /dev/null +++ b/tests/test_publish_ibkr_digest_cash.py @@ -0,0 +1,54 @@ +from datetime import date + +import pytest + +from application.ibkr_flex_source import FlexReport, FlexSourceError +from scripts.publish_ibkr_digest_cash import project_digest_cash + + +BINDINGS = {f"U000000{i}": (f"slot-{i}", f"opaque-{i}", f"target-{i}") for i in range(4)} + + +def _report(*, extra=False, wrong_cash=False): + accounts = [*BINDINGS, "U0000099"] if extra else list(BINDINGS) + statements = [] + for i, account in enumerate(accounts): + cash_account = accounts[0] if wrong_cash and i == 2 else account + statements.append( + f'' + f'' + '' + ) + raw = ('' + ''.join(statements) + + '').encode() + return FlexReport(content_sha256="a" * 64, content=raw) + + +def _project(report, latest=date(2026, 10, 15)): + return project_digest_cash(report, expected_primary=(next(iter(BINDINGS)),), allowed_accounts=tuple(BINDINGS), + bindings=BINDINGS, latest_day=latest) + + +def test_projects_each_cash_segment_without_broker_account_ids(): + result = _project(_report()) + assert set(result) == {f"slot-{i}" for i in range(4)} + assert result["slot-3"] == {"schema_version": "ibkr_digest_settled_cash.v1", "target_id": "target-3", + "opaque_account_uid": "opaque-3", "statement_date": "2026-10-15", + "currency": "USD", "amount": "-3.50"} + assert "U0000" not in repr(result) + + +def test_bad_cash_binding_only_excludes_affected_account_and_future_dates_are_ignored(): + assert set(_project(_report(wrong_cash=True))) == {"slot-0", "slot-1", "slot-3"} + assert _project(_report(), date(2026, 10, 14)) == {} + with pytest.raises(FlexSourceError): + _project(_report(extra=True)) + + +def test_multiday_report_uses_the_period_end_date(): + original = _report() + raw = original.content.replace(b'fromDate="20261015"', b'fromDate="20261001"') + result = _project(FlexReport(content_sha256="a" * 64, content=raw)) + assert len(result) == 4 + assert {record["statement_date"] for record in result.values()} == {"2026-10-15"}