From bbc54f5cd8acf10edd83ba0b3751d9a9a2778a83 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:44:22 +0800 Subject: [PATCH] feat: build IBKR reconciliation baseline candidates Co-Authored-By: Codex --- ...econciliation_baseline_enrollment.zh-CN.md | 15 +++ pyproject.toml | 4 +- ...build_reconciliation_baseline_candidate.py | 98 +++++++++++++++++++ ...econciliation_baseline_candidate_script.py | 90 +++++++++++++++++ uv.lock | 6 +- 5 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md create mode 100644 scripts/build_reconciliation_baseline_candidate.py create mode 100644 tests/test_reconciliation_baseline_candidate_script.py diff --git a/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md new file mode 100644 index 0000000..6f9509f --- /dev/null +++ b/docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md @@ -0,0 +1,15 @@ +# IBKR 旧实盘基线:审核材料生成 + +`scripts/build_reconciliation_baseline_candidate.py` 只处理两份或更多私有的、已脱敏 +`/reconcile` 回应或运行报告。它调用 QuantPlatformKit 的通用规则,验证这些收据是 +新鲜、时间分离、账户身份一致且全部状态摘要相同,然后输出 +`broker_reconciliation_baseline_candidate.v1`。 + +该工具不连接 IB Gateway、不访问原始账户资料、不写 Cloud Run 环境变量、不改 +`RUNTIME_TARGET_JSON`,更不会下单。它的非零退出码表示候选尚不能进入审计;这不是 +故障恢复授权。 + +私有控制面将候选的 `candidate_sha256` 交给 AIAuditBridge 的 +`reconciliation_baseline` 强制双审。双审结果与候选摘要一致后,统一管理站点仍必须 +让操作者人工确认“恢复原有实盘基线”。现有自动化权限策略将这类 broker/order +execution 变更视为高风险,禁止自动恢复。 diff --git a/pyproject.toml b/pyproject.toml index 42385fc..c79686c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ee8d996392f96e8bdf40988bd68ae30bf5911d2d", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1951dada893f1f05c897e6438b6c687d30b4e810", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@4584de176ca27955270471284663a13a2ce7f828", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@c526f2ed869c59f37a494d5fd3a64be299855ec8", ] @@ -64,5 +64,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ee8d996392f96e8bdf40988bd68ae30bf5911d2d", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1951dada893f1f05c897e6438b6c687d30b4e810", ] diff --git a/scripts/build_reconciliation_baseline_candidate.py b/scripts/build_reconciliation_baseline_candidate.py new file mode 100644 index 0000000..61511d0 --- /dev/null +++ b/scripts/build_reconciliation_baseline_candidate.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Build a review-ready legacy-baseline candidate from redacted IBKR receipts. + +The input files must be private runtime reports or ``/reconcile`` response +payloads. Only QPK's digest-only evidence is loaded; this tool never opens a +broker connection, changes Cloud Run configuration, writes an execution +marker, or submits an order. +""" + +from __future__ import annotations + +import argparse +import json +from collections.abc import Iterable, Mapping +from datetime import datetime +from pathlib import Path +from typing import Any + +from quant_platform_kit.common.broker_reconciliation import BrokerReconciliationEvidence +from quant_platform_kit.common.broker_reconciliation_enrollment import ( + evaluate_broker_reconciliation_baseline_enrollment, +) + + +def extract_reconciliation_evidence(payload: Mapping[str, Any]) -> BrokerReconciliationEvidence: + """Extract one digest-only receipt from an endpoint or persisted report.""" + + if not isinstance(payload, Mapping): + raise ValueError("reconciliation receipt must be a JSON object") + candidate = payload + diagnostics = payload.get("diagnostics") + if isinstance(diagnostics, Mapping): + nested = diagnostics.get("broker_reconciliation") + if isinstance(nested, Mapping): + candidate = nested + evidence = candidate.get("evidence") if isinstance(candidate, Mapping) else None + if not isinstance(evidence, Mapping): + raise ValueError("receipt does not contain broker_reconciliation evidence") + return BrokerReconciliationEvidence.from_dict(evidence) + + +def evaluate_receipts( + payloads: Iterable[Mapping[str, Any]], + *, + now: datetime | None = None, +) -> dict[str, object]: + """Return a redacted candidate or stable findings for a private controller.""" + + evidences = [extract_reconciliation_evidence(payload) for payload in payloads] + evaluation = evaluate_broker_reconciliation_baseline_enrollment(evidences, now=now) + result: dict[str, object] = { + "schema_version": "ibkr_reconciliation_baseline_enrollment.v1", + "ready_for_independent_review": evaluation.ready_for_independent_review, + "findings": [finding.value for finding in evaluation.findings], + } + if evaluation.candidate is not None: + result["candidate"] = evaluation.candidate.to_dict() + return result + + +def _load_payload(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ValueError(f"unable to read receipt: {path}") from exc + except json.JSONDecodeError as exc: + raise ValueError(f"receipt is not valid JSON: {path}") from exc + if not isinstance(value, dict): + raise ValueError(f"receipt must be a JSON object: {path}") + return value + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Create a non-authorising legacy IBKR reconciliation baseline candidate." + ) + parser.add_argument( + "--receipt", + action="append", + type=Path, + required=True, + help="Private /reconcile response or persisted runtime report; supply at least twice.", + ) + parser.add_argument("--now", help="Optional ISO-8601 time used for deterministic validation.") + args = parser.parse_args(argv) + if len(args.receipt) < 2: + parser.error("--receipt must be supplied at least twice") + try: + reference_now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else None + result = evaluate_receipts((_load_payload(path) for path in args.receipt), now=reference_now) + except ValueError as exc: + parser.error(str(exc)) + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 if result["ready_for_independent_review"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_reconciliation_baseline_candidate_script.py b/tests/test_reconciliation_baseline_candidate_script.py new file mode 100644 index 0000000..1353853 --- /dev/null +++ b/tests/test_reconciliation_baseline_candidate_script.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from scripts.build_reconciliation_baseline_candidate import ( + evaluate_receipts, + extract_reconciliation_evidence, +) +from quant_platform_kit.common.broker_reconciliation import build_broker_reconciliation_evidence + + +def _digest(character: str) -> str: + return character * 64 + + +def _payload(*, observed_at: datetime, **overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "platform_id": "ibkr", + "strategy_profile": "soxl_soxx_trend_income", + "account_scope_sha256": _digest("a"), + "baseline_id": "ibkr-soxl-lkg-20260830", + "baseline_target_sha256": _digest("b"), + "runtime_target_sha256": _digest("b"), + "observed_at": observed_at, + "broker_connected": True, + "account_identity_match": True, + "positions_match": False, + "cash_match": False, + "open_orders_match": False, + "recent_executions_match": False, + "local_execution_ledger_match": False, + "positions_sha256": _digest("c"), + "cash_sha256": _digest("d"), + "open_orders_sha256": _digest("e"), + "recent_executions_sha256": _digest("f"), + "local_execution_ledger_sha256": _digest("0"), + } + values.update(overrides) + return { + "schema_version": "ibkr_reconciliation_candidate.v1", + "evidence": build_broker_reconciliation_evidence(**values).to_dict(), + } + + +def test_builds_redacted_review_candidate_from_two_matching_receipts() -> None: + start = datetime(2026, 8, 30, 8, 0, tzinfo=timezone.utc) + result = evaluate_receipts( + [_payload(observed_at=start), _payload(observed_at=start + timedelta(minutes=2))], + now=start + timedelta(minutes=3), + ) + + assert result["ready_for_independent_review"] is True + assert result["findings"] == [] + candidate = result["candidate"] + assert isinstance(candidate, dict) + assert candidate["candidate_sha256"] + assert "account_scope" not in candidate + + +def test_rejects_mismatched_receipts_without_authorisation() -> None: + start = datetime(2026, 8, 30, 8, 0, tzinfo=timezone.utc) + result = evaluate_receipts( + [ + _payload(observed_at=start), + _payload(observed_at=start + timedelta(minutes=2), cash_sha256=_digest("9")), + ], + now=start + timedelta(minutes=3), + ) + + assert result == { + "schema_version": "ibkr_reconciliation_baseline_enrollment.v1", + "ready_for_independent_review": False, + "findings": ["broker_reconciliation_enrollment_observation_mismatch"], + } + + +def test_accepts_persisted_runtime_report_shape() -> None: + start = datetime(2026, 8, 30, 8, 0, tzinfo=timezone.utc) + payload = {"diagnostics": {"broker_reconciliation": _payload(observed_at=start)}} + + evidence = extract_reconciliation_evidence(payload) + + assert evidence.platform_id == "ibkr" + + +def test_missing_redacted_evidence_is_rejected() -> None: + with pytest.raises(ValueError, match="does not contain"): + extract_reconciliation_evidence({"diagnostics": {}}) diff --git a/uv.lock b/uv.lock index abc6bf5..337994a 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=ee8d996392f96e8bdf40988bd68ae30bf5911d2d" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1951dada893f1f05c897e6438b6c687d30b4e810" }] [[package]] name = "beautifulsoup4" @@ -791,7 +791,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=ee8d996392f96e8bdf40988bd68ae30bf5911d2d" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1951dada893f1f05c897e6438b6c687d30b4e810" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=4584de176ca27955270471284663a13a2ce7f828" }, @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "0.10.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=ee8d996392f96e8bdf40988bd68ae30bf5911d2d#ee8d996392f96e8bdf40988bd68ae30bf5911d2d" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1951dada893f1f05c897e6438b6c687d30b4e810#1951dada893f1f05c897e6438b6c687d30b4e810" } [[package]] name = "requests"