Skip to content

Commit f02a31f

Browse files
Pigbibicodex
andauthored
feat: publish immutable recovery state ledgers (#430)
Co-authored-by: Codex <noreply@openai.com>
1 parent 6f49153 commit f02a31f

4 files changed

Lines changed: 295 additions & 0 deletions

docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ QPK 计划中的五项摘要、`no_order=true`、`execution_authority_granted=fa
7373
既有实盘目标也不受影响。本阶段不写入该变量,也不创建账本对象;因此它只是经过测试的
7474
兼容入口,而不是一次自动或隐式的实盘恢复。
7575

76+
`scripts/publish_reconciliation_recovery_state_ledger.py` 则是与消费端分离的发布适配器。
77+
它只接受刚生成的 `ibkr_reconciliation_recovery_verification.v1`:验证结果必须无阻断、
78+
完整携带 QPK 计划,并且仍声明 `verify_only`、无订单、无执行授权和未尝试状态写入。默认
79+
只打印最小账本 JSON;只有显式提供状态前缀的 GCS URI 时才调用
80+
`if_generation_match=0` 创建对象。该写入不读取、列举、覆盖或删除对象,也不会设置工作流
81+
URI、部署 Cloud Run、连接券商或提交订单。实际启用仍需要单独的高风险控制面动作。
82+
7683
## 故障注入回归
7784

7885
恢复链路的回归测试会主动注入:控制台把不可执行策略篡改为可执行、五项摘要之一
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env python3
2+
"""Build or explicitly create an immutable IBKR recovery state ledger.
3+
4+
By default this command only prints a locally derived ledger. The optional
5+
GCS write uses create-only semantics and does not deploy, modify a runtime
6+
target, connect to IBKR, or submit an order.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
from collections.abc import Mapping
14+
from pathlib import Path
15+
from typing import Any
16+
17+
from scripts.reconciliation_recovery_state_ledger import (
18+
RECOVERY_STATE_LEDGER_SCHEMA_VERSION,
19+
build_recovery_state_ledger,
20+
)
21+
22+
23+
_STATE_LEDGER_PREFIX = "reconciliation-recovery/ibkr/state/"
24+
25+
26+
def _load_json(path: Path) -> dict[str, object]:
27+
try:
28+
value = json.loads(path.read_text(encoding="utf-8"))
29+
except OSError as exc:
30+
raise ValueError("unable to read reconciliation recovery verification") from exc
31+
except json.JSONDecodeError as exc:
32+
raise ValueError("reconciliation recovery verification is not valid JSON") from exc
33+
if not isinstance(value, dict):
34+
raise ValueError("reconciliation recovery verification must be a JSON object")
35+
return value
36+
37+
38+
def _parse_state_ledger_uri(value: str) -> tuple[str, str]:
39+
normalized = str(value or "").strip()
40+
if not normalized.startswith("gs://"):
41+
raise ValueError("state_ledger_uri must use gs://")
42+
bucket_name, separator, object_name = normalized.removeprefix("gs://").partition("/")
43+
if not bucket_name or not separator or not object_name.startswith(_STATE_LEDGER_PREFIX) or not object_name.endswith(".json"):
44+
raise ValueError("state_ledger_uri must use the IBKR recovery state prefix and .json suffix")
45+
return bucket_name, object_name
46+
47+
48+
def archive_recovery_state_ledger(
49+
ledger: Mapping[str, object],
50+
*,
51+
state_ledger_uri: str,
52+
storage_client_factory: Any | None = None,
53+
) -> dict[str, str]:
54+
"""Create an immutable ledger object without reading, listing, or replacing it."""
55+
56+
if ledger.get("schema_version") != RECOVERY_STATE_LEDGER_SCHEMA_VERSION:
57+
raise ValueError("reconciliation recovery state ledger has an unsupported schema")
58+
bucket_name, object_name = _parse_state_ledger_uri(state_ledger_uri)
59+
if storage_client_factory is None:
60+
try:
61+
from google.cloud import storage
62+
except ImportError as exc: # pragma: no cover - runtime image provides this dependency.
63+
raise RuntimeError("google-cloud-storage is required to archive a recovery state ledger") from exc
64+
client = storage.Client()
65+
else:
66+
client = storage_client_factory()
67+
try:
68+
client.bucket(bucket_name).blob(object_name).upload_from_string(
69+
json.dumps(dict(ledger), ensure_ascii=False, sort_keys=True, separators=(",", ":")),
70+
content_type="application/json",
71+
if_generation_match=0,
72+
)
73+
except Exception as exc:
74+
raise RuntimeError("reconciliation recovery state ledger was not created") from exc
75+
return {
76+
"uri": f"gs://{bucket_name}/{object_name}",
77+
"schema_version": RECOVERY_STATE_LEDGER_SCHEMA_VERSION,
78+
"recovery_id": str(ledger["recovery_id"]),
79+
}
80+
81+
82+
def main(argv: list[str] | None = None) -> int:
83+
parser = argparse.ArgumentParser(
84+
description="Build or explicitly archive an immutable IBKR recovery state ledger."
85+
)
86+
parser.add_argument("--verification", type=Path, required=True, help="Fresh verify-only recovery result")
87+
parser.add_argument("--service-name", required=True, help="One exact Cloud Run service name")
88+
parser.add_argument(
89+
"--archive-gcs-uri",
90+
help="Explicit private gs://.../reconciliation-recovery/ibkr/state/*.json object; uses create-only generation precondition",
91+
)
92+
args = parser.parse_args(argv)
93+
try:
94+
ledger = build_recovery_state_ledger(
95+
verification=_load_json(args.verification),
96+
service_name=args.service_name,
97+
)
98+
if args.archive_gcs_uri:
99+
print(json.dumps({"ledger": ledger, "archive": archive_recovery_state_ledger(
100+
ledger,
101+
state_ledger_uri=args.archive_gcs_uri,
102+
)}, ensure_ascii=False, sort_keys=True))
103+
else:
104+
print(json.dumps(ledger, ensure_ascii=False, sort_keys=True))
105+
except (RuntimeError, ValueError) as exc:
106+
parser.error(str(exc))
107+
return 0
108+
109+
110+
if __name__ == "__main__":
111+
raise SystemExit(main())

scripts/reconciliation_recovery_state_ledger.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
import json
1313
from collections.abc import Mapping
1414
from pathlib import Path
15+
from typing import Any
1516

1617
from quant_platform_kit.common.live_continuity import build_live_continuity
1718
from quant_platform_kit.common.reconciliation_recovery import ReconciliationRecoveryTransitionPlan
1819

1920

2021
RECOVERY_STATE_LEDGER_SCHEMA_VERSION = "ibkr_reconciliation_recovery_state_ledger.v1"
2122
RECOVERY_STATE_LEDGER_PATH_ENV = "IBKR_RECONCILIATION_RECOVERY_STATE_LEDGER_PATH"
23+
RECOVERY_VERIFICATION_SCHEMA_VERSION = "ibkr_reconciliation_recovery_verification.v1"
2224

2325

2426
def _ledger_service_name(ledger: Mapping[str, object]) -> str:
@@ -28,6 +30,64 @@ def _ledger_service_name(ledger: Mapping[str, object]) -> str:
2830
return service_name
2931

3032

33+
def build_recovery_state_ledger(
34+
*,
35+
verification: Mapping[str, object],
36+
service_name: str,
37+
) -> dict[str, object]:
38+
"""Build one minimal state ledger from an already fresh verify-only result.
39+
40+
This is pure serialization. It never creates a cloud object or changes a
41+
runtime target. Requiring the complete verify-only receipt prevents a
42+
caller from turning a standalone plan into an activation intent.
43+
"""
44+
45+
required = {
46+
"schema_version",
47+
"recovery_id",
48+
"candidate_sha256",
49+
"confirmation_sha256",
50+
"ready_for_atomic_state_transition",
51+
"findings",
52+
"transition_plan",
53+
"policy",
54+
}
55+
if not isinstance(verification, Mapping) or set(verification) != required:
56+
raise ValueError("reconciliation recovery verification has invalid fields")
57+
if verification.get("schema_version") != RECOVERY_VERIFICATION_SCHEMA_VERSION:
58+
raise ValueError("unsupported reconciliation recovery verification schema")
59+
if verification.get("ready_for_atomic_state_transition") is not True or verification.get("findings") != []:
60+
raise ValueError("reconciliation recovery verification is not ready for a state ledger")
61+
expected_policy = {
62+
"controller_mode": "verify_only",
63+
"no_order": True,
64+
"execution_authority_granted": False,
65+
"state_write_attempted": False,
66+
}
67+
if verification.get("policy") != expected_policy:
68+
raise ValueError("reconciliation recovery verification has an invalid non-execution policy")
69+
raw_plan = verification.get("transition_plan")
70+
if not isinstance(raw_plan, Mapping):
71+
raise ValueError("reconciliation recovery verification is missing transition_plan")
72+
plan = ReconciliationRecoveryTransitionPlan.from_dict(raw_plan)
73+
recovery_id = str(verification.get("recovery_id") or "").strip()
74+
candidate_sha256 = str(verification.get("candidate_sha256") or "").strip().lower().removeprefix("sha256:")
75+
confirmation_sha256 = str(verification.get("confirmation_sha256") or "").strip().lower().removeprefix("sha256:")
76+
if recovery_id != plan.recovery_id:
77+
raise ValueError("reconciliation recovery verification recovery_id mismatch")
78+
if candidate_sha256 != plan.candidate_sha256:
79+
raise ValueError("reconciliation recovery verification candidate digest mismatch")
80+
if confirmation_sha256 != plan.confirmation_sha256:
81+
raise ValueError("reconciliation recovery verification confirmation digest mismatch")
82+
_ledger_service_name({"service_name": service_name})
83+
return {
84+
"schema_version": RECOVERY_STATE_LEDGER_SCHEMA_VERSION,
85+
"recovery_id": plan.recovery_id,
86+
"service_name": service_name,
87+
"transition_plan": plan.to_dict(),
88+
}
89+
90+
3191
def apply_recovery_state_ledger(
3292
*,
3393
runtime_target: Mapping[str, object],
@@ -108,6 +168,8 @@ def apply_recovery_state_ledger_from_env(
108168
__all__ = [
109169
"RECOVERY_STATE_LEDGER_PATH_ENV",
110170
"RECOVERY_STATE_LEDGER_SCHEMA_VERSION",
171+
"RECOVERY_VERIFICATION_SCHEMA_VERSION",
111172
"apply_recovery_state_ledger",
112173
"apply_recovery_state_ledger_from_env",
174+
"build_recovery_state_ledger",
113175
]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
5+
import pytest
6+
7+
from quant_platform_kit.common.reconciliation_recovery import ReconciliationRecoveryTransitionPlan
8+
9+
from scripts.publish_reconciliation_recovery_state_ledger import archive_recovery_state_ledger
10+
from scripts.reconciliation_recovery_state_ledger import (
11+
RECOVERY_STATE_LEDGER_SCHEMA_VERSION,
12+
build_recovery_state_ledger,
13+
)
14+
15+
16+
def _digest(character: str) -> str:
17+
return character * 64
18+
19+
20+
def _verification() -> dict[str, object]:
21+
plan = ReconciliationRecoveryTransitionPlan(
22+
recovery_id="ibkr-soxl-live-recovery",
23+
candidate_sha256=_digest("a"),
24+
confirmation_sha256=_digest("b"),
25+
baseline_id="soxl-ibkr-lkg-20260830",
26+
baseline_target_sha256=_digest("c"),
27+
expected_digests={
28+
"positions_sha256": _digest("d"),
29+
"cash_sha256": _digest("e"),
30+
"open_orders_sha256": _digest("f"),
31+
"recent_executions_sha256": _digest("0"),
32+
"local_execution_ledger_sha256": _digest("1"),
33+
},
34+
verified_at=datetime(2026, 8, 31, 1, 5, tzinfo=timezone.utc),
35+
)
36+
return {
37+
"schema_version": "ibkr_reconciliation_recovery_verification.v1",
38+
"recovery_id": plan.recovery_id,
39+
"candidate_sha256": plan.candidate_sha256,
40+
"confirmation_sha256": plan.confirmation_sha256,
41+
"ready_for_atomic_state_transition": True,
42+
"findings": [],
43+
"transition_plan": plan.to_dict(),
44+
"policy": {
45+
"controller_mode": "verify_only",
46+
"no_order": True,
47+
"execution_authority_granted": False,
48+
"state_write_attempted": False,
49+
},
50+
}
51+
52+
53+
def test_build_state_ledger_requires_a_complete_non_executable_verification() -> None:
54+
ledger = build_recovery_state_ledger(
55+
verification=_verification(),
56+
service_name="interactive-brokers-live-service",
57+
)
58+
59+
assert ledger["schema_version"] == RECOVERY_STATE_LEDGER_SCHEMA_VERSION
60+
assert ledger["service_name"] == "interactive-brokers-live-service"
61+
assert set(ledger) == {"schema_version", "recovery_id", "service_name", "transition_plan"}
62+
63+
invalid = _verification()
64+
invalid["ready_for_atomic_state_transition"] = False
65+
with pytest.raises(ValueError, match="not ready"):
66+
build_recovery_state_ledger(
67+
verification=invalid,
68+
service_name="interactive-brokers-live-service",
69+
)
70+
71+
72+
def test_archive_state_ledger_uses_create_only_private_state_prefix() -> None:
73+
ledger = build_recovery_state_ledger(
74+
verification=_verification(),
75+
service_name="interactive-brokers-live-service",
76+
)
77+
received: dict[str, object] = {}
78+
79+
class Blob:
80+
def upload_from_string(self, payload: str, **kwargs: object) -> None:
81+
received["payload"] = payload
82+
received["kwargs"] = kwargs
83+
84+
class Bucket:
85+
@staticmethod
86+
def blob(name: str) -> Blob:
87+
received["object_name"] = name
88+
return Blob()
89+
90+
class Client:
91+
@staticmethod
92+
def bucket(name: str) -> Bucket:
93+
received["bucket_name"] = name
94+
return Bucket()
95+
96+
archive = archive_recovery_state_ledger(
97+
ledger,
98+
state_ledger_uri="gs://private-bucket/reconciliation-recovery/ibkr/state/recovery-1.json",
99+
storage_client_factory=Client,
100+
)
101+
102+
assert archive["uri"] == "gs://private-bucket/reconciliation-recovery/ibkr/state/recovery-1.json"
103+
assert received["bucket_name"] == "private-bucket"
104+
assert received["object_name"] == "reconciliation-recovery/ibkr/state/recovery-1.json"
105+
assert received["kwargs"] == {
106+
"content_type": "application/json",
107+
"if_generation_match": 0,
108+
}
109+
110+
with pytest.raises(ValueError, match="state prefix"):
111+
archive_recovery_state_ledger(
112+
ledger,
113+
state_ledger_uri="gs://private-bucket/reconciliation-recovery/ibkr/source/recovery-1.json",
114+
storage_client_factory=Client,
115+
)

0 commit comments

Comments
 (0)