11from __future__ import annotations
22
33import hashlib
4+ import json
5+ from datetime import datetime , timezone
46from pathlib import Path
57
68import 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+ )
916from 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+
2045def 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