Skip to content

Commit e3ba9f3

Browse files
authored
Merge pull request #55 from QuantStrategyLab/codex/qmt-e3-offline-receipt-validator-20260905
Add offline E3 receipt validation
2 parents 23444b9 + 2e5bb46 commit e3ba9f3

3 files changed

Lines changed: 246 additions & 4 deletions

File tree

runtime_preflight.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,29 @@
44
import os
55
import re
66
from dataclasses import dataclass
7+
from datetime import datetime, timezone
78
from pathlib import Path
89
from typing import Any
910

1011
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
1112
from strategy_loader import load_strategy_entrypoint_for_profile
1213

1314

15+
E3_RECEIPT_SCHEMA_VERSION = "qmt.e3.receipt.v1"
16+
E3_RECEIPT_REQUIRED_SUMMARY_COUNTS = ("accounts", "positions", "orders", "fills", "cash", "ledger")
17+
E3_RECEIPT_MAX_FRESHNESS_SECONDS = 300
18+
_E3_RECEIPT_FIELDS = frozenset(
19+
{
20+
"schema_version",
21+
"as_of",
22+
"freshness_seconds",
23+
"no_order",
24+
"verify_only",
25+
"summary_counts",
26+
}
27+
)
28+
29+
1430
@dataclass(frozen=True)
1531
class PreflightIssue:
1632
code: str
@@ -42,6 +58,113 @@ def to_payload(self) -> dict[str, Any]:
4258
}
4359

4460

61+
@dataclass(frozen=True)
62+
class E3ReceiptValidationReport:
63+
status: str
64+
schema_version: str | None
65+
issues: tuple[PreflightIssue, ...]
66+
67+
def to_payload(self) -> dict[str, Any]:
68+
return {
69+
"status": self.status,
70+
"schema_version": self.schema_version,
71+
"required_summary_counts": list(E3_RECEIPT_REQUIRED_SUMMARY_COUNTS),
72+
"issues": [
73+
{"code": issue.code, "message": issue.message}
74+
for issue in self.issues
75+
],
76+
}
77+
78+
79+
def validate_e3_receipt(
80+
receipt: object,
81+
*,
82+
now: datetime | None = None,
83+
) -> E3ReceiptValidationReport:
84+
"""Validate a local E3 receipt without connecting to miniQMT or a provider."""
85+
if not isinstance(receipt, dict):
86+
return E3ReceiptValidationReport(
87+
status="error",
88+
schema_version=None,
89+
issues=(PreflightIssue("invalid_receipt", "Receipt must be a JSON object."),),
90+
)
91+
92+
issues: list[PreflightIssue] = []
93+
schema_version = receipt.get("schema_version")
94+
reported_schema_version = schema_version if schema_version == E3_RECEIPT_SCHEMA_VERSION else None
95+
96+
receipt_fields = frozenset(receipt)
97+
if _E3_RECEIPT_FIELDS - receipt_fields:
98+
issues.append(PreflightIssue("missing_receipt_fields", "Receipt has required fields missing."))
99+
if receipt_fields - _E3_RECEIPT_FIELDS:
100+
issues.append(PreflightIssue("unexpected_receipt_fields", "Receipt must not contain detail-bearing fields."))
101+
if schema_version != E3_RECEIPT_SCHEMA_VERSION:
102+
issues.append(PreflightIssue("invalid_schema_version", "Receipt schema version is not accepted."))
103+
104+
_validate_e3_summary_counts(receipt.get("summary_counts"), issues)
105+
_validate_e3_declarations(receipt, issues)
106+
_validate_e3_freshness(receipt, now=now, issues=issues)
107+
108+
return E3ReceiptValidationReport(
109+
status="ok" if not issues else "error",
110+
schema_version=reported_schema_version,
111+
issues=tuple(issues),
112+
)
113+
114+
115+
def _validate_e3_summary_counts(value: object, issues: list[PreflightIssue]) -> None:
116+
if not isinstance(value, dict) or frozenset(value) != frozenset(E3_RECEIPT_REQUIRED_SUMMARY_COUNTS):
117+
issues.append(PreflightIssue("invalid_summary_counts", "Receipt must contain only the required summary counts."))
118+
return
119+
if any(not isinstance(count, int) or isinstance(count, bool) or count < 0 for count in value.values()):
120+
issues.append(PreflightIssue("invalid_summary_counts", "Receipt summary counts must be non-negative integers."))
121+
122+
123+
def _validate_e3_declarations(receipt: dict[object, object], issues: list[PreflightIssue]) -> None:
124+
if receipt.get("no_order") is not True:
125+
issues.append(PreflightIssue("no_order_required", "Receipt must declare no_order=true."))
126+
if receipt.get("verify_only") is not True:
127+
issues.append(PreflightIssue("verify_only_required", "Receipt must declare verify_only=true."))
128+
129+
130+
def _validate_e3_freshness(
131+
receipt: dict[object, object],
132+
*,
133+
now: datetime | None,
134+
issues: list[PreflightIssue],
135+
) -> None:
136+
freshness_seconds = receipt.get("freshness_seconds")
137+
if (
138+
not isinstance(freshness_seconds, int)
139+
or isinstance(freshness_seconds, bool)
140+
or not 0 <= freshness_seconds <= E3_RECEIPT_MAX_FRESHNESS_SECONDS
141+
):
142+
issues.append(PreflightIssue("invalid_freshness", "Receipt freshness is outside the accepted window."))
143+
return
144+
145+
as_of = receipt.get("as_of")
146+
if not isinstance(as_of, str):
147+
issues.append(PreflightIssue("invalid_as_of", "Receipt as_of must be a timezone-aware timestamp."))
148+
return
149+
try:
150+
as_of_time = datetime.fromisoformat(as_of.replace("Z", "+00:00"))
151+
except ValueError:
152+
issues.append(PreflightIssue("invalid_as_of", "Receipt as_of must be a timezone-aware timestamp."))
153+
return
154+
if as_of_time.tzinfo is None:
155+
issues.append(PreflightIssue("invalid_as_of", "Receipt as_of must be a timezone-aware timestamp."))
156+
return
157+
158+
reference_time = now or datetime.now(timezone.utc)
159+
if reference_time.tzinfo is None:
160+
reference_time = reference_time.replace(tzinfo=timezone.utc)
161+
age_seconds = (reference_time - as_of_time).total_seconds()
162+
if age_seconds < 0:
163+
issues.append(PreflightIssue("invalid_as_of", "Receipt as_of cannot be in the future."))
164+
elif age_seconds > freshness_seconds:
165+
issues.append(PreflightIssue("stale_receipt", "Receipt is older than its declared freshness window."))
166+
167+
45168
def run_preflight(*, paper_admission: bool = False) -> QmtPreflightReport:
46169
issues: list[PreflightIssue] = []
47170
try:

scripts/preflight_qmt_runtime.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
if str(ROOT) not in sys.path:
1111
sys.path.insert(0, str(ROOT))
1212

13-
from runtime_preflight import run_preflight # noqa: E402
13+
from runtime_preflight import run_preflight, validate_e3_receipt # noqa: E402
1414

1515

1616
def main(argv: list[str] | None = None) -> int:
@@ -20,11 +20,28 @@ def main(argv: list[str] | None = None) -> int:
2020
action="store_true",
2121
help="Validate the offline paper-admission contract without calling QMT or creating orders.",
2222
)
23+
parser.add_argument(
24+
"--e3-receipt",
25+
type=Path,
26+
help="Validate a local, sanitized E3 receipt without connecting to QMT or creating orders.",
27+
)
2328
args = parser.parse_args(argv)
2429

2530
report = run_preflight(paper_admission=args.paper_admission)
26-
print(json.dumps(report.to_payload(), ensure_ascii=False, indent=2))
27-
return 0 if report.status == "ok" else 2
31+
payload = report.to_payload()
32+
receipt_status = "ok"
33+
if args.e3_receipt:
34+
receipt_status = "error"
35+
try:
36+
receipt = json.loads(args.e3_receipt.read_text(encoding="utf-8"))
37+
except (OSError, json.JSONDecodeError):
38+
receipt = None
39+
receipt_report = validate_e3_receipt(receipt)
40+
payload["e3_receipt"] = receipt_report.to_payload()
41+
receipt_status = receipt_report.status
42+
43+
print(json.dumps(payload, ensure_ascii=False, indent=2))
44+
return 0 if report.status == "ok" and receipt_status == "ok" else 2
2845

2946

3047
if __name__ == "__main__":

tests/test_runtime_preflight.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import json
5+
from datetime import datetime, timezone
46
from pathlib import Path
57

68
import pytest
79

8-
from runtime_preflight import run_preflight
10+
from runtime_preflight import (
11+
E3_RECEIPT_MAX_FRESHNESS_SECONDS,
12+
E3_RECEIPT_SCHEMA_VERSION,
13+
run_preflight,
14+
validate_e3_receipt,
15+
)
916
from scripts.preflight_qmt_runtime import main
1017

1118

@@ -17,6 +24,24 @@ def _sha256(path: str) -> str:
1724
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
1825

1926

27+
def _valid_e3_receipt() -> dict[str, object]:
28+
return {
29+
"schema_version": E3_RECEIPT_SCHEMA_VERSION,
30+
"as_of": "2026-09-05T00:00:00+00:00",
31+
"freshness_seconds": 60,
32+
"no_order": True,
33+
"verify_only": True,
34+
"summary_counts": {
35+
"accounts": 1,
36+
"positions": 0,
37+
"orders": 0,
38+
"fills": 0,
39+
"cash": 1,
40+
"ledger": 1,
41+
},
42+
}
43+
44+
2045
def test_preflight_accepts_primary_dry_run_config(monkeypatch, market_history_csv: str):
2146
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
2247
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
@@ -136,3 +161,80 @@ def test_preflight_rejects_research_only_dividend_profile(monkeypatch):
136161

137162
assert report.status == "error"
138163
assert _issue_codes(report) == {"runtime_config_error"}
164+
165+
166+
def test_e3_receipt_validator_accepts_complete_sanitized_receipt():
167+
report = validate_e3_receipt(
168+
_valid_e3_receipt(),
169+
now=datetime(2026, 9, 5, 0, 0, 30, tzinfo=timezone.utc),
170+
)
171+
172+
assert report.status == "ok"
173+
assert report.schema_version == E3_RECEIPT_SCHEMA_VERSION
174+
assert report.issues == ()
175+
assert report.to_payload() == {
176+
"status": "ok",
177+
"schema_version": E3_RECEIPT_SCHEMA_VERSION,
178+
"required_summary_counts": ["accounts", "positions", "orders", "fills", "cash", "ledger"],
179+
"issues": [],
180+
}
181+
182+
183+
@pytest.mark.parametrize(
184+
("field", "value", "expected_code"),
185+
[
186+
("summary_counts", {"accounts": 1}, "invalid_summary_counts"),
187+
("as_of", "not-a-timestamp", "invalid_as_of"),
188+
("freshness_seconds", -1, "invalid_freshness"),
189+
("freshness_seconds", E3_RECEIPT_MAX_FRESHNESS_SECONDS + 1, "invalid_freshness"),
190+
("no_order", False, "no_order_required"),
191+
("verify_only", False, "verify_only_required"),
192+
],
193+
)
194+
def test_e3_receipt_validator_fails_closed_for_missing_or_unsafe_fields(field, value, expected_code):
195+
receipt = _valid_e3_receipt()
196+
receipt[field] = value
197+
198+
report = validate_e3_receipt(receipt, now=datetime(2026, 9, 5, tzinfo=timezone.utc))
199+
200+
assert report.status == "error"
201+
assert expected_code in _issue_codes(report)
202+
203+
204+
def test_e3_receipt_validator_fails_closed_for_stale_or_detail_bearing_receipts():
205+
stale_report = validate_e3_receipt(
206+
_valid_e3_receipt(),
207+
now=datetime(2026, 9, 5, 0, 6, tzinfo=timezone.utc),
208+
)
209+
detail_receipt = _valid_e3_receipt()
210+
detail_receipt["account"] = {"id": "must-not-be-accepted"}
211+
detail_report = validate_e3_receipt(detail_receipt, now=datetime(2026, 9, 5, tzinfo=timezone.utc))
212+
213+
assert _issue_codes(stale_report) == {"stale_receipt"}
214+
assert _issue_codes(detail_report) == {"unexpected_receipt_fields"}
215+
assert "must-not-be-accepted" not in str(detail_report.to_payload())
216+
217+
218+
def test_e3_receipt_validator_fails_closed_for_missing_required_fields():
219+
receipt = _valid_e3_receipt()
220+
receipt.pop("verify_only")
221+
222+
report = validate_e3_receipt(receipt, now=datetime(2026, 9, 5, tzinfo=timezone.utc))
223+
224+
assert _issue_codes(report) == {"missing_receipt_fields", "verify_only_required"}
225+
226+
227+
def test_preflight_cli_validates_e3_receipt_offline(monkeypatch, market_history_csv: str, tmp_path, capsys):
228+
monkeypatch.setenv("STRATEGY_PROFILE", "cn_industry_etf_rotation")
229+
monkeypatch.setenv("QMT_DRY_RUN_ONLY", "true")
230+
monkeypatch.setenv("QMT_MARKET_HISTORY_PATH", market_history_csv)
231+
monkeypatch.delenv("RUNTIME_TARGET_JSON", raising=False)
232+
receipt_path = tmp_path / "e3-receipt.json"
233+
receipt = _valid_e3_receipt()
234+
receipt["as_of"] = datetime.now(timezone.utc).isoformat()
235+
receipt_path.write_text(json.dumps(receipt), encoding="utf-8")
236+
237+
assert main(["--e3-receipt", str(receipt_path)]) == 0
238+
239+
payload = json.loads(capsys.readouterr().out)
240+
assert payload["e3_receipt"]["status"] == "ok"

0 commit comments

Comments
 (0)