diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 92b4fa8..dae4ff1 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -135,6 +135,14 @@ The fixed paper experiment is stricter than the general report: it accepts no missing price rows, performs no forward fill, and requires at least 274 signal warm-up sessions before the evaluation window. +`scripts/release_paper_artifacts.sh` is intentionally bound to the reviewed +input digest. When release-critical paths are unchanged, it regenerates from the +recorded source commit and timestamp. Changed experiment, report, or paper source +requires an explicit `QUANTCORTEX_GENERATED_AT` and regenerates from current +`HEAD`. The wrapper fails rather than labeling a different matrix with the +reviewed provider provenance. A new panel requires a new, explicitly reviewed +experiment configuration and source record. + For an explicitly requested live download: ```bash diff --git a/docs/evaluation-contracts.md b/docs/evaluation-contracts.md index 2dddbca..9df5725 100644 --- a/docs/evaluation-contracts.md +++ b/docs/evaluation-contracts.md @@ -37,8 +37,14 @@ the complete symbol set and gross limit: } ``` -`target_tape_to_payload` and `target_tape_from_payload` implement that envelope; -`schemas/canonical_target_tape.schema.json` specifies it for other engines. +`target_tape_to_payload` and `target_tape_from_payload` implement that envelope. +`schemas/canonical_target_tape.schema.json` specifies its serialized structure +and primitive constraints for other engines. Runtime validation additionally +enforces cross-record portfolio invariants: no duplicate timestamp-symbol rows, +the declared symbol universe at every decision, and the per-decision gross +limit. The paper experiment round-trips each variant through this boundary +before backtesting and publishes canonical payload hashes in +`paper/results/target_tape_hashes.json`. The evaluation contract also records post-overlay exposure rules and the paper-trading order-state policy: persist intent before submission, block diff --git a/docs/img/performance_manifest.json b/docs/img/performance_manifest.json index 753997e..96b3c53 100644 --- a/docs/img/performance_manifest.json +++ b/docs/img/performance_manifest.json @@ -1,19 +1,19 @@ { "schema_version": 4, - "generated_at": "2026-06-16T22:00:41Z", + "generated_at": "2026-06-18T22:06:35Z", "generator": { "path": "scripts/generate_report.py", "script_sha256": "b536aa7fc5e4fe7df6c7ff28c0992629a489869eaec46486db7aff1cb946099b", "git": { - "source_commit": "d185c44928af865882c4d830066432dc97eb9972", + "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", "worktree_clean_at_start": true }, "source_tree": { - "sha256": "a72c2fcd825bb15a2879a3b33d63487a34348b837ff733b7c1ee9d0232ed19cb", + "sha256": "f964d653b5e645e1efe97889e8acb43fbb0c2c8ddeb0835803f07c299abff5a0", "file_count": 107, "files": { - "poetry.lock": "fa8904674c2b24f3141acae607fd0a49d97125bc109c18def9fccbb2256c8de0", - "pyproject.toml": "175abd743d38f6b6c1cc8ae42471d62d83c86beb245d93fea231c666f51b2643", + "poetry.lock": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b", + "pyproject.toml": "eaeeb454c28bf7f6d9e530002bb7e88624b56b6c3e1fcb71e6414045cb9c42a0", "quantcortex/__init__.py": "14bf1ebdacd054c3738e4704d33da6709a39206463df8b8ced5376da342c4036", "quantcortex/alpha/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/alpha/factors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -37,7 +37,7 @@ "quantcortex/alpha/validation/alphalens_report.py": "8463289117add78a576e37a5b99a539be5c808fca86100e26deb29e8060aa60d", "quantcortex/alpha/validation/factor_decay.py": "9e6e049165f014db2122d9ed57415e45e48142b475dfb7221247d447d9a50397", "quantcortex/backtest/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "quantcortex/backtest/conformance.py": "e169cc67cac9eb56d8da4e4a0da1e257bb6cb8d1745baca681f50fab6c62fc57", + "quantcortex/backtest/conformance.py": "22f9b18101f0d9adcae98297e395c1ab6a90b18e411f24e7456f1d166b7df9b5", "quantcortex/backtest/costs/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/backtest/costs/transaction_costs.py": "48200419f37004c58df6f89041e31516d9ad98f66c22f75d11a0b9342474d5c7", "quantcortex/backtest/engines/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -58,7 +58,7 @@ "quantcortex/backtest/validation/multiple_testing.py": "6b68f6f150dcc8fb90213dcf769307454c0c506e99666a533d5d07ae3ccb0b5d", "quantcortex/backtest/validation/survivorship_check.py": "5b772bd6b40ffe44f590ec0ddb00b7e3dc61a1aba9cae8831227325f4f53beff", "quantcortex/data/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "quantcortex/data/local_csv.py": "30c11c3200530810b3345f8e29fdf966f35671e30c94fc5bb1b2db656e6df79b", + "quantcortex/data/local_csv.py": "6e5f3d24b1477ce122a1e80899e841fffd6d857034189f7d8605705f52c6839c", "quantcortex/data/processors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/data/processors/adjustments.py": "ae2d4d0362e3484b89b5a5c57a7fd6320973b208ee0cd5a6be9a3f29d2544e2c", "quantcortex/data/processors/calendar.py": "e591bccc3e7f632501b068e7abec1ece182d0ef930589381776fe89158d1df51", diff --git a/paper/README.md b/paper/README.md index a982b1e..027c735 100644 --- a/paper/README.md +++ b/paper/README.md @@ -38,11 +38,16 @@ scripts/release_paper_artifacts.sh \ ``` The input must contain `QQQ`, `VGT`, `GLD`, `TLT`, `SPY`, `VIG`, and `SHV` and -must match the SHA-256 digest in `results/manifest.json` for exact -reproduction. The wrapper requires committed tracked source, regenerates the -reviewed `docs/img/` gallery and paper experiment in a detached clean worktree -with the input mounted outside it, verifies the recorded source commit and clean -start state, then copies reviewed artifacts back. The +must match the SHA-256 digest in `results/manifest.json`; the release wrapper +rejects another matrix rather than attaching the reviewed provenance to it. It +uses the recorded source commit and timestamp when all release-critical paths +are unchanged, so the final artifact commit can reproduce without timestamp-only +drift. Changed experiment, report, or paper source must set +`QUANTCORTEX_GENERATED_AT` explicitly; that creates a release from current +`HEAD`. The wrapper requires committed tracked source, regenerates the reviewed +`docs/img/` gallery and paper experiment in a detached clean worktree with the +input mounted outside it, verifies the recorded source commit and clean start +state, then copies reviewed artifacts back. The fixed experiment requires complete rows, performs no forward fill, and rejects fewer than 274 pre-evaluation sessions. Raw provider data is not committed. Aggregate tables, generated LaTeX values, figures, the explicit experiment @@ -58,6 +63,9 @@ and 63-session sensitivity results. `sharpe_uncertainty.csv` directly resamples the conventional sample Sharpe statistic. `comparator_diagnostics.csv` records the causal target-exposure comparator after its own costs, while `evaluation_contract.json` records the machine-readable semantics. +`target_tape_hashes.json` records the canonical payload hash, decision count, +record count, and symbol set for every audited strategy variant without +publishing the underlying provider matrix. The primary accounting path is the event-driven engine. It holds explicit adjusted-close pseudo-shares between rebalances, sizes targets against post-cost diff --git a/paper/build_manifest.json b/paper/build_manifest.json index d766ab7..c1b1f14 100644 --- a/paper/build_manifest.json +++ b/paper/build_manifest.json @@ -1,18 +1,18 @@ { "anonymous_pdf": { "path": "quantcortex_audit_anonymous.pdf", - "sha256": "1ee5d885f03316f70c06dd0c9a7e2cd18ef352f83a7045077925c1f767bf20f9" + "sha256": "3bf902d420224032fa11492ddb4f8d3b636972f4a3d7740e4b7aef4bbf8484ff" }, "pdf": { "path": "quantcortex_audit_neurips2026.pdf", - "sha256": "d43a4c75a1b49b3d2c5e703128e5208be60688e8b1d3bd20c51a3ebbb29e6ede" + "sha256": "59fcd30467d449984118d746417d810757d32fbceb72af25d0d4a84a65203000" }, "schema_version": 1, - "source_commit": "d185c44928af865882c4d830066432dc97eb9972", - "source_date_epoch": 1781647230, + "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", + "source_date_epoch": 1781820388, "source_manifest": { "path": "quantcortex_audit_neurips2026.sources.sha256", - "sha256": "11096197aee3ed3a6b5505788756789b5deeddee140d3a204c4ed6ac4349b919" + "sha256": "ffd76bc58b7b0dd3499cb934a33abd613477478b3fb1fadabb16d5b47b5cbbde" }, "tectonic_bundle": { "name": "default_bundle_v33.tar", diff --git a/paper/main.tex b/paper/main.tex index 28740c0..c818483 100644 --- a/paper/main.tex +++ b/paper/main.tex @@ -319,7 +319,9 @@ \subsection{Reference implementation and order-state controls} versions, dependency lock, input SHA-256, experiment design, source-tree hashes, and the hash of every published table and figure. A versioned JSON contract and canonical long-form target-tape schema make the engine boundary machine -readable; deterministic fixtures exercise it without using the empirical price +readable. The fixed experiment round-trips every strategy decision stream +through the versioned payload boundary and records a canonical payload hash; +deterministic fixtures test the same boundary without using the empirical price matrix. The empirical sections below exercise the research and backtest contracts. Order-state controls are verified by deterministic tests: paper-trading state is written atomically as a versioned diff --git a/paper/quantcortex_audit_anonymous.pdf b/paper/quantcortex_audit_anonymous.pdf index 17f5422..c0d530e 100644 Binary files a/paper/quantcortex_audit_anonymous.pdf and b/paper/quantcortex_audit_anonymous.pdf differ diff --git a/paper/quantcortex_audit_anonymous.sha256 b/paper/quantcortex_audit_anonymous.sha256 index 00aedb3..2a0a59c 100644 --- a/paper/quantcortex_audit_anonymous.sha256 +++ b/paper/quantcortex_audit_anonymous.sha256 @@ -1 +1 @@ -1ee5d885f03316f70c06dd0c9a7e2cd18ef352f83a7045077925c1f767bf20f9 quantcortex_audit_anonymous.pdf +3bf902d420224032fa11492ddb4f8d3b636972f4a3d7740e4b7aef4bbf8484ff quantcortex_audit_anonymous.pdf diff --git a/paper/quantcortex_audit_neurips2026.pdf b/paper/quantcortex_audit_neurips2026.pdf index 6514369..0ad1de7 100644 Binary files a/paper/quantcortex_audit_neurips2026.pdf and b/paper/quantcortex_audit_neurips2026.pdf differ diff --git a/paper/quantcortex_audit_neurips2026.sha256 b/paper/quantcortex_audit_neurips2026.sha256 index 5f31dde..4f8efdd 100644 --- a/paper/quantcortex_audit_neurips2026.sha256 +++ b/paper/quantcortex_audit_neurips2026.sha256 @@ -1 +1 @@ -d43a4c75a1b49b3d2c5e703128e5208be60688e8b1d3bd20c51a3ebbb29e6ede quantcortex_audit_neurips2026.pdf +59fcd30467d449984118d746417d810757d32fbceb72af25d0d4a84a65203000 quantcortex_audit_neurips2026.pdf diff --git a/paper/quantcortex_audit_neurips2026.sources.sha256 b/paper/quantcortex_audit_neurips2026.sources.sha256 index 66a33e0..0752c99 100644 --- a/paper/quantcortex_audit_neurips2026.sources.sha256 +++ b/paper/quantcortex_audit_neurips2026.sources.sha256 @@ -1,9 +1,9 @@ -bbd36070a922fae4569f654b6b905a710c3dc8c318636dfa934ea82842e57d9a main.tex +26c3a3aac4ded1b4db38503857a8627d6d7b95d851f8b11897f303d4c6e01618 main.tex 9ef5395fcb6993271813e21c97ea594bfc45b5f8dcc9ec52fc4e16a809a0091a anonymous.tex 4bb1dc333bc0b23fb706bc5755b2cef765a2757284798d6ed9d090d530b47f6e checklist.tex c0c907c664fe2629306b7c07973747b44dc1c04f9ead03ffd292719374e1f531 references.bib 0c1ad36961fcd9198dcc2558cf2793e1df39973bde8264fd701f5e7970672757 neurips_2026.sty -f14a548364f13b40e75d44c2eb2c3aa0965668ac35a6acd30d22ef207abb1fdf results/generated_values.tex +98611c031c54382859488367dc1f5474a283014e3db2867f8210dab49ac85f25 results/generated_values.tex 83aea7fe6e6a97b55f8fe4b00218ae11972902947ed058d8281d40cb1d6e6f3c figures/accounting_summary.pdf a2a0284f9c0687ba6ff52b4a8077189d7a2ba21b63e5e77c4287639b6bbc23b4 figures/audit_protocol.pdf de3901b50dba906bc418660a4de8983ad311a0d4ed12b946e4d12b104435e4e2 figures/bootstrap_robustness.pdf diff --git a/paper/results/generated_values.tex b/paper/results/generated_values.tex index 555d9af..cc34141 100644 --- a/paper/results/generated_values.tex +++ b/paper/results/generated_values.tex @@ -4,7 +4,7 @@ \newcommand{\PaperRequiredWarmupSessions}{274} \newcommand{\PaperBootstrapReplications}{5,000} \newcommand{\PaperInputDigest}{efb384a62157e56a0cd8065abf45c1ed07d90ec26c681e5d54d74fe4cb9c55e1} -\newcommand{\PaperSourceTreeDigest}{df200735ec35013b252a3aa689752b9eae485c21abe7247bc590c444f18221b6} +\newcommand{\PaperSourceTreeDigest}{044c219b82295b187b92a7044757289faf91757d276206fe6abdec5d52252e60} \newcommand{\PaperNetCAGR}{1.40\%} \newcommand{\PaperGrossCAGR}{3.17\%} \newcommand{\PaperCashCAGR}{2.50\%} diff --git a/paper/results/manifest.json b/paper/results/manifest.json index 49506ec..d6a84df 100644 --- a/paper/results/manifest.json +++ b/paper/results/manifest.json @@ -20,15 +20,81 @@ "results/cost_sensitivity.csv": "25b9969cf8ecdabd19e7761ad2973252c9f5b9f994b6dc147595a2a112c24d88", "results/engine_comparison.csv": "ea0322dcd2aa4a1eb3b5996045c3a4b2ed25a85684a578ee788b013b99b643cd", "results/evaluation_contract.json": "77ee05ce64622ef9ba1bfbd7dae85c4f6fd44f07db0b21feaf6b3e0e418673e7", - "results/generated_values.tex": "f14a548364f13b40e75d44c2eb2c3aa0965668ac35a6acd30d22ef207abb1fdf", + "results/generated_values.tex": "98611c031c54382859488367dc1f5474a283014e3db2867f8210dab49ac85f25", "results/protocol_switches.csv": "20d5a0dc37e07a20c1f2772c8e9940ff6464ef1da98692edd5fdb06531bc2393", "results/return_decomposition.csv": "53a84aa9b91c92f0037e48dc09154807cbf003ccf0038f39da8406108bf709cc", "results/sharpe_uncertainty.csv": "aead873ced9b25c76c6944aa6e3ba0901ade65c784fa5cb8cc10ad1c6c01f136", "results/subperiods.csv": "9e6206fd38422640b4539e1207aade8821c2ee474af18e74268cb4bf69c249db", + "results/target_tape_hashes.json": "7eac818e4f37ed936561138c9a00f2038b6051723673a0162b609b2ae64bca03", "results/uncertainty.json": "6a6a0aa9a02def987f09de273d99cb28a6929cedf090d6245e2e1fa818d2c54a", "results/yearly_returns.csv": "53cfee7bb987c332b9db1766619b0eb085415e358724872a77ebd6c5f1ed6256" }, "configuration_sha256": "20ab6c2850734e9c7a44eae17b4c3f7db4dbfda18d85cb9ecd9fcc729b475128", + "decision_streams": { + "path": "results/target_tape_hashes.json", + "variants": { + "full": { + "canonical_payload_sha256": "a5c103d7d53dbc538971a7f85674843e5ace76ea10e4e09cc061f6bc9a05aaf4", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "no_regime": { + "canonical_payload_sha256": "cdbf2c1a4a500589ed0ee3b4954c2b85b9774bffd7fd046d77359a25c0908280", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "no_vol_scaler": { + "canonical_payload_sha256": "faeb6bca8e7a467d670b0be6ad1cbd44192e468b159c1a30456afa4dcf58c7fa", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "signal_only": { + "canonical_payload_sha256": "0746243609426143207f8c42a83026fb4f78c6564d893e57cab56831c7c18a69", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + } + } + }, "design": { "all_in_cost_levels_bps": [ 0.0, @@ -174,14 +240,14 @@ "path": "results/evaluation_contract.json", "schema_version": 1 }, - "generated_at": "2026-06-16T22:00:41Z", + "generated_at": "2026-06-18T22:06:35Z", "generator": { "dependency_lock": { "path": "poetry.lock", - "sha256": "fa8904674c2b24f3141acae607fd0a49d97125bc109c18def9fccbb2256c8de0" + "sha256": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b" }, "git": { - "source_commit": "d185c44928af865882c4d830066432dc97eb9972", + "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", "worktree_clean_at_start": true }, "packages": { @@ -195,12 +261,12 @@ "path": "scripts/run_paper_experiments.py", "platform": "macOS-26.4-arm64-arm-64bit-Mach-O", "python": "3.14.4", - "script_sha256": "2f7c00e1d387d7efde57c0264d3cd702176af2fa94e5f5646309b0641ddb34bc", + "script_sha256": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5", "source_tree": { - "file_count": 109, + "file_count": 110, "files": { - "poetry.lock": "fa8904674c2b24f3141acae607fd0a49d97125bc109c18def9fccbb2256c8de0", - "pyproject.toml": "175abd743d38f6b6c1cc8ae42471d62d83c86beb245d93fea231c666f51b2643", + "poetry.lock": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b", + "pyproject.toml": "eaeeb454c28bf7f6d9e530002bb7e88624b56b6c3e1fcb71e6414045cb9c42a0", "quantcortex/__init__.py": "14bf1ebdacd054c3738e4704d33da6709a39206463df8b8ced5376da342c4036", "quantcortex/alpha/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/alpha/factors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -224,7 +290,7 @@ "quantcortex/alpha/validation/alphalens_report.py": "8463289117add78a576e37a5b99a539be5c808fca86100e26deb29e8060aa60d", "quantcortex/alpha/validation/factor_decay.py": "9e6e049165f014db2122d9ed57415e45e48142b475dfb7221247d447d9a50397", "quantcortex/backtest/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "quantcortex/backtest/conformance.py": "e169cc67cac9eb56d8da4e4a0da1e257bb6cb8d1745baca681f50fab6c62fc57", + "quantcortex/backtest/conformance.py": "22f9b18101f0d9adcae98297e395c1ab6a90b18e411f24e7456f1d166b7df9b5", "quantcortex/backtest/costs/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/backtest/costs/transaction_costs.py": "48200419f37004c58df6f89041e31516d9ad98f66c22f75d11a0b9342474d5c7", "quantcortex/backtest/engines/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -245,7 +311,7 @@ "quantcortex/backtest/validation/multiple_testing.py": "6b68f6f150dcc8fb90213dcf769307454c0c506e99666a533d5d07ae3ccb0b5d", "quantcortex/backtest/validation/survivorship_check.py": "5b772bd6b40ffe44f590ec0ddb00b7e3dc61a1aba9cae8831227325f4f53beff", "quantcortex/data/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "quantcortex/data/local_csv.py": "30c11c3200530810b3345f8e29fdf966f35671e30c94fc5bb1b2db656e6df79b", + "quantcortex/data/local_csv.py": "6e5f3d24b1477ce122a1e80899e841fffd6d857034189f7d8605705f52c6839c", "quantcortex/data/processors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/data/processors/adjustments.py": "ae2d4d0362e3484b89b5a5c57a7fd6320973b208ee0cd5a6be9a3f29d2544e2c", "quantcortex/data/processors/calendar.py": "e591bccc3e7f632501b068e7abec1ece182d0ef930589381776fe89158d1df51", @@ -305,11 +371,12 @@ "quantcortex/timing/kama.py": "ffb848ba93041af1e04f2b7896ea7a8f33ac7e5e3e53db1e44f53af111829eda", "quantcortex/timing/tsmom.py": "c78ac313268091f45e0e589067f12e07e8a340fa243b8fae2853778ef4ba8ea9", "quantcortex/timing/vix_scaler.py": "a3667424e5573fb289e63c26c69da6a68d6c943742359f0466d29b25c56e3686", - "schemas/canonical_target_tape.schema.json": "81acbd7e03163ea0dc28f7b1a4bd2a73a4bbeef85dbdbc70aee01aa100c4e7e5", + "schemas/canonical_target_tape.schema.json": "4f1c0bf6d5360305d2982adea78de3f61c4bc1ebae9207cb2ba2bd4379b43d44", "schemas/evaluation_contract.schema.json": "970f24f587e669925306625d12c5a84dffd03ff5b222a59905849b2fa222784f", - "scripts/run_paper_experiments.py": "2f7c00e1d387d7efde57c0264d3cd702176af2fa94e5f5646309b0641ddb34bc" + "scripts/release_paper_artifacts.sh": "cf3de9434ab3991598e0c8d1d2c9346fde425fdf1eb792a6941fede0c66342e9", + "scripts/run_paper_experiments.py": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5" }, - "sha256": "df200735ec35013b252a3aa689752b9eae485c21abe7247bc590c444f18221b6" + "sha256": "044c219b82295b187b92a7044757289faf91757d276206fe6abdec5d52252e60" }, "threadpools": [ { @@ -321,7 +388,7 @@ } ] }, - "schema_version": 4, + "schema_version": 5, "source": { "adjustment_method": "yfinance adjusted close with auto_adjust=False", "cash_proxy": "SHV", diff --git a/paper/results/target_tape_hashes.json b/paper/results/target_tape_hashes.json new file mode 100644 index 0000000..17dff2a --- /dev/null +++ b/paper/results/target_tape_hashes.json @@ -0,0 +1,62 @@ +{ + "full": { + "canonical_payload_sha256": "a5c103d7d53dbc538971a7f85674843e5ace76ea10e4e09cc061f6bc9a05aaf4", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "no_regime": { + "canonical_payload_sha256": "cdbf2c1a4a500589ed0ee3b4954c2b85b9774bffd7fd046d77359a25c0908280", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "no_vol_scaler": { + "canonical_payload_sha256": "faeb6bca8e7a467d670b0be6ad1cbd44192e468b159c1a30456afa4dcf58c7fa", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + }, + "signal_only": { + "canonical_payload_sha256": "0746243609426143207f8c42a83026fb4f78c6564d893e57cab56831c7c18a69", + "decision_count": 521, + "max_gross": 1.0, + "record_count": 3126, + "schema_version": 1, + "symbols": [ + "GLD", + "QQQ", + "SPY", + "TLT", + "VGT", + "VIG" + ] + } +} diff --git a/poetry.lock b/poetry.lock index ac8e3df..b0cc87e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -403,7 +403,7 @@ version = "1.4.0" description = "Better dates & times for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"}, {file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"}, @@ -464,7 +464,7 @@ version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -1632,7 +1632,7 @@ version = "1.5.1" description = "Validates fully-qualified domain names against RFC 1123, so that they are acceptable to modern bowsers" optional = false python-versions = ">=2.7, !=3.0, !=3.1, !=3.2, !=3.3, !=3.4, <4" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014"}, {file = "fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f"}, @@ -2262,7 +2262,7 @@ version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, @@ -2395,7 +2395,7 @@ version = "20.11.0" description = "Operations with ISO 8601 durations" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042"}, {file = "isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9"}, @@ -2472,7 +2472,7 @@ version = "3.1.1" description = "Identify specific nodes in a JSON document (RFC 6901) " optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca"}, {file = "jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900"}, @@ -2484,7 +2484,7 @@ version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, @@ -2515,7 +2515,7 @@ version = "2025.9.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, @@ -2930,7 +2930,7 @@ version = "1.3.1" description = "a modern parsing library" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12"}, {file = "lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905"}, @@ -5370,7 +5370,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -5652,7 +5652,7 @@ version = "0.37.0" description = "JSON Referencing + Python" optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, @@ -5817,7 +5817,7 @@ version = "0.1.4" description = "A pure python RFC3339 validator" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, @@ -5832,7 +5832,7 @@ version = "0.1.1" description = "Pure python rfc3986 validator" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9"}, {file = "rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055"}, @@ -5844,7 +5844,7 @@ version = "1.1.0" description = "Helper functions to syntactically validate strings according to RFC 3987." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f"}, {file = "rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d"}, @@ -5882,7 +5882,7 @@ version = "2026.5.1" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.11" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036"}, {file = "rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc"}, @@ -6280,7 +6280,7 @@ version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -6846,12 +6846,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "python_version < \"3.14\" and (extra == \"brokers\" or extra == \"all\" or extra == \"nlp\" or extra == \"rl\" or extra == \"providers\" or extra == \"storage\") or extra == \"rl\" or extra == \"all\" or extra == \"nlp\" or extra == \"brokers\" or extra == \"providers\" or extra == \"storage\""} +markers = {main = "python_version < \"3.14\" and (extra == \"brokers\" or extra == \"all\" or extra == \"nlp\" or extra == \"rl\" or extra == \"providers\" or extra == \"storage\") or extra == \"rl\" or extra == \"all\" or extra == \"nlp\" or extra == \"brokers\" or extra == \"providers\" or extra == \"storage\"", test = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -6875,7 +6875,7 @@ version = "2025.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, @@ -6888,7 +6888,7 @@ version = "1.3.0" description = "RFC 6570 URI Template Processor" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7"}, {file = "uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363"}, @@ -6934,7 +6934,7 @@ version = "25.10.0" description = "A library for working with the color formats defined by HTML and CSS." optional = false python-versions = ">=3.10" -groups = ["dev"] +groups = ["dev", "test"] files = [ {file = "webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d"}, {file = "webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf"}, @@ -7264,4 +7264,4 @@ storage = ["psycopg2-binary", "redis", "sqlalchemy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" -content-hash = "fdf69ed71f0e77bc4e134fd0c617b6ad4ecb2bc28e3b34547f8513519bcbf3ed" +content-hash = "20db487a78d05dfdbc61f18045e883b20a6a82fc2a028ed30bd7902bf816f6ff" diff --git a/pyproject.toml b/pyproject.toml index 72c7f96..c67df2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ packages = [ poetry-plugin-export = ">=1.10,<1.11" [tool.poetry.group.test.dependencies] +jsonschema = { version = ">=4.20,<5", extras = ["format-nongpl"] } packaging = ">=24" pytest = ">=8.0" pytest-cov = ">=4.1" diff --git a/quantcortex/backtest/conformance.py b/quantcortex/backtest/conformance.py index 1d36a1c..898e48c 100644 --- a/quantcortex/backtest/conformance.py +++ b/quantcortex/backtest/conformance.py @@ -8,7 +8,9 @@ from __future__ import annotations +import re from collections.abc import Mapping, Sequence +from datetime import datetime import numpy as np import pandas as pd @@ -19,6 +21,69 @@ "target_weight", ) TARGET_TAPE_SCHEMA_VERSION = 1 +_RFC3339_TIMESTAMP = re.compile( + r"^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d" + r"(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$" +) + + +def _validated_payload_timestamp(value: object) -> str: + """Validate the RFC 3339 subset emitted by the canonical serializer.""" + if not isinstance(value, str): + raise TypeError( + "target-tape decision_timestamp must be an RFC 3339 string" + ) + if _RFC3339_TIMESTAMP.fullmatch(value) is None: + raise ValueError( + "target-tape decision_timestamp must be an RFC 3339 date-time" + ) + candidate = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(candidate) + except ValueError as exc: + raise ValueError( + "target-tape decision_timestamp must be an RFC 3339 date-time" + ) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError( + "target-tape decision_timestamp must include a UTC offset" + ) + return value + + +def _validated_payload_number(value: object, *, name: str) -> float: + """Accept only JSON number primitives, excluding booleans and strings.""" + if type(value) not in (int, float): + raise TypeError(f"{name} must be a JSON number") + try: + parsed = float(value) + except OverflowError as exc: + raise ValueError(f"{name} must be finite binary64") from exc + if not np.isfinite(parsed): + raise ValueError(f"{name} must be finite binary64") + return parsed + + +def _validated_payload_schema_version(value: object) -> int: + """Normalize JSON numbers that represent an integral schema version.""" + parsed = _validated_payload_number(value, name="target-tape schema_version") + if not parsed.is_integer(): + raise TypeError("target-tape schema_version must be an integer") + version = int(parsed) + if version != TARGET_TAPE_SCHEMA_VERSION: + raise ValueError("unsupported target-tape schema version") + return version + + +def _validated_payload_symbol(value: object, *, name: str) -> str: + """Validate a canonical wire symbol without silently normalizing it.""" + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + if not value or value != value.strip() or "\r" in value or "\n" in value: + raise ValueError( + f"{name} must be non-empty with no outer whitespace or line breaks" + ) + return value def _validated_max_gross(value: object) -> float: @@ -61,6 +126,7 @@ def validate_target_tape( try: timestamps = pd.to_datetime( normalized["decision_timestamp"], + format="mixed", utc=True, errors="raise", ) @@ -88,7 +154,7 @@ def validate_target_tape( raise ValueError("target_weight values must be finite") if (weights < -1e-12).any(): raise ValueError("target_weight values must be long-only") - normalized.loc[normalized["target_weight"].abs() < 1e-15, "target_weight"] = 0.0 + normalized.loc[normalized["target_weight"] < 0.0, "target_weight"] = 0.0 duplicate = normalized.duplicated( subset=["decision_timestamp", "symbol"], @@ -154,7 +220,12 @@ def target_tape_to_weights( ) weights.columns.name = None weights.index.name = None - return weights.sort_index().sort_index(axis=1) + column_order = ( + sorted(normalized["symbol"].unique()) + if expected_symbols is None + else [symbol.strip() for symbol in expected_symbols] + ) + return weights.sort_index().reindex(columns=column_order) def weights_to_target_tape( @@ -234,11 +305,14 @@ def target_tape_from_payload(payload: Mapping[str, object]) -> pd.DataFrame: "target-tape payload keys must be exactly " + ", ".join(sorted(required_keys)) ) - if payload["schema_version"] != TARGET_TAPE_SCHEMA_VERSION: - raise ValueError("unsupported target-tape schema version") + _validated_payload_schema_version(payload["schema_version"]) symbols = payload["symbols"] if not isinstance(symbols, list): raise TypeError("target-tape symbols must be a list") + symbols = [ + _validated_payload_symbol(symbol, name="target-tape symbol") + for symbol in symbols + ] records = payload["records"] if not isinstance(records, list) or not records: raise ValueError("target-tape records must be a non-empty list") @@ -249,10 +323,33 @@ def target_tape_from_payload(payload: Mapping[str, object]) -> pd.DataFrame: "target-tape record keys must be exactly " + ", ".join(TARGET_TAPE_COLUMNS) ) - tape = pd.DataFrame(records, columns=TARGET_TAPE_COLUMNS) + normalized_records = [] + for record in records: + timestamp = _validated_payload_timestamp(record["decision_timestamp"]) + symbol = _validated_payload_symbol( + record["symbol"], + name="target-tape record symbol", + ) + target_weight = _validated_payload_number( + record["target_weight"], + name="target-tape target_weight", + ) + if target_weight < 0.0: + raise ValueError("target-tape target_weight must be non-negative") + normalized_records.append( + { + "decision_timestamp": timestamp, + "symbol": symbol, + "target_weight": target_weight, + } + ) + max_gross = _validated_payload_number( + payload["max_gross"], + name="target-tape max_gross", + ) return validate_target_tape( - tape, - max_gross=_validated_max_gross(payload["max_gross"]), + pd.DataFrame(normalized_records, columns=TARGET_TAPE_COLUMNS), + max_gross=_validated_max_gross(max_gross), expected_symbols=symbols, ) diff --git a/quantcortex/data/local_csv.py b/quantcortex/data/local_csv.py index bb6084c..58c8a63 100644 --- a/quantcortex/data/local_csv.py +++ b/quantcortex/data/local_csv.py @@ -95,12 +95,16 @@ def load_price_matrix( start: str | None = None, end: str | None = None, max_ffill: int | None = 5, + *, + require_complete: bool = False, ) -> pd.DataFrame: """Load a wide adjusted-close CSV indexed by a required ``date`` column. Missing prices are forward-filled for at most ``max_ffill`` rows. Set it to ``None`` to disable filling; unlimited filling is intentionally unsupported - because it can keep stale or delisted assets alive indefinitely. + because it can keep stale or delisted assets alive indefinitely. When + ``require_complete`` is true, any missing value that remains in the requested + date window raises instead of silently removing that row. """ resolved, frame = _read_dated_csv(path) frame.columns = [str(column).strip() for column in frame.columns] @@ -126,6 +130,8 @@ def load_price_matrix( frame = _numeric(frame, resolved) if (frame <= 0).any(axis=None): raise LocalDataError(f"prices must be strictly positive in {resolved}") + if not isinstance(require_complete, bool): + raise LocalDataError("require_complete must be a boolean") if max_ffill is not None: if ( @@ -135,7 +141,20 @@ def load_price_matrix( ): raise LocalDataError("max_ffill must be a non-negative integer or None") frame = frame.ffill(limit=int(max_ffill)) if max_ffill > 0 else frame - frame = _slice(frame, start, end).dropna(how="any") + frame = _slice(frame, start, end) + if require_complete and frame.isna().any(axis=None): + missing = frame.isna().stack() + locations = [ + f"{timestamp.date().isoformat()}:{symbol}" + for (timestamp, symbol), is_missing in missing.items() + if is_missing + ] + preview = ", ".join(locations[:3]) + raise LocalDataError( + "price matrix contains missing required observations" + + (f"; first missing values: {preview}" if preview else "") + ) + frame = frame.dropna(how="any") if frame.empty: raise LocalDataError("no complete price rows remain after forward-filling") return frame diff --git a/requirements/test.lock b/requirements/test.lock index 0d7d335..0f53e4f 100644 --- a/requirements/test.lock +++ b/requirements/test.lock @@ -1,11 +1,20 @@ +arrow==1.4.0 ; python_version >= "3.11" and python_version < "3.15" +attrs==26.1.0 ; python_version >= "3.11" and python_version < "3.15" colorama==0.4.6 ; python_version >= "3.11" and python_version < "3.15" and (platform_system == "Windows" or sys_platform == "win32") contourpy==1.3.3 ; python_version >= "3.11" and python_version < "3.15" coverage==7.14.1 ; python_version >= "3.11" and python_version < "3.15" cycler==0.12.1 ; python_version >= "3.11" and python_version < "3.15" fonttools==4.63.0 ; python_version >= "3.11" and python_version < "3.15" +fqdn==1.5.1 ; python_version >= "3.11" and python_version < "3.15" +idna==3.18 ; python_version >= "3.11" and python_version < "3.15" iniconfig==2.3.0 ; python_version >= "3.11" and python_version < "3.15" +isoduration==20.11.0 ; python_version >= "3.11" and python_version < "3.15" joblib==1.5.3 ; python_version >= "3.11" and python_version < "3.15" +jsonpointer==3.1.1 ; python_version >= "3.11" and python_version < "3.15" +jsonschema-specifications==2025.9.1 ; python_version >= "3.11" and python_version < "3.15" +jsonschema==4.26.0 ; python_version >= "3.11" and python_version < "3.15" kiwisolver==1.5.0 ; python_version >= "3.11" and python_version < "3.15" +lark==1.3.1 ; python_version >= "3.11" and python_version < "3.15" matplotlib==3.11.0 ; python_version >= "3.11" and python_version < "3.15" narwhals==2.22.1 ; python_version >= "3.11" and python_version < "3.15" numpy==2.4.6 ; python_version >= "3.11" and python_version < "3.15" @@ -19,9 +28,17 @@ pyparsing==3.3.2 ; python_version >= "3.11" and python_version < "3.15" pytest-cov==7.1.0 ; python_version >= "3.11" and python_version < "3.15" pytest==9.1.0 ; python_version >= "3.11" and python_version < "3.15" python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "3.15" +referencing==0.37.0 ; python_version >= "3.11" and python_version < "3.15" +rfc3339-validator==0.1.4 ; python_version >= "3.11" and python_version < "3.15" +rfc3986-validator==0.1.1 ; python_version >= "3.11" and python_version < "3.15" +rfc3987-syntax==1.1.0 ; python_version >= "3.11" and python_version < "3.15" +rpds-py==2026.5.1 ; python_version >= "3.11" and python_version < "3.15" ruff==0.15.17 ; python_version >= "3.11" and python_version < "3.15" scikit-learn==1.9.0 ; python_version >= "3.11" and python_version < "3.15" scipy==1.17.1 ; python_version >= "3.11" and python_version < "3.15" six==1.17.0 ; python_version >= "3.11" and python_version < "3.15" threadpoolctl==3.6.0 ; python_version >= "3.11" and python_version < "3.15" -tzdata==2025.3 ; python_version >= "3.11" and python_version < "3.15" and (sys_platform == "win32" or sys_platform == "emscripten") +typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "3.14" +tzdata==2025.3 ; python_version >= "3.11" and python_version < "3.15" +uri-template==1.3.0 ; python_version >= "3.11" and python_version < "3.15" +webcolors==25.10.0 ; python_version >= "3.11" and python_version < "3.15" diff --git a/schemas/canonical_target_tape.schema.json b/schemas/canonical_target_tape.schema.json index 03ec18d..2c01426 100644 --- a/schemas/canonical_target_tape.schema.json +++ b/schemas/canonical_target_tape.schema.json @@ -7,6 +7,7 @@ "required": ["schema_version", "symbols", "max_gross", "records"], "properties": { "schema_version": { + "type": "integer", "const": 1 }, "symbols": { @@ -16,12 +17,13 @@ "items": { "type": "string", "minLength": 1, - "pattern": ".*\\S.*" + "pattern": "^(?:\\S|\\S.*\\S)$" } }, "max_gross": { "type": "number", - "exclusiveMinimum": 0.0 + "exclusiveMinimum": 0.0, + "maximum": 1.7976931348623157e308 }, "records": { "type": "array", @@ -37,15 +39,18 @@ "properties": { "decision_timestamp": { "type": "string", - "format": "date-time" + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$" }, "symbol": { "type": "string", - "minLength": 1 + "minLength": 1, + "pattern": "^(?:\\S|\\S.*\\S)$" }, "target_weight": { "type": "number", - "minimum": 0.0 + "minimum": 0.0, + "maximum": 1.7976931348623157e308 } } } diff --git a/scripts/release_paper_artifacts.sh b/scripts/release_paper_artifacts.sh index 7ca4b2f..f2f8a96 100755 --- a/scripts/release_paper_artifacts.sh +++ b/scripts/release_paper_artifacts.sh @@ -26,8 +26,93 @@ if [[ ! -f "${prices_csv}" ]]; then exit 1 fi -source_commit="$(git -C "${repo_root}" rev-parse HEAD)" -generated_at="${QUANTCORTEX_GENERATED_AT:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}" +reviewed_manifest="${repo_root}/paper/results/manifest.json" +if [[ ! -f "${reviewed_manifest}" ]]; then + printf '%s\n' \ + "reviewed paper manifest not found: ${reviewed_manifest}" >&2 + exit 1 +fi +expected_input_digest="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["source"]["input_sha256"])' \ + "${reviewed_manifest}" +)" +actual_input_digest="$( + "${python_bin}" -c \ + 'import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' \ + "${prices_csv}" +)" +if [[ "${actual_input_digest}" != "${expected_input_digest}" ]]; then + printf '%s\n' \ + "price matrix digest does not match the reviewed experiment" \ + "expected: ${expected_input_digest}" \ + "actual: ${actual_input_digest}" >&2 + exit 1 +fi +case "${prices_csv}" in + "${repo_root}"/*) + relative_prices="${prices_csv#${repo_root}/}" + if git -C "${repo_root}" ls-files --error-unmatch -- "${relative_prices}" \ + >/dev/null 2>&1; then + printf '%s\n' \ + "reviewed raw price input must not be tracked by Git: ${relative_prices}" >&2 + exit 1 + else + tracking_status=$? + if [[ "${tracking_status}" -ne 1 ]]; then + printf '%s\n' \ + "could not determine whether the raw price input is tracked" >&2 + exit 1 + fi + fi + ;; +esac + +current_commit="$(git -C "${repo_root}" rev-parse HEAD)" +reviewed_source_commit="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["generator"]["git"]["source_commit"])' \ + "${reviewed_manifest}" +)" +reviewed_generated_at="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["generated_at"])' \ + "${reviewed_manifest}" +)" +if [[ -n "${QUANTCORTEX_GENERATED_AT:-}" ]]; then + source_commit="${current_commit}" + generated_at="${QUANTCORTEX_GENERATED_AT}" +else + if ! git -C "${repo_root}" cat-file -e "${reviewed_source_commit}^{commit}" \ + >/dev/null 2>&1; then + printf '%s\n' "reviewed source commit is unavailable" >&2 + exit 1 + fi + release_source_paths=( + quantcortex + schemas + pyproject.toml + poetry.lock + scripts/build_paper.sh + scripts/generate_report.py + scripts/release_paper_artifacts.sh + scripts/run_paper_experiments.py + paper/main.tex + paper/anonymous.tex + paper/checklist.tex + paper/references.bib + paper/neurips_2026.sty + ) + if ! git -C "${repo_root}" diff --quiet \ + "${reviewed_source_commit}" "${current_commit}" -- \ + "${release_source_paths[@]}"; then + printf '%s\n' \ + "QUANTCORTEX_GENERATED_AT is required for changed release source" >&2 + exit 1 + fi + source_commit="${reviewed_source_commit}" + generated_at="${reviewed_generated_at}" +fi source_date_epoch="${SOURCE_DATE_EPOCH:-$(git -C "${repo_root}" show -s --format=%ct "${source_commit}")}" temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/quantcortex-paper-release.XXXXXX")" source_worktree="${temporary_root}/source" @@ -44,10 +129,26 @@ trap cleanup EXIT git -C "${repo_root}" worktree add --detach "${source_worktree}" "${source_commit}" \ >/dev/null -permission="Owner-supplied local input; publication of derived aggregate " -permission+="results authorized by the repository owner; provider terms not " -permission+="independently verified" -adjustment="yfinance adjusted close with auto_adjust=False" +provider="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["source"]["provider"])' \ + "${reviewed_manifest}" +)" +permission="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["source"]["permission_basis"])' \ + "${reviewed_manifest}" +)" +retrieved_at="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["source"]["retrieved_at"])' \ + "${reviewed_manifest}" +)" +adjustment="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["source"]["adjustment_method"])' \ + "${reviewed_manifest}" +)" ( cd "${source_worktree}" @@ -58,9 +159,9 @@ adjustment="yfinance adjusted close with auto_adjust=False" --cash-proxy-symbol SHV \ --output-dir "${generated_output}" \ --bootstrap-replications 5000 \ - --data-provider "Yahoo Finance via yfinance 1.4.1" \ + --data-provider "${provider}" \ --permission-basis "${permission}" \ - --retrieved-at 2026-06-16 \ + --retrieved-at "${retrieved_at}" \ --adjustment-method "${adjustment}" \ --generated-at "${generated_at}" \ --require-clean-source @@ -76,9 +177,9 @@ adjustment="yfinance adjusted close with auto_adjust=False" --imgdir "${performance_output}/img" \ --report-out "${performance_output}/report.md" \ --manifest-out "${performance_output}/img/performance_manifest.json" \ - --data-provider "Yahoo Finance via yfinance 1.4.1" \ + --data-provider "${provider}" \ --permission-basis "${permission}" \ - --retrieved-at 2026-06-16 \ + --retrieved-at "${retrieved_at}" \ --adjustment-method "${adjustment}" \ --generated-at "${generated_at}" \ --require-clean-source \ diff --git a/scripts/run_paper_experiments.py b/scripts/run_paper_experiments.py index d9f9c89..9ef0870 100644 --- a/scripts/run_paper_experiments.py +++ b/scripts/run_paper_experiments.py @@ -25,6 +25,12 @@ logging.getLogger("hmmlearn").setLevel(logging.ERROR) os.environ.setdefault("LOKY_MAX_CPU_COUNT", "1") +from quantcortex.backtest.conformance import ( + target_tape_from_payload, + target_tape_to_payload, + target_tape_to_weights, + weights_to_target_tape, +) from quantcortex.backtest.costs.transaction_costs import TransactionCostModel from quantcortex.backtest.engines.event_driven import EventDrivenBacktest from quantcortex.backtest.engines.vectorized import BacktestResult, VectorizedBacktest @@ -78,7 +84,7 @@ PRIMARY_BOOTSTRAP_BLOCK_LENGTH = 21 BOOTSTRAP_SEED = 42 PAPER_MAX_FORWARD_FILL = 0 -PROVENANCE_SCHEMA_VERSION = 4 +PROVENANCE_SCHEMA_VERSION = 5 EVALUATION_CONTRACT_SCHEMA_VERSION = 1 TARGET_TAPE_SCHEMA_VERSION = 1 TARGET_EXPOSURE_COMPARATOR_SYMBOL = "equal_initial_weight_basket" @@ -90,6 +96,7 @@ "net_excess_over_cash": "Net excess over cash", } SOURCE_TREE_FIXED_FILES = ( + "scripts/release_paper_artifacts.sh", "scripts/run_paper_experiments.py", "schemas/canonical_target_tape.schema.json", "schemas/evaluation_contract.schema.json", @@ -152,6 +159,32 @@ def _git_metadata(repo_root: Path) -> dict[str, str | bool]: } +def _git_path_is_tracked(repo_root: Path, path: Path) -> bool: + """Return whether ``path`` is tracked in the source repository.""" + try: + relative = path.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + return False + try: + result = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", relative], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + raise RuntimeError("could not determine whether paper input is tracked") from exc + if result.returncode == 0: + return True + if result.returncode == 1: + return False + detail = result.stderr.strip() or f"git exited with status {result.returncode}" + raise RuntimeError( + f"could not determine whether paper input is tracked: {detail}" + ) + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: @@ -1756,6 +1789,7 @@ def run_experiments( evaluation_end, ) strategy_weights: dict[str, pd.DataFrame] = {} + target_tape_metadata: dict[str, dict[str, object]] = {} ablation_rows: list[dict[str, float | str]] = [] variant_series: dict[str, dict[str, pd.Series]] = {} gross_active_by_variant: dict[str, pd.Series] = {} @@ -1776,8 +1810,28 @@ def run_experiments( ) for variant, strategy in strategies.items(): - weights = strategy.generate_weights(prices, weekly) + raw_weights = strategy.generate_weights(prices, weekly) + target_tape = weights_to_target_tape(raw_weights, max_gross=1.0) + target_payload = target_tape_to_payload( + target_tape, + max_gross=1.0, + expected_symbols=list(prices.columns), + ) + validated_tape = target_tape_from_payload(target_payload) + weights = target_tape_to_weights( + validated_tape, + max_gross=1.0, + expected_symbols=list(prices.columns), + ) strategy_weights[variant] = weights + target_tape_metadata[variant] = { + "schema_version": TARGET_TAPE_SCHEMA_VERSION, + "decision_count": int(weights.shape[0]), + "record_count": int(len(target_tape)), + "symbols": list(target_payload["symbols"]), + "max_gross": 1.0, + "canonical_payload_sha256": _json_sha256(target_payload), + } result = _engine_result( weights, prices, @@ -2088,6 +2142,7 @@ def protocol_row( "evaluation_index": evaluation_index, "warmup_sessions": available_warmup_sessions, "required_warmup_sessions": required_warmup_sessions, + "target_tape_metadata": target_tape_metadata, "accounting": pd.DataFrame(accounting_rows), "ablation": ablation, "ablation_uncertainty": ablation_uncertainty, @@ -2160,12 +2215,16 @@ def main(argv: list[str]) -> int: raise ValueError(f"dependency lock is missing: {dependency_lock_path}") source = args.prices_csv.expanduser().resolve() + raw_input_committed = _git_path_is_tracked(repo_root, source) + if raw_input_committed: + raise RuntimeError("paper price input must not be tracked by Git") input_digest = sha256_file(source) symbols = UNIVERSE + [args.cash_proxy_symbol] prices_with_cash = load_price_matrix( source, symbols=symbols, max_ffill=PAPER_MAX_FORWARD_FILL, + require_complete=True, ) prices = prices_with_cash.loc[:, UNIVERSE] cash_returns = prices_with_cash[args.cash_proxy_symbol].pct_change( @@ -2233,6 +2292,10 @@ def main(argv: list[str]) -> int: path=results_dir / "generated_values.tex", ), _write_json(contract, results_dir / "evaluation_contract.json"), + _write_json( + experiment["target_tape_metadata"], + results_dir / "target_tape_hashes.json", + ), ] uncertainty_path = results_dir / "uncertainty.json" artifacts.append(_write_json(experiment["uncertainty"], uncertainty_path)) @@ -2286,7 +2349,7 @@ def main(argv: list[str]) -> int: "source": { "file_name": source.name, "input_sha256": input_digest, - "raw_input_committed": False, + "raw_input_committed": raw_input_committed, "symbols": symbols, "cash_proxy": args.cash_proxy_symbol, "provider": args.data_provider, @@ -2414,6 +2477,10 @@ def main(argv: list[str]) -> int: "schema_version": EVALUATION_CONTRACT_SCHEMA_VERSION, "canonical_sha256": _json_sha256(contract), }, + "decision_streams": { + "path": "results/target_tape_hashes.json", + "variants": experiment["target_tape_metadata"], + }, "artifacts": { str(path.relative_to(output_dir)): _sha256(path) for path in sorted(artifacts) diff --git a/tests/test_backtest_conformance.py b/tests/test_backtest_conformance.py index f55b0a3..68f1374 100644 --- a/tests/test_backtest_conformance.py +++ b/tests/test_backtest_conformance.py @@ -1,10 +1,13 @@ from __future__ import annotations +import copy import json from pathlib import Path +import numpy as np import pandas as pd import pytest +from jsonschema import Draft202012Validator, FormatChecker from quantcortex.backtest.conformance import ( TARGET_TAPE_COLUMNS, @@ -26,6 +29,89 @@ def _fixture_tape() -> pd.DataFrame: return pd.read_csv(FIXTURE_ROOT / "target_tape.csv") +def _target_tape_schema_validator() -> Draft202012Validator: + schema = json.loads( + (REPO_ROOT / "schemas" / "canonical_target_tape.schema.json").read_text() + ) + return Draft202012Validator(schema, format_checker=FormatChecker()) + + +def _wire_case_payload(case: str) -> dict[str, object]: + payload = copy.deepcopy( + target_tape_to_payload(_fixture_tape(), expected_symbols=["A", "B"]) + ) + records = payload["records"] + assert isinstance(records, list) + + if case == "valid": + return payload + if case == "integral_float_schema_version": + payload["schema_version"] = 1.0 + elif case == "boolean_schema_version": + payload["schema_version"] = True + elif case == "nonintegral_schema_version": + payload["schema_version"] = 1.5 + elif case == "unsupported_schema_version": + payload["schema_version"] = 2 + elif case == "string_max_gross": + payload["max_gross"] = "1.0" + elif case == "negative_target_weight": + records[0]["target_weight"] = -5e-13 + elif case == "oversized_binary64_number": + oversized = 10**1000 + payload["max_gross"] = oversized + records[0]["target_weight"] = oversized + elif case == "maximum_binary64_number": + maximum = float.fromhex("0x1.fffffffffffffp+1023") + payload["max_gross"] = maximum + records[0]["target_weight"] = maximum + elif case in { + "date_only_timestamp", + "lowercase_timestamp", + "year_zero_timestamp", + "invalid_calendar_date", + "fractional_timestamp", + "maximum_offset_timestamp", + "local_hour_24", + "local_minute_60", + "local_second_60", + "offset_hour_24", + "offset_minute_60", + }: + replacements = { + "date_only_timestamp": "2024-01-01", + "lowercase_timestamp": "2024-01-01t00:00:00z", + "year_zero_timestamp": "0000-01-01T00:00:00Z", + "invalid_calendar_date": "2023-02-29T00:00:00Z", + "fractional_timestamp": "2024-01-01T00:00:00.123456789Z", + "maximum_offset_timestamp": "2024-01-01T00:00:00+23:59", + "local_hour_24": "2024-01-01T24:00:00Z", + "local_minute_60": "2024-01-01T00:60:00Z", + "local_second_60": "2024-01-01T00:00:60Z", + "offset_hour_24": "2024-01-01T00:00:00+24:00", + "offset_minute_60": "2024-01-01T00:00:00+00:60", + } + original = records[0]["decision_timestamp"] + for record in records: + if record["decision_timestamp"] == original: + record["decision_timestamp"] = replacements[case] + elif case in {"blank_symbol", "outer_whitespace_symbol"}: + replacement = " " if case == "blank_symbol" else " A" + symbols = payload["symbols"] + assert isinstance(symbols, list) + symbols[0] = replacement + for record in records: + if record["symbol"] == "A": + record["symbol"] = replacement + elif case == "unknown_payload_field": + payload["unexpected"] = True + elif case == "unknown_record_field": + records[0]["unexpected"] = True + else: # pragma: no cover - protects the test table itself + raise AssertionError(f"unknown wire test case: {case}") + return payload + + def test_canonical_target_tape_round_trips(): tape = validate_target_tape(_fixture_tape(), expected_symbols=["A", "B"]) weights = target_tape_to_weights(tape, expected_symbols=["A", "B"]) @@ -51,6 +137,73 @@ def test_canonical_target_tape_json_payload_round_trips(): pd.testing.assert_frame_equal(restored, tape) +@pytest.mark.parametrize( + ("case", "accepted"), + [ + ("valid", True), + ("integral_float_schema_version", True), + ("boolean_schema_version", False), + ("nonintegral_schema_version", False), + ("unsupported_schema_version", False), + ("string_max_gross", False), + ("negative_target_weight", False), + ("oversized_binary64_number", False), + ("maximum_binary64_number", True), + ("date_only_timestamp", False), + ("lowercase_timestamp", False), + ("year_zero_timestamp", False), + ("invalid_calendar_date", False), + ("fractional_timestamp", True), + ("maximum_offset_timestamp", True), + ("local_hour_24", False), + ("local_minute_60", False), + ("local_second_60", False), + ("offset_hour_24", False), + ("offset_minute_60", False), + ("blank_symbol", False), + ("outer_whitespace_symbol", False), + ("unknown_payload_field", False), + ("unknown_record_field", False), + ], +) +def test_target_tape_schema_and_runtime_align_on_wire_constraints(case, accepted): + """Keep primitive wire validation aligned without duplicating semantics.""" + payload = _wire_case_payload(case) + schema_accepts = not list(_target_tape_schema_validator().iter_errors(payload)) + try: + target_tape_from_payload(payload) + except (TypeError, ValueError): + runtime_accepts = False + else: + runtime_accepts = True + + assert schema_accepts is accepted + assert runtime_accepts is accepted + + +def test_canonical_target_tape_payload_survives_strict_json_round_trip(): + tape = validate_target_tape(_fixture_tape(), expected_symbols=["A", "B"]) + payload = target_tape_to_payload(tape, expected_symbols=["A", "B"]) + encoded = json.dumps(payload, allow_nan=False, sort_keys=True) + decoded = json.loads(encoded) + + _target_tape_schema_validator().validate(decoded) + restored = target_tape_from_payload(decoded) + + pd.testing.assert_frame_equal(restored, tape) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_canonical_target_tape_payload_rejects_nonfinite_json_numbers(value): + payload = target_tape_to_payload(_fixture_tape(), expected_symbols=["A", "B"]) + payload["records"][0]["target_weight"] = value + + with pytest.raises(ValueError, match="must be finite binary64"): + target_tape_from_payload(payload) + with pytest.raises(ValueError, match="Out of range float values"): + json.dumps(payload, allow_nan=False) + + @pytest.mark.parametrize( ("mutator", "message"), [ @@ -88,6 +241,81 @@ def test_canonical_target_tape_payload_rejects_unknown_fields(): target_tape_from_payload(payload) +def test_target_tape_to_weights_preserves_declared_symbol_order(): + weights = target_tape_to_weights( + _fixture_tape(), + expected_symbols=["B", "A"], + ) + + assert list(weights.columns) == ["B", "A"] + + +def test_target_tape_long_only_tolerance_preserves_positive_weights_only(): + tape = _fixture_tape() + tape.loc[0, "target_weight"] = 5e-16 + tape.loc[1, "target_weight"] = -5e-13 + + normalized = validate_target_tape(tape, expected_symbols=["A", "B"]) + + assert normalized.loc[0, "target_weight"] == 5e-16 + assert normalized.loc[1, "target_weight"] == 0.0 + payload = target_tape_to_payload(normalized, expected_symbols=["A", "B"]) + roundtrip = target_tape_to_weights( + target_tape_from_payload(payload), + expected_symbols=["A", "B"], + ) + assert roundtrip.iloc[0].to_dict() == {"A": 5e-16, "B": 0.0} + + tape.loc[1, "target_weight"] = -2e-12 + with pytest.raises(ValueError, match="long-only"): + validate_target_tape(tape, expected_symbols=["A", "B"]) + + +@pytest.mark.parametrize( + ("path", "value", "error", "message"), + [ + (("schema_version",), True, TypeError, "schema_version must be a JSON number"), + (("max_gross",), "1.0", TypeError, "max_gross must be a JSON number"), + ( + ("records", 0, "decision_timestamp"), + "2024-01-01", + ValueError, + "must be an RFC 3339 date-time", + ), + ( + ("records", 0, "decision_timestamp"), + 1_704_067_200, + TypeError, + "must be an RFC 3339 string", + ), + ( + ("records", 0, "target_weight"), + "1.0", + TypeError, + "target_weight must be a JSON number", + ), + ( + ("records", 0, "target_weight"), + np.float64(1.0), + TypeError, + "target_weight must be a JSON number", + ), + ], +) +def test_canonical_target_tape_payload_enforces_published_json_types( + path, value, error, message +): + payload = target_tape_to_payload(_fixture_tape(), expected_symbols=["A", "B"]) + mutated = copy.deepcopy(payload) + cursor = mutated + for key in path[:-1]: + cursor = cursor[key] + cursor[path[-1]] = value + + with pytest.raises(error, match=message): + target_tape_from_payload(mutated) + + def test_conformance_fixture_has_hand_computable_next_bar_returns(): weights = target_tape_to_weights( _fixture_tape(), @@ -109,9 +337,7 @@ def test_conformance_fixture_has_hand_computable_next_bar_returns(): def test_committed_contract_schemas_are_versioned_and_specific(): - target_schema = json.loads( - (REPO_ROOT / "schemas" / "canonical_target_tape.schema.json").read_text() - ) + target_schema = _target_tape_schema_validator().schema contract_schema = json.loads( (REPO_ROOT / "schemas" / "evaluation_contract.schema.json").read_text() ) @@ -136,3 +362,13 @@ def test_committed_contract_schemas_are_versioned_and_specific(): assert contract_schema["properties"]["target_tape"][ "additionalProperties" ] is False + + _target_tape_schema_validator().validate( + target_tape_to_payload(_fixture_tape(), expected_symbols=["A", "B"]) + ) + contract_validator = Draft202012Validator(contract_schema) + contract_validator.validate( + json.loads( + (REPO_ROOT / "paper" / "results" / "evaluation_contract.json").read_text() + ) + ) diff --git a/tests/test_local_csv.py b/tests/test_local_csv.py index 920f2cb..c060709 100644 --- a/tests/test_local_csv.py +++ b/tests/test_local_csv.py @@ -70,6 +70,31 @@ def test_load_price_matrix_rejects_missing_symbol(tmp_path): load_price_matrix(path, max_ffill=True) with pytest.raises(LocalDataError, match="valid timestamp"): load_price_matrix(path, start="not-a-date") + with pytest.raises(LocalDataError, match="must be a boolean"): + load_price_matrix(path, require_complete=1) + + +def test_load_price_matrix_can_fail_closed_on_incomplete_rows(tmp_path): + path = tmp_path / "prices.csv" + pd.DataFrame( + { + "date": ["2024-01-01", "2024-01-02", "2024-01-03"], + "AAA": [10.0, None, 12.0], + "BBB": [20.0, 21.0, 22.0], + } + ).to_csv(path, index=False) + + permissive = load_price_matrix(path, max_ffill=0) + assert permissive.index.tolist() == [ + pd.Timestamp("2024-01-01"), + pd.Timestamp("2024-01-03"), + ] + + with pytest.raises( + LocalDataError, + match=r"missing required observations.*2024-01-02:AAA", + ): + load_price_matrix(path, max_ffill=0, require_complete=True) def test_load_price_matrix_rejects_duplicate_csv_headers(tmp_path): diff --git a/tests/test_paper_artifacts.py b/tests/test_paper_artifacts.py index 7f186d7..ba0523b 100644 --- a/tests/test_paper_artifacts.py +++ b/tests/test_paper_artifacts.py @@ -22,7 +22,7 @@ def test_paper_artifacts_match_manifest_and_generator(): manifest = json.loads( (PAPER_ROOT / "results" / "manifest.json").read_text(encoding="utf-8") ) - assert manifest["schema_version"] == 4 + assert manifest["schema_version"] == 5 source = manifest["source"] assert source["raw_input_committed"] is False @@ -51,6 +51,7 @@ def test_paper_artifacts_match_manifest_and_generator(): source_commit, "--", "quantcortex", + "scripts/release_paper_artifacts.sh", "scripts/run_paper_experiments.py", "schemas", "pyproject.toml", @@ -64,6 +65,7 @@ def test_paper_artifacts_match_manifest_and_generator(): assert dependency_lock["sha256"] == _sha256(REPO_ROOT / "poetry.lock") assert generator["threadpools"] source_tree = generator["source_tree"] + assert "scripts/release_paper_artifacts.sh" in source_tree["files"] assert source_tree == source_tree_manifest( REPO_ROOT, list(source_tree["files"]), @@ -157,6 +159,7 @@ def test_paper_artifacts_match_manifest_and_generator(): assert "results/ablation_uncertainty.csv" in artifacts assert "results/comparator_diagnostics.csv" in artifacts assert "results/evaluation_contract.json" in artifacts + assert "results/target_tape_hashes.json" in artifacts assert "results/return_decomposition.csv" in artifacts assert "results/sharpe_uncertainty.csv" in artifacts assert "results/protocol_switches.csv" in artifacts @@ -167,6 +170,18 @@ def test_paper_artifacts_match_manifest_and_generator(): assert artifact.is_file(), relative_path assert _sha256(artifact) == expected_digest, relative_path + target_tape_path = PAPER_ROOT / manifest["decision_streams"]["path"] + target_tapes = json.loads(target_tape_path.read_text(encoding="utf-8")) + assert target_tapes == manifest["decision_streams"]["variants"] + assert set(target_tapes) == {"full", "no_regime", "no_vol_scaler", "signal_only"} + for metadata in target_tapes.values(): + assert metadata["symbols"] == sorted(metadata["symbols"]) + assert metadata["record_count"] == ( + metadata["decision_count"] * len(metadata["symbols"]) + ) + assert len(metadata["canonical_payload_sha256"]) == 64 + int(metadata["canonical_payload_sha256"], 16) + with (PAPER_ROOT / "results" / "protocol_switches.csv").open( encoding="utf-8", newline="", @@ -231,6 +246,9 @@ def test_paper_artifacts_match_manifest_and_generator(): def test_paper_source_and_reviewed_pdf_are_published(): main = (PAPER_ROOT / "main.tex").read_text(encoding="utf-8") + release_script = (REPO_ROOT / "scripts" / "release_paper_artifacts.sh").read_text( + encoding="utf-8" + ) assert "\\usepackage[preprint]{neurips_2026}" in main assert "\\usepackage{orcidlink}" in main @@ -243,6 +261,17 @@ def test_paper_source_and_reviewed_pdf_are_published(): assert "\\PaperInputDigest" in main assert "{bootstrap_robustness.pdf}" in main assert "target-exposure comparator" in main + assert "actual_input_digest" in release_script + assert "expected_input_digest" in release_script + assert "ls-files --error-unmatch" in release_script + assert "reviewed_generated_at" in release_script + assert "reviewed_source_commit" in release_script + assert "release_source_paths" in release_script + assert "scripts/release_paper_artifacts.sh" in release_script + assert "QUANTCORTEX_GENERATED_AT is required for changed release source" in ( + release_script + ) + assert '--data-provider "${provider}"' in release_script anonymous_source = (PAPER_ROOT / "anonymous.tex").read_text(encoding="ascii") assert "\\def\\quantcortexanonymous{1}" in anonymous_source diff --git a/tests/test_paper_experiments.py b/tests/test_paper_experiments.py index 9fd88ee..0d4b7df 100644 --- a/tests/test_paper_experiments.py +++ b/tests/test_paper_experiments.py @@ -4,6 +4,7 @@ import json import subprocess from pathlib import Path +from types import SimpleNamespace import numpy as np import pandas as pd @@ -20,6 +21,7 @@ _cagr, _costed_target_exposure_comparator, _git_metadata, + _git_path_is_tracked, _max_drawdown, _save_figures, _tex_number, @@ -385,6 +387,33 @@ def test_git_metadata_captures_cleanliness_before_writes(tmp_path): assert dirty["worktree_clean_at_start"] is False +def test_git_path_tracking_is_measured_from_the_source_repository(tmp_path): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + tracked = tmp_path / "tracked.csv" + untracked = tmp_path / "untracked.csv" + outside = tmp_path.parent / f"{tmp_path.name}-outside.csv" + tracked.write_text("date,A\n2024-01-01,1\n", encoding="ascii") + untracked.write_text("date,A\n2024-01-01,1\n", encoding="ascii") + outside.write_text("date,A\n2024-01-01,1\n", encoding="ascii") + subprocess.run(["git", "add", "tracked.csv"], cwd=tmp_path, check=True) + + assert _git_path_is_tracked(tmp_path, tracked) is True + assert _git_path_is_tracked(tmp_path, untracked) is False + assert _git_path_is_tracked(tmp_path, outside) is False + + +def test_git_path_tracking_fails_closed_on_git_errors(monkeypatch, tmp_path): + candidate = tmp_path / "prices.csv" + candidate.write_text("date,A\n2024-01-01,1\n", encoding="ascii") + monkeypatch.setattr( + "scripts.run_paper_experiments.subprocess.run", + lambda *args, **kwargs: SimpleNamespace(returncode=128, stderr="fatal"), + ) + + with pytest.raises(RuntimeError, match="could not determine"): + _git_path_is_tracked(tmp_path, candidate) + + def test_paper_cli_requires_clean_source_before_reading_input(monkeypatch, tmp_path): monkeypatch.setattr( "scripts.run_paper_experiments._git_metadata", @@ -412,6 +441,36 @@ def test_paper_cli_requires_clean_source_before_reading_input(monkeypatch, tmp_p ) +def test_paper_cli_rejects_a_git_tracked_price_input(monkeypatch): + monkeypatch.setattr( + "scripts.run_paper_experiments._git_metadata", + lambda _repo_root: { + "source_commit": "0" * 40, + "worktree_clean_at_start": True, + }, + ) + tracked_fixture = ( + Path(__file__).resolve().parent / "fixtures" / "conformance" / "prices.csv" + ) + + with pytest.raises(RuntimeError, match="must not be tracked by Git"): + paper_main( + [ + "run_paper_experiments.py", + "--prices-csv", + str(tracked_fixture), + "--data-provider", + "test provider", + "--permission-basis", + "test permission", + "--retrieved-at", + "2026-06-16", + "--adjustment-method", + "test adjustment", + ] + ) + + def test_paper_cli_writes_a_complete_test_only_artifact_set(monkeypatch, tmp_path): warmup = pd.bdate_range(end="2017-12-29", periods=300) first_period = pd.bdate_range("2018-01-02", periods=40) @@ -442,6 +501,16 @@ def test_paper_cli_writes_a_complete_test_only_artifact_set(monkeypatch, tmp_pat "scripts.run_paper_experiments._threadpool_environment", lambda: [{"user_api": "test", "num_threads": 1}], ) + from scripts import run_paper_experiments as paper_experiments + + parsed_payloads = [] + original_parser = paper_experiments.target_tape_from_payload + + def recording_parser(payload): + parsed_payloads.append(payload) + return original_parser(payload) + + monkeypatch.setattr(paper_experiments, "target_tape_from_payload", recording_parser) assert ( paper_main( @@ -476,7 +545,7 @@ def test_paper_cli_writes_a_complete_test_only_artifact_set(monkeypatch, tmp_pat manifest = json.loads( (output_dir / "results" / "manifest.json").read_text(encoding="utf-8") ) - assert manifest["schema_version"] == 4 + assert manifest["schema_version"] == 5 assert manifest["generated_at"] == "2026-06-16T00:00:00Z" assert manifest["generator"]["git"] == { "source_commit": "1" * 40, @@ -485,11 +554,24 @@ def test_paper_cli_writes_a_complete_test_only_artifact_set(monkeypatch, tmp_pat assert manifest["source"]["raw_input_committed"] is False assert manifest["source"]["provider"] == "deterministic synthetic test fixture" assert "results/evaluation_contract.json" in manifest["artifacts"] + assert "results/target_tape_hashes.json" in manifest["artifacts"] assert "results/sharpe_uncertainty.csv" in manifest["artifacts"] assert "figures/accounting_summary.pdf" in manifest["artifacts"] for relative_path in manifest["artifacts"]: assert (output_dir / relative_path).is_file(), relative_path + target_tapes = manifest["decision_streams"]["variants"] + assert set(target_tapes) == {"full", "no_regime", "no_vol_scaler", "signal_only"} + assert all(metadata["schema_version"] == 1 for metadata in target_tapes.values()) + assert all(metadata["decision_count"] > 0 for metadata in target_tapes.values()) + assert all(metadata["symbols"] == sorted(metadata["symbols"]) for metadata in target_tapes.values()) + assert all( + metadata["record_count"] + == metadata["decision_count"] * len(metadata["symbols"]) + for metadata in target_tapes.values() + ) + assert len(parsed_payloads) == 4 + def test_evaluation_contract_separates_attribution_and_tradable_comparator(): contract = evaluation_contract("SHV") diff --git a/tests/test_repository_data_policy.py b/tests/test_repository_data_policy.py index 66345ba..017039a 100644 --- a/tests/test_repository_data_policy.py +++ b/tests/test_repository_data_policy.py @@ -4,6 +4,7 @@ import hashlib import json import re +import subprocess from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -42,6 +43,28 @@ def test_redistributed_market_data_is_absent(): present = [path for path in FORBIDDEN_MARKET_DATA if (REPO_ROOT / path).exists()] assert not present, f"redistributed market-data snapshots reappeared: {present}" + tracked = subprocess.run( + ["git", "ls-files"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.splitlines() + assert [path for path in tracked if path.startswith("local_data/")] == [ + "local_data/README.md" + ] + + raw_columns = {"date", "QQQ", "VGT", "GLD", "TLT", "SPY", "VIG", "SHV"} + leaked = [] + for relative_path in tracked: + if not relative_path.endswith(".csv"): + continue + with (REPO_ROOT / relative_path).open(encoding="utf-8", newline="") as handle: + header = set(next(csv.reader(handle), [])) + if raw_columns <= header: + leaked.append(relative_path) + assert not leaked, f"tracked raw paper price matrices detected: {leaked}" + def test_published_performance_charts_match_manifest_and_readme(): image_dir = REPO_ROOT / "docs" / "img"