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
15 changes: 15 additions & 0 deletions docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md
Original file line number Diff line number Diff line change
@@ -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 变更视为高风险,禁止自动恢复。
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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",
]
98 changes: 98 additions & 0 deletions scripts/build_reconciliation_baseline_candidate.py
Original file line number Diff line number Diff line change
@@ -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())
90 changes: 90 additions & 0 deletions tests/test_reconciliation_baseline_candidate_script.py
Original file line number Diff line number Diff line change
@@ -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": {}})
6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.