diff --git a/AGENTS.md b/AGENTS.md index 3542ca4..281cafc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ PYTHONPATH=. .venv/bin/python scripts/generate_report.py \ --prices-csv local_data/published_rotation_prices.csv \ --cash-proxy-symbol SHV scripts/build_paper.sh +scripts/release_expansion_artifacts.sh local_data/expansion ``` Install from `requirements/dev.lock`. Scripts need `PYTHONPATH=.` unless @@ -68,8 +69,8 @@ update regression tests. Never commit `.env`, credentials, broker account data, local state, market-data snapshots, or executed notebook outputs. Published performance and paper artifacts require owner approval, adjacent provenance, an input digest, and -artifact hashes; release them from a clean commit with -`scripts/release_paper_artifacts.sh`. Ordinary reports remain ignored. Use +artifact hashes; release them from a clean commit with the release wrappers. +Ordinary reports remain ignored. Use `.env.example`. Synthetic data is limited to tests and clearly labeled dry runs. Preserve pre-trade risk checks, paper-mode defaults, and point-in-time data discipline. diff --git a/CLAUDE.md b/CLAUDE.md index 3cbba96..efdf42c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ the development lock with `pip install -r requirements/dev.lock`, then run `generate_report.py --live-yfinance`; `validate_performance.py` requires `--live-yfinance`; `survivorship_demo.py` requires `--live-yfinance`; `paper_trade_cycle.py` requires either `--offline` or `--live-yfinance`. +- Expansion release: freeze and commit the protocol before provider requests; + keep both matrices under ignored `local_data/expansion/`. After committing + source, run `scripts/release_expansion_artifacts.sh local_data/expansion`. + A first or changed release requires a fixed UTC + `QUANTCORTEX_EXPANSION_GENERATED_AT`. Never edit aggregate tables, generated + LaTeX, manifests, or figures by hand. - Paper release: commit source changes, then run `scripts/release_paper_artifacts.sh local_data/published_rotation_prices.csv`. The wrapper regenerates `docs/img/` and the paper from a detached clean @@ -126,6 +132,8 @@ or vol-scaled book is NOT required to sum to 1. Violations raise may be published only with explicit owner approval, adjacent provenance, an input digest, and artifact hashes. The same rule applies to reviewed paper aggregates and figures under `paper/`; raw provider matrices remain local. + Repository freezing reduces post-data discretion but is not external + preregistration or a temporal holdout, and documentation must not imply it is. Synthetic fixtures remain appropriate for tests and the clearly labeled `paper_trade_cycle.py --offline` dry run. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index dae4ff1..4258d35 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -1,9 +1,10 @@ # Performance Evaluation -This repository publishes one owner-authorized reference run as derived chart -artifacts. The raw market data remains uncommitted. Ordinary generated reports -and executed notebook outputs remain excluded unless publication is explicitly -approved and accompanied by complete provenance and artifact hashes. +This repository publishes one owner-authorized reference audit and one +repository-frozen multi-panel expansion as derived artifacts. Raw market data +remains uncommitted. Ordinary generated reports and executed notebook outputs +remain excluded unless publication is explicitly approved and accompanied by +complete provenance and artifact hashes. ## Published Reference Run @@ -101,6 +102,46 @@ never an executable path. Assigning zero return to residual cash lowers the consistently measured SHV-excess Sharpe to -0.42. See `paper/results/protocol_switches.csv`. +## Repository-Frozen Expansion + +The expansion protocol was frozen in public commit +`4018f4063f46889f41d6981db5a71079e1dbd713` before the two provider requests. +It was not externally registered, and the 2018-2025 evaluation interval had +already occurred. The design therefore limits researcher degrees of freedom +relative to this run but is not a temporal out-of-sample test. + +The evaluation covers time-series momentum, cross-sectional momentum, +short-term reversal, and a walk-forward gradient-boosted model across U.S. +sector and country-equity ETF panels. Each family uses monthly mature signals, +next-bar event accounting, SHV residual cash, and the same 13 bps proportional +cost. The learned family reports all five frozen seeds. + +| Panel | Strategy family | Arithmetic return | SHV-excess Sharpe | +|---|---|---:|---:| +| U.S. sectors | Time-series momentum | 11.44% | 0.49 | +| U.S. sectors | Cross-sectional momentum | 14.19% | 0.60 | +| U.S. sectors | Short-term reversal | 11.02% | 0.44 | +| U.S. sectors | Learned GBRT | 13.66% | 0.53 | +| Country equities | Time-series momentum | 2.18% | -0.02 | +| Country equities | Cross-sectional momentum | 8.84% | 0.33 | +| Country equities | Short-term reversal | 6.03% | 0.20 | +| Country equities | Learned GBRT | 7.84% | 0.31 | + +These baselines are inputs to the audit, not discoveries. Under the primary +21-session circular-block analysis, removing modeled cost raises annualized +return by 0.57-1.81 percentage points and has an interval above zero in all +eight family-panel cells. Invalid same-close assignment is below zero in three +cells and overlaps zero in five; zero residual-cash return is below zero in +five and overlaps zero in three. The maximum one-day vectorized-versus-event +difference is 93.21 bps and the largest absolute terminal-wealth gap is 3.89%. +The engine result reflects different documented holding conventions, not an +external-engine conformance claim. + +All intervals are unstudentized, pointwise, and conditional on the selected +historical paths. They are not multiplicity-adjusted evidence of future alpha. +See `paper/expansion/results/`, the plots under `paper/expansion/figures/`, and +the frozen design in `paper/preregistration.md`. + ## Generate a Report Use a licensed or otherwise permitted wide adjusted-close CSV: @@ -188,6 +229,13 @@ capacity and slippage curves only with spread, volume, and order-size inputs; factor attribution only with validated factor returns/exposures; and fill quality only from authenticated order and execution records. +The expansion release is separate from the general report. From a clean source +commit, `scripts/release_expansion_artifacts.sh local_data/expansion` regenerates +the six aggregate CSVs, target-tape and data-provenance JSON, generated LaTeX, +and five figures in a detached worktree. Its manifest binds the frozen protocol, +both input hashes, source tree, environment, and every output hash. The paper +release requires expansion artifacts from the same clean source commit. + ## Reporting Requirements - Report the data provider, license or permission basis, retrieval date, date diff --git a/README.md b/README.md index cacb252..72e5355 100644 --- a/README.md +++ b/README.md @@ -128,11 +128,41 @@ chart hashes and provenance are recorded in That owner-supplied authorization does not independently establish that the provider's terms permit publication. +## Repository-Frozen Expansion + +The paper also evaluates four fixed strategy families across U.S. sector and +country-equity ETF panels from 2018 through 2025. The protocol was committed +before either panel was requested, but it was not externally registered and the +historical interval is not a temporal holdout. All models use mature features, +next-bar execution, SHV residual cash, 13 bps per dollar traded, and 5,000 joint +circular-block draws. + +![After-cost baseline outcomes across the frozen strategy-panel cells](paper/expansion/figures/baseline_performance.png) + +Seven of eight baseline sample Sharpes are positive, but these descriptive +outcomes are not alpha claims. The central result is sensitivity to evaluation +semantics: + +| 21-session pointwise interval result | Cells out of 8 | +|---|---:| +| Removing modeled cost: above zero | 8 | +| Invalid same-close assignment: below / overlaps / above | 3 / 5 / 0 | +| Zero cash return: below / overlaps / above | 5 / 3 / 0 | +| Strategy minus costed exposure comparator: below / overlaps / above | 1 / 7 / 0 | + +![Paired annualized-return effects under each contract switch](paper/expansion/figures/contract_effects_return.png) + +The intervals are pointwise, conditional on two selected panels, and not +multiplicity adjusted. Raw matrices are not distributed; panel digests, target +tape hashes, aggregate tables, source fingerprints, and figure hashes are in +[`paper/expansion/results/manifest.json`](paper/expansion/results/manifest.json). + ## Research Paper The [NeurIPS 2026-format public preprint](paper/quantcortex_audit_neurips2026.pdf) formalizes the executable contracts, exact attribution, controlled protocol -diagnostics, fixed negative result, uncertainty, limitations, and provenance. +diagnostics, the retrospective negative result, the repository-frozen expansion, +uncertainty, limitations, and provenance. It is not represented as accepted by or submitted to NeurIPS. An [anonymized preprint build](paper/quantcortex_audit_anonymous.pdf) is generated from the same source and omits author and repository identifiers. @@ -141,7 +171,7 @@ from the same source and omits author and repository identifiers. See [paper/README.md](paper/README.md) for the build and reproduction workflow. Aggregate tables, generated paper values, source fingerprints, and artifact -hashes are committed under `paper/results/`. +hashes are committed under `paper/results/` and `paper/expansion/results/`. ## Quick Start @@ -190,6 +220,9 @@ PYTHONPATH=. python scripts/generate_report.py \ scripts/release_paper_artifacts.sh \ local_data/published_rotation_prices.csv +# Regenerate the reviewed expansion aggregates and figures from a clean commit. +scripts/release_expansion_artifacts.sh local_data/expansion + # Explicit live-data diagnostics; review the provider's terms first. python scripts/validate_performance.py --live-yfinance python scripts/survivorship_demo.py --live-yfinance @@ -254,7 +287,8 @@ before using production capital. Security reporting is documented in - [docs/data-source-due-diligence.md](docs/data-source-due-diligence.md): publication-data acceptance rules - [docs/production-readiness.md](docs/production-readiness.md): external release gates - [paper/README.md](paper/README.md): paper reproduction and submission constraints -- [paper/preregistration.md](paper/preregistration.md): prospective expansion protocol, not yet registered +- [paper/COMPUTE.md](paper/COMPUTE.md): reviewed host and release wall times +- [paper/preregistration.md](paper/preregistration.md): repository-frozen expansion protocol; not an external registry entry - [local_data/README.md](local_data/README.md): accepted local-data schemas and provenance - [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow - [AGENTS.md](AGENTS.md): concise repository guidance for coding agents diff --git a/docs/data-source-due-diligence.md b/docs/data-source-due-diligence.md index 0e4d9b1..659a206 100644 --- a/docs/data-source-due-diligence.md +++ b/docs/data-source-due-diligence.md @@ -5,23 +5,26 @@ new empirical panel becomes publication evidence, record the provider, dataset, contracting party, permitted uses, redistribution terms, reviewer-access path, retrieval timestamp, adjustment method, symbol mapping, and content digest. -## Current Historical Case +## Current Historical Evidence -The fixed 2018-2025 case was computed from an owner-supplied Yahoo Finance -adjusted-close matrix retrieved through yfinance. The raw matrix is ignored and -not distributed. The repository publishes derived aggregates and records the -input SHA-256, while explicitly stating that provider authorization for public -publication has not been independently verified. +The fixed 2018-2025 case and the two-panel expansion were computed from +owner-supplied Yahoo Finance adjusted-close matrices retrieved through +yfinance. The raw matrices are ignored and not distributed. The repository +publishes derived aggregates and records each input SHA-256, while explicitly +stating that provider authorization for public publication has not been +independently verified. -This case is retained as historical negative evidence. It is not an acceptable -source for a new confirmatory panel unless the author documents an applicable -permission basis. The open code and conformance fixtures reproduce software -semantics, not the unavailable observations. +The first case is retained as historical negative evidence. The expansion was +repository-frozen before retrieval but was not externally registered and is not +a temporal holdout. Neither is represented as reviewer-reproducible empirical +evidence. The open code and conformance fixtures reproduce software semantics, +not the unavailable observations. ## Acceptance Criteria for New Panels -A panel may enter a preregistered evaluation only when all of the following are -documented before results are inspected: +A panel may be represented as publication-ready, independently reproducible +empirical evidence only when all of the following are documented before results +are inspected: 1. Lawful research use and publication of derived results are permitted. 2. Adjustment, calendar, timestamp, and survivorship policies are explicit. @@ -43,5 +46,5 @@ performance. For each candidate source, create a dated record under ignored local research notes before acquisition. Do not add provider files to Git. If the permission -basis is ambiguous, exclude the panel from publication evidence and report the -scope reduction. +basis is ambiguous, disclose that limitation and do not claim the panel is open, +independently reproducible, or submission-ready. diff --git a/docs/evaluation-contracts.md b/docs/evaluation-contracts.md index 9df5725..c0bf163 100644 --- a/docs/evaluation-contracts.md +++ b/docs/evaluation-contracts.md @@ -42,9 +42,10 @@ the complete symbol set and gross limit: 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`. +limit. The paper experiments round-trip each variant through this boundary +before backtesting and publish canonical payload hashes in +`paper/results/target_tape_hashes.json` and +`paper/expansion/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/allocation_and_exposure.png b/docs/img/allocation_and_exposure.png index 38774ad..cdedb9b 100644 Binary files a/docs/img/allocation_and_exposure.png and b/docs/img/allocation_and_exposure.png differ diff --git a/docs/img/drawdown.png b/docs/img/drawdown.png index ac96678..521bee6 100644 Binary files a/docs/img/drawdown.png and b/docs/img/drawdown.png differ diff --git a/docs/img/equity_vs_benchmarks.png b/docs/img/equity_vs_benchmarks.png index 4ad9ca3..29b468d 100644 Binary files a/docs/img/equity_vs_benchmarks.png and b/docs/img/equity_vs_benchmarks.png differ diff --git a/docs/img/monthly_returns.png b/docs/img/monthly_returns.png index 196c938..947d753 100644 Binary files a/docs/img/monthly_returns.png and b/docs/img/monthly_returns.png differ diff --git a/docs/img/performance_attribution.png b/docs/img/performance_attribution.png index b8b1b46..98887cd 100644 Binary files a/docs/img/performance_attribution.png and b/docs/img/performance_attribution.png differ diff --git a/docs/img/performance_manifest.json b/docs/img/performance_manifest.json index 96b3c53..ecb5631 100644 --- a/docs/img/performance_manifest.json +++ b/docs/img/performance_manifest.json @@ -1,18 +1,18 @@ { "schema_version": 4, - "generated_at": "2026-06-18T22:06:35Z", + "generated_at": "2026-06-18T23:53:33Z", "generator": { "path": "scripts/generate_report.py", "script_sha256": "b536aa7fc5e4fe7df6c7ff28c0992629a489869eaec46486db7aff1cb946099b", "git": { - "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", + "source_commit": "e0443b8f77cd23aee8f1fa64a2bc237e47626c47", "worktree_clean_at_start": true }, "source_tree": { - "sha256": "f964d653b5e645e1efe97889e8acb43fbb0c2c8ddeb0835803f07c299abff5a0", - "file_count": 107, + "sha256": "441586b7f43e68245a94a797d291fa80f51bb413c3174651d2e3dada17f56d3a", + "file_count": 109, "files": { - "poetry.lock": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b", + "poetry.lock": "c0fd02871263d959522bb3d3d4717cffa1b89bfa047f584c68b81c7ad7cbbb5b", "pyproject.toml": "eaeeb454c28bf7f6d9e530002bb7e88624b56b6c3e1fcb71e6414045cb9c42a0", "quantcortex/__init__.py": "14bf1ebdacd054c3738e4704d33da6709a39206463df8b8ced5376da342c4036", "quantcortex/alpha/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -100,6 +100,8 @@ "quantcortex/portfolio/mean_variance.py": "235a8917e107ab6274ee68891a489a130ae61fe36d359dc43e049d256453d80d", "quantcortex/portfolio/minimum_variance.py": "253a9d80fe6dc6e4c95219b76a8965230b408093d1521a72587505ae0ddfc739", "quantcortex/portfolio/risk_parity.py": "ed371437655ae980fabfc4ae450ca2100d90f304d9ce225c306f904ce1e32e91", + "quantcortex/research/__init__.py": "46e11b28979db5adfbe08f946e59b8e858de7d2a1feca714be5b0bd7cce1c32f", + "quantcortex/research/expansion.py": "a4cde375dcd650a1bd367d07da11e20e5b770a34496ccf3c82ba8e0c7467ed2a", "quantcortex/risk/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/risk/circuit_breaker.py": "65cf205e7303cff5ce4785a64fadc6b29ba8b85e31913e001330fb084e502ff1", "quantcortex/risk/factor_exposure.py": "5503426c590338b57512adf409e873751929464459d464f2b6940db8cadbf0ad", @@ -128,7 +130,7 @@ "pandas": "3.0.3", "scipy": "1.17.1", "scikit-learn": "1.9.0", - "matplotlib": "3.10.9", + "matplotlib": "3.11.0", "threadpoolctl": "3.6.0", "yfinance": "1.4.1" } @@ -231,15 +233,15 @@ "exposure_matched_equal_weight_cash_excess_sharpe_gross": 0.7984966497852871 }, "artifacts": { - "allocation_and_exposure.png": "4f88060765153f436a0fb54bbd7399a9a82117acc84497bee7495b6398ba72cc", - "drawdown.png": "5a4f8e7c4f8a5a1fa3f18b9916f47015582323d66697344216846f8992dc10f2", - "equity_vs_benchmarks.png": "d94d2afc0502a6b061324637bf80312f5ff243384471c06d00f16bfcfe770350", - "monthly_returns.png": "abcb5de3ba5cbb9acf2caf5996564547d8398841b23f4edd2ae2834b3d9d61ad", - "performance_attribution.png": "ab1113f4645d2a7e70eb7bbf4ee432e76430a3d3fbf3d06189114fe62127bb30", - "report_overview.png": "18f4548523b2ed6bfb520c7b5cdc764641b93f9e87d3f4e544ad8a47bc02e892", - "return_distribution.png": "7c40d473e63c8b5a54e8aba7ac8c048e1fe77a9d741020d58281315970dc3fb5", - "rolling_risk.png": "e8a97664c425f341d340e590f290e5464be679cc9294f58fb23eace0dde4bd36", - "rolling_sharpe.png": "a1b4fa535aa12185763fd7ef8f6b77e5b83644a02b6f945474f53ca9fa4d2ff0", - "turnover_and_costs.png": "a405e4b8231b8183c2e66c652afb8208fd60c0a092fe7cb6a91e2f97d79c7f57" + "allocation_and_exposure.png": "e310e33a2fe36793eb1724e09cd7132de9cee64e171005b12dc1dce674c84d7a", + "drawdown.png": "277868c7b4b59fe5bddf29005e8707d028ee97440c7f23c96ecf83142da1ea1f", + "equity_vs_benchmarks.png": "61794487a4649c4747a05c6c3990b176668c8205ccbe957923875a41eb089d15", + "monthly_returns.png": "18d04ab0f2a16ecd48780cddb4849c5c034a3093262c88b48d173801580ca9bc", + "performance_attribution.png": "ef13a1714481f52712e0038eb025d10a30ce0ff1031296355d2ab7165e362f20", + "report_overview.png": "f06aa32a4c2b51c9b32a0513caeea4edca16429f222938254f068241af5330ea", + "return_distribution.png": "6cfa64dfeb0051478e85264db720ed1121c05bf00ebed5931a827e0a856b8ecf", + "rolling_risk.png": "257b9a1a1706a8d52b3bdb707a3149b1c60b4f779fa530950b80acec3ef10f8b", + "rolling_sharpe.png": "8dbbabb64f9e049155f9e5cfd3fed083eb2e71a3662b5092c0847e26418ade89", + "turnover_and_costs.png": "f4034abbaacb2a04c852f2cf4f36d0794a9e048490ca96f4e1eb2b80215836d2" } } diff --git a/docs/img/report_overview.png b/docs/img/report_overview.png index 9e85e43..f0410f0 100644 Binary files a/docs/img/report_overview.png and b/docs/img/report_overview.png differ diff --git a/docs/img/return_distribution.png b/docs/img/return_distribution.png index 1b50828..17c6956 100644 Binary files a/docs/img/return_distribution.png and b/docs/img/return_distribution.png differ diff --git a/docs/img/rolling_risk.png b/docs/img/rolling_risk.png index db6ce2e..73448f2 100644 Binary files a/docs/img/rolling_risk.png and b/docs/img/rolling_risk.png differ diff --git a/docs/img/rolling_sharpe.png b/docs/img/rolling_sharpe.png index 43f4194..d73d7d7 100644 Binary files a/docs/img/rolling_sharpe.png and b/docs/img/rolling_sharpe.png differ diff --git a/docs/img/turnover_and_costs.png b/docs/img/turnover_and_costs.png index 47e9c22..c489efb 100644 Binary files a/docs/img/turnover_and_costs.png and b/docs/img/turnover_and_costs.png differ diff --git a/local_data/README.md b/local_data/README.md index 859f854..8f7ea6a 100644 --- a/local_data/README.md +++ b/local_data/README.md @@ -10,8 +10,9 @@ outside the CSV. ## Wide Adjusted-Close CSV -`scripts/generate_report.py`, `scripts/run_paper_experiments.py`, and all -research notebooks accept a date-by-symbol matrix: +`scripts/generate_report.py`, `scripts/run_paper_experiments.py`, +`scripts/run_expansion_experiments.py`, and all research notebooks accept a +date-by-symbol matrix: ```csv date,QQQ,VGT,GLD,TLT,SPY,VIG,SHV @@ -42,6 +43,22 @@ record the owner's provenance assertions but do not independently establish that redistribution is permitted. Pass `--manifest-out` as well so the input, source tree, settings, and generated artifacts are hash-bound. +The expansion uses two separate complete matrices plus metadata sidecars under +`local_data/expansion/`: + +- `us_sector_etfs.csv`: `date`, XLB, XLE, XLF, XLI, XLK, XLP, XLU, XLV, XLY, + and SHV. +- `country_equity_etfs.csv`: `date`, EWA, EWC, EWG, EWH, EWJ, EWL, EWP, EWQ, + EWS, EWU, and SHV. + +Each `.metadata.json` records the exact request, provider version, retrieval +timestamp, protocol digest, row coverage, missingness, and CSV SHA-256. The +frozen expansion requires complete rows from 2014-01-02 through 2025-12-31, +performs no forward fill, and rejects a missing evaluation month or hash +mismatch. `scripts/fetch_expansion_data.py` can create these files through the +explicit provider adapter; using it does not establish permission to publish or +redistribute the observations. + ## Single-Symbol OHLCV CSV Notebook 02 also requires actual OHLCV data when using local files: diff --git a/paper/COMPUTE.md b/paper/COMPUTE.md new file mode 100644 index 0000000..650d625 --- /dev/null +++ b/paper/COMPUTE.md @@ -0,0 +1,35 @@ +# Reviewed Compute Record + +This record describes the artifact release generated from source commit +`e0443b8f77cd23aee8f1fa64a2bc237e47626c47` with fixed timestamp +`2026-06-18T23:53:33Z`. It is a reproducibility note, not a performance +benchmark. + +## Host + +- Apple M1, 8 physical and 8 logical CPU cores, arm64 +- 8 GiB system memory +- macOS 26.4 +- CPU-only execution; no GPU or remote worker +- Model fitting constrained to one thread by `threadpoolctl` + +Package, Python, BLAS/OpenMP, and platform details are recorded in the two +experiment manifests. Peak resident memory was not instrumented; both releases +completed on the 8 GiB host. Final paper, figure, and aggregate artifacts occupy +less than 6 MiB. Temporary worktrees, package caches, and LaTeX intermediates +require additional local storage. + +## Measured Releases + +| Release command | Wall time | User CPU | System CPU | +|---|---:|---:|---:| +| `scripts/release_expansion_artifacts.sh local_data/expansion` | 325.19 s | 297.16 s | 10.61 s | +| `scripts/release_paper_artifacts.sh local_data/published_rotation_prices.csv` | 145.83 s | 124.67 s | 7.70 s | +| Total | 471.02 s | 421.83 s | 18.31 s | + +The expansion time includes ten seeded walk-forward GBRT runs, 120 five-thousand-draw +bootstrap cells, five PNG/PDF figure pairs, and manifest hashing. The paper +release time includes the retrospective experiment, ten-plot report gallery, +public and anonymous Tectonic builds, and checksum validation. Cold Matplotlib +font-cache creation is included. Provider retrieval, dependency installation, +the test suite, and manual visual review are not included. diff --git a/paper/README.md b/paper/README.md index 027c735..13d6365 100644 --- a/paper/README.md +++ b/paper/README.md @@ -1,9 +1,11 @@ # Research Paper -`main.tex` is a NeurIPS 2026-format public preprint on executable evaluation contracts -for target-weight trading pipelines. It presents exact return attribution, -single-assumption diagnostics, a causal costed comparator, and a fixed negative -case study with uncertainty-aware ablations and block-length sensitivity. +`main.tex` is a NeurIPS 2026-format public preprint on executable evaluation +contracts for target-weight trading pipelines. It presents exact return +attribution, a retrospective negative case, and a repository-frozen expansion +across four strategy families and two real-data panels. The expansion estimates +five one-switch effects with joint block resampling and reports every frozen +learned-model seed. `anonymous.tex` builds the same preprint without author or repository identifiers. The work is not represented as accepted by or submitted to NeurIPS 2026; the full-paper deadline was May 6, 2026. @@ -28,6 +30,8 @@ checks that body text does not spill past the nine-page NeurIPS limit; both PDF checksum files and `build_manifest.json`. It also writes `quantcortex_audit_neurips2026.sources.sha256`, which binds the tracked PDF to the current LaTeX, bibliography, generated values, and figures. +Reviewed host details and measured wrapper wall times are in +[`COMPUTE.md`](COMPUTE.md). Release the fixed experiment from a committed source revision with an authorized local adjusted-close matrix: @@ -67,22 +71,39 @@ the causal target-exposure comparator after its own costs, while record count, and symbol set for every audited strategy variant without publishing the underlying provider matrix. +Release the expansion from the same clean source revision and its two local +panel matrices: + +```bash +QUANTCORTEX_EXPANSION_GENERATED_AT=2026-06-18T22:38:40Z \ + scripts/release_expansion_artifacts.sh local_data/expansion +``` + +The directory must contain `us_sector_etfs.csv`, +`country_equity_etfs.csv`, and their `.metadata.json` sidecars. The wrapper +rejects a protocol or input hash mismatch, runs in a detached worktree, and +publishes only aggregate CSV/JSON, generated LaTeX, and figures under +`paper/expansion/`. The paper wrapper requires these artifacts to name the same +clean source commit. A changed release must use explicit, fixed UTC timestamps +for both wrappers; unchanged reviewed source reuses the recorded timestamps. + The primary accounting path is the event-driven engine. It holds explicit adjusted-close pseudo-shares between rebalances, sizes targets against post-cost NAV, and reports both one-way turnover and gross two-sided traded notional. The -vectorized engine remains an approximation and parity diagnostic. +vectorized engine remains an approximation and model-convention sensitivity +diagnostic; equality with pseudo-share accounting is not expected. Citation keys are checked against `references.bib`, and DOI/arXiv identifiers are kept explicit. Revalidate the unversioned 2026 preprints before any future submission because their metadata and claims may change. -The absent raw matrix is a material reproducibility limitation. The open target -tape, schemas, and synthetic conformance fixtures reproduce software semantics, -not the historical returns. Exact independent reproduction still requires -authorized access to a matrix matching the manifest digest. Data-source -acceptance rules are in `docs/data-source-due-diligence.md`; the prospective -expansion protocol is in `paper/preregistration.md` and is explicitly not yet -registered. +The three absent raw matrices are a material reproducibility limitation. The +open target tape, schemas, and synthetic conformance fixtures reproduce software +semantics, not the historical returns. Exact independent reproduction still +requires access to matrices matching the manifests' digests. Data-source +acceptance rules are in `docs/data-source-due-diligence.md`; the expansion +protocol in `paper/preregistration.md` was repository-frozen before retrieval +but was not externally registered. ## Submission Readiness diff --git a/paper/build_manifest.json b/paper/build_manifest.json index c1b1f14..3ca9e27 100644 --- a/paper/build_manifest.json +++ b/paper/build_manifest.json @@ -1,18 +1,18 @@ { "anonymous_pdf": { "path": "quantcortex_audit_anonymous.pdf", - "sha256": "3bf902d420224032fa11492ddb4f8d3b636972f4a3d7740e4b7aef4bbf8484ff" + "sha256": "cbcee2583ea2da700b259c108d9991061033a17dd97c11d0aab676396eabef7b" }, "pdf": { "path": "quantcortex_audit_neurips2026.pdf", - "sha256": "59fcd30467d449984118d746417d810757d32fbceb72af25d0d4a84a65203000" + "sha256": "9bf806b296b6bade747de580142545a9825e2d3107c6a55531ea1456932ad2a5" }, "schema_version": 1, - "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", - "source_date_epoch": 1781820388, + "source_commit": "e0443b8f77cd23aee8f1fa64a2bc237e47626c47", + "source_date_epoch": 1781826790, "source_manifest": { "path": "quantcortex_audit_neurips2026.sources.sha256", - "sha256": "ffd76bc58b7b0dd3499cb934a33abd613477478b3fb1fadabb16d5b47b5cbbde" + "sha256": "51039d9c9da79353f3a26298189881f1669252496cf6dcf2e186bee5e94e9f22" }, "tectonic_bundle": { "name": "default_bundle_v33.tar", diff --git a/paper/checklist.tex b/paper/checklist.tex index bcf1d4a..e2b64f5 100644 --- a/paper/checklist.tex +++ b/paper/checklist.tex @@ -5,7 +5,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Claims} \item[] Question: Do the main claims made in the abstract and introduction accurately reflect the paper's contributions and scope? \item[] Answer: \answerYes{} - \item[] Justification: The abstract and Introduction identify the primary contribution as an evaluation methodology and describe the strategy as a fixed negative case study rather than a new alpha model, general benchmark, or deployment claim. All reported values are generated from the experiment artifacts used in Sections 4 and 5. + \item[] Justification: The abstract and Introduction identify an evaluation methodology, distinguish the retrospective worked audit from the repository-frozen expansion, and avoid alpha, population-benchmark, or deployment claims. Reported values are generated from the artifacts used in Sections 4 and 5. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the abstract and introduction do not include the claims made in the paper. @@ -17,7 +17,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Limitations} \item[] Question: Does the paper discuss the limitations of the work performed by the authors? \item[] Answer: \answerYes{} - \item[] Justification: Section 6 discusses the single-universe design, attribution and engine semantics, the ex-post attribution control and costed comparator, cash proxy, adjusted-close data, proportional costs, scale-sensitive regime features, bootstrap assumptions, unknown prior trials, data availability, scaling limits, and untested live transport. + \item[] Justification: Section 6 discusses dependent panels and strategy cells, the historical rather than temporal holdout, unknown retrospective trials, pointwise intervals, comparator and engine semantics, ETF-universe selection, cash and cost proxies, data availability, and untested live transport. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper has no limitation while the answer \answerNo{} means that the paper has limitations, but those are not discussed in the paper. @@ -47,7 +47,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Experimental result reproducibility} \item[] Question: Does the paper fully disclose all the information needed to reproduce the main experimental results of the paper to the extent that it affects the main claims and/or conclusions of the paper (regardless of whether the code and data are provided or not)? \item[] Answer: \answerNo{} - \item[] Justification: Sections 3--4 and Appendix A disclose the algorithms, dates, symbols, parameters, versions, clean-worktree release command, and exact input digest. The raw price matrix is not redistributed, however, so independent bitwise reproduction is not fully open. + \item[] Justification: Sections 3--4 and the appendices disclose algorithms, dates, symbols, hyperparameters, seeds, versions, release commands, and input hashes for both studies. The three raw price matrices are not redistributed, so independent bitwise reproduction of the empirical returns is not fully open. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not include experiments. @@ -67,7 +67,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Open access to data and code} \item[] Question: Does the paper provide open access to the data and code, with sufficient instructions to faithfully reproduce the main experimental results, as described in supplemental material? \item[] Answer: \answerNo{} - \item[] Justification: The MIT-licensed code, aggregate results, figures, and manifests are open, but the provider-derived adjusted-close matrix is not. Appendix A explains the exact command and required columns for an authorized local copy. + \item[] Justification: The MIT-licensed code, schemas, fixtures, aggregate results, figures, and manifests are open, but the provider-derived adjusted-close matrices are not. The reproducibility appendix gives exact commands and required local inputs. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that paper does not include experiments requiring code. @@ -84,7 +84,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Experimental setting/details} \item[] Question: Does the paper specify all the training and test details (e.g., data splits, hyperparameters, how they were chosen, type of optimizer) necessary to understand the results? \item[] Answer: \answerYes{} - \item[] Justification: Section 4 specifies the fixed audit configuration, lookbacks, execution timing, variants, cost grid, warm-up and evaluation windows, separate attribution and costed comparators, bootstrap settings, and the unknown earlier research trial count. + \item[] Justification: Section 4 specifies both studies' panels, dates, decision timing, strategy rules, learned-model features and hyperparameters, five seeds, cash and cost rules, comparators, engines, bootstrap settings, and retrospective trial-count limitation. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not include experiments. @@ -95,7 +95,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Experiment statistical significance} \item[] Question: Does the paper report error bars suitably and correctly defined or other appropriate information about the statistical significance of the experiments? \item[] Answer: \answerYes{} - \item[] Justification: Sections 3--5 report joint circular-block intervals for arithmetic effects and directly resampled conventional sample Sharpe statistics, including sensitivity to 5-, 21-, and 63-session blocks. The paper states the 5,000 replications, seed, percentile construction, dependence rationale, and descriptive interpretation of positive-draw counts. + \item[] Justification: Sections 3--5 report joint circular-block intervals for paired arithmetic-return effects and recomputed conventional sample Sharpe differences, with 5-, 21-, and 63-session block sensitivity. The paper states the 5,000 replications, shared-draw construction, dependence rationale, and pointwise, non-multiplicity-adjusted interpretation. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not include experiments. @@ -112,7 +112,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Experiments compute resources} \item[] Question: For each experiment, does the paper provide sufficient information on the computer resources (type of compute workers, memory, time of execution) needed to reproduce the experiments? \item[] Answer: \answerYes{} - \item[] Justification: Appendix A reports CPU model, core count, memory, Python version, runtime, GPU use, and distinguishes experiment compute from broader engineering validation. + \item[] Justification: The reproducibility appendix identifies CPU-only execution, supported Python versions, machine-readable environment and thread-pool metadata, and distinguishes experiment runs from repository-wide engineering validation. Reviewed wall times and hardware are recorded with the release notes. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not include experiments. @@ -124,7 +124,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Code of ethics} \item[] Question: Does the research conducted in the paper conform, in every respect, with the NeurIPS Code of Ethics \url{https://neurips.cc/public/EthicsGuidelines}? \item[] Answer: \answerNo{} - \item[] Justification: The work uses no human subjects or private personal data and places no live orders. However, as disclosed in Section 4 and item 12, the project has not independently verified that the provider's terms permit public publication of derived aggregates, so it cannot certify conformity in every respect. + \item[] Justification: The work uses no human subjects or private personal data and places no live orders. However, as disclosed in Sections 4 and 6 and item 12, provider permission for publication of the derived aggregates has not been independently verified, so conformity cannot be certified in every respect. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the authors have not reviewed the NeurIPS Code of Ethics. @@ -162,7 +162,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf Licenses for existing assets} \item[] Question: Are the creators or original owners of assets (e.g., code, data, models), used in the paper, properly credited and are the license and terms of use explicitly mentioned and properly respected? \item[] Answer: \answerNo{} - \item[] Justification: Section 4 identifies Yahoo Finance via yfinance 1.4.1, links the provider usage notice, records the owner's authorization to publish derived aggregates, and states that raw quotes are not redistributed. However, the project has not independently verified provider authorization for public publication, so it cannot certify full terms compliance. + \item[] Justification: The manifests identify Yahoo Finance via yfinance, record request settings and provider notices, and confirm that raw quotes are not redistributed. The project has not independently verified provider authorization for public publication of the derived aggregates, so it cannot certify full terms compliance. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not use existing assets. @@ -178,7 +178,7 @@ \section*{NeurIPS Paper Checklist} \item {\bf New assets} \item[] Question: Are new assets introduced in the paper well documented and is the documentation provided alongside the assets? \item[] Answer: \answerYes{} - \item[] Justification: The repository documents the MIT-licensed code, target-tape and evaluation-contract schemas, conformance fixtures, architecture, tests, security policy, release command, aggregate result tables, figures, provenance manifest, and known limitations. + \item[] Justification: The repository documents the MIT-licensed code, target-tape and evaluation-contract schemas, conformance fixtures, frozen expansion protocol, architecture, tests, release commands, aggregate tables, figures, provenance manifests, and known limitations. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the paper does not release new assets. @@ -213,8 +213,8 @@ \section*{NeurIPS Paper Checklist} \item {\bf Declaration of LLM usage} \item[] Question: Does the paper describe the usage of LLMs if it is an important, original, or non-standard component of the core methods in this research? Note that if the LLM is used only for writing, editing, or formatting purposes and does \emph{not} impact the core methodology, scientific rigor, or originality of the research, declaration is not required. %this research? - \item[] Answer: \answerNA{} - \item[] Justification: LLMs are not an important, original, or non-standard component of the strategy or empirical method. The use-of-language-model-assistance appendix voluntarily discloses assistance with code review, regression-test drafting, and copyediting. + \item[] Answer: \answerYes{} + \item[] Justification: LLM-based coding assistants were used for implementation planning, code drafting, adversarial review, regression tests, and copyediting. They are not an evaluated model or data source. The assistance appendix discloses this use; the author froze the protocol before retrieval, reviewed the artifacts, and remains responsible for every claim. \item[] Guidelines: \begin{itemize} \item The answer \answerNA{} means that the core method development in this research does not involve LLMs as any important, original, or non-standard components. diff --git a/paper/expansion/figures/baseline_performance.pdf b/paper/expansion/figures/baseline_performance.pdf new file mode 100644 index 0000000..8559448 Binary files /dev/null and b/paper/expansion/figures/baseline_performance.pdf differ diff --git a/paper/expansion/figures/baseline_performance.png b/paper/expansion/figures/baseline_performance.png new file mode 100644 index 0000000..ba76c29 Binary files /dev/null and b/paper/expansion/figures/baseline_performance.png differ diff --git a/paper/expansion/figures/contract_effects_return.pdf b/paper/expansion/figures/contract_effects_return.pdf new file mode 100644 index 0000000..c585a21 Binary files /dev/null and b/paper/expansion/figures/contract_effects_return.pdf differ diff --git a/paper/expansion/figures/contract_effects_return.png b/paper/expansion/figures/contract_effects_return.png new file mode 100644 index 0000000..8ad4ad3 Binary files /dev/null and b/paper/expansion/figures/contract_effects_return.png differ diff --git a/paper/expansion/figures/contract_effects_sharpe.pdf b/paper/expansion/figures/contract_effects_sharpe.pdf new file mode 100644 index 0000000..e579fdf Binary files /dev/null and b/paper/expansion/figures/contract_effects_sharpe.pdf differ diff --git a/paper/expansion/figures/contract_effects_sharpe.png b/paper/expansion/figures/contract_effects_sharpe.png new file mode 100644 index 0000000..5741f14 Binary files /dev/null and b/paper/expansion/figures/contract_effects_sharpe.png differ diff --git a/paper/expansion/figures/engine_conformance.pdf b/paper/expansion/figures/engine_conformance.pdf new file mode 100644 index 0000000..31f18cb Binary files /dev/null and b/paper/expansion/figures/engine_conformance.pdf differ diff --git a/paper/expansion/figures/engine_conformance.png b/paper/expansion/figures/engine_conformance.png new file mode 100644 index 0000000..10bacac Binary files /dev/null and b/paper/expansion/figures/engine_conformance.png differ diff --git a/paper/expansion/figures/learned_seed_sensitivity.pdf b/paper/expansion/figures/learned_seed_sensitivity.pdf new file mode 100644 index 0000000..0fc60cb Binary files /dev/null and b/paper/expansion/figures/learned_seed_sensitivity.pdf differ diff --git a/paper/expansion/figures/learned_seed_sensitivity.png b/paper/expansion/figures/learned_seed_sensitivity.png new file mode 100644 index 0000000..c2d685d Binary files /dev/null and b/paper/expansion/figures/learned_seed_sensitivity.png differ diff --git a/paper/expansion/protocol.json b/paper/expansion/protocol.json new file mode 100644 index 0000000..2ccb1fa --- /dev/null +++ b/paper/expansion/protocol.json @@ -0,0 +1,134 @@ +{ + "schema_version": 1, + "status": "repository_frozen_prospective_not_externally_registered", + "freeze_date": "2026-06-18", + "historical_case_confirmatory": false, + "data": { + "start": "2014-01-02", + "evaluation_start": "2018-01-02", + "evaluation_end": "2025-12-31", + "cash_proxy": "SHV", + "provider_adapter": "yfinance", + "provider_request": { + "request_start": "2014-01-02", + "request_end_exclusive": "2026-01-01", + "auto_adjust": false, + "actions": false, + "repair": false, + "threads": false, + "selected_field": "Adj Close" + }, + "adjustment": "provider adjusted close with auto_adjust disabled", + "alignment": "complete-row intersection", + "forward_fill": false, + "minimum_pre_evaluation_sessions": 252, + "minimum_mature_training_months": 24, + "raw_data_committed": false + }, + "panels": { + "us_sector_etfs": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ], + "country_equity_etfs": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + "decision_schedule": "first complete panel session of each calendar month", + "score_tie_break": "ascending symbol", + "strategies": { + "ts_momentum": { + "lookback_sessions": 252, + "selection": "all positive signals", + "weighting": "equal weight across selected assets" + }, + "cross_sectional_momentum": { + "lookback_start_sessions": 252, + "skip_recent_sessions": 21, + "selection_count": 3, + "weighting": "one third per selected asset" + }, + "short_term_reversal": { + "lookback_sessions": 5, + "selection_count": 3, + "eligibility": "negative trailing return", + "weighting": "one third per selected asset without renormalizing" + }, + "learned_gbrt": { + "estimator": "sklearn.ensemble.GradientBoostingRegressor", + "label_horizon_sessions": 21, + "label_cutoff": "forward endpoint on or before current decision", + "training_observation": "pooled asset-month row without symbol identity", + "return_transform": "log", + "feature_return_windows": [5, 21, 63, 126, 252], + "feature_volatility_windows": [21, 63], + "feature_volatility": "sample standard deviation of daily log returns", + "include_trailing_high_distance": true, + "trailing_high_distance": "price divided by trailing 252-session high minus one", + "cross_sectional_rank_windows": [21, 252], + "cross_sectional_rank": "ordinal ascending rank normalized to [0,1] with symbol tie-break", + "feature_standardization": false, + "feature_winsorization": false, + "training_decision_months": 60, + "minimum_training_decision_months": 24, + "n_estimators": 100, + "learning_rate": 0.03, + "max_depth": 2, + "min_samples_leaf": 10, + "subsample": 0.8, + "seeds": [11, 29, 47, 71, 97], + "selection_count": 3, + "eligibility": "positive predicted forward return", + "weighting": "one third per selected asset without renormalizing" + } + }, + "execution": { + "primary_engine": "event_driven", + "diagnostic_engine": "vectorized", + "timing": "first strictly later panel close", + "initial_nav": 1.0, + "long_only": true, + "max_gross": 1.0, + "residual_cash_return": "SHV", + "cost_per_one_way_gross_notional": 0.0013 + }, + "comparator": { + "type": "causal target-exposure-matched equal weight", + "same_dates": true, + "same_cash_proxy": true, + "same_cost_rate": true + }, + "switches": [ + "same_close_assignment", + "zero_cash_return", + "zero_transaction_cost", + "causal_exposure_matched_comparator", + "vectorized_engine" + ], + "uncertainty": { + "method": "joint circular block bootstrap", + "replications": 5000, + "primary_block_sessions": 21, + "sensitivity_block_sessions": [5, 63], + "seed": 20260618, + "interval": "two-sided 95 percent percentile", + "annualized_arithmetic_return": "252 times daily arithmetic mean", + "sharpe": "sqrt(252) times mean daily strategy-minus-SHV return divided by sample standard deviation" + } +} diff --git a/paper/expansion/results/contract_effects.csv b/paper/expansion/results/contract_effects.csv new file mode 100644 index 0000000..e232827 --- /dev/null +++ b/paper/expansion/results/contract_effects.csv @@ -0,0 +1,121 @@ +panel,strategy,switch,observations,family_size,block_length,replications,seed,annualized_mean_difference,annualized_mean_ci_95_lower,annualized_mean_ci_95_upper,sharpe_difference,sharpe_ci_95_lower,sharpe_ci_95_upper +us_sector_etfs,ts_momentum,same_close_minus_baseline,2011,1,21,5000,20260618,0.00580947411366,-0.00334116731312,0.0152539723222,0.0321331461384,-0.0185738471993,0.0917149360721 +us_sector_etfs,ts_momentum,same_close_minus_baseline,2011,1,5,5000,20260618,0.00580947411366,-0.00334718545777,0.0151683533464,0.0321331461384,-0.0190854528311,0.0877211296818 +us_sector_etfs,ts_momentum,same_close_minus_baseline,2011,1,63,5000,20260618,0.00580947411366,-0.00184080644417,0.0144963233502,0.0321331461384,-0.0103649907904,0.088557442294 +us_sector_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,21,5000,20260618,1.14152540727e-05,-5.32776947559e-16,3.42457622181e-05,6.28783952607e-05,-2.99760216649e-15,0.000228134580081 +us_sector_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,5,5000,20260618,1.14152540727e-05,-6.29086626541e-16,3.42457622184e-05,6.28783952607e-05,-3.66373598126e-15,0.000211268034887 +us_sector_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,63,5000,20260618,1.14152540727e-05,-5.33118471244e-16,3.42457622181e-05,6.28783952607e-05,-2.99829605588e-15,0.000232807270644 +us_sector_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.00571707430812,0.00455478298482,0.00701209447698,0.0313497214962,0.023401156141,0.0421249449854 +us_sector_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.00571707430812,0.00422159211285,0.00738729069226,0.0313497214962,0.0225285587732,0.0420905195821 +us_sector_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.00571707430812,0.00417869514542,0.00743415401761,0.0313497214962,0.0225262723468,0.0446940865762 +us_sector_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,21,5000,20260618,-0.0114628570233,-0.0425598230515,0.0186749881014,-0.0540124993911,-0.236254201492,0.108605830812 +us_sector_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,5,5000,20260618,-0.0114628570233,-0.0443301660374,0.0204464571583,-0.0540124993911,-0.236352325744,0.123149635903 +us_sector_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,63,5000,20260618,-0.0114628570233,-0.0419531244987,0.0163002834159,-0.0540124993911,-0.254554696749,0.0965672429937 +us_sector_etfs,ts_momentum,vectorized_minus_event,2011,1,21,5000,20260618,0.00142755229164,0.00030513602743,0.00257203893353,0.00635340133542,0.000440139366536,0.0125466542183 +us_sector_etfs,ts_momentum,vectorized_minus_event,2011,1,5,5000,20260618,0.00142755229164,-1.43430034787e-05,0.00290444254969,0.00635340133542,-0.00115792472071,0.0142000690876 +us_sector_etfs,ts_momentum,vectorized_minus_event,2011,1,63,5000,20260618,0.00142755229164,0.000546548951508,0.00229647799634,0.00635340133542,0.00142088672329,0.0122115424623 +us_sector_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,21,5000,20260618,-0.00479125749034,-0.016566101486,0.0073383529959,-0.0237112755108,-0.088307301926,0.0385441927803 +us_sector_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,5,5000,20260618,-0.00479125749034,-0.0171751104579,0.0070461298504,-0.0237112755108,-0.0889358009783,0.0368107480656 +us_sector_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,63,5000,20260618,-0.00479125749034,-0.0161573301254,0.00668455399302,-0.0237112755108,-0.0914091393558,0.0345777833902 +us_sector_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,21,5000,20260618,1.14152540725e-05,-6.01081684426e-16,3.4245762218e-05,5.8083131929e-05,-2.99829605588e-15,0.000204250367831 +us_sector_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,5,5000,20260618,1.14152540725e-05,-8.46978737146e-16,3.42457622182e-05,5.8083131929e-05,-4.21884749358e-15,0.000192420667725 +us_sector_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,63,5000,20260618,1.14152540725e-05,-5.73759789679e-16,3.4245762218e-05,5.8083131929e-05,-2.88657986403e-15,0.000207816550371 +us_sector_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.00684384091432,0.00559527303313,0.008132881858,0.03474902217,0.0260742387151,0.0454984331313 +us_sector_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.00684384091432,0.00516714256519,0.0086540849812,0.03474902217,0.0253799505128,0.0457988133777 +us_sector_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.00684384091432,0.00533409358036,0.00834900478749,0.03474902217,0.024984224471,0.0482947559034 +us_sector_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,21,5000,20260618,0.016043880124,-0.0378560531374,0.0692147292302,0.0479186781179,-0.273916523984,0.322137989181 +us_sector_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,5,5000,20260618,0.016043880124,-0.0415110041994,0.0741038080405,0.0479186781179,-0.266761544057,0.350742942049 +us_sector_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,63,5000,20260618,0.016043880124,-0.0289114257806,0.0622191095278,0.0479186781179,-0.242990635921,0.282983841293 +us_sector_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,21,5000,20260618,0.00191438298586,0.000373456279094,0.00351765497092,0.00825860032169,0.000519092939404,0.015478250099 +us_sector_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,5,5000,20260618,0.00191438298586,0.000288702098797,0.00351035802578,0.00825860032169,0.000156395580603,0.016023339347 +us_sector_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,63,5000,20260618,0.00191438298586,0.000500047927451,0.00330226551551,0.00825860032169,0.00108264829382,0.014872914778 +us_sector_etfs,short_term_reversal,same_close_minus_baseline,2011,1,21,5000,20260618,-0.0747219727301,-0.104026172989,-0.046047694018,-0.383846555262,-0.621491722624,-0.219309603397 +us_sector_etfs,short_term_reversal,same_close_minus_baseline,2011,1,5,5000,20260618,-0.0747219727301,-0.106039995471,-0.0438013075931,-0.383846555262,-0.582387121817,-0.218486951673 +us_sector_etfs,short_term_reversal,same_close_minus_baseline,2011,1,63,5000,20260618,-0.0747219727301,-0.105758377789,-0.0448723110273,-0.383846555262,-0.670559403683,-0.201416368199 +us_sector_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,21,5000,20260618,-0.00574201597429,-0.0079845386489,-0.00377211421343,-0.0295185705275,-0.0488447427522,-0.0173606053494 +us_sector_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,5,5000,20260618,-0.00574201597429,-0.00703812903654,-0.00447407422047,-0.0295185705275,-0.0399796971779,-0.0216408478568 +us_sector_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,63,5000,20260618,-0.00574201597429,-0.00854263137673,-0.00321567050073,-0.0295185705275,-0.055188256302,-0.0147612214649 +us_sector_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.0179952528319,0.0162664561572,0.019795963758,0.0917994265257,0.0697734577077,0.1259333981 +us_sector_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.0179952528319,0.0145514078095,0.0216758635962,0.0917994265257,0.070528928744,0.120473943084 +us_sector_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.0179952528319,0.0163336120537,0.0196572328324,0.0917994265257,0.0678670259177,0.130737197907 +us_sector_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,21,5000,20260618,-0.00987611689354,-0.0632795752803,0.039854691658,-0.135003923685,-0.395155205482,0.116510791813 +us_sector_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,5,5000,20260618,-0.00987611689354,-0.0615519651236,0.0420072158969,-0.135003923685,-0.409532087241,0.124816904127 +us_sector_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,63,5000,20260618,-0.00987611689354,-0.0544635520474,0.0352589253188,-0.135003923685,-0.400010015484,0.0995151202892 +us_sector_etfs,short_term_reversal,vectorized_minus_event,2011,1,21,5000,20260618,0.00123851296752,-0.000964254356764,0.00380854073617,0.00373145851183,-0.00625925895831,0.014363820924 +us_sector_etfs,short_term_reversal,vectorized_minus_event,2011,1,5,5000,20260618,0.00123851296752,-0.00135296717128,0.00388981258912,0.00373145851183,-0.00869924264215,0.016893764219 +us_sector_etfs,short_term_reversal,vectorized_minus_event,2011,1,63,5000,20260618,0.00123851296752,-0.000667701229806,0.00332899065697,0.00373145851183,-0.00521273592206,0.0124376712652 +us_sector_etfs,learned_gbrt,same_close_minus_baseline,2011,5,21,5000,20260618,-0.0250944161316,-0.047046035882,-0.00311108646069,-0.119940260384,-0.236325040597,-0.0164789686655 +us_sector_etfs,learned_gbrt,same_close_minus_baseline,2011,5,5,5000,20260618,-0.0250944161316,-0.0476452880591,-0.00334577700747,-0.119940260384,-0.230503619267,-0.0167398029182 +us_sector_etfs,learned_gbrt,same_close_minus_baseline,2011,5,63,5000,20260618,-0.0250944161316,-0.0484499481424,-0.00133195989767,-0.119940260384,-0.243647277427,-0.00774056108293 +us_sector_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,21,5000,20260618,-0.000205607907617,-0.00051630211777,-7.9658700898e-06,-0.000972680991691,-0.00253792741568,-3.7001326995e-05 +us_sector_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,5,5000,20260618,-0.000205607907617,-0.000387186168207,-6.30425853632e-05,-0.000972680991691,-0.00189271144273,-0.0003005804682 +us_sector_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,63,5000,20260618,-0.000205607907617,-0.00057704135731,8.23577584529e-06,-0.000972680991691,-0.00292266876327,3.65628339595e-05 +us_sector_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,21,5000,20260618,0.0164698984714,0.0149349220451,0.018038099499,0.0776333042363,0.0619159602987,0.098909458511 +us_sector_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,5,5000,20260618,0.0164698984714,0.0134381219598,0.0198353126395,0.0776333042363,0.0609102563709,0.0983340115669 +us_sector_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,63,5000,20260618,0.0164698984714,0.0150549315462,0.017944576198,0.0776333042363,0.0607670204472,0.10530734409 +us_sector_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,21,5000,20260618,0.0104208491989,-0.0361778244177,0.057892278595,-0.0225954954133,-0.262669743932,0.208312370928 +us_sector_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,5,5000,20260618,0.0104208491989,-0.0395582873125,0.0600029264963,-0.0225954954133,-0.280301415249,0.211806950776 +us_sector_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,63,5000,20260618,0.0104208491989,-0.0291142754519,0.0505893776606,-0.0225954954133,-0.246336546548,0.180008980961 +us_sector_etfs,learned_gbrt,vectorized_minus_event,2011,5,21,5000,20260618,0.00153171362141,-0.0015218326554,0.00494561995005,0.00345469127986,-0.00879786561082,0.0168325870917 +us_sector_etfs,learned_gbrt,vectorized_minus_event,2011,5,5,5000,20260618,0.00153171362141,-0.00261896754685,0.00624702725221,0.00345469127986,-0.0149221427869,0.0233692156198 +us_sector_etfs,learned_gbrt,vectorized_minus_event,2011,5,63,5000,20260618,0.00153171362141,-0.000613656605327,0.00385560941613,0.00345469127986,-0.00522574944913,0.013583677498 +country_equity_etfs,ts_momentum,same_close_minus_baseline,2011,1,21,5000,20260618,0.0100066698891,-0.0032517270113,0.0236034458212,0.0623974853919,-0.0207455306988,0.142464682234 +country_equity_etfs,ts_momentum,same_close_minus_baseline,2011,1,5,5000,20260618,0.0100066698891,-0.0033781713059,0.0246912457038,0.0623974853919,-0.0204455651315,0.158469472037 +country_equity_etfs,ts_momentum,same_close_minus_baseline,2011,1,63,5000,20260618,0.0100066698891,-0.000917139632422,0.0216614473251,0.0623974853919,-0.00652596035195,0.124450265744 +country_equity_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,21,5000,20260618,-0.00242355360706,-0.00398931466449,-0.00109992967587,-0.015037066913,-0.0274849616371,-0.00644664103345 +country_equity_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,5,5000,20260618,-0.00242355360706,-0.00333535369079,-0.00159241242941,-0.015037066913,-0.021903824566,-0.0096735984666 +country_equity_etfs,ts_momentum,zero_cash_minus_baseline,2011,1,63,5000,20260618,-0.00242355360706,-0.00486712690631,-0.000524845578939,-0.015037066913,-0.0340019089147,-0.00314612308281 +country_equity_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.00637566667444,0.00510009177967,0.00778271430159,0.0395445763455,0.0301498752029,0.0518593497727 +country_equity_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.00637566667444,0.00472008361792,0.00820621356206,0.0395445763455,0.028330466048,0.0535690022629 +country_equity_etfs,ts_momentum,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.00637566667444,0.00469363124805,0.00821606696369,0.0395445763455,0.0293274447229,0.0538556755918 +country_equity_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,21,5000,20260618,-0.027437838652,-0.0517297948737,-0.00568599323891,-0.167111325354,-0.332298353366,-0.0334161653936 +country_equity_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,5,5000,20260618,-0.027437838652,-0.0511438755294,-0.00594282146654,-0.167111325354,-0.324062204745,-0.0341891281519 +country_equity_etfs,ts_momentum,strategy_minus_costed_comparator,2011,1,63,5000,20260618,-0.027437838652,-0.0513923524507,-0.00664139757225,-0.167111325354,-0.33137202648,-0.0413785571317 +country_equity_etfs,ts_momentum,vectorized_minus_event,2011,1,21,5000,20260618,0.000846904057458,0.000190317585565,0.00157264279703,0.00529792839031,0.00162509641974,0.00949628988147 +country_equity_etfs,ts_momentum,vectorized_minus_event,2011,1,5,5000,20260618,0.000846904057458,-0.000143780262155,0.00195150807555,0.00529792839031,-0.000375099171208,0.0112544249383 +country_equity_etfs,ts_momentum,vectorized_minus_event,2011,1,63,5000,20260618,0.000846904057458,0.000323107174405,0.00138862310024,0.00529792839031,0.00233586031008,0.00879239059991 +country_equity_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,21,5000,20260618,-0.00307096115837,-0.0151328107818,0.00841785113666,-0.0165601193192,-0.0811545930941,0.045030207689 +country_equity_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,5,5000,20260618,-0.00307096115837,-0.0146370149565,0.00809371918129,-0.0165601193192,-0.0779262139778,0.0420477045431 +country_equity_etfs,cross_sectional_momentum,same_close_minus_baseline,2011,1,63,5000,20260618,-0.00307096115837,-0.014854979186,0.00799915556394,-0.0165601193192,-0.0820800964469,0.0426144887004 +country_equity_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,21,5000,20260618,1.14152540724e-05,-7.10710787097e-16,3.4245762218e-05,5.90201877496e-05,-4.32986979604e-15,0.000215492433161 +country_equity_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,5,5000,20260618,1.14152540724e-05,-8.74300631892e-16,3.42457622181e-05,5.90201877496e-05,-4.88498130835e-15,0.000201804583564 +country_equity_etfs,cross_sectional_momentum,zero_cash_minus_baseline,2011,1,63,5000,20260618,1.14152540724e-05,-6.55725473919e-16,3.42457622179e-05,5.90201877496e-05,-3.99680288865e-15,0.000220916158768 +country_equity_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.00676103023016,0.00549440926291,0.00805446478739,0.0350152959606,0.0254656900708,0.0485022856402 +country_equity_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.00676103023016,0.0051180276189,0.008550695349,0.0350152959606,0.0254446348854,0.0476026848514 +country_equity_etfs,cross_sectional_momentum,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.00676103023016,0.00530177328028,0.00827792103779,0.0350152959606,0.0249794695799,0.0512439911431 +country_equity_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,21,5000,20260618,0.00563396479906,-0.0285129857343,0.0384947522923,0.00415652488798,-0.178555416927,0.184289990975 +country_equity_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,5,5000,20260618,0.00563396479906,-0.029645795464,0.041090280227,0.00415652488798,-0.185784468996,0.19695124262 +country_equity_etfs,cross_sectional_momentum,strategy_minus_costed_comparator,2011,1,63,5000,20260618,0.00563396479906,-0.025334846672,0.0342215719517,0.00415652488798,-0.170652713948,0.165326664657 +country_equity_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,21,5000,20260618,0.0008905998446,0.000250823060642,0.00171113039887,0.00371466011878,0.00101437209764,0.00691685225487 +country_equity_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,5,5000,20260618,0.0008905998446,8.30121595968e-05,0.00183348645918,0.00371466011878,-0.000110856766818,0.00780204134121 +country_equity_etfs,cross_sectional_momentum,vectorized_minus_event,2011,1,63,5000,20260618,0.0008905998446,0.000298051714808,0.00163436387786,0.00371466011878,0.0011892961735,0.00677581289108 +country_equity_etfs,short_term_reversal,same_close_minus_baseline,2011,1,21,5000,20260618,-0.0694808744485,-0.100009788557,-0.0387191594018,-0.383143315232,-0.639392483547,-0.207237290131 +country_equity_etfs,short_term_reversal,same_close_minus_baseline,2011,1,5,5000,20260618,-0.0694808744485,-0.1038064686,-0.0375387724529,-0.383143315232,-0.616465154072,-0.203446420451 +country_equity_etfs,short_term_reversal,same_close_minus_baseline,2011,1,63,5000,20260618,-0.0694808744485,-0.0985082517709,-0.0411288752375,-0.383143315232,-0.667600771717,-0.197821159056 +country_equity_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,21,5000,20260618,-0.00763435808132,-0.0100396023724,-0.00540175043388,-0.0421667171285,-0.0688466334933,-0.026099915379 +country_equity_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,5,5000,20260618,-0.00763435808132,-0.00903364333344,-0.00630775809853,-0.0421667171285,-0.0573937737503,-0.0316053750115 +country_equity_etfs,short_term_reversal,zero_cash_minus_baseline,2011,1,63,5000,20260618,-0.00763435808132,-0.0108866364549,-0.00468362155079,-0.0421667171285,-0.0771862193578,-0.0228054832448 +country_equity_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,21,5000,20260618,0.0181289474788,0.0165230449025,0.0198000242915,0.0998568059433,0.0747137910535,0.138074684499 +country_equity_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,5,5000,20260618,0.0181289474788,0.0148006558646,0.0217918018618,0.0998568059433,0.0760089544663,0.133707255657 +country_equity_etfs,short_term_reversal,zero_cost_minus_baseline,2011,1,63,5000,20260618,0.0181289474788,0.0165496788486,0.0197294575942,0.0998568059433,0.0731013539015,0.142456839231 +country_equity_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,21,5000,20260618,-0.0114128220015,-0.0455203112516,0.021561965425,-0.100635800085,-0.296160183736,0.100806321334 +country_equity_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,5,5000,20260618,-0.0114128220015,-0.0482499096284,0.0258099570262,-0.100635800085,-0.316098474678,0.11016974095 +country_equity_etfs,short_term_reversal,strategy_minus_costed_comparator,2011,1,63,5000,20260618,-0.0114128220015,-0.0439338420837,0.0181968467812,-0.100635800085,-0.287288442928,0.0775536976135 +country_equity_etfs,short_term_reversal,vectorized_minus_event,2011,1,21,5000,20260618,0.000680536049188,-0.000252679672781,0.00180564686666,0.00364454736252,-0.00194282214677,0.00796847781535 +country_equity_etfs,short_term_reversal,vectorized_minus_event,2011,1,5,5000,20260618,0.000680536049188,-0.000304405823036,0.00171219914831,0.00364454736252,-0.00206843701961,0.00866980520167 +country_equity_etfs,short_term_reversal,vectorized_minus_event,2011,1,63,5000,20260618,0.000680536049188,-0.000263516824303,0.00196268534599,0.00364454736252,-0.00204429704278,0.00832842479721 +country_equity_etfs,learned_gbrt,same_close_minus_baseline,2011,5,21,5000,20260618,-0.0124118167995,-0.0291037932568,0.00377260916224,-0.0719653239126,-0.16703132118,0.0210281662013 +country_equity_etfs,learned_gbrt,same_close_minus_baseline,2011,5,5,5000,20260618,-0.0124118167995,-0.0291551318434,0.00374688736448,-0.0719653239126,-0.171234371082,0.0207861718307 +country_equity_etfs,learned_gbrt,same_close_minus_baseline,2011,5,63,5000,20260618,-0.0124118167995,-0.029135116292,0.00443977859834,-0.0719653239126,-0.169492664784,0.0284670734645 +country_equity_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,21,5000,20260618,-0.00377842781918,-0.00587016161633,-0.0019996075094,-0.0219802468407,-0.0363930481392,-0.0110826623864 +country_equity_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,5,5000,20260618,-0.00377842781918,-0.00483372510767,-0.00278967932694,-0.0219802468407,-0.0292293671272,-0.0157899947906 +country_equity_etfs,learned_gbrt,zero_cash_minus_baseline,2011,5,63,5000,20260618,-0.00377842781918,-0.00694878488043,-0.00118013315474,-0.0219802468407,-0.0447574957563,-0.00664578430911 +country_equity_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,21,5000,20260618,0.0170641970987,0.0154114005004,0.0187420572546,0.0988179788523,0.0828716573614,0.118780628274 +country_equity_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,5,5000,20260618,0.0170641970987,0.013830979364,0.0206077875116,0.0988179788523,0.0786786245858,0.122642921686 +country_equity_etfs,learned_gbrt,zero_cost_minus_baseline,2011,5,63,5000,20260618,0.0170641970987,0.0153196081098,0.0187134307121,0.0988179788523,0.0796731228561,0.125044098591 +country_equity_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,21,5000,20260618,0.0225320153315,-0.0110118217618,0.0583234151146,0.131406512027,-0.0918349499425,0.314094014613 +country_equity_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,5,5000,20260618,0.0225320153315,-0.0131142643276,0.0591981891061,0.131406512027,-0.0874914113315,0.334961549436 +country_equity_etfs,learned_gbrt,strategy_minus_costed_comparator,2011,5,63,5000,20260618,0.0225320153315,-0.010445799465,0.0583989825797,0.131406512027,-0.0954889009298,0.307465406209 +country_equity_etfs,learned_gbrt,vectorized_minus_event,2011,5,21,5000,20260618,0.000245703517226,-0.000726899355411,0.00112415443424,0.0012807453541,-0.00435654607283,0.00627225849831 +country_equity_etfs,learned_gbrt,vectorized_minus_event,2011,5,5,5000,20260618,0.000245703517226,-0.0008096142539,0.00125412524203,0.0012807453541,-0.00475230085532,0.00697433339775 +country_equity_etfs,learned_gbrt,vectorized_minus_event,2011,5,63,5000,20260618,0.000245703517226,-0.000594075448447,0.00106182189233,0.0012807453541,-0.00358107532841,0.00596040295296 diff --git a/paper/expansion/results/data_provenance.json b/paper/expansion/results/data_provenance.json new file mode 100644 index 0000000..286dd55 --- /dev/null +++ b/paper/expansion/results/data_provenance.json @@ -0,0 +1,118 @@ +[ + { + "complete_rows": 3018, + "dropped_incomplete_rows": 0, + "evaluation_sessions": 2011, + "first_date": "2014-01-02", + "input_sha256": "c5947c4ca6ad6d21ad834c8f344dcdd07acc59ba411e3dcb2202a2413642b2f9", + "last_date": "2025-12-31", + "missing_by_symbol": { + "SHV": 0, + "XLB": 0, + "XLE": 0, + "XLF": 0, + "XLI": 0, + "XLK": 0, + "XLP": 0, + "XLU": 0, + "XLV": 0, + "XLY": 0 + }, + "panel": "us_sector_etfs", + "pre_evaluation_months": 48, + "pre_evaluation_sessions": 1007, + "protocol_path": "paper/expansion/protocol.json", + "protocol_sha256": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "provider": "Yahoo Finance via yfinance", + "provider_rows": 3018, + "provider_terms_independently_verified": false, + "raw_data_committed": false, + "request": { + "actions": false, + "auto_adjust": false, + "repair": false, + "request_end_exclusive": "2026-01-01", + "request_start": "2014-01-02", + "selected_field": "Adj Close", + "threads": false + }, + "retrieved_at": "2026-06-18T22:37:51Z", + "schema_version": 1, + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY", + "SHV" + ], + "terms_urls": [ + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html" + ], + "yfinance_version": "1.4.1" + }, + { + "complete_rows": 3018, + "dropped_incomplete_rows": 0, + "evaluation_sessions": 2011, + "first_date": "2014-01-02", + "input_sha256": "870fa926b5080378c65bd629b9959bd73b16391dded5720c894c0f35558132a9", + "last_date": "2025-12-31", + "missing_by_symbol": { + "EWA": 0, + "EWC": 0, + "EWG": 0, + "EWH": 0, + "EWJ": 0, + "EWL": 0, + "EWP": 0, + "EWQ": 0, + "EWS": 0, + "EWU": 0, + "SHV": 0 + }, + "panel": "country_equity_etfs", + "pre_evaluation_months": 48, + "pre_evaluation_sessions": 1007, + "protocol_path": "paper/expansion/protocol.json", + "protocol_sha256": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "provider": "Yahoo Finance via yfinance", + "provider_rows": 3018, + "provider_terms_independently_verified": false, + "raw_data_committed": false, + "request": { + "actions": false, + "auto_adjust": false, + "repair": false, + "request_end_exclusive": "2026-01-01", + "request_start": "2014-01-02", + "selected_field": "Adj Close", + "threads": false + }, + "retrieved_at": "2026-06-18T22:37:51Z", + "schema_version": 1, + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU", + "SHV" + ], + "terms_urls": [ + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html" + ], + "yfinance_version": "1.4.1" + } +] diff --git a/paper/expansion/results/engine_conformance.csv b/paper/expansion/results/engine_conformance.csv new file mode 100644 index 0000000..0f5e6e9 --- /dev/null +++ b/paper/expansion/results/engine_conformance.csv @@ -0,0 +1,17 @@ +panel,strategy,seed,observations,max_absolute_daily_return_difference,final_wealth_difference,event_return_sha256,vectorized_return_sha256 +us_sector_etfs,ts_momentum,deterministic,2011,0.00209000016836,0.0232825030988,08087e5829f98d394cc0e7c5a614814cf5f64fefeb925828843d61911e485a69,5aa3c24230a2819ffef6aee5935338ea907f68fcb25704f4dffe8d422e97aa0f +us_sector_etfs,cross_sectional_momentum,deterministic,2011,0.00190390175971,0.0389368239024,41199d1e6356b2bdc71d2a668bd13006569db40729ffa5ba62705b6438ec3322,49c86f76a5d3cf7e04866aeea988b87171d9694590ef2323d6a35d310ba7892f +us_sector_etfs,short_term_reversal,deterministic,2011,0.00421571763654,0.0167104930474,546bde070a938c064936c7ae761b6e648324175f85513a304033e6d3a74bd288,c5607bc77e12a1aa211faa6762b997f1f57c9a4453a12becd8f4b6163016b8c5 +us_sector_etfs,learned_gbrt,11,2011,0.00932137915964,0.0249698386397,729acbbce22d636c1596e7d98a7c85e6a351defdd6dd627a0b7617d9e73ae3fe,3f6ea337036f8bad73ffa602b133476dd462afecd1ee0eb677adc177d18383bf +us_sector_etfs,learned_gbrt,29,2011,0.0076669501632,0.0108467187567,d0913698e22fc8d61e6299b936190bf33bb469b431954a53f8fefb93d755904c,728660df4cba9753db66d4f233d1c2d4a98ad58d9232403d52a0311e403be371 +us_sector_etfs,learned_gbrt,47,2011,0.00932137915964,0.0386680333742,7029a1f5f8a6bedebdbca1fc31e6fafad7f85e3c598b07cb799bdb392033ce91,331406ede3a013a06b3734f74d353ff14dcaf7a0599741bc25c68120376258df +us_sector_etfs,learned_gbrt,71,2011,0.00932137915964,0.0332230221293,0f523f1c83d08c19e6a0b28b3f187bf8a68b8be630111245d1aa1aa66b495f0e,bf29f27f0ec601faf96e4271d9bc5a8b6d8f98039d1777935ab099e979726c0a +us_sector_etfs,learned_gbrt,97,2011,0.0076669501632,0.0133469606037,572b8196e6f5b9bfe66842033c911200714f52d3114635a977e74d358267a17e,a8f82b0a76111e222c3bfa7fd0498a56001b552a64ca1e45bb93cee7862b4c2d +country_equity_etfs,ts_momentum,deterministic,2011,0.00236717871285,0.00651155946283,b22b2f8f547ac7267d4092e1d2f50a49fe6f51d8b7b45402db5e0c9adc09b1fa,a4fc9768ac413bc713a0ea4a3a9b496f171b5ec65cfaccf31c2afbc533b72295 +country_equity_etfs,cross_sectional_momentum,deterministic,2011,0.00257410669301,0.0110683006691,c6e19dd423a82b53237240c30073b1b08691ca7ea50233cae24884f5ce4dd151,d34449366ff08e3841c5e418baadbfb5ea3a6a187a210eb39ab17f7a95c662f2 +country_equity_etfs,short_term_reversal,deterministic,2011,0.00113777547747,0.00754862935186,f07a51bbf72367e53dff3f87a75dac73cb429e3d21b64455782fa9da3bbf6bc6,7c52ec0dc9f14f0544794cd4467c556f2b530163d4f33c02a30358392549b938 +country_equity_etfs,learned_gbrt,11,2011,0.00119164980343,0.002195457181,aadd2f1795af13d6ec99a9516f3978cbc7502a298f6105f0751e806225b8fe19,f52290a993ce711d2e1a270b2a1e241719d3c1a1d99c7cefaaddf95123df2deb +country_equity_etfs,learned_gbrt,29,2011,0.00119164980343,0.0076939258856,0dae26fa8ba544a55363edf6f2597dfc20247eb14d2d59bffd0f242a5a1a70f8,1dd0aebae91a67e611d219a120d976671159a0e4d671dc4dc9e04dca6862794f +country_equity_etfs,learned_gbrt,47,2011,0.00198729769911,-0.00170267670836,616f212990dbace1b9ef0b2072a287e409d4cacd2777f5d1aa9475bcd7dba23d,ca9bfebe7cab454cff87e3a082cd6eaa402de5ac52d3a1197303ae19df7c9d56 +country_equity_etfs,learned_gbrt,71,2011,0.00206160236182,-0.00177770445733,25d0e333f78a2db3650d67b70717b0661b1d49d5b8bc5641603fdf14098615ce,ba652b65325f8ad5e65f115ec5d88736fdf6d2bff91d1e8eeb9aa8486628a0f7 +country_equity_etfs,learned_gbrt,97,2011,0.00119164980343,0.00784999209542,b3c950d7feeec2aeaf294cae5364485d29a350a22a04530116f7bb7c76ad816a,d384373250e10c99176be0d817c7b0d30ad740c24585afe13c915f08762654b8 diff --git a/paper/expansion/results/family_summary.csv b/paper/expansion/results/family_summary.csv new file mode 100644 index 0000000..2e10eff --- /dev/null +++ b/paper/expansion/results/family_summary.csv @@ -0,0 +1,49 @@ +panel,strategy,variant,family_size,observations,annualized_arithmetic_return,cash_excess_sharpe,cagr,total_return,max_drawdown,annualized_volatility,annualized_one_way_turnover,annualized_gross_traded_notional,arithmetic_cost_drag,mean_risky_exposure +country_equity_etfs,cross_sectional_momentum,baseline,1,2011,0.0884315662018,0.329325683402,0.0720886943175,0.742793428829,-0.407258984168,0.193325454503,2.66697868899,5.20220910055,0.0539540944161,0.999005469915 +country_equity_etfs,cross_sectional_momentum,costed_comparator,1,2011,0.0827976014027,0.325169158514,0.0690183895672,0.703359458263,-0.3635717287,0.178500716815,0.239318382399,0.35319220933,0.00366961652371,0.999005469915 +country_equity_etfs,cross_sectional_momentum,same_close,1,2011,0.0853606050434,0.312765564083,0.0687146767288,0.699501431662,-0.415000317458,0.193738430692,2.52873208199,5.05089799658,0.0522815198484,1 +country_equity_etfs,cross_sectional_momentum,vectorized,1,2011,0.0893221660464,0.333040343521,0.072939540501,0.753861729499,-0.407544080667,0.193841617988,2.58975634013,5.05420188961,0.0524138334348,0.999005469915 +country_equity_etfs,cross_sectional_momentum,zero_cash,1,2011,0.0884429814558,0.32938470359,0.0721009331097,0.742952204001,-0.407258984168,0.193325431079,2.66697868899,5.20220910055,0.0539540944161,0.999005469915 +country_equity_etfs,cross_sectional_momentum,zero_cost,1,2011,0.0951925964319,0.364340979363,0.0793674787275,0.839486088313,-0.395108174469,0.193288513677,2.66488725743,5.20446372421,-3.33066907388e-16,0.999005469915 +country_equity_etfs,learned_gbrt,baseline,5,2011,0.0783837005643,0.311928221965,0.0656329272651,0.662880195882,-0.335591876018,0.172017437081,7.41253018269,13.0917501319,0.136175001451,0.892616154536 +country_equity_etfs,learned_gbrt,costed_comparator,5,2011,0.0558516852328,0.180521709938,0.0417331658303,0.38624637318,-0.419012968025,0.172340366059,1.80680932845,1.89263638067,0.019670737918,0.892712138533 +country_equity_etfs,learned_gbrt,same_close,5,2011,0.0659718837648,0.239962898053,0.0525149260147,0.505699773422,-0.334338176495,0.171825850333,7.28372803712,12.9587503274,0.134082969224,0.893665749942 +country_equity_etfs,learned_gbrt,vectorized,5,2011,0.0786294040815,0.313208967319,0.0658770669322,0.665731994681,-0.336610700454,0.17210393933,7.35992043759,12.9905519642,0.135120973392,0.892259240842 +country_equity_etfs,learned_gbrt,zero_cash,5,2011,0.0746052727451,0.289947975125,0.061615378002,0.613570500196,-0.339647615422,0.172021194314,7.41228186642,13.0919358185,0.136176032437,0.892655454471 +country_equity_etfs,learned_gbrt,zero_cost,5,2011,0.095447897663,0.410746200818,0.0839373725571,0.90461357462,-0.309675415594,0.172170997364,7.41256095813,13.1042040621,-7.54951656745e-16,0.892616154536 +country_equity_etfs,short_term_reversal,baseline,1,2011,0.0603237363886,0.19668911285,0.0447767104604,0.418438184436,-0.439116951436,0.180905793558,9.63307385663,13.9072779796,0.144671878492,0.708809521781 +country_equity_etfs,short_term_reversal,costed_comparator,1,2011,0.0717365583901,0.297324912935,0.0609171053424,0.603031515857,-0.352248328384,0.158001721302,5.40055981709,5.44539008332,0.0565591309166,0.709024185492 +country_equity_etfs,short_term_reversal,same_close,1,2011,-0.00915713805989,-0.186454202382,-0.0254542259236,-0.185970547426,-0.521677545831,0.181463987383,9.62887497888,13.8992336382,0.143856432396,0.709146772481 +country_equity_etfs,short_term_reversal,vectorized,1,2011,0.0610042724378,0.200333660212,0.0454718324348,0.425986813787,-0.437080528557,0.181008772412,9.60716061661,13.8677274988,0.144259337554,0.708934195259 +country_equity_etfs,short_term_reversal,zero_cash,1,2011,0.0526893783073,0.154522395721,0.036834669911,0.334640777549,-0.445741422323,0.180894657781,9.63342970757,13.9083907215,0.144679894168,0.708856518783 +country_equity_etfs,short_term_reversal,zero_cost,1,2011,0.0784526838674,0.296545918793,0.0638542676365,0.638791659197,-0.421701077314,0.181075015125,9.63767991904,13.9204721815,-1.99840144433e-15,0.708809521781 +country_equity_etfs,ts_momentum,baseline,1,2011,0.0218219607389,-0.0179402804522,0.0087810501862,0.0722597111498,-0.40452457504,0.1609767922,3.00974169007,4.88761091148,0.0508788320726,0.886126305321 +country_equity_etfs,ts_momentum,costed_comparator,1,2011,0.0492597993909,0.149171044902,0.0362575768899,0.328724225217,-0.387842521358,0.164368386949,1.21844018235,1.30965923322,0.0136240817852,0.886126305321 +country_equity_etfs,ts_momentum,same_close,1,2011,0.031828630628,0.0444572049397,0.0191101568132,0.163070182458,-0.408038668315,0.159872844648,2.88231502772,4.75791160149,0.049180725028,0.887120835405 +country_equity_etfs,ts_momentum,vectorized,1,2011,0.0226688647963,-0.0126423520619,0.00954668171353,0.0787712706126,-0.406006624026,0.161525330322,2.99129786176,4.85479860766,0.0505411305453,0.886126305321 +country_equity_etfs,ts_momentum,zero_cash,1,2011,0.0193984071318,-0.0329773473651,0.00633962460419,0.0517248393511,-0.409381244624,0.16097524938,3.00974169007,4.88761091148,0.0508783590056,0.886126305321 +country_equity_etfs,ts_momentum,zero_cost,1,2011,0.0281976274133,0.0216042958934,0.0152146936152,0.128062187209,-0.394916581953,0.161091681751,3.009120474,4.89044383214,4.4408920985e-16,0.886126305321 +us_sector_etfs,cross_sectional_momentum,baseline,1,2011,0.141938223631,0.59570698571,0.130377267384,1.65907936456,-0.320576872001,0.196609316398,2.69492292158,5.25802500505,0.0546149368202,0.999005469915 +us_sector_etfs,cross_sectional_momentum,costed_comparator,1,2011,0.125894343507,0.547788307593,0.114912904345,1.38226580479,-0.367244611855,0.184512699534,0.283623304862,0.441687011013,0.00458887331259,0.999005469915 +us_sector_etfs,cross_sectional_momentum,same_close,1,2011,0.13714696614,0.5719957102,0.125027072915,1.56028699333,-0.315069394928,0.196388161718,2.56101306738,5.11537614577,0.0529717271989,1 +us_sector_etfs,cross_sectional_momentum,vectorized,1,2011,0.143852606617,0.603965586032,0.132438259252,1.69801618846,-0.320537520264,0.197086800449,2.58975634013,5.05420188961,0.0525026112164,0.999005469915 +us_sector_etfs,cross_sectional_momentum,zero_cash,1,2011,0.141949638885,0.595765068842,0.130390171589,1.6593216169,-0.320576872001,0.196609281032,2.69492292158,5.25802500505,0.0546149368202,0.999005469915 +us_sector_etfs,cross_sectional_momentum,zero_cost,1,2011,0.148782064545,0.63045600788,0.13813449914,1.80823695979,-0.319983960716,0.196618818006,2.69302548234,5.26074017403,-3.33066907388e-16,0.999005469915 +us_sector_etfs,learned_gbrt,baseline,5,2011,0.136641265338,0.52883961707,0.120998987924,1.49367665708,-0.399599703247,0.211495721758,6.52502429319,12.6419577421,0.131432404072,0.987379591128 +us_sector_etfs,learned_gbrt,costed_comparator,5,2011,0.126220416139,0.551435112484,0.115406421738,1.39071795401,-0.367244611855,0.183882042593,0.545806882771,0.698822731844,0.00725834613169,0.98742737345 +us_sector_etfs,learned_gbrt,same_close,5,2011,0.111546849206,0.408899356686,0.0930741913515,1.03840569249,-0.403534521312,0.212121281225,6.39798322243,12.5128334043,0.129608147935,0.98839699565 +us_sector_etfs,learned_gbrt,vectorized,5,2011,0.138172978959,0.53229430835,0.122350533497,1.51788757178,-0.404392565279,0.213003577957,6.43262058677,12.4726006962,0.129675693898,0.987369467926 +us_sector_etfs,learned_gbrt,zero_cash,5,2011,0.13643565743,0.527866936079,0.120768497913,1.48957194052,-0.399599703247,0.211495820289,6.52496891523,12.6419346065,0.131431971186,0.987382649989 +us_sector_etfs,learned_gbrt,zero_cost,5,2011,0.153111163809,0.606472921307,0.139591432422,1.84368987071,-0.398655268238,0.211563758172,6.52325079487,12.6536801837,-3.77475828373e-16,0.987379591128 +us_sector_etfs,short_term_reversal,baseline,1,2011,0.110173402373,0.43884135042,0.0954134900584,1.06937596379,-0.450787916028,0.194538299543,8.99246547734,13.79175979,0.143604973988,0.777337165964 +us_sector_etfs,short_term_reversal,costed_comparator,1,2011,0.120049519267,0.573845274105,0.112049248517,1.33387186456,-0.367244611855,0.165908113355,4.25483054101,4.32910146463,0.0449540902905,0.777565735719 +us_sector_etfs,short_term_reversal,same_close,1,2011,0.0354514296434,0.0549947951577,0.0164630131649,0.139178909741,-0.447727724439,0.195023025891,8.86005417733,13.6503447911,0.141415583353,0.776945176622 +us_sector_etfs,short_term_reversal,vectorized,1,2011,0.111411915341,0.442572808931,0.0965180458682,1.08608645684,-0.45292557355,0.195692853405,8.9388363998,13.7006464446,0.14266072121,0.777722526106 +us_sector_etfs,short_term_reversal,zero_cash,1,2011,0.104431386399,0.409322779892,0.0891426950711,0.976707703313,-0.450787916028,0.194545296834,8.99241462283,13.7921341503,0.14360586948,0.777370884203 +us_sector_etfs,short_term_reversal,zero_cost,1,2011,0.128168655205,0.530640776945,0.115248651661,1.38799679725,-0.450294158699,0.194761801746,8.99542386955,13.8053970107,-1.33226762955e-15,0.777337165964 +us_sector_etfs,ts_momentum,baseline,1,2011,0.114431486483,0.493775808201,0.102851763783,1.18420573268,-0.341585504759,0.181474648619,2.25960752924,4.38852457093,0.0456231604509,0.999005469915 +us_sector_etfs,ts_momentum,costed_comparator,1,2011,0.125894343507,0.547788307593,0.114912904345,1.38226580479,-0.367244611855,0.184512699534,0.283623304862,0.441687011013,0.00458887331259,0.999005469915 +us_sector_etfs,ts_momentum,same_close,1,2011,0.120240960597,0.52590895434,0.109285324436,1.28798105323,-0.341558614345,0.181422168291,2.1339314849,4.26232195126,0.0441872006013,1 +us_sector_etfs,ts_momentum,vectorized,1,2011,0.115859038775,0.500129209537,0.10431807389,1.20748823578,-0.341734780699,0.182019380685,2.22217802089,4.31904525112,0.0449055575942,0.999005469915 +us_sector_etfs,ts_momentum,zero_cash,1,2011,0.114442901738,0.493838686597,0.102864353761,1.18440472221,-0.341585504759,0.181474617173,2.25960752924,4.38852457093,0.0456231604509,0.999005469915 +us_sector_etfs,ts_momentum,zero_cost,1,2011,0.120148560792,0.525125529698,0.109164772653,1.2859975651,-0.341567442062,0.181515027177,2.25793870958,4.3905666285,1.11022302463e-15,0.999005469915 diff --git a/paper/expansion/results/generated_values.tex b/paper/expansion/results/generated_values.tex new file mode 100644 index 0000000..025d3da --- /dev/null +++ b/paper/expansion/results/generated_values.tex @@ -0,0 +1,35 @@ +% Generated by scripts/run_expansion_experiments.py; do not edit. +\newcommand{\ExpansionPanelCount}{2} +\newcommand{\ExpansionFamilyCount}{4} +\newcommand{\ExpansionFamilyPanelCount}{8} +\newcommand{\ExpansionPositiveBaselineSharpeCount}{7} +\newcommand{\ExpansionEvaluationSessions}{2,011} +\newcommand{\ExpansionBootstrapReplications}{5,000} +\newcommand{\ExpansionProtocolDigest}{e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c} +\newcommand{\ExpansionSectorInputDigest}{c5947c4ca6ad6d21ad834c8f344dcdd07acc59ba411e3dcb2202a2413642b2f9} +\newcommand{\ExpansionCountryInputDigest}{870fa926b5080378c65bd629b9959bd73b16391dded5720c894c0f35558132a9} +\newcommand{\ExpansionBaselineRows}{U.S. sector ETFs & TS momentum & 11.44\% & 0.49 & 10.29\% & -34.16\% \\ +U.S. sector ETFs & Cross-sectional momentum & 14.19\% & 0.60 & 13.04\% & -32.06\% \\ +U.S. sector ETFs & Short-term reversal & 11.02\% & 0.44 & 9.54\% & -45.08\% \\ +U.S. sector ETFs & Learned GBRT & 13.66\% & 0.53 & 12.10\% & -39.96\% \\ +Country equity ETFs & TS momentum & 2.18\% & -0.02 & 0.88\% & -40.45\% \\ +Country equity ETFs & Cross-sectional momentum & 8.84\% & 0.33 & 7.21\% & -40.73\% \\ +Country equity ETFs & Short-term reversal & 6.03\% & 0.20 & 4.48\% & -43.91\% \\ +Country equity ETFs & Learned GBRT & 7.84\% & 0.31 & 6.56\% & -33.56\% \\} +\newcommand{\ExpansionEffectRows}{Same-close - next-bar & 3/5/0 & 3/5/0 \\ +Zero cash - SHV cash & 5/3/0 & 5/3/0 \\ +Zero cost - 13 bp cost & 0/0/8 & 0/0/8 \\ +Strategy - costed comparator & 1/7/0 & 1/7/0 \\ +Vectorized - event-driven & 0/4/4 & 0/4/4 \\} +\newcommand{\ExpansionCostEffectRange}{0.57--1.81 percentage points} +\newcommand{\ExpansionSameCloseBelowCount}{3} +\newcommand{\ExpansionSameCloseOverlapCount}{5} +\newcommand{\ExpansionZeroCashBelowCount}{5} +\newcommand{\ExpansionZeroCashOverlapCount}{3} +\newcommand{\ExpansionEngineMaxDailyBp}{93.21} +\newcommand{\ExpansionEngineMaxWealthGap}{3.89\%} +\newcommand{\ExpansionSectorSeedRange}{0.47--0.60} +\newcommand{\ExpansionCountrySeedRange}{0.27--0.37} +\newcommand{\ExpansionSameCloseRankChanges}{4} +\newcommand{\ExpansionZeroCostRankChanges}{4} +\newcommand{\ExpansionComparatorRankChanges}{5} diff --git a/paper/expansion/results/learned_fit_diagnostics.csv b/paper/expansion/results/learned_fit_diagnostics.csv new file mode 100644 index 0000000..ce12286 --- /dev/null +++ b/paper/expansion/results/learned_fit_diagnostics.csv @@ -0,0 +1,11 @@ +panel,seed,first_training_rows,minimum_training_rows,maximum_training_rows,minimum_training_months,maximum_training_months +us_sector_etfs,11,315,315,540,35,60 +us_sector_etfs,29,315,315,540,35,60 +us_sector_etfs,47,315,315,540,35,60 +us_sector_etfs,71,315,315,540,35,60 +us_sector_etfs,97,315,315,540,35,60 +country_equity_etfs,11,350,350,600,35,60 +country_equity_etfs,29,350,350,600,35,60 +country_equity_etfs,47,350,350,600,35,60 +country_equity_etfs,71,350,350,600,35,60 +country_equity_etfs,97,350,350,600,35,60 diff --git a/paper/expansion/results/manifest.json b/paper/expansion/results/manifest.json new file mode 100644 index 0000000..49f54be --- /dev/null +++ b/paper/expansion/results/manifest.json @@ -0,0 +1,298 @@ +{ + "artifacts": { + "figures/baseline_performance.pdf": "901c28fa834ea698359205f1439989d89f00487d0b89c2d0b489c7b4b22187cb", + "figures/baseline_performance.png": "d68a87859f6506f9fca32b1b4d83185fa2cbc4cb06bda3ff1fb6fe25c0a7e68d", + "figures/contract_effects_return.pdf": "09d9e90ccc7f18d02eec0792cca6b29dec6362d843c26cc399b4762a3b95c7c9", + "figures/contract_effects_return.png": "afbae305b8e3ce5199b2856855ace6322f7747dae86cf8d0ed932ae6cab6cb31", + "figures/contract_effects_sharpe.pdf": "a3871730f0dd18b1cfebe7907015d8ac877ffe55883a86a4af4c20e59947db9e", + "figures/contract_effects_sharpe.png": "56ec7e16cb328ce246f9d777906a6d83c87826df6b0796867e4a3891dfba0e27", + "figures/engine_conformance.pdf": "8916bd39a5354fb6f46032d434c2d7099a6ab431c963c07bccc476e5f8568073", + "figures/engine_conformance.png": "146199d9c42b55f33ade1eebc5f7c2109e7c58b245d1327f6760022168390ae5", + "figures/learned_seed_sensitivity.pdf": "3a593434c6ca6bcbd837b5aa87ae7f0270435f86ec5fbdb8cc439d34c66d339d", + "figures/learned_seed_sensitivity.png": "c48ca49e3dc2aa64253a39accf2f4f1ff8960ba4dab01ee82f995de65dee2834", + "results/contract_effects.csv": "0036ea95cebf29b943387a8eb2114325be3b08f97f5bc2a29821f05523f5fb07", + "results/data_provenance.json": "8552d5930dd3d4c254995830f3eecb1fd30c4c245d2929813fae84bff8ef7c72", + "results/engine_conformance.csv": "41c29cf466ce5a096aa6d1004e90f80b1f1dec44e2ebe37cf7be460224341b33", + "results/family_summary.csv": "8a2bb140a5d78d5d95a9dc48e77611e0435255117f65499b8e44a7e3bf7ee708", + "results/generated_values.tex": "2dbaa11bfdd9a1936b45114f61bd96c53e3d57eefe103a75c488352486c0e2f9", + "results/learned_fit_diagnostics.csv": "64782a7a2d3f9f180ee9e44456dd1eeee6579c8b7402266406e5b967a1d03cc3", + "results/rank_reversals.csv": "62dde67618e60aed205f2aa24b83492aa0caa4fd7484fabe39181ec18d8e4695", + "results/seed_variant_metrics.csv": "d3b33b8b156ac39a8cf400f2406df3caacaed882024433756896a7d8f3b6a1ab", + "results/target_tape_hashes.json": "a0b1f048eade7cc0bdabdbbae02d80732a6be03dbade8321a21b15d297a4acf3" + }, + "counts": { + "contract_effect_rows": 120, + "panels": 2, + "seed_variant_rows": 96, + "strategy_families": 4, + "target_tapes": 16 + }, + "data": [ + { + "complete_rows": 3018, + "dropped_incomplete_rows": 0, + "evaluation_sessions": 2011, + "first_date": "2014-01-02", + "input_sha256": "c5947c4ca6ad6d21ad834c8f344dcdd07acc59ba411e3dcb2202a2413642b2f9", + "last_date": "2025-12-31", + "missing_by_symbol": { + "SHV": 0, + "XLB": 0, + "XLE": 0, + "XLF": 0, + "XLI": 0, + "XLK": 0, + "XLP": 0, + "XLU": 0, + "XLV": 0, + "XLY": 0 + }, + "panel": "us_sector_etfs", + "pre_evaluation_months": 48, + "pre_evaluation_sessions": 1007, + "protocol_path": "paper/expansion/protocol.json", + "protocol_sha256": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "provider": "Yahoo Finance via yfinance", + "provider_rows": 3018, + "provider_terms_independently_verified": false, + "raw_data_committed": false, + "request": { + "actions": false, + "auto_adjust": false, + "repair": false, + "request_end_exclusive": "2026-01-01", + "request_start": "2014-01-02", + "selected_field": "Adj Close", + "threads": false + }, + "retrieved_at": "2026-06-18T22:37:51Z", + "schema_version": 1, + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY", + "SHV" + ], + "terms_urls": [ + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html" + ], + "yfinance_version": "1.4.1" + }, + { + "complete_rows": 3018, + "dropped_incomplete_rows": 0, + "evaluation_sessions": 2011, + "first_date": "2014-01-02", + "input_sha256": "870fa926b5080378c65bd629b9959bd73b16391dded5720c894c0f35558132a9", + "last_date": "2025-12-31", + "missing_by_symbol": { + "EWA": 0, + "EWC": 0, + "EWG": 0, + "EWH": 0, + "EWJ": 0, + "EWL": 0, + "EWP": 0, + "EWQ": 0, + "EWS": 0, + "EWU": 0, + "SHV": 0 + }, + "panel": "country_equity_etfs", + "pre_evaluation_months": 48, + "pre_evaluation_sessions": 1007, + "protocol_path": "paper/expansion/protocol.json", + "protocol_sha256": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "provider": "Yahoo Finance via yfinance", + "provider_rows": 3018, + "provider_terms_independently_verified": false, + "raw_data_committed": false, + "request": { + "actions": false, + "auto_adjust": false, + "repair": false, + "request_end_exclusive": "2026-01-01", + "request_start": "2014-01-02", + "selected_field": "Adj Close", + "threads": false + }, + "retrieved_at": "2026-06-18T22:37:51Z", + "schema_version": 1, + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU", + "SHV" + ], + "terms_urls": [ + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html" + ], + "yfinance_version": "1.4.1" + } + ], + "environment": { + "matplotlib": "3.11.0", + "numpy": "2.4.6", + "pandas": "3.0.3", + "platform": "macOS-26.4-arm64-arm-64bit-Mach-O", + "python": "3.14.4", + "scikit_learn": "1.9.0", + "threadpoolctl": "3.6.0", + "threadpools": [ + { + "internal_api": "openmp", + "num_threads": 8, + "prefix": "libomp", + "user_api": "openmp", + "version": null + } + ] + }, + "generated_at": "2026-06-18T23:53:33Z", + "git": { + "source_commit": "e0443b8f77cd23aee8f1fa64a2bc237e47626c47", + "tracked_worktree_clean_at_start": true + }, + "protocol": { + "freeze_commit": "4018f4063f46889f41d6981db5a71079e1dbd713", + "path": "paper/expansion/protocol.json", + "sha256": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "status": "repository_frozen_prospective_not_externally_registered" + }, + "schema_version": 1, + "source_tree": { + "file_count": 114, + "files": { + "paper/expansion/protocol.json": "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c", + "paper/preregistration.md": "06f4407daed7bcd594e00bbf2751e7bd32c5d00eae530b2a6b2f66625b864162", + "poetry.lock": "c0fd02871263d959522bb3d3d4717cffa1b89bfa047f584c68b81c7ad7cbbb5b", + "pyproject.toml": "eaeeb454c28bf7f6d9e530002bb7e88624b56b6c3e1fcb71e6414045cb9c42a0", + "quantcortex/__init__.py": "14bf1ebdacd054c3738e4704d33da6709a39206463df8b8ced5376da342c4036", + "quantcortex/alpha/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/factors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/factors/classical/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/factors/classical/_cross_section.py": "6288960a3801e9bcb15c2d9d90ee09b7ab7a6d110468b9af3e4fbf0bc2105ddc", + "quantcortex/alpha/factors/classical/_fundamentals.py": "6d3176904fb2e3f1ae8b3f02aa62e911a8bead70672185d1cf310478877f466c", + "quantcortex/alpha/factors/classical/low_vol.py": "c851dbbde448eb57b1d4b03bdedd605c0aa757334e6d4300e08ec3a904de6ffd", + "quantcortex/alpha/factors/classical/momentum.py": "87c73fa5726ed213ee0ba777cc2d13e88ace39ae8f7a5dbf2e802864976f2bd6", + "quantcortex/alpha/factors/classical/quality.py": "cf380e4279cb3ff44012c586b25a8acb2e70d9c45f536999f7d295aea582d4c2", + "quantcortex/alpha/factors/classical/value.py": "55bf8b6cfe1c5ee4682018898dfdeb6d1e76431775759846949ada561719e3b4", + "quantcortex/alpha/factors/ml/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/factors/ml/gbdt_factor.py": "72ad0019df2c2fb01753b537c539107dda294e04b6423aa2a30993a641eb6517", + "quantcortex/alpha/factors/ml/neural_factor.py": "52f106e5e2fac20c3c042425e0909bc7afd66fed6b720ffb1e933fae3b7bfcc0", + "quantcortex/alpha/factors/nlp/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/factors/nlp/finbert_sentiment.py": "3e96f7fbdb2828742b7ca5c1e8200740cb29fda9d3b288b20d31875d33db051d", + "quantcortex/alpha/factors/nlp/news_scorer.py": "6bdb4d9719106ef615d15739ea3ae22c22a1e009fba4ff575bcd3c37ae8c4864", + "quantcortex/alpha/feature_engineering/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/feature_engineering/alpha158.py": "764f25a8c4bc8b17abe394cc7ddeb5c9ead35105d57b5c712ed3ad447cfac63e", + "quantcortex/alpha/feature_engineering/macro_features.py": "95e0e6b256245fcd8a3118fc9b33f622e7f518fa4241290f260aee0d9e28551f", + "quantcortex/alpha/validation/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/alpha/validation/alphalens_report.py": "8463289117add78a576e37a5b99a539be5c808fca86100e26deb29e8060aa60d", + "quantcortex/alpha/validation/factor_decay.py": "9e6e049165f014db2122d9ed57415e45e48142b475dfb7221247d447d9a50397", + "quantcortex/backtest/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/conformance.py": "22f9b18101f0d9adcae98297e395c1ab6a90b18e411f24e7456f1d166b7df9b5", + "quantcortex/backtest/costs/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/costs/transaction_costs.py": "48200419f37004c58df6f89041e31516d9ad98f66c22f75d11a0b9342474d5c7", + "quantcortex/backtest/engines/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/engines/cash.py": "878c84ba05f715cd3586f4a7016552776108e355de415be94a4a95d01f7e802b", + "quantcortex/backtest/engines/event_driven.py": "8a4d3b8c47fca30477035e92bfbe6c56dbb21ccf24cb1afcc365e11319ad24c5", + "quantcortex/backtest/engines/vectorized.py": "37521c25a3cfbd54b0ccf99122773671ae05381fca0bdddd7f35d66cb9e43737", + "quantcortex/backtest/engines/walk_forward.py": "4e1fe771434e02adb0c33b5aae3cfd4a4ac125ec382808510b5cbf84add64d75", + "quantcortex/backtest/execution_models/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/execution_models/ideal_fill.py": "760e2a7dd145784204d0881bffea1a312db8150aa4f27e5fd60f9e174b46d95f", + "quantcortex/backtest/execution_models/market_impact.py": "1978e447260bf69757d733e747b1034046d3568dc0f5f84e84ace092c6743c32", + "quantcortex/backtest/execution_models/vwap_fill.py": "cb0c49474058b69c3190eb6ffc9d28b23cdff0267ca025bf2c8d453ad36a6090", + "quantcortex/backtest/metrics/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/metrics/plotting.py": "edd7efb4c36300998db75fd418575ee97f4bd8cc670f1421e1fe9104d7c9ea8b", + "quantcortex/backtest/metrics/tearsheet.py": "0815563560696e3faca85533e940c8a947eccb956bb2fdee7312c50bdb01fdff", + "quantcortex/backtest/validation/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/backtest/validation/deflated_sharpe.py": "f0fb5fd81277c985225f66e3817af54eb668cc96b32afc505b6bbdf034b5bc0e", + "quantcortex/backtest/validation/lookahead_audit.py": "670f36817a672e2ff4d762a73119eeed09950cd1879ba2537a707a3ede6a6b11", + "quantcortex/backtest/validation/multiple_testing.py": "6b68f6f150dcc8fb90213dcf769307454c0c506e99666a533d5d07ae3ccb0b5d", + "quantcortex/backtest/validation/survivorship_check.py": "5b772bd6b40ffe44f590ec0ddb00b7e3dc61a1aba9cae8831227325f4f53beff", + "quantcortex/data/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/data/local_csv.py": "6e5f3d24b1477ce122a1e80899e841fffd6d857034189f7d8605705f52c6839c", + "quantcortex/data/processors/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/data/processors/adjustments.py": "ae2d4d0362e3484b89b5a5c57a7fd6320973b208ee0cd5a6be9a3f29d2544e2c", + "quantcortex/data/processors/calendar.py": "e591bccc3e7f632501b068e7abec1ece182d0ef930589381776fe89158d1df51", + "quantcortex/data/processors/lookahead_detector.py": "9979ac8db2e6b860c2a4d8e8d2e59ea946f65451a10f4b7e9f8af71c7b0fd4a0", + "quantcortex/data/processors/pit_enforcer.py": "8adada8cb485f049323d4f2fdf47ed0678ddf746cc71996e078c61bc0e159507", + "quantcortex/data/providers/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/data/providers/alpaca_provider.py": "02c1bc239c31c1577663f9316f8c98e034988405ea94b25054036a4d864ce97f", + "quantcortex/data/providers/base.py": "0890637fec612f69c7632a764dd0808a7f457ddd9010060327f041d1d4eef072", + "quantcortex/data/providers/ccxt_provider.py": "e362a767512ccbc980e738c471cddd4744fe3e9cc02b8c9ea63e1b4ac7b2ad3d", + "quantcortex/data/providers/fmp_provider.py": "f1b2a5b5ca6ff440c12c00b5aa143463cdb8840d4359a8482b6f18a97a881039", + "quantcortex/data/providers/fred_provider.py": "82f1c174e811c282a18d564fd409c85092fb6c832dda1394f1900fe3a53ec468", + "quantcortex/data/providers/polygon_provider.py": "6d39895ca7de2b83ef2dc2a249134ca46c339c6b3ea8dfae9d0b454ed2bc62bf", + "quantcortex/data/providers/yfinance_provider.py": "2d5d0d5d9d19a850cd66038acf17b8ac522115d5c9341c3417f8a2db442ed256", + "quantcortex/data/storage/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/data/storage/parquet_store.py": "a724eeaf865ef2708f124d9016917d4e6b83b31f71239709fe52096a128cdfd5", + "quantcortex/data/storage/redis_cache.py": "2643b09a813a2c11f21690f2cfac19a9dfee246f7df13676aa17695b984fab56", + "quantcortex/data/storage/timescale_store.py": "c5ba85d3b4a133a2568eff5c807535184732b40bba11ca11fb84b888da450aef", + "quantcortex/data/universe/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/data/universe/base.py": "4d43a1b35ea923a6d289e073f8be22d01e97bfb6e69446e07af3ad965308033f", + "quantcortex/data/universe/nasdaq100_universe.py": "b3916b13948aa8c516ee24be07713847b9f441f2193deeffc5169df71b52672e", + "quantcortex/data/universe/sp500_universe.py": "99debed68bb82331483d174dc92617c3106a5c27c9b21d32e71c14a1bc7b3ff1", + "quantcortex/data/universe/sp500_wikipedia.py": "66180a77bd70edb7d047fb787c2a863050105c0827d0d0dbe733e8525470f734", + "quantcortex/execution/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/execution/brokers/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/execution/brokers/alpaca_broker.py": "25f1c69d6791d9a21dbb11684ab1ee2e38f54f1a62a797cb8dad102a6964f864", + "quantcortex/execution/brokers/base.py": "691f4e26555e5581e41e53e3001b814616ddf92397926e29c69ef04ea4e6650f", + "quantcortex/execution/brokers/ccxt_broker.py": "cb7fd56ddfba4428da9e3a5147f506c0f6ea4038fcf569ff65a61748fb9ddd03", + "quantcortex/execution/brokers/ib_broker.py": "d7a76993ee54e95b312431eaff18745178db8a27d766f50b1ba0198ca395651c", + "quantcortex/execution/order_manager.py": "e5f2868c01bc49e9ddcd1e5f529f31f7c9b6a0684ba20246fc6bb16396b58ee9", + "quantcortex/execution/position_manager.py": "e20af146a362f8192966caaa716cf1d48df870f4413be83d453d6f56f5df24b1", + "quantcortex/execution/pre_trade_risk.py": "d23fef346531c6019bf2c8574840e23372c14d1796ff2bbf6f97ac5c8b7cc9e4", + "quantcortex/execution/state_persistence.py": "de8bc1d210de62eebdd1a027cb5a6d38e57850bd632ad730f7a5d20980a43393", + "quantcortex/portfolio/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/portfolio/base.py": "d11cdd7301b33e169c624653c1fbde7b132c394d6a6bf269b73e85fefd1bcf6f", + "quantcortex/portfolio/black_litterman.py": "760d99295bac31b1edafa28b50301eff97c442bd489f2e86df3e6f1833fed3fe", + "quantcortex/portfolio/drl_allocator.py": "3d2cadc8b249213ac36165f3973c4e2ab80f62b5ae6189cf93c40dcad4e237e0", + "quantcortex/portfolio/equal_weight.py": "f4c2c84ad3d86bc9f24d24123f2c2db4d255750e452beaf7ac5a55794a472fd2", + "quantcortex/portfolio/hrp.py": "20a0b28744d71d28a0f0f4e872d550e71507850da5ec41209016ca71b025ea35", + "quantcortex/portfolio/mean_variance.py": "235a8917e107ab6274ee68891a489a130ae61fe36d359dc43e049d256453d80d", + "quantcortex/portfolio/minimum_variance.py": "253a9d80fe6dc6e4c95219b76a8965230b408093d1521a72587505ae0ddfc739", + "quantcortex/portfolio/risk_parity.py": "ed371437655ae980fabfc4ae450ca2100d90f304d9ce225c306f904ce1e32e91", + "quantcortex/research/__init__.py": "46e11b28979db5adfbe08f946e59b8e858de7d2a1feca714be5b0bd7cce1c32f", + "quantcortex/research/expansion.py": "a4cde375dcd650a1bd367d07da11e20e5b770a34496ccf3c82ba8e0c7467ed2a", + "quantcortex/risk/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/risk/circuit_breaker.py": "65cf205e7303cff5ce4785a64fadc6b29ba8b85e31913e001330fb084e502ff1", + "quantcortex/risk/factor_exposure.py": "5503426c590338b57512adf409e873751929464459d464f2b6940db8cadbf0ad", + "quantcortex/risk/kelly.py": "18138e2be1734792c1942e19d6f483dce20f573a4ee67006e094482200bcd839", + "quantcortex/risk/var_cvar.py": "ed57b06b2df061d48814779974a36c926f6dfa24f58e90666cbf6b5f94fdaab1", + "quantcortex/risk/vol_targeting.py": "f71477acb3ce07578444d43ab851759ad2301185f3fc3672004eb1cae9debbdd", + "quantcortex/strategies/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/strategies/base_strategy.py": "31367d5652f6f73a433e41c2b1b752f9654caa420dafa45bd64e693836cbbb90", + "quantcortex/strategies/drl_portfolio.py": "7fdcbc0d1439fdd5d81783ffef6870f90749c3a0e0c3f01782fb5c18203a0127", + "quantcortex/strategies/macro_timing.py": "15977b8958ebb578d96d0f7f72ff9c28a872be4f6b8b667e88f43ffb45ffbbb5", + "quantcortex/strategies/momentum_ml.py": "1f3b14446b8d0441e469083150067eeb4de27a91b30f02d60e5787dd61550b11", + "quantcortex/strategies/multi_asset_rotation.py": "0ad337441ad0fdb845d2b886cf3c3b3f2abac99d596158c7b8826f36afff2ccb", + "quantcortex/strategies/sentiment_nlp.py": "acb4ea692d5707f84146d9045fb9325e8e35db451c30c1c0c21c5f9b1ff93a03", + "quantcortex/timing/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "quantcortex/timing/hmm_regime.py": "f8f6f03184e3f6d191ba8011fb201b99527ad07a66e32a4b9e179b4de45633db", + "quantcortex/timing/kama.py": "ffb848ba93041af1e04f2b7896ea7a8f33ac7e5e3e53db1e44f53af111829eda", + "quantcortex/timing/tsmom.py": "c78ac313268091f45e0e589067f12e07e8a340fa243b8fae2853778ef4ba8ea9", + "quantcortex/timing/vix_scaler.py": "a3667424e5573fb289e63c26c69da6a68d6c943742359f0466d29b25c56e3686", + "schemas/canonical_target_tape.schema.json": "4f1c0bf6d5360305d2982adea78de3f61c4bc1ebae9207cb2ba2bd4379b43d44", + "scripts/fetch_expansion_data.py": "678b5c7fcc1b89e333fc5298b1fdaeb8994de713bc7b2b5ed461e1ec1eb94403", + "scripts/release_expansion_artifacts.sh": "6d5da5f2a3c744d248f388249a0e956ec12c4f99995c3872238bdd3bf4472ae5", + "scripts/run_expansion_experiments.py": "df9932dc67a1e1151faebc5dfd742f0aa622dfcf8093ce5ef9c55d9be4fbaf59" + }, + "sha256": "91c1957542d9397c0478985389ba2236d94eaa78f81c31c517c298e92fcd733d" + } +} diff --git a/paper/expansion/results/rank_reversals.csv b/paper/expansion/results/rank_reversals.csv new file mode 100644 index 0000000..110a05d --- /dev/null +++ b/paper/expansion/results/rank_reversals.csv @@ -0,0 +1,49 @@ +panel,variant,strategy,baseline_rank,variant_rank,rank_change +country_equity_etfs,baseline,cross_sectional_momentum,1,1,0 +country_equity_etfs,baseline,learned_gbrt,2,2,0 +country_equity_etfs,baseline,short_term_reversal,3,3,0 +country_equity_etfs,baseline,ts_momentum,4,4,0 +country_equity_etfs,same_close,cross_sectional_momentum,1,1,0 +country_equity_etfs,same_close,learned_gbrt,2,2,0 +country_equity_etfs,same_close,short_term_reversal,3,4,-1 +country_equity_etfs,same_close,ts_momentum,4,3,1 +country_equity_etfs,zero_cash,cross_sectional_momentum,1,1,0 +country_equity_etfs,zero_cash,learned_gbrt,2,2,0 +country_equity_etfs,zero_cash,short_term_reversal,3,3,0 +country_equity_etfs,zero_cash,ts_momentum,4,4,0 +country_equity_etfs,zero_cost,cross_sectional_momentum,1,2,-1 +country_equity_etfs,zero_cost,learned_gbrt,2,1,1 +country_equity_etfs,zero_cost,short_term_reversal,3,3,0 +country_equity_etfs,zero_cost,ts_momentum,4,4,0 +country_equity_etfs,costed_comparator,cross_sectional_momentum,1,1,0 +country_equity_etfs,costed_comparator,learned_gbrt,2,3,-1 +country_equity_etfs,costed_comparator,short_term_reversal,3,2,1 +country_equity_etfs,costed_comparator,ts_momentum,4,4,0 +country_equity_etfs,vectorized,cross_sectional_momentum,1,1,0 +country_equity_etfs,vectorized,learned_gbrt,2,2,0 +country_equity_etfs,vectorized,short_term_reversal,3,3,0 +country_equity_etfs,vectorized,ts_momentum,4,4,0 +us_sector_etfs,baseline,cross_sectional_momentum,1,1,0 +us_sector_etfs,baseline,learned_gbrt,2,2,0 +us_sector_etfs,baseline,short_term_reversal,4,4,0 +us_sector_etfs,baseline,ts_momentum,3,3,0 +us_sector_etfs,same_close,cross_sectional_momentum,1,1,0 +us_sector_etfs,same_close,learned_gbrt,2,3,-1 +us_sector_etfs,same_close,short_term_reversal,4,4,0 +us_sector_etfs,same_close,ts_momentum,3,2,1 +us_sector_etfs,zero_cash,cross_sectional_momentum,1,1,0 +us_sector_etfs,zero_cash,learned_gbrt,2,2,0 +us_sector_etfs,zero_cash,short_term_reversal,4,4,0 +us_sector_etfs,zero_cash,ts_momentum,3,3,0 +us_sector_etfs,zero_cost,cross_sectional_momentum,1,1,0 +us_sector_etfs,zero_cost,learned_gbrt,2,2,0 +us_sector_etfs,zero_cost,short_term_reversal,4,3,1 +us_sector_etfs,zero_cost,ts_momentum,3,4,-1 +us_sector_etfs,costed_comparator,cross_sectional_momentum,1,3,-2 +us_sector_etfs,costed_comparator,learned_gbrt,2,2,0 +us_sector_etfs,costed_comparator,short_term_reversal,4,1,3 +us_sector_etfs,costed_comparator,ts_momentum,3,4,-1 +us_sector_etfs,vectorized,cross_sectional_momentum,1,1,0 +us_sector_etfs,vectorized,learned_gbrt,2,2,0 +us_sector_etfs,vectorized,short_term_reversal,4,4,0 +us_sector_etfs,vectorized,ts_momentum,3,3,0 diff --git a/paper/expansion/results/seed_variant_metrics.csv b/paper/expansion/results/seed_variant_metrics.csv new file mode 100644 index 0000000..451175a --- /dev/null +++ b/paper/expansion/results/seed_variant_metrics.csv @@ -0,0 +1,97 @@ +panel,strategy,seed,variant,observations,annualized_arithmetic_return,cash_excess_sharpe,cagr,total_return,max_drawdown,annualized_volatility,annualized_one_way_turnover,annualized_gross_traded_notional,arithmetic_cost_drag,mean_risky_exposure +us_sector_etfs,ts_momentum,deterministic,baseline,2011,0.114431486483,0.493775808201,0.102851763783,1.18420573268,-0.341585504759,0.181474648619,2.25960752924,4.38852457093,0.0456231604509,0.999005469915 +us_sector_etfs,ts_momentum,deterministic,same_close,2011,0.120240960597,0.52590895434,0.109285324436,1.28798105323,-0.341558614345,0.181422168291,2.1339314849,4.26232195126,0.0441872006013,1 +us_sector_etfs,ts_momentum,deterministic,zero_cash,2011,0.114442901738,0.493838686597,0.102864353761,1.18440472221,-0.341585504759,0.181474617173,2.25960752924,4.38852457093,0.0456231604509,0.999005469915 +us_sector_etfs,ts_momentum,deterministic,zero_cost,2011,0.120148560792,0.525125529698,0.109164772653,1.2859975651,-0.341567442062,0.181515027177,2.25793870958,4.3905666285,1.11022302463e-15,0.999005469915 +us_sector_etfs,ts_momentum,deterministic,costed_comparator,2011,0.125894343507,0.547788307593,0.114912904345,1.38226580479,-0.367244611855,0.184512699534,0.283623304862,0.441687011013,0.00458887331259,0.999005469915 +us_sector_etfs,ts_momentum,deterministic,vectorized,2011,0.115859038775,0.500129209537,0.10431807389,1.20748823578,-0.341734780699,0.182019380685,2.22217802089,4.31904525112,0.0449055575942,0.999005469915 +us_sector_etfs,cross_sectional_momentum,deterministic,baseline,2011,0.141938223631,0.59570698571,0.130377267384,1.65907936456,-0.320576872001,0.196609316398,2.69492292158,5.25802500505,0.0546149368202,0.999005469915 +us_sector_etfs,cross_sectional_momentum,deterministic,same_close,2011,0.13714696614,0.5719957102,0.125027072915,1.56028699333,-0.315069394928,0.196388161718,2.56101306738,5.11537614577,0.0529717271989,1 +us_sector_etfs,cross_sectional_momentum,deterministic,zero_cash,2011,0.141949638885,0.595765068842,0.130390171589,1.6593216169,-0.320576872001,0.196609281032,2.69492292158,5.25802500505,0.0546149368202,0.999005469915 +us_sector_etfs,cross_sectional_momentum,deterministic,zero_cost,2011,0.148782064545,0.63045600788,0.13813449914,1.80823695979,-0.319983960716,0.196618818006,2.69302548234,5.26074017403,-3.33066907388e-16,0.999005469915 +us_sector_etfs,cross_sectional_momentum,deterministic,costed_comparator,2011,0.125894343507,0.547788307593,0.114912904345,1.38226580479,-0.367244611855,0.184512699534,0.283623304862,0.441687011013,0.00458887331259,0.999005469915 +us_sector_etfs,cross_sectional_momentum,deterministic,vectorized,2011,0.143852606617,0.603965586032,0.132438259252,1.69801618846,-0.320537520264,0.197086800449,2.58975634013,5.05420188961,0.0525026112164,0.999005469915 +us_sector_etfs,short_term_reversal,deterministic,baseline,2011,0.110173402373,0.43884135042,0.0954134900584,1.06937596379,-0.450787916028,0.194538299543,8.99246547734,13.79175979,0.143604973988,0.777337165964 +us_sector_etfs,short_term_reversal,deterministic,same_close,2011,0.0354514296434,0.0549947951577,0.0164630131649,0.139178909741,-0.447727724439,0.195023025891,8.86005417733,13.6503447911,0.141415583353,0.776945176622 +us_sector_etfs,short_term_reversal,deterministic,zero_cash,2011,0.104431386399,0.409322779892,0.0891426950711,0.976707703313,-0.450787916028,0.194545296834,8.99241462283,13.7921341503,0.14360586948,0.777370884203 +us_sector_etfs,short_term_reversal,deterministic,zero_cost,2011,0.128168655205,0.530640776945,0.115248651661,1.38799679725,-0.450294158699,0.194761801746,8.99542386955,13.8053970107,-1.33226762955e-15,0.777337165964 +us_sector_etfs,short_term_reversal,deterministic,costed_comparator,2011,0.120049519267,0.573845274105,0.112049248517,1.33387186456,-0.367244611855,0.165908113355,4.25483054101,4.32910146463,0.0449540902905,0.777565735719 +us_sector_etfs,short_term_reversal,deterministic,vectorized,2011,0.111411915341,0.442572808931,0.0965180458682,1.08608645684,-0.45292557355,0.195692853405,8.9388363998,13.7006464446,0.14266072121,0.777722526106 +us_sector_etfs,learned_gbrt,11,baseline,2011,0.139658073418,0.544593089625,0.124499436922,1.55072032249,-0.390424564646,0.210866272798,6.32488513353,12.2589337315,0.127468461213,0.98810907632 +us_sector_etfs,learned_gbrt,11,same_close,2011,0.117534458972,0.438307568767,0.0997377417236,1.13547164687,-0.396329072723,0.211560362311,6.19965992443,12.1330939241,0.125660799463,0.989128560093 +us_sector_etfs,learned_gbrt,11,zero_cash,2011,0.139498917298,0.543838971875,0.12432065203,1.54748583877,-0.390424564646,0.21086597981,6.32482945145,12.2588789514,0.127467759094,0.988110904087 +us_sector_etfs,learned_gbrt,11,zero_cost,2011,0.155631247075,0.620046184969,0.14257861322,1.89694461252,-0.38936659186,0.210944141949,6.32297736697,12.2701935076,3.33066907388e-16,0.98810907632 +us_sector_etfs,learned_gbrt,11,costed_comparator,2011,0.126187411953,0.551968359322,0.115418162602,1.3908948193,-0.367244611855,0.183645775194,0.529136427368,0.682193658159,0.00708772001098,0.988129195395 +us_sector_etfs,learned_gbrt,11,vectorized,2011,0.141212132355,0.547949448187,0.125873,1.57569016113,-0.39527976169,0.212404441946,6.22376926902,12.0716061661,0.125523794653,0.988065638986 +us_sector_etfs,learned_gbrt,29,baseline,2011,0.138245403225,0.526574878216,0.121819170047,1.50260509299,-0.404401418236,0.215419861991,6.31689408721,12.1597759,0.126428770173,0.984596108311 +us_sector_etfs,learned_gbrt,29,same_close,2011,0.118060656713,0.431778910373,0.0992689227926,1.12821768506,-0.410316961044,0.215991091816,6.19110196478,12.0328605711,0.124598926961,0.985610379512 +us_sector_etfs,learned_gbrt,29,zero_cash,2011,0.138003356852,0.525448118665,0.121547514882,1.49777303714,-0.404401418236,0.215421124159,6.31683833348,12.1597762344,0.126428509411,0.984599917242 +us_sector_etfs,learned_gbrt,29,zero_cost,2011,0.154088292377,0.599864460732,0.139708581093,1.83938107882,-0.403367703532,0.215489976486,6.31526836657,12.1708747656,1.11022302463e-16,0.984596108311 +us_sector_etfs,learned_gbrt,29,costed_comparator,2011,0.125725869864,0.549653467337,0.114917051652,1.38233652324,-0.367244611855,0.183580719327,0.609863793609,0.760364348043,0.00789753891187,0.984674282502 +us_sector_etfs,learned_gbrt,29,vectorized,2011,0.139112680261,0.526999042062,0.122427298904,1.51345181174,-0.409072160107,0.216887888597,6.22376926902,11.988065639,0.124648716668,0.98458478369 +us_sector_etfs,learned_gbrt,47,baseline,2011,0.129148953492,0.501019435313,0.113345737502,1.35567409736,-0.39885625795,0.20825920055,6.52700302657,12.6617536682,0.131646029271,0.988169156328 +us_sector_etfs,learned_gbrt,47,same_close,2011,0.102330160024,0.371308580607,0.0837573905531,0.90004322149,-0.400440554477,0.208842711248,6.39821839638,12.5297200958,0.129772093427,0.989187235525 +us_sector_etfs,learned_gbrt,47,zero_cash,2011,0.128909429356,0.499867282675,0.113079029565,1.35117454254,-0.39885625795,0.208260064474,6.52694812565,12.6617539534,0.131645795082,0.988173027259 +us_sector_etfs,learned_gbrt,47,zero_cost,2011,0.145645621506,0.579949049958,0.131838763529,1.68663925177,-0.397812919123,0.208336473955,6.52518708158,12.6732610812,1.11022302463e-16,0.988169156328 +us_sector_etfs,learned_gbrt,47,costed_comparator,2011,0.126702991767,0.55286377682,0.115862125396,1.39849955663,-0.367244611855,0.184281027288,0.53031743692,0.68428537451,0.00710384491646,0.988269696823 +us_sector_etfs,learned_gbrt,47,vectorized,2011,0.131521529366,0.508586185949,0.115619568661,1.39434213073,-0.403823529746,0.209817935242,6.43262058677,12.4893088016,0.129855864751,0.988231394 +us_sector_etfs,learned_gbrt,71,baseline,2011,0.150885768846,0.599706774501,0.137338711615,1.79260587187,-0.405204247022,0.210209062482,6.85274959115,13.231436572,0.137589672589,0.984341490344 +us_sector_etfs,learned_gbrt,71,same_close,2011,0.119953347279,0.451090576632,0.102536634675,1.17923015651,-0.405995400653,0.210943863193,6.72291304862,13.0962670368,0.135651458432,0.985353387013 +us_sector_etfs,learned_gbrt,71,zero_cash,2011,0.15065508982,0.598611125387,0.137076688245,1.78747581468,-0.405204247022,0.210208446312,6.85269386737,13.2314298841,0.137589380776,0.984345387254 +us_sector_etfs,learned_gbrt,71,zero_cost,2011,0.168127239504,0.68139787577,0.157088580397,2.20387516199,-0.404659993212,0.210289986428,6.85112303747,13.243887661,-1.66533453694e-15,0.984341490344 +us_sector_etfs,learned_gbrt,71,costed_comparator,2011,0.125321273255,0.547520436423,0.114471107919,1.37474295415,-0.367244611855,0.183556864853,0.610126685282,0.761155593883,0.00790860080359,0.984339082811 +us_sector_etfs,learned_gbrt,71,vectorized,2011,0.152698531896,0.603915250918,0.139025493752,1.825828894,-0.410143250765,0.211739882122,6.76678269518,13.0740924913,0.135960049498,0.984253273662 +us_sector_etfs,learned_gbrt,97,baseline,2011,0.125268127708,0.472303907697,0.107991883533,1.26677790068,-0.399112028379,0.212724210968,6.6035896275,12.8978888386,0.134029087112,0.991682124337 +us_sector_etfs,learned_gbrt,97,same_close,2011,0.0998556230429,0.352011147052,0.0800702670139,0.849065752525,-0.404590617661,0.213268377556,6.47802277793,12.7722253934,0.132357461394,0.992705416106 +us_sector_etfs,learned_gbrt,97,zero_cash,2011,0.125111493826,0.471569181791,0.107818604841,1.26395046947,-0.399112028379,0.212723486692,6.60353479822,12.8978340093,0.134028411567,0.991684014104 +us_sector_etfs,learned_gbrt,97,zero_cost,2011,0.142063418585,0.551107035104,0.126742623871,1.59160924844,-0.398069133465,0.212758212044,6.60169812173,12.9101839027,-7.77156117238e-16,0.991682124337 +us_sector_etfs,learned_gbrt,97,costed_comparator,2011,0.127164533855,0.555169522515,0.116363661119,1.40711591671,-0.367244611855,0.184345826304,0.449590070679,0.606114684627,0.00629402601556,0.991724609717 +us_sector_etfs,learned_gbrt,97,vectorized,2011,0.126320020918,0.474021614634,0.10880730617,1.28012486128,-0.403644124087,0.214167741877,6.51616111387,12.7399303829,0.132390043918,0.991712249296 +country_equity_etfs,ts_momentum,deterministic,baseline,2011,0.0218219607389,-0.0179402804522,0.0087810501862,0.0722597111498,-0.40452457504,0.1609767922,3.00974169007,4.88761091148,0.0508788320726,0.886126305321 +country_equity_etfs,ts_momentum,deterministic,same_close,2011,0.031828630628,0.0444572049397,0.0191101568132,0.163070182458,-0.408038668315,0.159872844648,2.88231502772,4.75791160149,0.049180725028,0.887120835405 +country_equity_etfs,ts_momentum,deterministic,zero_cash,2011,0.0193984071318,-0.0329773473651,0.00633962460419,0.0517248393511,-0.409381244624,0.16097524938,3.00974169007,4.88761091148,0.0508783590056,0.886126305321 +country_equity_etfs,ts_momentum,deterministic,zero_cost,2011,0.0281976274133,0.0216042958934,0.0152146936152,0.128062187209,-0.394916581953,0.161091681751,3.009120474,4.89044383214,4.4408920985e-16,0.886126305321 +country_equity_etfs,ts_momentum,deterministic,costed_comparator,2011,0.0492597993909,0.149171044902,0.0362575768899,0.328724225217,-0.387842521358,0.164368386949,1.21844018235,1.30965923322,0.0136240817852,0.886126305321 +country_equity_etfs,ts_momentum,deterministic,vectorized,2011,0.0226688647963,-0.0126423520619,0.00954668171353,0.0787712706126,-0.406006624026,0.161525330322,2.99129786176,4.85479860766,0.0505411305453,0.886126305321 +country_equity_etfs,cross_sectional_momentum,deterministic,baseline,2011,0.0884315662018,0.329325683402,0.0720886943175,0.742793428829,-0.407258984168,0.193325454503,2.66697868899,5.20220910055,0.0539540944161,0.999005469915 +country_equity_etfs,cross_sectional_momentum,deterministic,same_close,2011,0.0853606050434,0.312765564083,0.0687146767288,0.699501431662,-0.415000317458,0.193738430692,2.52873208199,5.05089799658,0.0522815198484,1 +country_equity_etfs,cross_sectional_momentum,deterministic,zero_cash,2011,0.0884429814558,0.32938470359,0.0721009331097,0.742952204001,-0.407258984168,0.193325431079,2.66697868899,5.20220910055,0.0539540944161,0.999005469915 +country_equity_etfs,cross_sectional_momentum,deterministic,zero_cost,2011,0.0951925964319,0.364340979363,0.0793674787275,0.839486088313,-0.395108174469,0.193288513677,2.66488725743,5.20446372421,-3.33066907388e-16,0.999005469915 +country_equity_etfs,cross_sectional_momentum,deterministic,costed_comparator,2011,0.0827976014027,0.325169158514,0.0690183895672,0.703359458263,-0.3635717287,0.178500716815,0.239318382399,0.35319220933,0.00366961652371,0.999005469915 +country_equity_etfs,cross_sectional_momentum,deterministic,vectorized,2011,0.0893221660464,0.333040343521,0.072939540501,0.753861729499,-0.407544080667,0.193841617988,2.58975634013,5.05420188961,0.0524138334348,0.999005469915 +country_equity_etfs,short_term_reversal,deterministic,baseline,2011,0.0603237363886,0.19668911285,0.0447767104604,0.418438184436,-0.439116951436,0.180905793558,9.63307385663,13.9072779796,0.144671878492,0.708809521781 +country_equity_etfs,short_term_reversal,deterministic,same_close,2011,-0.00915713805989,-0.186454202382,-0.0254542259236,-0.185970547426,-0.521677545831,0.181463987383,9.62887497888,13.8992336382,0.143856432396,0.709146772481 +country_equity_etfs,short_term_reversal,deterministic,zero_cash,2011,0.0526893783073,0.154522395721,0.036834669911,0.334640777549,-0.445741422323,0.180894657781,9.63342970757,13.9083907215,0.144679894168,0.708856518783 +country_equity_etfs,short_term_reversal,deterministic,zero_cost,2011,0.0784526838674,0.296545918793,0.0638542676365,0.638791659197,-0.421701077314,0.181075015125,9.63767991904,13.9204721815,-1.99840144433e-15,0.708809521781 +country_equity_etfs,short_term_reversal,deterministic,costed_comparator,2011,0.0717365583901,0.297324912935,0.0609171053424,0.603031515857,-0.352248328384,0.158001721302,5.40055981709,5.44539008332,0.0565591309166,0.709024185492 +country_equity_etfs,short_term_reversal,deterministic,vectorized,2011,0.0610042724378,0.200333660212,0.0454718324348,0.425986813787,-0.437080528557,0.181008772412,9.60716061661,13.8677274988,0.144259337554,0.708934195259 +country_equity_etfs,learned_gbrt,11,baseline,2011,0.075930256939,0.294949976038,0.0627092865789,0.624769316848,-0.306262907538,0.173601948849,7.15288244941,12.8377209154,0.133486910176,0.902656033427 +country_equity_etfs,learned_gbrt,11,same_close,2011,0.0709426504218,0.265842601538,0.057380177401,0.560876487821,-0.306407112646,0.173832710537,7.02540041615,12.7076458341,0.131514563744,0.903578254236 +country_equity_etfs,learned_gbrt,11,zero_cash,2011,0.07227801915,0.27389834198,0.0588349113599,0.578095907406,-0.31055774639,0.173608852197,7.15254052357,12.8377218608,0.13348614055,0.902694870972 +country_equity_etfs,learned_gbrt,11,zero_cost,2011,0.0926576071948,0.391026684647,0.0806102294708,0.856455562859,-0.279353890451,0.173715970765,7.15246299182,12.8497442643,-9.99200722163e-16,0.902656033427 +country_equity_etfs,learned_gbrt,11,costed_comparator,2011,0.0603661331833,0.206521917277,0.0464087130855,0.436216388097,-0.421225068888,0.172513376082,1.54382151847,1.6323452296,0.0169633695749,0.902782293481 +country_equity_etfs,learned_gbrt,11,vectorized,2011,0.0761166558162,0.295853284699,0.062889123848,0.626964774029,-0.307479321422,0.173703055502,7.10094480358,12.7399303829,0.132470932717,0.902370296701 +country_equity_etfs,learned_gbrt,29,baseline,2011,0.0725175424453,0.280565705214,0.0596835577945,0.588217725803,-0.336267566609,0.170338144019,7.77134083953,13.4918014832,0.140344706819,0.881688304319 +country_equity_etfs,learned_gbrt,29,same_close,2011,0.0641314007832,0.231479560053,0.0508508559765,0.485597829339,-0.316590362709,0.170239046007,7.64237307559,13.3580612126,0.138227827223,0.882704689291 +country_equity_etfs,learned_gbrt,29,zero_cash,2011,0.0686062746759,0.257590073248,0.0555466412862,0.53940751878,-0.341389165749,0.170345982053,7.7710522822,13.4920765808,0.140346493413,0.881727345585 +country_equity_etfs,learned_gbrt,29,zero_cost,2011,0.0901042486204,0.383414213063,0.0784510550228,0.827059600748,-0.310379362457,0.170503972634,7.77183932116,13.504816957,-7.77156117238e-16,0.881688304319 +country_equity_etfs,learned_gbrt,29,costed_comparator,2011,0.0505075128332,0.149965961464,0.0362580915965,0.328729491924,-0.429431580466,0.171883735584,2.12175441204,2.20580271546,0.0229002629618,0.881838276149 +country_equity_etfs,learned_gbrt,29,vectorized,2011,0.0731453693231,0.284032384212,0.0603254828213,0.595911651688,-0.337092295066,0.1704701973,7.72749875684,13.4082545997,0.139475141041,0.881485164926 +country_equity_etfs,learned_gbrt,47,baseline,2011,0.0849131118446,0.347972758986,0.0724146310533,0.747026161929,-0.353164420799,0.172953097294,7.24646428537,12.8623592067,0.133774571928,0.899361759951 +country_equity_etfs,learned_gbrt,47,same_close,2011,0.0697385769787,0.260378603982,0.0562811013228,0.5479761354,-0.348365113706,0.172857423613,7.11493179521,12.7238539078,0.131639896328,0.900437094583 +country_equity_etfs,learned_gbrt,47,zero_cash,2011,0.0814850236971,0.328147502349,0.0687456924606,0.69989506993,-0.357233801891,0.172953433084,7.24635086604,12.8626971217,0.133777311764,0.8993987412 +country_equity_etfs,learned_gbrt,47,zero_cost,2011,0.101676509222,0.444509645012,0.0905128554676,0.996639571721,-0.329068090474,0.173094199093,7.2462524778,12.8744226239,-6.66133814775e-16,0.899361759951 +country_equity_etfs,learned_gbrt,47,costed_comparator,2011,0.0582788076781,0.194325898712,0.0442105557167,0.41231591602,-0.415460401974,0.172604620576,1.70565699756,1.79191625034,0.0186306167869,0.899337967014 +country_equity_etfs,learned_gbrt,47,vectorized,2011,0.0848014955343,0.347203527989,0.0722836015257,0.74532348522,-0.354249894591,0.173015843798,7.18448533068,12.7399303829,0.13249671964,0.898889441406 +country_equity_etfs,learned_gbrt,71,baseline,2011,0.0876524426328,0.368126408423,0.0757334182118,0.790639756346,-0.35255683052,0.170912132407,7.11841035272,12.5232055753,0.13026428196,0.889649050759 +country_equity_etfs,learned_gbrt,71,same_close,2011,0.0691027196321,0.260394172376,0.0560575093622,0.545363182169,-0.360279322391,0.170404237233,6.98738554766,12.3860726197,0.128142236561,0.890711333193 +country_equity_etfs,learned_gbrt,71,zero_cash,2011,0.0837806390651,0.345463435771,0.0715772745283,0.736170024079,-0.35612258402,0.170914957886,7.11820234694,12.5233935399,0.130265345176,0.889693852596 +country_equity_etfs,learned_gbrt,71,zero_cost,2011,0.103975962799,0.463197514372,0.0934051288822,1.03929186785,-0.328413311801,0.171062504922,7.11867276254,12.535149444,-1.11022302463e-15,0.889649050759 +country_equity_etfs,learned_gbrt,71,costed_comparator,2011,0.0536765076038,0.168123277892,0.0394979161474,0.362244873962,-0.421531761938,0.17214747108,1.78711439433,1.87116037724,0.0194536341826,0.889712632944 +country_equity_etfs,learned_gbrt,71,vectorized,2011,0.0875371048831,0.367335272403,0.0755995328459,0.788862051888,-0.353753881115,0.170967504902,7.05917454003,12.4057682745,0.129038675899,0.889109895574 +country_equity_etfs,learned_gbrt,97,baseline,2011,0.0709051489599,0.268026261166,0.057623742687,0.563748018484,-0.329707654623,0.172281862834,7.77355298642,13.7436634788,0.143004536371,0.889725624224 +country_equity_etfs,learned_gbrt,97,same_close,2011,0.055944071008,0.181719552313,0.0420049860111,0.388685232379,-0.340048971021,0.171795834274,7.64854935101,13.6181180627,0.140890322263,0.890897378407 +country_equity_etfs,learned_gbrt,97,zero_cash,2011,0.0668764071374,0.244640522274,0.0533723703749,0.514283980785,-0.332934779061,0.172282746351,7.77326331333,13.7437899893,0.143004871279,0.889762462003 +country_equity_etfs,learned_gbrt,97,zero_cost,2011,0.0888251604792,0.371582946993,0.0767075939419,0.803621269927,-0.301162422785,0.172478339405,7.77357723733,13.7568870215,-2.22044604925e-16,0.889725624224 +country_equity_etfs,learned_gbrt,97,costed_comparator,2011,0.0564294648657,0.183671494346,0.0422905526055,0.391725195895,-0.40741602686,0.172552626975,1.87569931985,1.96195733074,0.0204058060837,0.889889523079 +country_equity_etfs,learned_gbrt,97,vectorized,2011,0.071546394851,0.271620367294,0.0582875936199,0.571598010579,-0.330478110076,0.172363095145,7.72749875684,13.658876181,0.142123397662,0.889441405603 diff --git a/paper/expansion/results/target_tape_hashes.json b/paper/expansion/results/target_tape_hashes.json new file mode 100644 index 0000000..e8518d7 --- /dev/null +++ b/paper/expansion/results/target_tape_hashes.json @@ -0,0 +1,314 @@ +[ + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "deterministic", + "sha256": "9d0e08318f01be79ef39599241456bac4675dfe066e5cb330f2807e78d76a05a", + "strategy": "ts_momentum", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "deterministic", + "sha256": "9545811bb9c8d413be048c4014f64f8f1bb7a3e7b7b066341e7c0e29fb4b57a1", + "strategy": "cross_sectional_momentum", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "deterministic", + "sha256": "649bbc004d077c2845eb4884d35a27022cf0583fd3dc3b2e0588b51da24ce0ab", + "strategy": "short_term_reversal", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "11", + "sha256": "c04600e960059adb4efaa7043edc9947cb60a496921ab7d87ef89fd2fe69ae4e", + "strategy": "learned_gbrt", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "29", + "sha256": "a55ffe7e6694fbfecbd80258dbc7fb504ddcbca778496ef4a65ebc6c9957d900", + "strategy": "learned_gbrt", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "47", + "sha256": "9970d2a590571a2568f4ec8b2e46a7816a14a2ab84b18d4a2eeccc7a2e60081e", + "strategy": "learned_gbrt", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "71", + "sha256": "40b146bafac5c3102ae92d7800039f357a84f64685b321e27b139f6f2d0d0579", + "strategy": "learned_gbrt", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "us_sector_etfs", + "record_count": 864, + "seed": "97", + "sha256": "bc9096451ec5894b097f28c49032ecd7c59c6e168119fc0ea6e970f1d29c39bd", + "strategy": "learned_gbrt", + "symbols": [ + "XLB", + "XLE", + "XLF", + "XLI", + "XLK", + "XLP", + "XLU", + "XLV", + "XLY" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "deterministic", + "sha256": "e7ffb31af0ebbc7c640a85e63db85ac4bab72febd6668fd51769eedfc03d701a", + "strategy": "ts_momentum", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "deterministic", + "sha256": "14a9f59ee53e4430fb22448858327e375be4fae9dc9255b5635dcbf7d66712a1", + "strategy": "cross_sectional_momentum", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "deterministic", + "sha256": "f9ed37cb97fe623eeccdf3e13cbc6ebfab568a629328675fffd7b27a0dc07bc9", + "strategy": "short_term_reversal", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "11", + "sha256": "86b6502e337e29d3b6def87f9329946ac20d18c90c7dfd25ac32d866f825a81e", + "strategy": "learned_gbrt", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "29", + "sha256": "9c07c8550fb54792918af1fdfcc428e9fe13023bd5d46c071cd92c86b1412d4b", + "strategy": "learned_gbrt", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "47", + "sha256": "9d11c35907a5a357ae8aad57e6d4a596ebdb58bcefe0d51f534b5ac81e31b193", + "strategy": "learned_gbrt", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "71", + "sha256": "f3047262af46f736e368ee4353a6d61d186431d0224cff2903e54435af056e6e", + "strategy": "learned_gbrt", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + }, + { + "decision_count": 96, + "panel": "country_equity_etfs", + "record_count": 960, + "seed": "97", + "sha256": "46d4b9944122a78505141ff6b2cfaac577ecf6f47ae8b1bed2afd903a499eb8e", + "strategy": "learned_gbrt", + "symbols": [ + "EWA", + "EWC", + "EWG", + "EWH", + "EWJ", + "EWL", + "EWP", + "EWQ", + "EWS", + "EWU" + ] + } +] diff --git a/paper/figures/accounting_summary.pdf b/paper/figures/accounting_summary.pdf index 79fbb49..eece45e 100644 Binary files a/paper/figures/accounting_summary.pdf and b/paper/figures/accounting_summary.pdf differ diff --git a/paper/figures/accounting_summary.png b/paper/figures/accounting_summary.png index 5ce6df8..99f40f7 100644 Binary files a/paper/figures/accounting_summary.png and b/paper/figures/accounting_summary.png differ diff --git a/paper/figures/audit_protocol.pdf b/paper/figures/audit_protocol.pdf index 1fb6c57..60501be 100644 Binary files a/paper/figures/audit_protocol.pdf and b/paper/figures/audit_protocol.pdf differ diff --git a/paper/figures/audit_protocol.png b/paper/figures/audit_protocol.png index 5824079..50f991f 100644 Binary files a/paper/figures/audit_protocol.png and b/paper/figures/audit_protocol.png differ diff --git a/paper/figures/bootstrap_robustness.pdf b/paper/figures/bootstrap_robustness.pdf index edd7939..7b84753 100644 Binary files a/paper/figures/bootstrap_robustness.pdf and b/paper/figures/bootstrap_robustness.pdf differ diff --git a/paper/figures/bootstrap_robustness.png b/paper/figures/bootstrap_robustness.png index b5f6744..e436abf 100644 Binary files a/paper/figures/bootstrap_robustness.png and b/paper/figures/bootstrap_robustness.png differ diff --git a/paper/figures/engine_comparison.pdf b/paper/figures/engine_comparison.pdf index 73e83c1..c3a09f3 100644 Binary files a/paper/figures/engine_comparison.pdf and b/paper/figures/engine_comparison.pdf differ diff --git a/paper/figures/engine_comparison.png b/paper/figures/engine_comparison.png index 6f4ca25..4c2c802 100644 Binary files a/paper/figures/engine_comparison.png and b/paper/figures/engine_comparison.png differ diff --git a/paper/figures/return_attribution_and_protocol_switches.pdf b/paper/figures/return_attribution_and_protocol_switches.pdf index 5a6417b..3aeee80 100644 Binary files a/paper/figures/return_attribution_and_protocol_switches.pdf and b/paper/figures/return_attribution_and_protocol_switches.pdf differ diff --git a/paper/figures/return_attribution_and_protocol_switches.png b/paper/figures/return_attribution_and_protocol_switches.png index 5454ef9..e1c24ca 100644 Binary files a/paper/figures/return_attribution_and_protocol_switches.png and b/paper/figures/return_attribution_and_protocol_switches.png differ diff --git a/paper/figures/sensitivity_and_ablation.pdf b/paper/figures/sensitivity_and_ablation.pdf index d99ec0c..05282ad 100644 Binary files a/paper/figures/sensitivity_and_ablation.pdf and b/paper/figures/sensitivity_and_ablation.pdf differ diff --git a/paper/figures/sensitivity_and_ablation.png b/paper/figures/sensitivity_and_ablation.png index 2948c45..aa06cf1 100644 Binary files a/paper/figures/sensitivity_and_ablation.png and b/paper/figures/sensitivity_and_ablation.png differ diff --git a/paper/main.tex b/paper/main.tex index c818483..b3f4ba0 100644 --- a/paper/main.tex +++ b/paper/main.tex @@ -24,9 +24,10 @@ urlcolor=blue } -\graphicspath{{figures/}} +\graphicspath{{figures/}{expansion/figures/}} \newcommand{\system}{\textsc{quantcortex}} \input{results/generated_values} +\input{expansion/results/generated_values} \title{Executable Evaluation Contracts for Target-Weight Trading Pipelines} @@ -50,33 +51,33 @@ Reported performance also depends on when signals become executable, what residual cash earns, how turnover incurs cost, and whether the comparator bears the same exposure. We encode these choices as executable contracts in -\system, an open-source research and paper-trading system. The contracts cover -causal inputs, bounded targets, adjusted-close pseudo-share execution, explicit -cash and costs, separate attribution and tradable comparators, fail-closed order -state, and provenance for every published result artifact. - -We apply the protocol to a fixed six-ETF rotation strategy over -\PaperObservationCount{} U.S. trading sessions from 2018--2025. Under the -audited conventions, the strategy records a \PaperNetCAGR{} net CAGR and a -\PaperNetSharpe{} conventional sample Sharpe relative to a short-Treasury ETF -cash proxy (SHV). A causal target-exposure comparator, after its own modeled -costs, records \PaperCostedComparatorCAGR{} and \PaperCostedComparatorSharpe{}; -the strategy's annualized arithmetic shortfall is \PaperCostedActiveMean{} -[\PaperCostedActiveLower{}, \PaperCostedActiveUpper{}]. An exact daily identity -decomposes its \PaperNetExcessMean{} annualized excess over cash into -\PaperPassiveExposureMean{} from passive risky exposure, -\PaperAllocationMean{} from active risky allocation, \PaperTimingMean{} from -exposure timing, and \PaperImplementationCostMean{} from modeled cost. The -active-allocation interval is [\PaperAllocationLower{}, -\PaperAllocationUpper{}]. Holding the targets and data fixed, omitting costs -changes the Sharpe to \PaperZeroCostSharpe{}; deliberately assigning a -close-derived target to the return ending at that close changes it to -\PaperSameCloseSharpe{}. - -This is an evaluation methodology and a negative case study, not a new alpha -model. It shows that deterministic, artifact-traced computation can still -produce an economically misleading estimate when timing, cash, costs, and -comparator exposure are left implicit. +\system, with a canonical target tape, fail-closed invariants, explicit cash and +cost accounting, and content-hashed result artifacts. + +We first use a retrospective six-ETF rotation case to show how an exact daily +identity localizes a negative result: net CAGR is \PaperNetCAGR{}, conventional +cash-excess Sharpe is \PaperNetSharpe{}, and active risky allocation contributes +\PaperAllocationMean{} per year. We then run a protocol frozen in the public +repository before the two input panels were retrieved. The expansion evaluates +time-series momentum, cross-sectional momentum, short-term reversal, and a +walk-forward gradient-boosted model over \ExpansionEvaluationSessions{} sessions +in U.S. sector and country-equity ETF panels. Across the resulting +\ExpansionFamilyPanelCount{} family--panel cells, removing 13-basis-point costs +raises annualized arithmetic return by \ExpansionCostEffectRange{}; all +\ExpansionFamilyPanelCount{} +pointwise 95\% block-bootstrap intervals lie above zero. Setting residual-cash +return to zero produces intervals below zero in \ExpansionZeroCashBelowCount{} +cells. Deliberately assigning close-derived targets to the return ending at that +close produces intervals below zero in \ExpansionSameCloseBelowCount{} cells and +changes \ExpansionSameCloseRankChanges{} within-panel family ranks. Engine +conventions create a maximum +daily return difference of \ExpansionEngineMaxDailyBp{} basis points. + +These are controlled evaluation effects, not evidence of profitable alpha. +The historical panels are neither temporally out of sample nor openly +redistributable, and the intervals are pointwise rather than +multiplicity-adjusted. The result is a reproducible evaluation methodology and +reference implementation with an explicit boundary around its empirical claims. \end{abstract} \section{Introduction} @@ -105,17 +106,17 @@ \section{Introduction} target-weight pipeline make its economic and operational semantics executable, testable, and traceable from the information set to paper-order state? -The primary contribution is the evaluation methodology. The strategy is a test -case, and the software is a reference implementation. Specifically, we +The primary contribution is the evaluation methodology; the strategies are test +vehicles and the software is a reference implementation. Specifically, we (i) define a machine-readable evaluation contract and canonical target tape for -information timing, weights, overlays, execution lag, cash, costs, comparison, -and uncertain order outcomes; -(ii) formalize an exact daily return decomposition for portfolios with changing -risky exposure and preserve that identity under joint block resampling; and -(iii) bind the fixed case-study evidence to its configuration, source tree, -environment, input digest, and published artifacts. The empirical result is -intentionally negative: it localizes why one fully traced rotation strategy -fails rather than presenting it as profitable alpha. +information timing, weights, execution lag, cash, costs, comparison, and +uncertain order outcomes; (ii) formalize an exact daily return decomposition for +changing risky exposure and preserve it under joint block resampling; (iii) +estimate five one-switch effects under a repository-frozen protocol across four +heterogeneous strategy families and two real-data panels; and (iv) bind every +reported result to its protocol, clean source revision, environment, input +digest, and artifact hash. The purpose is to expose sensitivity to evaluation +semantics, not to select the best-performing strategy after inspection. \section{Related work} @@ -138,10 +139,11 @@ \section{Related work} exposure changes over time. The identity separates passive exposure, exposure timing around the sample mean, active allocation, and modeled cost. It is an exact arithmetic decomposition, not a new geometric linking method. The -case-study signal draws on cross-sectional, time-series, and residual momentum -ideas -\citep{jegadeesh1993returns,moskowitz2012timeseries, -blitz2011residual}, but is used only as a fixed test vehicle. +strategy set draws on cross-sectional and time-series momentum, short-term +reversal, residual momentum, and gradient boosting +\citep{jegadeesh1993returns,moskowitz2012timeseries,lehmann1990fads, +blitz2011residual,friedman2001greedy}, but each model is used only as a fixed +test vehicle. \paragraph{Research software and implementation risk.} Qlib and FinRL provide modular financial-AI workflows @@ -153,9 +155,10 @@ \section{Related work} \citep{perignon2024computational}. In a recent preprint, \citet{yin2026implementation} compare five engines across 15 strategies and find material divergence from implementation choices, -including transaction costs. Our two-engine result is deliberately more -limited: it is an internal parity check for one configuration, not an estimate -of industry-wide implementation risk. The manifest and source fingerprints +including transaction costs. Our two-engine diagnostic is deliberately more +limited: it compares two documented holding conventions on identical target +tapes, not independent third-party implementations or an estimate of +industry-wide implementation risk. The manifest and source fingerprints follow the broader reproducibility objectives of \citet{pineau2021reproducibility}. Our contribution lies in connecting those artifacts to explicit economic contracts within one executable evaluation. @@ -195,7 +198,7 @@ \section{Executable audit protocol} Overlay & Timing and risk overlays may reduce exposure but cannot create an invalid book. & Reject non-finite or over-limit weights. \\ Timing & A close-derived target earns returns only after the first strictly later execution bar. & Shift holdings; never use the observed close twice. \\ Accounting & Risky assets, residual cash, turnover, and costs share one capital clock. & Reject missing cash bars or cost models. \\ - Comparison & Exact attribution uses realized exposure; a tradable control follows target exposure and pays costs. & Label ex-post controls and cost implementable comparators. \\ + Comparison & Exact attribution uses realized exposure; a tradable control follows target exposure and pays costs. & Label ex-post controls; charge costs to implementable comparators. \\ Execution & Persist intent before broker submission; uncertain outcomes are not retried automatically. & Block until broker reconciliation. \\ \bottomrule \end{tabular} @@ -210,8 +213,8 @@ \subsection{Portfolio, execution, and cash contracts} allocation matches $w_d$. It holds $q_i$ fixed between rebalances. If $P_{i,t}$ is the adjusted close and $V_t$ is portfolio NAV, the realized risky weight at the prior close is $h_{i,t-1}=q_{i,t-1}P_{i,t-1}/V_{t-1}$. Close-derived targets -therefore earn neither the close they observe nor the return ending at their -execution close. For the long-only case, $h_{i,t}\geq 0$ and +are therefore not filled at the close they observe and do not earn the return +ending at their execution close. For the long-only case, $h_{i,t}\geq 0$ and $\sum_i h_{i,t}\leq 1$; the residual cash holding is \begin{equation} h^c_t = 1-\sum_{i=1}^N h_{i,t}. @@ -232,8 +235,8 @@ \subsection{Portfolio, execution, and cash contracts} pre-trade NAV and $V_{t-1}$ is prior-close NAV, the return drag is $d_t=\kappa_t V^-_t/V_{t-1}$ and $r^{\mathrm{net}}_t=r^{\mathrm{gross}}_t-d_t$. Target shares are solved against -post-cost NAV. -It does not separately estimate commissions, bid--ask spread, taxes, or market +post-cost NAV. The cost model does not separately estimate commissions, +bid--ask spread, taxes, or market impact. This proportional model is a first-order sensitivity assumption; large or urgent orders require models that account for order size, volatility, and execution urgency, such as \citet{almgren2001execution}. @@ -248,8 +251,9 @@ \subsection{Portfolio, execution, and cash contracts} \label{eq:sharpe} \end{equation} Equation~\ref{eq:sharpe} is a descriptive statistic, not an iid-normal inference -procedure; serial dependence is handled separately through circular-block -resampling \citep{lo2002statistics}. An SHV adjusted-close series supplies the +procedure; serial dependence is represented approximately in a separate +circular-block sensitivity analysis \citep{lo2002statistics}. An SHV +adjusted-close series supplies the return credited to residual cash; the engine does not trade SHV when cash weight changes. It is a short-duration cash proxy, not a frictionless risk-free asset. Because the input contains adjusted @@ -319,7 +323,7 @@ \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. The fixed experiment round-trips every strategy decision stream +readable. Each empirical run 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 @@ -332,326 +336,250 @@ \subsection{Reference implementation and order-state controls} identities, property tests, counterfactuals, fault injection, SDK conformance, and untested live behavior. -\section{Case study and evaluation design} - -\subsection{Fixed audit configuration} - -The universe contains QQQ, VGT, GLD, TLT, SPY, and VIG, grouped as growth -equities (QQQ, VGT), gold and nominal duration (GLD, TLT), and broad and dividend -equities (SPY, VIG). On the first session of each week, the -strategy ranks groups by a 126-session information ratio relative to QQQ and -keeps the top two. Within selected groups, it estimates a one-factor regression -on a preceding 126-session window and sums residual returns over a separate -126-session formation window, skipping the latest 21 sessions. Positive scores -receive proportional long-only weights, capped at 60\% per asset; if no selected -member has a positive score, the target is all cash. -QQQ is also one of the two growth-group members, so that group's active-return -spread contains one zero benchmark leg. This asymmetry is a fixed design -choice. - -Two overlays control risky exposure. A seeded three-component Gaussian mixture -maps the latest observation to zero, half, or full exposure. Its inputs are QQQ -return and rolling realized volatility configured for 20 sessions, represented -both as a decimal and in volatility points. The earliest 19 feature rows use -the shorter history then available. The two volatility columns are exact -rescalings, not independent signals. At each weekly decision, the model is -refit on the expanding history available through that close after at least 60 -QQQ return observations. Components are ordered by fitted-sample mean return, -and only the latest label controls exposure. A separate scaler targets 20 -volatility points using a 21-session QQQ realized-volatility proxy because no -VIX series is supplied. - -The full strategy uses both overlays. Three named variants remove the regime -gate, the volatility scaler, or both. Every experiment-facing parameter is -recorded in the manifest, and the source-tree digest detects changes to -implementation constants. The configuration was frozen for the reported run, -but the project was not preregistered and its earlier research trial count is -unknown. We therefore report all named variants and make no -multiple-testing-adjusted discovery claim. +\section{Evaluation design} + +\subsection{Retrospective worked audit} + +The first study is a fixed six-ETF weekly rotation strategy evaluated from +2018-01-02 through 2025-12-31. It combines group ranking, residual momentum, a +Gaussian-mixture exposure gate, and volatility scaling. The study was assembled +before a prospective protocol existed, and its earlier trial count is unknown; +it is therefore a worked accounting audit, not confirmatory evidence. Its +known construct issues are retained rather than repaired after seeing the +result: QQQ is both a group member and the ranking benchmark, two regime +features are exact volatility rescalings, early regime windows are immature, +and one seed controls the mixture fit. Appendix~\ref{app:retrospective} gives the +complete configuration. + +The owner-supplied adjusted-close matrix is not distributed. It is identified by +a manifest-recorded SHA-256. The event engine credits SHV to residual +cash, charges 13 basis points per dollar traded on each buy or sell leg, and +executes close-derived weekly targets on the first strictly later bar. The +exact attribution uses a realized-exposure ex-post control; a separate causal +target-exposure comparator pays the same modeled costs. + +\subsection{Repository-frozen expansion} + +The second study was specified in the public repository before its two provider +requests. The freeze is a Git timestamp, not an external registry entry, and the +historical interval is not temporally out of sample. Two complete-row panels use +adjusted closes from 2014-01-02 through 2025-12-31: nine U.S. sector ETFs and ten +country-equity ETFs, each with SHV. Evaluation covers +\ExpansionEvaluationSessions{} sessions from 2018-01-02 through 2025-12-31. +There is no forward fill or symbol substitution. The panels contain surviving, +currently available ETFs and share broad market exposures, so they are not +independent replications. + +Table~\ref{tab:expansion-design} summarizes the frozen strategy families. Each +decision uses the first complete session of the month and executes on the first +strictly later panel close. Long-only target exposure is at most one; unused +capital earns SHV. The primary event engine holds adjusted-close pseudo-shares +between rebalances and charges 13 basis points per dollar traded. Every target +round-trips through the canonical JSON tape before evaluation. \begin{table}[t] - \caption{Experiment specification recorded in the manifest.} - \label{tab:design} + \caption{Frozen expansion strategies. Ties use ascending symbols.} + \label{tab:expansion-design} \centering \small - \begin{tabular}{>{\raggedright\arraybackslash}p{0.24\linewidth} - >{\raggedright\arraybackslash}p{0.68\linewidth}} + \begin{tabular}{>{\raggedright\arraybackslash}p{0.22\linewidth} + >{\raggedright\arraybackslash}p{0.70\linewidth}} \toprule - Component & Fixed value \\ + Family & Fixed rule \\ \midrule - Universe & QQQ, VGT, GLD, TLT, SPY, VIG; QQQ benchmark \\ - Rebalance & First observed session of each calendar week \\ - Group ranking & 126-session active-return information ratio; top 2 groups \\ - Residual momentum & 126-session estimation + 126-session formation; 21-session gap \\ - Allocation & Positive scores, proportional long-only weights, 60\% asset cap \\ - Regime overlay & Rolling vol feature, up to 20 sessions; seeded 3-component full-covariance GMM; 100 EM iterations; $10^{-5}$ regularization \\ - Volatility overlay & 20-point target; 0.3--1.0 multiplier; 21-session proxy \\ - Execution & Event-driven adjusted-close pseudo-shares; first bar strictly after the close-derived decision \\ - Cash and costs & SHV return credited to residual cash; 13 bps of pre-trade gross traded notional \\ - Evaluation & 2018-01-02 to 2025-12-31 after \PaperWarmupSessions{} warm-up sessions (\PaperRequiredWarmupSessions{} required) \\ - Uncertainty & \PaperBootstrapReplications{} joint circular-block draws; seed 42 \\ + Time-series momentum & Positive 252-session total returns; equal weight across eligible assets. \\ + Cross-sectional momentum & Top three returns from $t-252$ to $t-21$; one-third each. \\ + Short-term reversal & Up to three lowest negative 5-session returns; one-third each, without renormalizing unused exposure. \\ + Learned GBRT & Walk-forward 21-session return prediction from return, volatility, trailing-high, and rank features; top three positive predictions; five frozen seeds. \\ \bottomrule \end{tabular} \end{table} -\subsection{Data, comparators, and uncertainty} - -The owner-supplied matrix is documented as adjusted closes from 2016-01-04 -through 2025-12-31, retrieved on 2026-06-16 through yfinance 1.4.1 with -\texttt{auto\_adjust=False}. The yfinance documentation describes the Yahoo -Finance API as intended for personal use and directs users to Yahoo's terms of -use.\footnote{\url{https://ranaroussi.github.io/yfinance/}} The owner authorizes -publication of the derived aggregates, but the raw matrix is not distributed. -The audited loader requires complete rows and performs no forward filling. -The manifest records the provider, retrieval date, adjustment setting, and -input SHA-256: -\begin{center} - \small\texttt{\PaperInputDigest} -\end{center} -The first \PaperWarmupSessions{} sessions are signal history; evaluation covers -\PaperObservationCount{} sessions -from 2018-01-02 through 2025-12-31. - -Comparators share one capital clock. SPY and a six-ETF buy-and-hold basket -initialized at equal dollar weights are measured from the close immediately -preceding the evaluation window. The exact attribution control multiplies the -basket return by the strategy's realized risky exposure each day and assigns -the remainder to SHV. It is ex-post and gross of comparator costs, so it is used -to explain return rather than presented as tradable. Gross-to-gross and -net-to-gross differences against this control remain useful attribution -quantities. - -We separately run a causal, costed comparator. At each original strategy -decision time it targets the strategy's declared gross risky exposure in the -same equal-initial-weight buy-and-hold basket, executes on the next bar, assigns -residual capital to SHV, and pays the same 13-bps proportional cost. Its initial -position is established on the pre-evaluation close, outside the reported -window. For Equation~\ref{eq:decomposition}, the basket is also held at the -strategy's full-sample mean exposure. That constant-exposure series is another -explicitly ex-post diagnostic. - -For dependent daily observations, we jointly resample return-component rows -with the circular-block bootstrap of \citet{politis1992circular}. We use -\PaperBootstrapReplications{} -replications, a 21-session primary block (approximately one trading month), -seed 42, and unstudentized two-sided 95\% percentile intervals. We repeat the -active-return and conventional sample Sharpe calculations with 5- and -63-session blocks to span roughly one week through one quarter. Sharpe is -recomputed inside every draw rather than assigned an iid standard error. -Intervals quantify sampling variation conditional on this historical path, -strategy specification, cash proxy, and block length. They do not establish -stationarity or future performance. Fractions of positive draws are descriptive -and are not interpreted as significance tests. +The learned family pools asset-month observations without symbol identity. +Features are 5-, 21-, 63-, 126-, and 252-session log returns; 21- and 63-session +sample volatility; distance from the trailing 252-session high; and ordinal +cross-sectional ranks of 21- and 252-session returns. They are neither +standardized nor winsorized. At each evaluation decision the model uses only +training labels whose 21-session endpoint is observable by that close, retains +at most 60 mature decision months, and requires at least 24. A gradient-boosted +regressor with 100 depth-2 trees, learning rate 0.03, minimum leaf size 10, and +subsample 0.8 is refit separately for seeds 11, 29, 47, 71, and 97. Features and +labels are recomputed walk-forward; no fitted model crosses a decision boundary. + +\subsection{One-switch estimands and uncertainty} + +For each family--panel cell, five paired switches compare (i) invalid +same-close assignment with next-bar execution, (ii) zero cash return with SHV, +(iii) zero cost with 13-basis-point cost, (iv) the strategy with a causal costed +equal-weight comparator at the same target risky exposure, and (v) vectorized +with event-driven accounting. The comparator is conditional on the strategy's +target exposure and is a conditional exposure control, not an independent market +benchmark. The vectorized engine re-pegs weights between decisions, whereas the +event engine holds drifting pseudo-shares; their difference is a model-convention +sensitivity, not automatically an implementation error. + +Primary outcomes are paired changes in annualized arithmetic return and +conventional SHV-excess Sharpe. We use \ExpansionBootstrapReplications{} joint +circular-block draws with a 21-session primary block and repeat the calculation +with 5 and 63 sessions \citep{politis1992circular}. Return series, cash, and all +five learned seeds share each draw. Intervals are unstudentized pointwise 95\% +percentile intervals. They condition on the selected panels and frozen models, +are not multiplicity adjusted, and do not establish future performance. \section{Results} -\subsection{Cash accounting changes performance, not the verdict} - -Table~\ref{tab:accounting} and Figure~\ref{fig:accounting} separate cash and -cost effects. Crediting residual cash raises net CAGR from -\PaperZeroCashCAGR{} under a zero-return cash assumption to \PaperNetCAGR{}. -That correction is economically material because mean risky exposure is only -\PaperMeanExposure{} and the strategy is fully in cash on \PaperFullyCash{} of -sessions; the exposure path in Figure~\ref{fig:accounting} makes that changing -capital allocation explicit. It does not create evidence of alpha. SHV alone -compounds at \PaperCashCAGR{}. The realized-exposure attribution control -compounds at \PaperMatchedCAGR{} before comparator costs; the causal -target-exposure comparator compounds at \PaperCostedComparatorCAGR{} after its -own modeled costs. - -\begin{table}[t] - \caption{Audited accounting over 2018--2025. Conventional sample Sharpe - statistics use SHV except for the explicitly zero-cash row, which uses a zero - reference rate.} - \label{tab:accounting} - \centering - \small - \begin{tabular}{lrrr} - \toprule - Series & CAGR & Sharpe & Max drawdown \\ - \midrule - Strategy, after costs and SHV cash & \PaperNetCAGR & \PaperNetSharpe & \PaperNetMaxDrawdown \\ - Strategy, before costs and SHV cash & \PaperGrossCAGR & \PaperGrossSharpe & \PaperGrossMaxDrawdown \\ - SHV cash proxy & \PaperCashCAGR & -- & \PaperCashMaxDrawdown \\ - Realized-exposure attribution control, gross & \PaperMatchedCAGR & \PaperMatchedSharpe & \PaperMatchedMaxDrawdown \\ - Target-exposure comparator, after costs & \PaperCostedComparatorCAGR & \PaperCostedComparatorSharpe & \PaperCostedComparatorMaxDrawdown \\ - Strategy, after costs and zero cash & \PaperZeroCashCAGR & \PaperZeroCashSharpe & \PaperZeroCashMaxDrawdown \\ - \bottomrule - \end{tabular} -\end{table} - -\begin{figure}[t] - \centering - \includegraphics[width=\textwidth]{accounting_summary.pdf} - \caption{(a) Growth of one dollar under audited cash and cost accounting. - (b) The daily capital split between risky assets and residual cash; the - dashed line is mean risky exposure. Panel (a) distinguishes the gross ex-post - attribution control from the causal target-exposure comparator after costs.} - \label{fig:accounting} -\end{figure} - -\subsection{Allocation and cost explain the shortfall} - -Equation~\ref{eq:decomposition} changes the interpretation of the headline -return. Passive risky exposure contributes \PaperPassiveExposureMean{} per -year; active allocation contributes \PaperAllocationMean{}, exposure timing -\PaperTimingMean{}, and modeled cost \PaperImplementationCostMean{}. These -components sum exactly to \PaperNetExcessMean{} annualized excess over SHV -before rounding. - -Table~\ref{tab:decomposition} shows that active allocation is the clearest -source of underperformance: its 95\% interval is -[\PaperAllocationLower{}, \PaperAllocationUpper{}], wholly below zero. The -timing interval spans zero, so this sample does not isolate a reliable timing -effect. Passive exposure is positive, while modeled cost is negative by -construction. The net-cash interval also spans zero; that does not contradict -the stronger matched-comparator result, because the positive passive-exposure -component partially offsets allocation and cost losses. - -\begin{table}[t] - \caption{Exact attribution of annualized arithmetic net excess over SHV. - Intervals use common 21-session circular-block draws. ``Positive'' is the - number of resampled annualized means above zero, not a posterior probability.} - \label{tab:decomposition} - \centering +\subsection{Baseline outcomes are inputs to the audit, not discoveries} + +Appendix Figure~\ref{fig:expansion-baseline} reports after-cost arithmetic return and +SHV-excess Sharpe. \ExpansionPositiveBaselineSharpeCount{} of +\ExpansionFamilyPanelCount{} family--panel cells have positive sample +Sharpe; country-panel time-series momentum is slightly negative. The sector +panel is stronger for every family. These rankings are descriptive: the panels, +families, and 2018--2025 interval are not a random sample, and no baseline +interval or multiple-testing correction supports an alpha claim. + +\subsection{Contract effects depend on the strategy} + +Table~\ref{tab:effect-counts} counts pointwise intervals across the eight +family--panel cells. Cost removal is a positive-by-construction check, but its +size is material: annualized arithmetic return rises by +\ExpansionCostEffectRange{}. In contrast, same-close assignment is not a +uniformly optimistic bias. Its interval is below zero in +\ExpansionSameCloseBelowCount{} cells and overlaps zero in +\ExpansionSameCloseOverlapCount{}, +driven by the deliberately contrarian reversal rule and, in one panel, the +learned model. Zero cash return is below zero in +\ExpansionZeroCashBelowCount{} cells and overlaps zero in +\ExpansionZeroCashOverlapCount{}. These patterns +show why a convention cannot be summarized by one global correction factor. + +\refstepcounter{table} +\label{tab:effect-counts} +\begin{center} + \parbox{0.86\textwidth}{\small Table~\thetable: Number of 21-session pointwise + intervals below zero / overlapping zero / above zero, out of eight + family--panel cells. Counts are descriptive and are not + multiplicity-adjusted tests.} + \smallskip \small - \begin{tabular}{lrrr} + \begin{tabular}{lcc} \toprule - Component & Mean & 95\% interval & Positive \\ + Switch difference & Annual return & Sharpe \\ \midrule - \PaperDecompositionRows + \ExpansionEffectRows \bottomrule \end{tabular} -\end{table} +\end{center} -Panel (b) of Figure~\ref{fig:attribution-switches} reports the -single-assumption diagnostics. Omitting cash return lowers the CAGR to -\PaperZeroCashCAGR{}; evaluated relative to SHV, its Sharpe is -\PaperZeroCashSHVSharpe{}. Omitting modeled costs raises the CAGR and Sharpe to -\PaperZeroCostCAGR{} and \PaperZeroCostSharpe{}. The deliberately invalid -same-close calculation reports \PaperSameCloseCAGR{} and -\PaperSameCloseSharpe{}. Thus cost omission and the invalid same-close -assignment each change the Sharpe sign relative to the audited value of -\PaperNetSharpe{}. -This sign flip is evidence of protocol sensitivity, not evidence that either -shortcut is tradable. +Figure~\ref{fig:contract-return} displays the return effects. Relative to its +costed exposure-matched comparator, only country-panel time-series momentum has +an interval below zero; the other seven overlap zero. This does not validate the +strategies. It says the conditional comparator is not precise enough to rank +most cells on this single path. Same-close and zero-cost switches change +\ExpansionSameCloseRankChanges{} and \ExpansionZeroCostRankChanges{} of the +\ExpansionFamilyPanelCount{} within-panel family ranks, respectively; replacing +strategies with their costed comparators changes +\ExpansionComparatorRankChanges{} ranks. \begin{figure}[t] \centering - \includegraphics[width=\textwidth]{return_attribution_and_protocol_switches.pdf} - \caption{(a) Exact arithmetic return attribution with primary block-bootstrap - intervals. (b) One-assumption diagnostics. Amber bars change economic - assumptions; the red same-close bar violates causality. None is a strategy - alternative.} - \label{fig:attribution-switches} + \includegraphics[width=\textwidth]{contract_effects_return.pdf} + \caption{Paired annualized arithmetic-return effects with 21-session + circular-block percentile intervals. Marker shape and color encode whether + the pointwise interval is above, overlaps, or is below zero. The same-close + switch is causally invalid and is never a candidate strategy.} + \label{fig:contract-return} \end{figure} -\subsection{Cost sensitivity and overlay ablations} - -The strategy turns over \PaperTurnover{} times per year on a one-way basis and -trades \PaperGrossTradedNotional{} times NAV per year on a gross two-sided -basis. At zero cost, its cash-excess Sharpe is \PaperCostZeroSharpe{}; it falls -to -\PaperCostFiveSharpe{} at 5 bps, \PaperCostBaselineSharpe{} at the 13-bps -baseline, \PaperCostTwentyFiveSharpe{} at 25 bps, and -\PaperCostFiftySharpe{} at 50 bps. The performance estimate is therefore highly -sensitive to modest proportional friction. - -Appendix Figure~\ref{fig:sensitivity} compares each variant before costs with a passive -basket at the same daily exposure. This avoids attributing the result solely to -strategy turnover. The full model has gross cash-excess Sharpe -\PaperGrossSharpe{} versus \PaperMatchedSharpe{} for its comparator. Removing -the regime gate, the volatility scaler, or both increases strategy exposure and -raw returns, but every gross strategy still trails its matched comparator. The -corresponding annualized gross active returns are \PaperGrossActiveFull{}, -\PaperGrossActiveNoRegime{}, \PaperGrossActiveNoVolScaler{}, and -\PaperGrossActiveSignalOnly{}; every 21-session block-bootstrap interval remains -below zero. No variant is selected as a replacement model. - -\subsection{Uncertainty, subperiods, and engine parity} - -The net strategy's annualized arithmetic excess return over SHV is -\PaperNetCashActiveMean{}, with a 95\% block-bootstrap interval of -[\PaperNetCashActiveLower{}, \PaperNetCashActiveUpper{}]. Before costs, the -corresponding estimate is \PaperGrossCashActiveMean{} -[\PaperGrossCashActiveLower{}, \PaperGrossCashActiveUpper{}]. Neither supports -a positive cash-relative claim. Its conventional sample Sharpe is -\PaperNetSharpe{}, with a direct block-bootstrap interval of -[\PaperNetSharpeLower{}, \PaperNetSharpeUpper{}]. - -Relative to the causal comparator after both sides pay modeled costs, annualized -arithmetic active return is \PaperCostedActiveMean{} -[\PaperCostedActiveLower{}, \PaperCostedActiveUpper{}], and conventional active -Sharpe is \PaperCostedActiveSharpe{} -[\PaperCostedActiveSharpeLower{}, \PaperCostedActiveSharpeUpper{}]. Relative to -the realized-exposure attribution control, gross active return is -\PaperGrossMatchedActiveMean{} -[\PaperGrossMatchedActiveLower{}, \PaperGrossMatchedActiveUpper{}], and only -\PaperGrossMatchedPositive{} of primary bootstrap draws are positive. After -costs it is \PaperNetMatchedActiveMean{} -[\PaperNetMatchedActiveLower{}, \PaperNetMatchedActiveUpper{}], with no positive -primary draw among \PaperBootstrapReplications{}. - -Appendix Figure~\ref{fig:block-sensitivity} shows that changing the -circular-block length from 5 to 21 or 63 sessions does not change the -matched-comparator conclusion: all before- and after-cost intervals remain -below zero. - -The costed target-exposure comparator also outperforms in both fixed calendar -halves (\texttt{subperiods.csv}). The adjusted-close pseudo-share event engine -is primary. Its net CAGR and Sharpe are \PaperEventCAGR{} and -\PaperEventSharpe{}, versus \PaperVectorizedCAGR{} and -\PaperVectorizedSharpe{} for the vectorized diagnostic. This close agreement is -local and does not make their rebalancing semantics equivalent. +\subsection{Seed and engine sensitivity remain visible} + +The learned family is not represented by a favorable seed. Across the five +frozen seeds, baseline SHV-excess Sharpe ranges from +\ExpansionSectorSeedRange{} in sectors and \ExpansionCountrySeedRange{} in +countries; every seed-level result is published. The vectorized-minus-event +mean effect is small, but the maximum one-day difference reaches +\ExpansionEngineMaxDailyBp{} basis points and the largest absolute terminal +wealth gap is \ExpansionEngineMaxWealthGap{}. This divergence is expected from +daily weight re-pegging versus drifting pseudo-shares. Appendix +Figures~\ref{fig:seed-sensitivity} and \ref{fig:engine-sensitivity} report both +diagnostics. + +\subsection{The worked audit localizes one negative result} + +The retrospective case records \PaperNetCAGR{} net CAGR and +\PaperNetSharpe{} SHV-excess Sharpe. Its causal target-exposure comparator, +after its own costs, records \PaperCostedComparatorCAGR{} and +\PaperCostedComparatorSharpe{}; annualized arithmetic shortfall is +\PaperCostedActiveMean{} [\PaperCostedActiveLower{}, +\PaperCostedActiveUpper{}]. Equation~\ref{eq:decomposition} attributes net +excess over SHV exactly: passive risky exposure contributes +\PaperPassiveExposureMean{}, active risky allocation \PaperAllocationMean{}, +dynamic exposure timing \PaperTimingMean{}, and modeled cost +\PaperImplementationCostMean{} per year. The active-allocation interval +[\PaperAllocationLower{}, \PaperAllocationUpper{}] is wholly below zero. + +Crediting SHV instead of zero to residual cash raises CAGR from +\PaperZeroCashCAGR{} to \PaperNetCAGR{} because mean risky exposure is only +\PaperMeanExposure{}. Omitting modeled costs changes Sharpe from +\PaperNetSharpe{} to \PaperZeroCostSharpe{}; invalid same-close assignment +changes it to \PaperSameCloseSharpe{}. The expansion qualifies that last result: +look-ahead changes estimates and ranks, but its direction depends on the signal. +Appendix~\ref{app:retrospective-results} contains accounting, cost, subperiod, +and block-length diagnostics for the worked case. \section{Limitations and broader impact} \subsection{Statistical validity} -The configuration was frozen only before the reported run. The project was not -preregistered, and no complete log of earlier strategy trials survives. -Reporting all four named variants prevents selection within that set but cannot -recover unknown prior trials; a Deflated Sharpe Ratio or Reality Check would -therefore create false precision -\citep{white2000reality,bailey2014deflated}. The block bootstrap conditions on -one path and approximate dependence through the chosen block length. Its -positive-draw fractions are descriptive, and its marginal intervals are not -multiplicity-adjusted. Stability across 5, 21, and 63 sessions is sensitivity -evidence, not proof of stationarity or future performance. Component interval -endpoints need not add, although the decomposition holds within every draw. -The conventional $\sqrt{252}$ Sharpe remains a descriptive statistic; block -resampling addresses uncertainty but does not remove the weak-dependence and -stationarity assumptions needed to interpret that uncertainty. +The retrospective strategy predates the protocol, and no complete log of its +earlier trials survives. The expansion protocol was committed before the two +provider requests, but it was not externally registered and evaluates an +historical interval. Neither study is a temporal holdout. A Deflated Sharpe +Ratio or Reality Check would require a defensible count of prior trials that is +not available \citep{white2000reality,bailey2014deflated}. + +The eight family--panel cells are dependent: models share dates, panels share +global market exposures, and five learned seeds are repeated fits rather than +independent strategies. The circular-block bootstrap conditions on these paths +and approximates serial dependence through fixed block lengths. Pointwise +intervals and interval-category counts are not multiplicity adjusted. Stability +across 5, 21, and 63 sessions is sensitivity evidence, not proof of stationarity +or future performance. The conventional $\sqrt{252}$ Sharpe remains a +descriptive statistic; block resampling does not remove the assumptions needed +to interpret it. Component interval endpoints need not add, although the exact +decomposition holds within every joint draw. \subsection{Economic construct validity} -``Active risky allocation'' combines group selection, within-risky weighting, -and the engine's between-rebalance semantics. ``Dynamic exposure timing'' is -relative to full-sample mean exposure, hence diagnostic rather than tradable. -The realized-exposure attribution control is not tradable and excludes its own -costs. The target-exposure comparator is causal and costed, but its risky leg is -a constructed buy-and-hold basket rather than a quoted security; its cost model -still omits spread and impact. Arithmetic means decompose exactly; compounded +``Active risky allocation'' in the worked case combines group selection, +within-risky weighting, and between-rebalance engine semantics. ``Dynamic +exposure timing'' is relative to full-sample mean exposure, hence diagnostic +rather than tradable. In both studies, the exposure-matched comparator is +conditional on the strategy's own target path; it controls exposure but is not +an independent benchmark. Arithmetic means decompose exactly; compounded wealth does not. -SHV is only a cash proxy: it has duration, expenses, and market-price variation. -Adjusted-close pseudo-shares embed total-return adjustments and omit broker -share records, spreads, queue position, and fill uncertainty. The proportional -cost grid has no order-size, volatility, venue, impact, ADV, or capacity term. -Without VIX, the regime model receives realized volatility twice in different -units; its covariance regularization is scale-sensitive, and the separate -volatility scaler uses the same underlying proxy. +The frozen models are deliberately simple. The ETF panels do not represent +point-in-time constituent universes, the country funds are unhedged U.S.-listed +securities, and the learned model pools symbols without identity or +hyperparameter search. SHV has duration, expenses, and market-price variation; +it is not a risk-free account. Adjusted-close pseudo-shares embed total-return +adjustments and omit broker share records, spreads, queue position, and fill +uncertainty. The proportional cost model has no order-size, volatility, venue, +impact, ADV, or capacity term. \subsection{External validity and reproducibility} -The study covers one U.S.-listed ETF universe, one strategy family, and one -eight-year path; it does not estimate failure magnitudes for other settings or +The expansion broadens the worked case but still covers two U.S.-listed ETF +panels, four hand-specified families, one eight-year interval, and two internal +engines. It does not estimate a population distribution of backtest errors or match broader multi-model and multi-engine studies -\citep{zhang2026alpha,yin2026implementation}. The provider can revise adjusted -histories. The raw input is not redistributed, and provider permission for the -derived publication has not been independently verified. Open schemas and -conformance fixtures reproduce software semantics, not these returns; an open -performance evaluation requires a permitted, reviewer-accessible panel. +\citep{zhang2026alpha,yin2026implementation}. Provider-adjusted histories can be +revised. Raw inputs are not redistributed, and provider permission for the +derived publication has not been independently verified. Open schemas, +canonical tapes, and conformance fixtures reproduce software semantics, not the +reported returns; full empirical reproduction requires owner-supplied matrices +with the recorded hashes. \subsection{Operational scope and broader impact} @@ -666,37 +594,131 @@ \section{Conclusion} A target-weight generator does not determine a realized return; information timing, execution, cash, costs, and comparator exposure complete the experiment. -Here, crediting residual cash corrects performance but does not create alpha: -passive exposure contributes positively, while active allocation and modeled -cost erase that benefit. A causal comparator using the same target exposure and -cost model reaches the same negative verdict as the exact ex-post attribution. -Cost omission or same-close return assignment changes the Sharpe sign without -changing the target generator. The contribution is a localized, testable -failure analysis rather than a profitable signal. +In the retrospective case, exact attribution shows that active allocation and +modeled cost erase the benefit of passive risky exposure. In the frozen +expansion, removing modeled cost raises annualized return in every family--panel +cell, while cash, +same-close timing, comparator, and engine effects vary by strategy. The +same-close switch can worsen a contrarian model rather than mechanically inflate +all results; the error is using unavailable information, not a guaranteed sign +of bias. Canonical tapes, one-switch counterfactuals, and artifact provenance +make those distinctions testable. The contribution is an executable evaluation +methodology and bounded negative evidence, not a profitable signal or deployment +claim. \bibliographystyle{plainnat} \bibliography{references} \appendix +\section{Retrospective case specification} +\label{app:retrospective} + +The universe is QQQ, VGT, GLD, TLT, SPY, and VIG, grouped as growth equities, +gold and nominal duration, and broad and dividend equities. On the first session +of each week, the strategy keeps the top two groups by a 126-session information +ratio relative to QQQ. Within selected groups, it estimates a one-factor +regression over 126 sessions and ranks a separate 126-session residual-return +window with a 21-session gap. Positive scores receive proportional long-only +weights capped at 60\% per asset. + +A seeded three-component Gaussian mixture maps QQQ return and two exact +rescalings of realized volatility to zero, half, or full exposure. A separate +21-session volatility scaler targets 20 volatility points. Named variants remove +the regime gate, the volatility scaler, or both; none was selected after the +result. The full manifest records every parameter and warm-up requirement. + +\subsection{Retrospective accounting results} +\label{app:retrospective-results} + +\begin{table}[h] + \caption{Audited retrospective accounting over 2018--2025.} + \centering + \small + \begin{tabular}{lrrr} + \toprule + Series & CAGR & Sharpe & Max drawdown \\ + \midrule + Strategy, after costs and SHV cash & \PaperNetCAGR & \PaperNetSharpe & \PaperNetMaxDrawdown \\ + Strategy, before costs and SHV cash & \PaperGrossCAGR & \PaperGrossSharpe & \PaperGrossMaxDrawdown \\ + SHV cash proxy & \PaperCashCAGR & -- & \PaperCashMaxDrawdown \\ + Realized-exposure control, gross & \PaperMatchedCAGR & \PaperMatchedSharpe & \PaperMatchedMaxDrawdown \\ + Target-exposure comparator, after costs & \PaperCostedComparatorCAGR & \PaperCostedComparatorSharpe & \PaperCostedComparatorMaxDrawdown \\ + Strategy, after costs and zero cash & \PaperZeroCashCAGR & \PaperZeroCashSharpe & \PaperZeroCashMaxDrawdown \\ + \bottomrule + \end{tabular} +\end{table} + +\begin{table}[h] + \caption{Exact attribution of annualized arithmetic net excess over SHV in + the retrospective case. Intervals use common 21-session block draws.} + \label{tab:decomposition} + \centering + \small + \begin{tabular}{lrrr} + \toprule + Component & Mean & 95\% interval & Positive draws \\ + \midrule + \PaperDecompositionRows + \bottomrule + \end{tabular} +\end{table} + +\refstepcounter{figure} +\begin{center} + \centering + \includegraphics[width=0.92\textwidth]{accounting_summary.pdf} + \parbox{0.92\textwidth}{\small Figure~\thefigure: Growth of one dollar and the + capital split between risky assets and SHV under the audited retrospective + accounting.} +\end{center} + +\refstepcounter{figure} +\begin{center} + \centering + \includegraphics[width=0.92\textwidth]{return_attribution_and_protocol_switches.pdf} + \parbox{0.92\textwidth}{\small Figure~\thefigure: Exact return attribution and + one-assumption diagnostics for the retrospective case. The same-close result + is causally invalid.} +\end{center} + \section{Reproducibility details} -The complete generator is \texttt{scripts/run\_paper\_experiments.py}. The -release wrapper checks out the committed source revision in a detached clean -worktree, writes experiment outputs outside that worktree, verifies the recorded -revision and start-state cleanliness, then builds the paper with Tectonic 0.16.9. -The reviewed invocation is: +The retrospective and expansion generators are +\texttt{scripts/run\_paper\_experiments.py} and +\texttt{scripts/run\_expansion\_experiments.py}. Their release wrappers check +out the recorded source revision in a detached clean worktree, keep raw inputs +outside that worktree, and verify source cleanliness and output hashes. The +reviewed invocations are: \begin{verbatim} +scripts/release_expansion_artifacts.sh \ + local_data/expansion scripts/release_paper_artifacts.sh \ local_data/published_rotation_prices.csv \end{verbatim} -The input must contain adjusted-close columns QQQ, VGT, GLD, TLT, SPY, VIG, -and SHV and match the input digest reported in Section 4 for exact -reproduction. Aggregate CSVs, generated LaTeX values, figures, and their hashes -are listed in \texttt{paper/results/manifest.json}. The manifest also records -the clean source commit, dependency lock, canonical configuration hash, thread -libraries, contract hash, and every package Python file individually. The -aggregate source-tree digest is: +Exact empirical reproduction requires three owner-supplied files matching the +recorded SHA-256 values: the six-ETF retrospective matrix and the two expansion +panels. Aggregate CSVs, generated LaTeX, figures, and hashes are listed in +\texttt{paper/results/manifest.json} and +\texttt{paper/expansion/results/manifest.json}. The expansion manifest also +records the pre-data protocol commit, provider-request metadata, canonical tape +hashes, thread libraries, dependency lock, and every source file used by the +run. The three matrix digests are: +\begin{center} + \scriptsize + Retrospective: \texttt{\PaperInputDigest}\\ + U.S. sectors: \texttt{\ExpansionSectorInputDigest}\\ + Country equities: \texttt{\ExpansionCountryInputDigest} +\end{center} +The expansion protocol digest is +\begin{center} + \scriptsize\texttt{\ExpansionProtocolDigest} +\end{center} +The frozen JSON key +\texttt{cost\_per\_one\_way\_gross\_notional} is misnamed: its 0.0013 value is +applied to gross two-sided traded notional, as defined in Section 3. The key is +retained to preserve the pre-retrieval protocol hash. The retrospective +source-tree digest is: \begin{center} \scriptsize\texttt{\PaperSourceTreeDigest} \end{center} @@ -706,14 +728,64 @@ \section{Reproducibility details} The public code is available at \url{https://github.com/magnaquant/quantcortex}. \fi -The reviewed run -used Python 3.14.4 on an eight-core Apple M1 with 8 GiB RAM and completed in -under 40 seconds on CPU; no GPU was used. Supported CI runs use Python -3.11 through 3.14. The reported experiment consumed less than one CPU-minute; -repository-wide testing, packaging, notebook execution, and broker conformance -checks were additional engineering validation rather than model search. +Both reviewed runs were CPU-only; environment versions and thread-pool metadata +are machine readable. Supported CI runs use Python 3.11 through 3.14. +Repository-wide testing, packaging, notebook execution, and broker conformance +checks are engineering validation rather than model search. + +\section{Expansion tables and diagnostics} + +\begin{center} +\small +\begin{tabular}{llrrrr} + \toprule + Panel & Family & Ann. return & Sharpe & CAGR & Max DD \\ + \midrule + \ExpansionBaselineRows + \bottomrule +\end{tabular} +\end{center} + +\refstepcounter{figure} +\label{fig:expansion-baseline} +\begin{center} + \centering + \includegraphics[width=0.90\textwidth]{baseline_performance.pdf} + \parbox{0.90\textwidth}{\small Figure~\thefigure: After-cost baseline + estimates under the frozen expansion protocol. The learned point is the + arithmetic mean across five seed-level metrics.} +\end{center} + +\refstepcounter{figure} +\begin{center} + \centering + \includegraphics[width=0.94\textwidth]{contract_effects_sharpe.pdf} + \parbox{0.94\textwidth}{\small Figure~\thefigure: Paired conventional + cash-excess Sharpe effects and 21-session pointwise block-bootstrap intervals + for all expansion cells.} +\end{center} + +\refstepcounter{figure} +\label{fig:seed-sensitivity} +\begin{center} + \centering + \includegraphics[width=0.90\textwidth]{learned_seed_sensitivity.pdf} + \parbox{0.90\textwidth}{\small Figure~\thefigure: Learned-model baseline + Sharpe for all five frozen random seeds. Dashed lines are within-panel seed + means.} +\end{center} + +\refstepcounter{figure} +\label{fig:engine-sensitivity} +\begin{center} + \centering + \includegraphics[width=0.82\textwidth]{engine_conformance.pdf} + \parbox{0.82\textwidth}{\small Figure~\thefigure: Maximum absolute one-day + return difference between event-driven pseudo-share accounting and the + vectorized daily re-pegging convention.} +\end{center} -\section{Block-length sensitivity} +\section{Retrospective block-length sensitivity} \refstepcounter{figure} \label{fig:block-sensitivity} @@ -727,7 +799,7 @@ \section{Block-length sensitivity} retains modeled strategy costs while the control remains gross.} \end{center} -\section{Additional engine comparison} +\section{Retrospective engine comparison} \refstepcounter{figure} \label{fig:engine-comparison} @@ -740,7 +812,7 @@ \section{Additional engine comparison} here does not remove the semantic distinction.} \end{center} -\section{Cost and overlay sensitivity} +\section{Retrospective cost and overlay sensitivity} \refstepcounter{figure} \label{fig:sensitivity} @@ -770,7 +842,7 @@ \section{Contract-to-evidence map} \midrule Exact identity & Daily return components reconstruct net cash excess & Per-row assertion and \texttt{return\_decomposition.csv} \\ Property test & Weight, timing, cash, and target-tape invariants hold & Deterministic pytest cases, including hand-computable tapes \\ - Counterfactual & One declared assumption changes the reported metric & \texttt{protocol\_switches.csv} \\ + Counterfactual & One declared assumption changes the reported metric & \texttt{protocol\_switches.csv} and \texttt{contract\_effects.csv} \\ Fault injection & Unsafe or stale execution state fails closed & Truncated reconciliation, unknown status, and stale-writer tests \\ SDK conformance & Offline request and response mappings match pinned SDKs & Broker model-construction tests and behavioral mocks \\ Untested live & Authenticated transport, venue state, and funded orders & No supporting evidence; explicitly outside scope \\ @@ -780,11 +852,13 @@ \section{Contract-to-evidence map} \section{Use of language-model assistance} -The author used an LLM-based coding assistant for repository inspection, code -review, regression-test drafting, and copyediting. The author retained -responsibility for the strategy, data, statistical methods, experiment design, -and all reported claims, and reviewed the resulting code and prose. Numerical -claims are generated by committed scripts and machine-readable artifacts. +The author used LLM-based coding assistants for implementation planning, +repository inspection, code drafting, adversarial review, regression tests, and +copyediting. The assistants are not an evaluated model or data source. The +author approved and publicly froze the expansion protocol before its provider +requests, reviewed the code and artifacts, and remains responsible for the +methods and claims. Numerical claims are generated by committed scripts and +machine-readable artifacts. \input{checklist} diff --git a/paper/preregistration.md b/paper/preregistration.md index fac9b05..297caeb 100644 --- a/paper/preregistration.md +++ b/paper/preregistration.md @@ -1,8 +1,11 @@ # Prospective Evaluation Protocol -Status: draft only. This document has not been registered, and the published -2018-2025 case is retrospective. A future study must freeze and timestamp a -completed version before inspecting confirmatory outcomes. +Status: repository-frozen prospective protocol. Commit +`4018f4063f46889f41d6981db5a71079e1dbd713` and protocol SHA-256 +`e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c` +are the pre-retrieval public record. This is not an external registry entry. The +2018-2025 six-ETF case in the paper was inspected before this freeze and is not +confirmatory evidence for the expansion. ## Research Question @@ -10,49 +13,120 @@ How much do explicit timing, cash, cost, comparator, and engine contracts change reported performance across heterogeneous target-weight strategies and real data panels? -## Confirmatory Scope - -- Strategy archetypes: time-series momentum, cross-sectional ranking, - mean-reversion, and at least one substantive learned model. -- Data: at least two real panels that pass - `docs/data-source-due-diligence.md`. Crypto is excluded from the first study to - avoid mixing calendar and microstructure changes with the contract effects. -- Feature maturity: no decision is evaluated before every declared feature and - training window is mature. -- Learned models: architecture, features, training window, hyperparameters, - stopping rule, and seed set are frozen. Every declared seed is reported. -- Engines: the canonical target tape is frozen before any engine comparison. - -Panel names, date windows, universes, and precise strategy configurations must -be inserted here before registration. Placeholders make this draft incomplete. - -## Primary Outcomes - -For each strategy-panel pair, estimate paired changes in annualized arithmetic -return and conventional sample Sharpe caused by one declared contract switch at -a time. Primary switches are execution timing, residual cash return, -transaction costs, comparator exposure, and engine semantics. Report rank -reversals across strategies as a separate outcome. - -The main uncertainty analysis uses joint circular-block resampling with block -lengths fixed before outcome inspection. Exact accounting identities are checked -within every draw. Sharpe intervals resample the statistic directly; no iid -normal standard error is used. - -## Controls Against Researcher Degrees of Freedom - -1. Archive all configurations, seeds, failures, and exclusions. -2. Define the primary metric, comparator, block lengths, and missing-data policy - before running the confirmatory windows. -3. Preserve the current negative case without retuning it. -4. Label exploratory analyses and keep them out of confirmatory claims. -5. Do not call a previously inspected period out of sample. -6. Record every protocol deviation with its timestamp and rationale. - -## Planned Evidence - -Publish the machine-readable evaluation contract, canonical target tapes, -conformance fixtures, aggregate result tables, forest plots of paired effects, -engine-conformance matrices, rank-reversal summaries, environment locks, and -content hashes. Raw data availability and reviewer access are reported per -panel; no unavailable data are implied to be open. +## Frozen Panels + +Both panels use daily adjusted closes from 2014-01-02 through 2025-12-31 and +SHV as the residual-cash proxy. Evaluation begins 2018-01-02 and ends +2025-12-31. Raw matrices remain untracked; each run records provider metadata, +retrieval time, input SHA-256, and row coverage. + +1. `us_sector_etfs`: XLB, XLE, XLF, XLI, XLK, XLP, XLU, XLV, XLY. +2. `country_equity_etfs`: EWA, EWC, EWG, EWH, EWJ, EWL, EWP, EWQ, EWS, EWU. + +The accepted matrix is the complete-row intersection of the declared symbols +and SHV. There is no forward fill or symbol substitution. A panel is excluded +only if it has fewer than 252 complete pre-evaluation sessions or a missing +evaluation month. The learned model additionally requires 24 mature monthly +training dates before evaluation. Any exclusion is reported as a protocol +deviation; a new universe is not substituted after outcomes are inspected. +Provider terms and publication rights are reported per panel without legal +inference. + +The frozen retrieval adapter is yfinance with `auto_adjust=False`, +`actions=False`, `repair=False`, and `threads=False`; the adjusted-close field +is selected explicitly. The request ends at 2026-01-01 because the provider's +end date is exclusive. This choice does not assert permission to redistribute +the observations. + +## Frozen Strategies + +All decisions occur on the first observed session of each calendar month after +every feature is mature. Signals use that session's close and execute on the +first strictly later panel row. + +- `ts_momentum`: 252-session total return. Each positive-signal asset receives + `1 / N_positive`; otherwise capital remains in SHV. +- `cross_sectional_momentum`: return from session `t-252` through `t-21`. + The top three assets receive one-third each, irrespective of sign. +- `short_term_reversal`: negative five-session return. Up to the three assets + with the lowest negative returns receive one-third each; unused exposure + remains in SHV. +- `learned_gbrt`: a walk-forward gradient-boosted regression model predicts + 21-session forward log return. Features are 5-, 21-, 63-, 126-, and + 252-session log returns; sample standard deviations of daily log returns over + 21 and 63 sessions; `price / trailing_252_session_high - 1`; and normalized + ordinal cross-sectional ranks of 21- and 252-session returns. The lowest rank + is zero and the highest is one. Training examples are prior monthly decisions + whose labels end on or before the current decision. The pooled training rows + are asset-month pairs; symbol identity is not a feature. The rolling training + set is capped at 60 decision months and must contain at least 24. The estimator + is scikit-learn `GradientBoostingRegressor` with 100 estimators, learning rate + 0.03, depth 2, minimum leaf size 10, and subsample 0.8. Seeds are 11, 29, 47, + 71, and 97. Each seed is reported; the family estimate is the arithmetic mean + of seed-level metrics. Up to three positive predictions receive one-third + each. + +All score and rank ties are broken by ascending symbol. Features are not +standardized or winsorized. No hyperparameter, universe, threshold, seed, or +window may change in response to observed performance. + +## Execution And Comparators + +Primary accounting uses the event-driven engine, initial NAV 1.0, next-bar +close execution, SHV residual-cash returns, and 13 basis points per unit of +one-way gross traded notional. Targets are long-only with gross exposure at +most one. The vectorized engine is a model-convention sensitivity diagnostic on +the identical canonical target tape; equality with pseudo-share accounting is +not expected. + +For each strategy, a causal costed comparator follows the strategy's target +risky exposure, allocates it equally across the panel, rebalances on the same +dates, holds residual SHV, and pays the same cost rate. A buy-and-hold equal +weight panel portfolio is descriptive only. + +The one-switch diagnostics are: + +1. next-bar close versus deliberately invalid same-close assignment; +2. SHV residual cash versus zero cash return; +3. 13 basis-point cost versus zero cost; +4. raw net return versus the causal exposure-matched comparator; and +5. event-driven versus vectorized accounting. + +The invalid same-close result is a diagnostic, not a candidate strategy. + +## Outcomes And Uncertainty + +Primary outcomes are the paired change in annualized arithmetic return and the +paired change in conventional sample Sharpe for each switch, strategy family, +and panel. Annualized arithmetic return is 252 times the daily mean. Sharpe is +the mean daily strategy return minus the SHV return, divided by its sample +standard deviation and multiplied by the square root of 252. Secondary outcomes +are net CAGR, maximum drawdown, turnover, cost drag, active return versus the +costed comparator, engine return divergence, strategy-rank reversals, and +learned-model seed dispersion. + +Joint circular-block bootstrap intervals use 5,000 draws, primary block length +21 sessions, sensitivity lengths 5 and 63, and seed 20260618. Return components +are resampled jointly. Exact accounting identities must hold in every original +series and sampled draw. Intervals are percentile intervals and are not called +posterior probabilities or multiple-testing-adjusted evidence. + +## Researcher Degrees Of Freedom + +1. Archive all configurations, seeds, failures, exclusions, and deviations. +2. Preserve the current negative case without retuning it. +3. Label analyses outside this protocol exploratory. +4. Do not call any period inspected before this commit out of sample. +5. Publish the machine-readable protocol, canonical target-tape hashes, + aggregate result tables, forest plots, engine-conformance matrix, + rank-reversal summary, environment lock, and content hashes. +6. Do not publish raw provider matrices unless explicit redistribution rights + are documented. + +The machine-readable source of truth is `paper/expansion/protocol.json`. + +Post-freeze clarification: the JSON key +`cost_per_one_way_gross_notional` is a naming error retained to preserve the +pre-retrieval protocol hash. The implementation applies its 0.0013 value to +gross two-sided traded notional, `sum(abs(delta_weight))`, not to one-way +turnover. This clarification changes no calculation or frozen parameter. diff --git a/paper/quantcortex_audit_anonymous.pdf b/paper/quantcortex_audit_anonymous.pdf index c0d530e..432b45f 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 2a0a59c..77fc144 100644 --- a/paper/quantcortex_audit_anonymous.sha256 +++ b/paper/quantcortex_audit_anonymous.sha256 @@ -1 +1 @@ -3bf902d420224032fa11492ddb4f8d3b636972f4a3d7740e4b7aef4bbf8484ff quantcortex_audit_anonymous.pdf +cbcee2583ea2da700b259c108d9991061033a17dd97c11d0aab676396eabef7b quantcortex_audit_anonymous.pdf diff --git a/paper/quantcortex_audit_neurips2026.pdf b/paper/quantcortex_audit_neurips2026.pdf index 0ad1de7..1e8cb87 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 4f8efdd..3f6d8bc 100644 --- a/paper/quantcortex_audit_neurips2026.sha256 +++ b/paper/quantcortex_audit_neurips2026.sha256 @@ -1 +1 @@ -59fcd30467d449984118d746417d810757d32fbceb72af25d0d4a84a65203000 quantcortex_audit_neurips2026.pdf +9bf806b296b6bade747de580142545a9825e2d3107c6a55531ea1456932ad2a5 quantcortex_audit_neurips2026.pdf diff --git a/paper/quantcortex_audit_neurips2026.sources.sha256 b/paper/quantcortex_audit_neurips2026.sources.sha256 index 0752c99..21c658d 100644 --- a/paper/quantcortex_audit_neurips2026.sources.sha256 +++ b/paper/quantcortex_audit_neurips2026.sources.sha256 @@ -1,12 +1,22 @@ -26c3a3aac4ded1b4db38503857a8627d6d7b95d851f8b11897f303d4c6e01618 main.tex +cc66acf01ed08e2f95f203ff0d91a219c2d635990f927fd63ab63027370af74f main.tex 9ef5395fcb6993271813e21c97ea594bfc45b5f8dcc9ec52fc4e16a809a0091a anonymous.tex -4bb1dc333bc0b23fb706bc5755b2cef765a2757284798d6ed9d090d530b47f6e checklist.tex -c0c907c664fe2629306b7c07973747b44dc1c04f9ead03ffd292719374e1f531 references.bib +c2b36aafee0ad2e3ac631e05a5fc1b20e1acce10dd0b4758667f0a809cafff51 checklist.tex +62609e68cbc90516cf19a46d52f80bf07ab0a4751880e4c078cc9be4ba842a5c references.bib 0c1ad36961fcd9198dcc2558cf2793e1df39973bde8264fd701f5e7970672757 neurips_2026.sty -98611c031c54382859488367dc1f5474a283014e3db2867f8210dab49ac85f25 results/generated_values.tex -83aea7fe6e6a97b55f8fe4b00218ae11972902947ed058d8281d40cb1d6e6f3c figures/accounting_summary.pdf -a2a0284f9c0687ba6ff52b4a8077189d7a2ba21b63e5e77c4287639b6bbc23b4 figures/audit_protocol.pdf -de3901b50dba906bc418660a4de8983ad311a0d4ed12b946e4d12b104435e4e2 figures/bootstrap_robustness.pdf -35c9bc06689fdf884d4aaf09d23d934025c257921fb5fe49b7ed9da6ddb36436 figures/engine_comparison.pdf -6eb5380a36035e3c22ad7c1801551edcdc2ab57b5def122031113492e5725d03 figures/return_attribution_and_protocol_switches.pdf -0b3c1296f6b747243ae74659ed0a1b2382ed7e5e2b291e803a514b83e38b0d38 figures/sensitivity_and_ablation.pdf +06f4407daed7bcd594e00bbf2751e7bd32c5d00eae530b2a6b2f66625b864162 preregistration.md +c18f94e5566cfaf6336a01db507763dc48e8ce4a8fbec2cbdcd34555ae14fc37 results/generated_values.tex +8847942a3b68fe2ad0311bb4525907b26558892e7cfb6bcde16b0ee23b82046b results/manifest.json +e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c expansion/protocol.json +2dbaa11bfdd9a1936b45114f61bd96c53e3d57eefe103a75c488352486c0e2f9 expansion/results/generated_values.tex +838a143d4b153370c44a0240ca359ce66d7356cb2bd18e81158f972a454935a4 expansion/results/manifest.json +18608aa1250c4554b2e27b507211f764fdf1ec1fb8e4b9f09e080601505e2e3a figures/accounting_summary.pdf +6765f7d1f827577ba648af7545d006ef7f005f0091194605c6130100215ac18c figures/audit_protocol.pdf +19df4c9eabe88add863ed4be4e9315aa0084b833be9deb4d5bb7633e769ced07 figures/bootstrap_robustness.pdf +270a23679216f411c796316ed4fd9061715fd3cbf89babdf2f86ec1c6dbb4eb5 figures/engine_comparison.pdf +0de9b97e871737e5d074562b89333ffabeb5a17b3ff8fa5da3e26bb365c57b23 figures/return_attribution_and_protocol_switches.pdf +98ac21b6817e152a0df0089e898c56d408ec29d544b0115ba0ca29e79db261c6 figures/sensitivity_and_ablation.pdf +901c28fa834ea698359205f1439989d89f00487d0b89c2d0b489c7b4b22187cb expansion/figures/baseline_performance.pdf +09d9e90ccc7f18d02eec0792cca6b29dec6362d843c26cc399b4762a3b95c7c9 expansion/figures/contract_effects_return.pdf +a3871730f0dd18b1cfebe7907015d8ac877ffe55883a86a4af4c20e59947db9e expansion/figures/contract_effects_sharpe.pdf +8916bd39a5354fb6f46032d434c2d7099a6ab431c963c07bccc476e5f8568073 expansion/figures/engine_conformance.pdf +3a593434c6ca6bcbd837b5aa87ae7f0270435f86ec5fbdb8cc439d34c66d339d expansion/figures/learned_seed_sensitivity.pdf diff --git a/paper/references.bib b/paper/references.bib index 043168e..31025ef 100644 --- a/paper/references.bib +++ b/paper/references.bib @@ -42,6 +42,28 @@ @article{moskowitz2012timeseries doi = {10.1016/j.jfineco.2011.11.003} } +@article{lehmann1990fads, + author = {Bruce N. Lehmann}, + title = {Fads, Martingales, and Market Efficiency}, + journal = {The Quarterly Journal of Economics}, + year = {1990}, + volume = {105}, + number = {1}, + pages = {1--28}, + doi = {10.2307/2937816} +} + +@article{friedman2001greedy, + author = {Jerome H. Friedman}, + title = {Greedy Function Approximation: A Gradient Boosting Machine}, + journal = {The Annals of Statistics}, + year = {2001}, + volume = {29}, + number = {5}, + pages = {1189--1232}, + doi = {10.1214/aos/1013203451} +} + @article{blitz2011residual, author = {David Blitz and Joop Huij and Martin Martens}, title = {Residual Momentum}, diff --git a/paper/results/generated_values.tex b/paper/results/generated_values.tex index cc34141..aa0ba40 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}{044c219b82295b187b92a7044757289faf91757d276206fe6abdec5d52252e60} +\newcommand{\PaperSourceTreeDigest}{e5cbb756404c6776eb5945a1590664bb91b22dc7b0daea22efbf80a5be78c6b2} \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 d6a84df..3720c01 100644 --- a/paper/results/manifest.json +++ b/paper/results/manifest.json @@ -1,17 +1,17 @@ { "artifacts": { - "figures/accounting_summary.pdf": "83aea7fe6e6a97b55f8fe4b00218ae11972902947ed058d8281d40cb1d6e6f3c", - "figures/accounting_summary.png": "d7cf237467814941d9acb939df0cc4ba13a8ee12ebf792648db777438f786ad9", - "figures/audit_protocol.pdf": "a2a0284f9c0687ba6ff52b4a8077189d7a2ba21b63e5e77c4287639b6bbc23b4", - "figures/audit_protocol.png": "acf80aabaadc7cfef1222f9e86aac4f194e644d7229f8cd8f5fcd156b1ff0abb", - "figures/bootstrap_robustness.pdf": "de3901b50dba906bc418660a4de8983ad311a0d4ed12b946e4d12b104435e4e2", - "figures/bootstrap_robustness.png": "3df48e16eea06d664bab9a50c5f528d14313afa837f7c4b75d024e437f36344e", - "figures/engine_comparison.pdf": "35c9bc06689fdf884d4aaf09d23d934025c257921fb5fe49b7ed9da6ddb36436", - "figures/engine_comparison.png": "88cb5f1537fb8ee382a4cb6c39c9c6a8d433c2c921a8d9018f45b34e56d5447d", - "figures/return_attribution_and_protocol_switches.pdf": "6eb5380a36035e3c22ad7c1801551edcdc2ab57b5def122031113492e5725d03", - "figures/return_attribution_and_protocol_switches.png": "e394d5d68c57fd9b87bf5d251a11ba56fccbc9468dc51776731784855ee6207f", - "figures/sensitivity_and_ablation.pdf": "0b3c1296f6b747243ae74659ed0a1b2382ed7e5e2b291e803a514b83e38b0d38", - "figures/sensitivity_and_ablation.png": "bb8cd93e788f7b218eecfe8d59b7f5e014060c79a0bb6eef489359f685dc7dfb", + "figures/accounting_summary.pdf": "18608aa1250c4554b2e27b507211f764fdf1ec1fb8e4b9f09e080601505e2e3a", + "figures/accounting_summary.png": "56b902c5e735d20f98d9fe79a365849e7f2953868b828ccbee5c81ddf893100f", + "figures/audit_protocol.pdf": "6765f7d1f827577ba648af7545d006ef7f005f0091194605c6130100215ac18c", + "figures/audit_protocol.png": "20e148cf6cd5b5bb5d49b3e7c5508735ea67c87d535d691dcded6dd4360270ff", + "figures/bootstrap_robustness.pdf": "19df4c9eabe88add863ed4be4e9315aa0084b833be9deb4d5bb7633e769ced07", + "figures/bootstrap_robustness.png": "23e6b3169225657b1757e1de0005f200e69f5772cf3339c9c41a5e608a533d19", + "figures/engine_comparison.pdf": "270a23679216f411c796316ed4fd9061715fd3cbf89babdf2f86ec1c6dbb4eb5", + "figures/engine_comparison.png": "c5539c6f4f4a1d15ad35dc299bb3c4045a8157fd9d27861c099f879d69e12cc5", + "figures/return_attribution_and_protocol_switches.pdf": "0de9b97e871737e5d074562b89333ffabeb5a17b3ff8fa5da3e26bb365c57b23", + "figures/return_attribution_and_protocol_switches.png": "98470760a1e57a6bad977e2d98601f6a1c949a8542238014d3efcce6185bc3fb", + "figures/sensitivity_and_ablation.pdf": "98ac21b6817e152a0df0089e898c56d408ec29d544b0115ba0ca29e79db261c6", + "figures/sensitivity_and_ablation.png": "aa79435ba8682e29d9bbbfa35321b27bb3af5cdbc59b911cbf476de238b69fdf", "results/ablation.csv": "a75be6ff0a0279f87c20b00dbdd94a9f5d05525b0bf0ef501394d88b4dd6ac27", "results/ablation_uncertainty.csv": "b1768f4d86267ca318ce29b7ee5612dcadbe817ff5a69d2d7a4f0d42f54b6af0", "results/accounting.csv": "39e67c4ad06307116ed2e4e46172b227b63357ecf26101cb52f8a8e1bbcf9082", @@ -20,7 +20,7 @@ "results/cost_sensitivity.csv": "25b9969cf8ecdabd19e7761ad2973252c9f5b9f994b6dc147595a2a112c24d88", "results/engine_comparison.csv": "ea0322dcd2aa4a1eb3b5996045c3a4b2ed25a85684a578ee788b013b99b643cd", "results/evaluation_contract.json": "77ee05ce64622ef9ba1bfbd7dae85c4f6fd44f07db0b21feaf6b3e0e418673e7", - "results/generated_values.tex": "98611c031c54382859488367dc1f5474a283014e3db2867f8210dab49ac85f25", + "results/generated_values.tex": "c18f94e5566cfaf6336a01db507763dc48e8ce4a8fbec2cbdcd34555ae14fc37", "results/protocol_switches.csv": "20d5a0dc37e07a20c1f2772c8e9940ff6464ef1da98692edd5fdb06531bc2393", "results/return_decomposition.csv": "53a84aa9b91c92f0037e48dc09154807cbf003ccf0038f39da8406108bf709cc", "results/sharpe_uncertainty.csv": "aead873ced9b25c76c6944aa6e3ba0901ade65c784fa5cb8cc10ad1c6c01f136", @@ -240,18 +240,18 @@ "path": "results/evaluation_contract.json", "schema_version": 1 }, - "generated_at": "2026-06-18T22:06:35Z", + "generated_at": "2026-06-18T23:53:33Z", "generator": { "dependency_lock": { "path": "poetry.lock", - "sha256": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b" + "sha256": "c0fd02871263d959522bb3d3d4717cffa1b89bfa047f584c68b81c7ad7cbbb5b" }, "git": { - "source_commit": "ef6e7e3414aed0ae9b77d50748a22823529486af", + "source_commit": "e0443b8f77cd23aee8f1fa64a2bc237e47626c47", "worktree_clean_at_start": true }, "packages": { - "matplotlib": "3.10.9", + "matplotlib": "3.11.0", "numpy": "2.4.6", "pandas": "3.0.3", "scikit-learn": "1.9.0", @@ -263,9 +263,9 @@ "python": "3.14.4", "script_sha256": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5", "source_tree": { - "file_count": 110, + "file_count": 112, "files": { - "poetry.lock": "abce2cd132651851d81034dea085d7dbfbe5e44321f2e13997db69f46d7f080b", + "poetry.lock": "c0fd02871263d959522bb3d3d4717cffa1b89bfa047f584c68b81c7ad7cbbb5b", "pyproject.toml": "eaeeb454c28bf7f6d9e530002bb7e88624b56b6c3e1fcb71e6414045cb9c42a0", "quantcortex/__init__.py": "14bf1ebdacd054c3738e4704d33da6709a39206463df8b8ced5376da342c4036", "quantcortex/alpha/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", @@ -353,6 +353,8 @@ "quantcortex/portfolio/mean_variance.py": "235a8917e107ab6274ee68891a489a130ae61fe36d359dc43e049d256453d80d", "quantcortex/portfolio/minimum_variance.py": "253a9d80fe6dc6e4c95219b76a8965230b408093d1521a72587505ae0ddfc739", "quantcortex/portfolio/risk_parity.py": "ed371437655ae980fabfc4ae450ca2100d90f304d9ce225c306f904ce1e32e91", + "quantcortex/research/__init__.py": "46e11b28979db5adfbe08f946e59b8e858de7d2a1feca714be5b0bd7cce1c32f", + "quantcortex/research/expansion.py": "a4cde375dcd650a1bd367d07da11e20e5b770a34496ccf3c82ba8e0c7467ed2a", "quantcortex/risk/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "quantcortex/risk/circuit_breaker.py": "65cf205e7303cff5ce4785a64fadc6b29ba8b85e31913e001330fb084e502ff1", "quantcortex/risk/factor_exposure.py": "5503426c590338b57512adf409e873751929464459d464f2b6940db8cadbf0ad", @@ -373,10 +375,10 @@ "quantcortex/timing/vix_scaler.py": "a3667424e5573fb289e63c26c69da6a68d6c943742359f0466d29b25c56e3686", "schemas/canonical_target_tape.schema.json": "4f1c0bf6d5360305d2982adea78de3f61c4bc1ebae9207cb2ba2bd4379b43d44", "schemas/evaluation_contract.schema.json": "970f24f587e669925306625d12c5a84dffd03ff5b222a59905849b2fa222784f", - "scripts/release_paper_artifacts.sh": "cf3de9434ab3991598e0c8d1d2c9346fde425fdf1eb792a6941fede0c66342e9", + "scripts/release_paper_artifacts.sh": "cbbde7be2dbe5fa51da153ded2d5f27a8e35a4844a5f658584fe527db5370fbd", "scripts/run_paper_experiments.py": "922bf3c414e0eadca5c13ee831528347dd83a0cd174efbdf15a987b511845de5" }, - "sha256": "044c219b82295b187b92a7044757289faf91757d276206fe6abdec5d52252e60" + "sha256": "e5cbb756404c6776eb5945a1590664bb91b22dc7b0daea22efbf80a5be78c6b2" }, "threadpools": [ { diff --git a/poetry.lock b/poetry.lock index b0cc87e..8cd91f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2658,14 +2658,14 @@ jupyter_server = ">=1.1.2" [[package]] name = "jupyter-server" -version = "2.19.0" +version = "2.20.0" description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "jupyter_server-2.19.0-py3-none-any.whl", hash = "sha256:cb76591b76d7093584c2ad2ae72ac3d58614a4b597507a1bb04e1f9f683cf9ea"}, - {file = "jupyter_server-2.19.0.tar.gz", hash = "sha256:1731236bc32b680223e1ceb9d68209a845203475012ef68773a81434b46a31a7"}, + {file = "jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc"}, + {file = "jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14"}, ] [package.dependencies] @@ -2681,7 +2681,7 @@ nbformat = ">=5.3.0" overrides = {version = ">=5.0", markers = "python_version < \"3.12\""} packaging = ">=22.0" prometheus-client = ">=0.9" -pywinpty = {version = ">=2.0.1", markers = "os_name == \"nt\""} +pywinpty = {version = ">=2.0.1,<3.0.4 || >3.0.4", markers = "os_name == \"nt\""} pyzmq = ">=24" send2trash = ">=1.8.2" terminado = ">=0.8.3" diff --git a/quantcortex/research/__init__.py b/quantcortex/research/__init__.py new file mode 100644 index 0000000..a2a36a9 --- /dev/null +++ b/quantcortex/research/__init__.py @@ -0,0 +1 @@ +"""Reusable research protocols that sit above the execution pipeline.""" diff --git a/quantcortex/research/expansion.py b/quantcortex/research/expansion.py new file mode 100644 index 0000000..d37d84c --- /dev/null +++ b/quantcortex/research/expansion.py @@ -0,0 +1,753 @@ +"""Frozen strategy and evaluation primitives for the prospective expansion.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +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 + +PERIODS_PER_YEAR = 252 +FROZEN_PROTOCOL_COMMIT = "4018f4063f46889f41d6981db5a71079e1dbd713" +FROZEN_PROTOCOL_SHA256 = ( + "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c" +) + + +@dataclass(frozen=True) +class LearnedTargetResult: + """Walk-forward learned targets and fit diagnostics for one random seed.""" + + weights: pd.DataFrame + training_rows: pd.Series + training_months: pd.Series + + +def validate_price_panel( + prices: pd.DataFrame, + *, + risky_symbols: Sequence[str], + cash_symbol: str, +) -> pd.DataFrame: + """Validate a complete, positive daily adjusted-close panel.""" + if not isinstance(prices, pd.DataFrame) or prices.empty: + raise ValueError("prices must be a non-empty DataFrame") + if not isinstance(prices.index, pd.DatetimeIndex): + raise TypeError("prices must use a DatetimeIndex") + if prices.index.hasnans or prices.index.has_duplicates: + raise ValueError("prices index must contain unique valid timestamps") + if prices.columns.has_duplicates: + raise ValueError("prices columns must be unique") + symbols = _validated_symbols(risky_symbols) + if not isinstance(cash_symbol, str) or not cash_symbol.strip(): + raise ValueError("cash_symbol must be a non-empty string") + cash_symbol = cash_symbol.strip() + if cash_symbol in symbols: + raise ValueError("cash_symbol must not be a risky symbol") + required = [*symbols, cash_symbol] + missing = [symbol for symbol in required if symbol not in prices.columns] + if missing: + raise ValueError(f"price panel is missing symbols: {missing}") + + normalized = prices.loc[:, required].copy().sort_index() + if normalized.index.tz is not None: + normalized.index = normalized.index.tz_convert("UTC").tz_localize(None) + normalized = normalized.apply(pd.to_numeric, errors="coerce").astype("float64") + values = normalized.to_numpy(dtype=float) + if not np.all(np.isfinite(values)): + raise ValueError("price panel must be complete and finite") + if np.any(values <= 0.0): + raise ValueError("price panel must be strictly positive") + return normalized + + +def monthly_decision_dates( + index: pd.DatetimeIndex, + *, + start: str | pd.Timestamp | None = None, + end: str | pd.Timestamp | None = None, +) -> pd.DatetimeIndex: + """Return the first observed panel session in each calendar month.""" + if not isinstance(index, pd.DatetimeIndex): + raise TypeError("index must be a DatetimeIndex") + if index.hasnans or index.has_duplicates or not index.is_monotonic_increasing: + raise ValueError("index must be sorted with unique valid timestamps") + selected = index + if start is not None: + selected = selected[selected >= pd.Timestamp(start)] + if end is not None: + selected = selected[selected <= pd.Timestamp(end)] + if selected.empty: + raise ValueError("decision window contains no sessions") + periods = selected.to_period("M") + first = ~periods.duplicated(keep="first") + return pd.DatetimeIndex(selected[first]) + + +def time_series_momentum_targets( + prices: pd.DataFrame, + *, + symbols: Sequence[str], + decisions: pd.DatetimeIndex, + lookback: int = 252, +) -> pd.DataFrame: + """Equal-weight assets with positive trailing total return.""" + symbols = _validated_symbols(symbols) + _validate_lookback(lookback) + panel = _risky_panel(prices, symbols) + rows = [] + for decision in decisions: + position = _decision_position(panel.index, decision, lookback) + signal = panel.iloc[position] / panel.iloc[position - lookback] - 1.0 + selected = sorted(symbol for symbol in symbols if signal[symbol] > 0.0) + row = pd.Series(0.0, index=symbols, dtype=float) + if selected: + row.loc[selected] = 1.0 / len(selected) + rows.append(row) + return _canonical_targets(rows, decisions, symbols) + + +def cross_sectional_momentum_targets( + prices: pd.DataFrame, + *, + symbols: Sequence[str], + decisions: pd.DatetimeIndex, + lookback_start: int = 252, + skip_recent: int = 21, + selection_count: int = 3, +) -> pd.DataFrame: + """Select the strongest long-horizon returns with a recent-month gap.""" + symbols = _validated_symbols(symbols) + _validate_lookback(lookback_start) + _validate_selection_count(selection_count, len(symbols)) + if not isinstance(skip_recent, int) or not 0 < skip_recent < lookback_start: + raise ValueError("skip_recent must be in (0, lookback_start)") + panel = _risky_panel(prices, symbols) + rows = [] + for decision in decisions: + position = _decision_position(panel.index, decision, lookback_start) + signal = ( + panel.iloc[position - skip_recent] + / panel.iloc[position - lookback_start] + - 1.0 + ) + selected = _ordered_symbols(signal, descending=True)[:selection_count] + row = pd.Series(0.0, index=symbols, dtype=float) + row.loc[selected] = 1.0 / selection_count + rows.append(row) + return _canonical_targets(rows, decisions, symbols) + + +def short_term_reversal_targets( + prices: pd.DataFrame, + *, + symbols: Sequence[str], + decisions: pd.DatetimeIndex, + lookback: int = 5, + selection_count: int = 3, +) -> pd.DataFrame: + """Select up to three assets with the lowest negative short-term return.""" + symbols = _validated_symbols(symbols) + _validate_lookback(lookback) + _validate_selection_count(selection_count, len(symbols)) + panel = _risky_panel(prices, symbols) + rows = [] + for decision in decisions: + position = _decision_position(panel.index, decision, lookback) + trailing = panel.iloc[position] / panel.iloc[position - lookback] - 1.0 + eligible = trailing[trailing < 0.0] + selected = _ordered_symbols(eligible, descending=False)[:selection_count] + row = pd.Series(0.0, index=symbols, dtype=float) + row.loc[selected] = 1.0 / selection_count + rows.append(row) + return _canonical_targets(rows, decisions, symbols) + + +def learned_gbrt_targets( + prices: pd.DataFrame, + *, + symbols: Sequence[str], + all_decisions: pd.DatetimeIndex, + evaluation_decisions: pd.DatetimeIndex, + config: Mapping[str, object], + seed: int, +) -> LearnedTargetResult: + """Fit the frozen pooled walk-forward GBRT and emit causal monthly targets.""" + from sklearn.ensemble import GradientBoostingRegressor + from threadpoolctl import threadpool_limits + + symbols = _validated_symbols(symbols) + if isinstance(seed, bool) or not isinstance(seed, int): + raise TypeError("seed must be an integer") + panel = _risky_panel(prices, symbols) + features, labels = _learned_feature_table( + panel, + symbols=symbols, + decisions=all_decisions, + return_windows=_integer_sequence(config, "feature_return_windows"), + volatility_windows=_integer_sequence( + config, + "feature_volatility_windows", + ), + label_horizon=_positive_int(config, "label_horizon_sessions"), + ) + maximum_months = _positive_int(config, "training_decision_months") + minimum_months = _positive_int(config, "minimum_training_decision_months") + selection_count = _positive_int(config, "selection_count") + _validate_selection_count(selection_count, len(symbols)) + if minimum_months > maximum_months: + raise ValueError("minimum training months exceed the rolling window") + + evaluation_set = set(pd.DatetimeIndex(evaluation_decisions)) + rows = [] + row_index = [] + training_rows: dict[pd.Timestamp, int] = {} + training_months: dict[pd.Timestamp, int] = {} + for decision in all_decisions: + decision = pd.Timestamp(decision) + if decision not in evaluation_set: + continue + current = features.xs(decision, level="decision_timestamp") + candidates = labels.loc[labels["label_end"] <= decision] + candidate_dates = pd.DatetimeIndex( + candidates.index.get_level_values("decision_timestamp").unique() + ).sort_values() + selected_dates = candidate_dates[-maximum_months:] + if len(selected_dates) < minimum_months: + raise ValueError( + f"learned model has only {len(selected_dates)} mature months " + f"at {decision.date().isoformat()}" + ) + train_index = candidates.index[ + candidates.index.get_level_values("decision_timestamp").isin( + selected_dates + ) + ] + train_x = features.loc[train_index] + train_y = candidates.loc[train_index, "label"] + if train_x.isna().any(axis=None) or train_y.isna().any(): + raise ValueError("learned training data must be complete") + model = GradientBoostingRegressor( + n_estimators=_positive_int(config, "n_estimators"), + learning_rate=_positive_float(config, "learning_rate"), + max_depth=_positive_int(config, "max_depth"), + min_samples_leaf=_positive_int(config, "min_samples_leaf"), + subsample=_unit_interval(config, "subsample"), + random_state=seed, + loss="squared_error", + ) + with threadpool_limits(limits=1): + model.fit(train_x.to_numpy(dtype=float), train_y.to_numpy(dtype=float)) + predictions = pd.Series( + model.predict(current.to_numpy(dtype=float)), + index=current.index, + dtype=float, + ) + positive = predictions[predictions > 0.0] + selected = _ordered_symbols(positive, descending=True)[:selection_count] + row = pd.Series(0.0, index=symbols, dtype=float) + row.loc[selected] = 1.0 / selection_count + rows.append(row) + row_index.append(decision) + training_rows[decision] = int(len(train_x)) + training_months[decision] = int(len(selected_dates)) + + if not rows: + raise ValueError("learned model produced no evaluation decisions") + weights = _canonical_targets( + rows, + pd.DatetimeIndex(row_index), + symbols, + ) + return LearnedTargetResult( + weights=weights, + training_rows=pd.Series(training_rows, name="training_rows"), + training_months=pd.Series(training_months, name="training_months"), + ) + + +def run_engine( + weights: pd.DataFrame, + prices: pd.DataFrame, + cash_returns: pd.Series, + *, + cost_rate: float, + engine: str, +) -> BacktestResult: + """Run one frozen cost/cash/engine configuration.""" + if not np.isfinite(cost_rate) or not 0.0 <= cost_rate < 1.0: + raise ValueError("cost_rate must be finite and in [0, 1)") + cost_model = TransactionCostModel(commission=0.0, slippage=cost_rate) + if engine == "event_driven": + runner = EventDrivenBacktest(cost_model, capital=1.0) + elif engine == "vectorized": + runner = VectorizedBacktest(cost_model, capital=1.0) + else: + raise ValueError("engine must be 'event_driven' or 'vectorized'") + return runner.run(weights, prices, cash_returns=cash_returns) + + +def invalid_same_close_result( + weights: pd.DataFrame, + prices: pd.DataFrame, + cash_returns: pd.Series, + *, + cost_rate: float, +) -> BacktestResult: + """Deliberately apply close-derived targets to the return ending there.""" + if not isinstance(weights.index, pd.DatetimeIndex): + raise TypeError("weights must use a DatetimeIndex") + decisions = weights.sort_index().reindex(columns=prices.columns, fill_value=0.0) + positions = prices.index.searchsorted(decisions.index, side="left") + keep = positions < len(prices) + decisions = decisions.iloc[keep].copy() + execution_positions = np.maximum(positions[keep] - 1, 0) + decisions.index = pd.DatetimeIndex( + [ + prices.index[position] - pd.Timedelta(microseconds=1) + for position in execution_positions + ] + ) + decisions = decisions[~decisions.index.duplicated(keep="last")] + return run_engine( + decisions, + prices, + cash_returns, + cost_rate=cost_rate, + engine="event_driven", + ) + + +def exposure_matched_comparator_targets(weights: pd.DataFrame) -> pd.DataFrame: + """Allocate each target's risky gross exposure equally across its panel.""" + if not isinstance(weights, pd.DataFrame) or weights.empty: + raise ValueError("weights must be a non-empty DataFrame") + if weights.shape[1] == 0 or weights.columns.has_duplicates: + raise ValueError("weights must have unique, non-empty columns") + normalized = weights.apply(pd.to_numeric, errors="coerce").astype("float64") + values = normalized.to_numpy(dtype=float) + if not np.all(np.isfinite(values)): + raise ValueError("target weights must be finite") + if np.any(values < -1e-12): + raise ValueError("target weights must be long-only") + normalized = normalized.clip(lower=0.0) + exposure = normalized.sum(axis=1) + if (exposure > 1.0 + 1e-12).any(): + raise ValueError("target exposure must remain in [0, 1]") + comparator = pd.DataFrame( + np.repeat((exposure / weights.shape[1]).to_numpy()[:, None], weights.shape[1], axis=1), + index=weights.index, + columns=weights.columns, + ) + return _canonical_targets( + [row for _, row in comparator.iterrows()], + comparator.index, + list(comparator.columns), + ) + + +def performance_metrics( + result: BacktestResult, + cash_returns: pd.Series, + evaluation_index: pd.DatetimeIndex, +) -> dict[str, float | int]: + """Compute the frozen daily performance estimands.""" + returns = result.returns.reindex(evaluation_index) + cash = cash_returns.reindex(evaluation_index) + if returns.isna().any() or cash.isna().any(): + raise ValueError("returns and cash must cover the evaluation window") + return_values = returns.to_numpy(dtype=float) + cash_values = cash.to_numpy(dtype=float) + if not np.all(np.isfinite(return_values)) or not np.all(np.isfinite(cash_values)): + raise ValueError("returns and cash must be finite") + if np.any(return_values <= -1.0): + raise ValueError("returns must remain above -100 percent") + excess = returns - cash + years = len(returns) / PERIODS_PER_YEAR + growth = (1.0 + returns).cumprod() + total_return = float(growth.iloc[-1] - 1.0) + cagr = float(growth.iloc[-1] ** (1.0 / years) - 1.0) + drawdown = growth / growth.cummax().clip(lower=1.0) - 1.0 + standard_deviation = float(excess.std(ddof=1)) + sharpe = ( + float(excess.mean() / standard_deviation * np.sqrt(PERIODS_PER_YEAR)) + if standard_deviation > 0.0 + else float("nan") + ) + active_exposure = result.weights.shift(1).reindex(evaluation_index).fillna(0.0) + return { + "observations": int(len(returns)), + "annualized_arithmetic_return": float(returns.mean() * PERIODS_PER_YEAR), + "cash_excess_sharpe": sharpe, + "cagr": cagr, + "total_return": total_return, + "max_drawdown": float(drawdown.min()), + "annualized_volatility": float(returns.std(ddof=1) * np.sqrt(PERIODS_PER_YEAR)), + "annualized_one_way_turnover": float( + result.turnover.reindex(evaluation_index).mean() * PERIODS_PER_YEAR + ), + "annualized_gross_traded_notional": float( + result.traded_notional.reindex(evaluation_index).mean() + * PERIODS_PER_YEAR + ), + "arithmetic_cost_drag": float( + result.costs.reindex(evaluation_index).sum() + ), + "mean_risky_exposure": float(active_exposure.abs().sum(axis=1).mean()), + } + + +def circular_block_indices( + observations: int, + *, + block_length: int, + replications: int, + seed: int, +) -> np.ndarray: + """Return reproducible circular-block row indices.""" + if isinstance(observations, bool) or not isinstance(observations, int): + raise TypeError("observations must be an integer") + if observations < 2: + raise ValueError("at least two observations are required") + if isinstance(block_length, bool) or not isinstance(block_length, int): + raise TypeError("block_length must be an integer") + if not 0 < block_length <= observations: + raise ValueError("block_length must be in [1, observations]") + if isinstance(replications, bool) or not isinstance(replications, int): + raise TypeError("replications must be an integer") + if replications <= 0: + raise ValueError("replications must be positive") + if isinstance(seed, bool) or not isinstance(seed, int): + raise TypeError("seed must be an integer") + rng = np.random.default_rng(seed) + block_count = int(np.ceil(observations / block_length)) + starts = rng.integers(0, observations, size=(replications, block_count)) + offsets = np.arange(block_length) + return ((starts[:, :, None] + offsets) % observations).reshape( + replications, + -1, + )[:, :observations] + + +def bootstrap_metric_difference( + lhs_returns: Sequence[pd.Series], + rhs_returns: Sequence[pd.Series], + cash_returns: pd.Series, + *, + block_length: int, + replications: int, + seed: int, + chunk_size: int = 100, +) -> dict[str, float | int]: + """Joint block intervals for family-mean return and Sharpe differences.""" + if len(lhs_returns) == 0 or len(lhs_returns) != len(rhs_returns): + raise ValueError("lhs_returns and rhs_returns must have equal non-zero size") + if isinstance(chunk_size, bool) or not isinstance(chunk_size, int) or chunk_size <= 0: + raise ValueError("chunk_size must be a positive integer") + aligned = pd.concat( + { + **{f"lhs_{i}": series for i, series in enumerate(lhs_returns)}, + **{f"rhs_{i}": series for i, series in enumerate(rhs_returns)}, + "cash": cash_returns, + }, + axis=1, + join="inner", + ) + if aligned.isna().any(axis=None) or len(aligned) < 2: + raise ValueError("bootstrap inputs must be complete with at least two rows") + values = aligned.to_numpy(dtype=float) + if not np.all(np.isfinite(values)): + raise ValueError("bootstrap inputs must be finite") + family_size = len(lhs_returns) + lhs = values[:, :family_size].T + rhs = values[:, family_size : 2 * family_size].T + cash = values[:, -1] + indices = circular_block_indices( + len(aligned), + block_length=block_length, + replications=replications, + seed=seed, + ) + annualized = np.empty(replications, dtype=float) + sharpe = np.empty(replications, dtype=float) + for start in range(0, replications, chunk_size): + stop = min(replications, start + chunk_size) + sample = indices[start:stop] + lhs_sample = lhs[:, sample] + rhs_sample = rhs[:, sample] + cash_sample = cash[sample] + annualized[start:stop] = ( + (lhs_sample.mean(axis=2) - rhs_sample.mean(axis=2)).mean(axis=0) + * PERIODS_PER_YEAR + ) + lhs_excess = lhs_sample - cash_sample[None, :, :] + rhs_excess = rhs_sample - cash_sample[None, :, :] + lhs_sharpe = _sample_sharpe(lhs_excess) + rhs_sharpe = _sample_sharpe(rhs_excess) + sharpe[start:stop] = (lhs_sharpe - rhs_sharpe).mean(axis=0) + if not np.all(np.isfinite(sharpe)): + raise ValueError("bootstrap Sharpe difference is undefined") + annualized_point = float( + (lhs.mean(axis=1) - rhs.mean(axis=1)).mean() * PERIODS_PER_YEAR + ) + lhs_point_sharpe = _sample_sharpe((lhs - cash[None, :])[:, None, :])[:, 0] + rhs_point_sharpe = _sample_sharpe((rhs - cash[None, :])[:, None, :])[:, 0] + if not np.all(np.isfinite(lhs_point_sharpe)) or not np.all( + np.isfinite(rhs_point_sharpe) + ): + raise ValueError("point Sharpe difference is undefined") + sharpe_point = float((lhs_point_sharpe - rhs_point_sharpe).mean()) + annualized_interval = np.quantile(annualized, [0.025, 0.975]) + sharpe_interval = np.quantile(sharpe, [0.025, 0.975]) + return { + "observations": int(len(aligned)), + "family_size": int(family_size), + "block_length": int(block_length), + "replications": int(replications), + "seed": int(seed), + "annualized_mean_difference": annualized_point, + "annualized_mean_ci_95_lower": float(annualized_interval[0]), + "annualized_mean_ci_95_upper": float(annualized_interval[1]), + "sharpe_difference": sharpe_point, + "sharpe_ci_95_lower": float(sharpe_interval[0]), + "sharpe_ci_95_upper": float(sharpe_interval[1]), + } + + +def _learned_feature_table( + prices: pd.DataFrame, + *, + symbols: Sequence[str], + decisions: pd.DatetimeIndex, + return_windows: Sequence[int], + volatility_windows: Sequence[int], + label_horizon: int, +) -> tuple[pd.DataFrame, pd.DataFrame]: + maximum_window = max(*return_windows, *volatility_windows, 252) + log_prices = np.log(prices) + daily_log_returns = log_prices.diff() + feature_rows = [] + label_rows = [] + index_rows = [] + for decision in decisions: + position = prices.index.get_indexer([decision])[0] + if position < maximum_window: + continue + raw: dict[str, pd.Series] = {} + for window in return_windows: + raw[f"log_return_{window}"] = ( + log_prices.iloc[position] - log_prices.iloc[position - window] + ) + for window in volatility_windows: + raw[f"log_volatility_{window}"] = daily_log_returns.iloc[ + position - window + 1 : position + 1 + ].std(ddof=1) + trailing_high = prices.iloc[position - 251 : position + 1].max(axis=0) + raw["trailing_high_distance_252"] = ( + prices.iloc[position] / trailing_high - 1.0 + ) + raw["rank_log_return_21"] = _normalized_ordinal_rank( + raw["log_return_21"] + ) + raw["rank_log_return_252"] = _normalized_ordinal_rank( + raw["log_return_252"] + ) + feature_frame = pd.DataFrame(raw, index=symbols) + if feature_frame.isna().any(axis=None): + raise ValueError("learned feature construction produced missing values") + label_end_position = position + label_horizon + has_label = label_end_position < len(prices) + if has_label: + label = log_prices.iloc[label_end_position] - log_prices.iloc[position] + label_end = prices.index[label_end_position] + for symbol in symbols: + index_rows.append((pd.Timestamp(decision), symbol)) + feature_rows.append(feature_frame.loc[symbol].to_dict()) + if has_label: + label_rows.append( + { + "decision_timestamp": pd.Timestamp(decision), + "symbol": symbol, + "label": float(label[symbol]), + "label_end": pd.Timestamp(label_end), + } + ) + if not feature_rows or not label_rows: + raise ValueError("insufficient history for learned feature construction") + feature_index = pd.MultiIndex.from_tuples( + index_rows, + names=["decision_timestamp", "symbol"], + ) + features = pd.DataFrame(feature_rows, index=feature_index).sort_index() + labels = ( + pd.DataFrame(label_rows) + .set_index(["decision_timestamp", "symbol"]) + .sort_index() + ) + return features, labels + + +def _canonical_targets( + rows: Sequence[pd.Series], + decisions: pd.DatetimeIndex, + symbols: Sequence[str], +) -> pd.DataFrame: + if len(rows) != len(decisions): + raise ValueError("target rows and decisions must have equal length") + weights = pd.DataFrame(rows, index=decisions, columns=symbols, dtype=float) + tape = weights_to_target_tape(weights, max_gross=1.0) + payload = target_tape_to_payload( + tape, + max_gross=1.0, + expected_symbols=list(symbols), + ) + decoded = json.loads(json.dumps(payload, allow_nan=False, sort_keys=True)) + restored = target_tape_to_weights( + target_tape_from_payload(decoded), + max_gross=1.0, + expected_symbols=list(symbols), + ) + restored.index.name = None + return restored + + +def _risky_panel(prices: pd.DataFrame, symbols: Sequence[str]) -> pd.DataFrame: + missing = [symbol for symbol in symbols if symbol not in prices.columns] + if missing: + raise ValueError(f"prices are missing risky symbols: {missing}") + panel = prices.loc[:, list(symbols)].copy() + if panel.isna().any(axis=None) or not np.all( + np.isfinite(panel.to_numpy(dtype=float)) + ): + raise ValueError("risky price panel must be complete and finite") + if (panel <= 0.0).any(axis=None): + raise ValueError("risky prices must be strictly positive") + return panel + + +def _validated_symbols(symbols: Sequence[str]) -> list[str]: + if isinstance(symbols, (str, bytes)): + raise TypeError("symbols must be a sequence") + parsed = list(symbols) + if not parsed or any(not isinstance(symbol, str) or not symbol.strip() for symbol in parsed): + raise ValueError("symbols must contain non-empty strings") + parsed = [symbol.strip() for symbol in parsed] + if len(parsed) != len(set(parsed)): + raise ValueError("symbols must be unique") + return parsed + + +def _decision_position( + index: pd.DatetimeIndex, + decision: pd.Timestamp, + required_history: int, +) -> int: + position = int(index.get_indexer([decision])[0]) + if position < 0: + raise ValueError(f"decision {decision!s} is not a price row") + if position < required_history: + raise ValueError(f"decision {decision!s} lacks required history") + return position + + +def _ordered_symbols(values: pd.Series, *, descending: bool) -> list[str]: + if values.empty: + return [] + if not np.all(np.isfinite(values.to_numpy(dtype=float))): + raise ValueError("ranking values must be finite") + direction = -1.0 if descending else 1.0 + return sorted(values.index, key=lambda symbol: (direction * values[symbol], symbol)) + + +def _normalized_ordinal_rank(values: pd.Series) -> pd.Series: + ordered = _ordered_symbols(values, descending=False) + denominator = max(1, len(ordered) - 1) + return pd.Series( + {symbol: position / denominator for position, symbol in enumerate(ordered)}, + index=values.index, + dtype=float, + ) + + +def _sample_sharpe(excess: np.ndarray) -> np.ndarray: + standard_deviation = excess.std(axis=2, ddof=1) + with np.errstate(divide="ignore", invalid="ignore"): + return excess.mean(axis=2) / standard_deviation * np.sqrt(PERIODS_PER_YEAR) + + +def _validate_lookback(value: int) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError("lookback must be a positive integer") + + +def _validate_selection_count(value: int, symbol_count: int) -> None: + if isinstance(value, bool) or not isinstance(value, int) or not 0 < value <= symbol_count: + raise ValueError("selection_count must be in [1, symbol_count]") + + +def _positive_int(config: Mapping[str, object], key: str) -> int: + value = config.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{key} must be a positive integer") + return value + + +def _positive_float(config: Mapping[str, object], key: str) -> float: + value = config.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{key} must be positive") + parsed = float(value) + if not np.isfinite(parsed) or parsed <= 0.0: + raise ValueError(f"{key} must be positive") + return parsed + + +def _unit_interval(config: Mapping[str, object], key: str) -> float: + value = _positive_float(config, key) + if value > 1.0: + raise ValueError(f"{key} must be in (0, 1]") + return value + + +def _integer_sequence(config: Mapping[str, object], key: str) -> tuple[int, ...]: + value = config.get(key) + if not isinstance(value, list) or not value: + raise ValueError(f"{key} must be a non-empty integer list") + parsed = tuple(value) + if any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in parsed): + raise ValueError(f"{key} must contain positive integers") + if len(parsed) != len(set(parsed)): + raise ValueError(f"{key} must not contain duplicates") + return parsed + + +__all__ = [ + "FROZEN_PROTOCOL_COMMIT", + "FROZEN_PROTOCOL_SHA256", + "LearnedTargetResult", + "PERIODS_PER_YEAR", + "bootstrap_metric_difference", + "circular_block_indices", + "cross_sectional_momentum_targets", + "exposure_matched_comparator_targets", + "invalid_same_close_result", + "learned_gbrt_targets", + "monthly_decision_dates", + "performance_metrics", + "run_engine", + "short_term_reversal_targets", + "time_series_momentum_targets", + "validate_price_panel", +] diff --git a/requirements/dev.lock b/requirements/dev.lock index a1bdb78..0fe9dba 100644 --- a/requirements/dev.lock +++ b/requirements/dev.lock @@ -47,7 +47,7 @@ jupyter-core==5.9.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-events==0.12.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-lsp==2.3.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-server-terminals==0.5.4 ; python_version >= "3.11" and python_version < "3.15" -jupyter-server==2.19.0 ; python_version >= "3.11" and python_version < "3.15" +jupyter-server==2.20.0 ; python_version >= "3.11" and python_version < "3.15" jupyter==1.1.1 ; python_version >= "3.11" and python_version < "3.15" jupyterlab-pygments==0.3.0 ; python_version >= "3.11" and python_version < "3.15" jupyterlab-server==2.28.0 ; python_version >= "3.11" and python_version < "3.15" diff --git a/requirements/notebooks.lock b/requirements/notebooks.lock index 9694d7d..d846224 100644 --- a/requirements/notebooks.lock +++ b/requirements/notebooks.lock @@ -45,7 +45,7 @@ jupyter-core==5.9.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-events==0.12.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-lsp==2.3.1 ; python_version >= "3.11" and python_version < "3.15" jupyter-server-terminals==0.5.4 ; python_version >= "3.11" and python_version < "3.15" -jupyter-server==2.19.0 ; python_version >= "3.11" and python_version < "3.15" +jupyter-server==2.20.0 ; python_version >= "3.11" and python_version < "3.15" jupyter==1.1.1 ; python_version >= "3.11" and python_version < "3.15" jupyterlab-pygments==0.3.0 ; python_version >= "3.11" and python_version < "3.15" jupyterlab-server==2.28.0 ; python_version >= "3.11" and python_version < "3.15" diff --git a/scripts/build_paper.sh b/scripts/build_paper.sh index 91a3aab..3d07d53 100755 --- a/scripts/build_paper.sh +++ b/scripts/build_paper.sh @@ -132,13 +132,23 @@ source_files=( "checklist.tex" "references.bib" "neurips_2026.sty" + "preregistration.md" "results/generated_values.tex" + "results/manifest.json" + "expansion/protocol.json" + "expansion/results/generated_values.tex" + "expansion/results/manifest.json" "figures/accounting_summary.pdf" "figures/audit_protocol.pdf" "figures/bootstrap_robustness.pdf" "figures/engine_comparison.pdf" "figures/return_attribution_and_protocol_switches.pdf" "figures/sensitivity_and_ablation.pdf" + "expansion/figures/baseline_performance.pdf" + "expansion/figures/contract_effects_return.pdf" + "expansion/figures/contract_effects_sharpe.pdf" + "expansion/figures/engine_conformance.pdf" + "expansion/figures/learned_seed_sensitivity.pdf" ) : > "${source_manifest_tmp}" for relative_path in "${source_files[@]}"; do diff --git a/scripts/fetch_expansion_data.py b/scripts/fetch_expansion_data.py new file mode 100644 index 0000000..8076daa --- /dev/null +++ b/scripts/fetch_expansion_data.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Fetch the frozen expansion panels into ignored local storage.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantcortex.research.expansion import FROZEN_PROTOCOL_SHA256 + +YFINANCE_TERMS = "https://ranaroussi.github.io/yfinance/" +YAHOO_TERMS = "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _protocol_digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _parse_aware_timestamp(value: str, *, name: str) -> datetime: + if not isinstance(value, str): + raise TypeError(f"{name} must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{name} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError(f"{name} must include a UTC offset") + return parsed + + +def _is_tracked(repo_root: Path, path: Path) -> bool: + relative = path.resolve().relative_to(repo_root.resolve()).as_posix() + result = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", relative], + cwd=repo_root, + check=False, + capture_output=True, + text=True, + ) + if result.returncode not in (0, 1): + raise RuntimeError(f"could not determine Git status for {relative}") + return result.returncode == 0 + + +def _adjusted_close(download: pd.DataFrame, symbols: list[str]) -> pd.DataFrame: + if not isinstance(download, pd.DataFrame) or download.empty: + raise ValueError("provider returned no observations") + if isinstance(download.columns, pd.MultiIndex): + if "Adj Close" not in download.columns.get_level_values(0): + raise ValueError("provider response has no adjusted-close field") + adjusted = download["Adj Close"].copy() + else: + if symbols != [str(download.columns.name)] or "Adj Close" not in download: + raise ValueError("provider response has an unexpected column layout") + adjusted = download[["Adj Close"]].rename(columns={"Adj Close": symbols[0]}) + adjusted.columns = [str(column) for column in adjusted.columns] + missing = [symbol for symbol in symbols if symbol not in adjusted.columns] + extra = [symbol for symbol in adjusted.columns if symbol not in symbols] + if missing or extra: + raise ValueError(f"provider symbol mismatch; missing={missing}, extra={extra}") + adjusted = adjusted.loc[:, symbols].copy() + adjusted.index = pd.to_datetime(adjusted.index, utc=True).tz_localize(None) + adjusted.index.name = "date" + if adjusted.index.hasnans or adjusted.index.has_duplicates: + raise ValueError("provider returned invalid or duplicate dates") + return adjusted.sort_index().apply(pd.to_numeric, errors="coerce") + + +def _validate_panel( + adjusted: pd.DataFrame, + *, + evaluation_start: str, + evaluation_end: str, + minimum_pre_evaluation_sessions: int, + minimum_mature_training_months: int, +) -> tuple[pd.DataFrame, dict[str, object]]: + missing_by_symbol = { + symbol: int(count) + for symbol, count in adjusted.isna().sum().items() + } + complete = adjusted.dropna(how="any") + values = complete.to_numpy(dtype=float) + if complete.empty or not np.all(np.isfinite(values)) or np.any(values <= 0.0): + raise ValueError("complete panel must contain finite positive prices") + before = complete.index < pd.Timestamp(evaluation_start) + pre_evaluation_sessions = int(before.sum()) + if pre_evaluation_sessions < minimum_pre_evaluation_sessions: + raise ValueError( + f"panel has {pre_evaluation_sessions} pre-evaluation sessions; " + f"requires {minimum_pre_evaluation_sessions}" + ) + evaluation = complete.loc[evaluation_start:evaluation_end] + if evaluation.empty: + raise ValueError("panel has no evaluation observations") + expected_months = pd.period_range( + pd.Timestamp(evaluation_start).to_period("M"), + pd.Timestamp(evaluation_end).to_period("M"), + freq="M", + ) + observed_months = evaluation.index.to_period("M").unique() + missing_months = expected_months.difference(observed_months) + if len(missing_months): + raise ValueError( + "panel is missing evaluation months: " + + ", ".join(str(month) for month in missing_months) + ) + mature_months = complete.index[before].to_period("M").nunique() + if mature_months < minimum_mature_training_months: + raise ValueError( + f"panel has {mature_months} pre-evaluation months; " + f"requires {minimum_mature_training_months}" + ) + diagnostics = { + "provider_rows": int(len(adjusted)), + "complete_rows": int(len(complete)), + "dropped_incomplete_rows": int(len(adjusted) - len(complete)), + "missing_by_symbol": missing_by_symbol, + "pre_evaluation_sessions": pre_evaluation_sessions, + "pre_evaluation_months": int(mature_months), + "evaluation_sessions": int(len(evaluation)), + "first_date": complete.index[0].date().isoformat(), + "last_date": complete.index[-1].date().isoformat(), + } + return complete, diagnostics + + +def _write_panel(path: Path, panel: pd.DataFrame) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + panel.to_csv(temporary, index=True, float_format="%.17g") + os.replace(temporary, path) + + +def fetch_panels( + protocol_path: Path, + output_dir: Path, + *, + retrieved_at: str, +) -> list[dict[str, object]]: + """Fetch every frozen panel and return local provenance records.""" + import yfinance as yf + + _parse_aware_timestamp(retrieved_at, name="retrieved_at") + if _protocol_digest(protocol_path) != FROZEN_PROTOCOL_SHA256: + raise ValueError("expansion protocol differs from the pre-data freeze") + protocol = json.loads(protocol_path.read_text(encoding="utf-8")) + if protocol.get("status") != ( + "repository_frozen_prospective_not_externally_registered" + ): + raise ValueError("expansion protocol is not frozen") + data = protocol["data"] + request = data["provider_request"] + cash_symbol = data["cash_proxy"] + repo_root = protocol_path.resolve().parents[2] + resolved_output = output_dir.expanduser().resolve() + try: + resolved_output.relative_to((repo_root / "local_data").resolve()) + except ValueError as exc: + raise ValueError("output_dir must remain under ignored local_data/") from exc + + records = [] + for panel_name, risky_symbols in protocol["panels"].items(): + symbols = [*risky_symbols, cash_symbol] + downloaded = yf.download( + tickers=symbols, + start=request["request_start"], + end=request["request_end_exclusive"], + auto_adjust=request["auto_adjust"], + actions=request["actions"], + repair=request["repair"], + threads=request["threads"], + progress=False, + group_by="column", + multi_level_index=True, + ) + adjusted = _adjusted_close(downloaded, symbols) + panel, diagnostics = _validate_panel( + adjusted, + evaluation_start=data["evaluation_start"], + evaluation_end=data["evaluation_end"], + minimum_pre_evaluation_sessions=data[ + "minimum_pre_evaluation_sessions" + ], + minimum_mature_training_months=data[ + "minimum_mature_training_months" + ], + ) + output_path = resolved_output / f"{panel_name}.csv" + metadata_path = resolved_output / f"{panel_name}.metadata.json" + if _is_tracked(repo_root, output_path) or _is_tracked(repo_root, metadata_path): + raise ValueError("raw expansion data paths must not be tracked") + _write_panel(output_path, panel) + record = { + "schema_version": 1, + "panel": panel_name, + "symbols": symbols, + "provider": "Yahoo Finance via yfinance", + "provider_terms_independently_verified": False, + "retrieved_at": retrieved_at, + "request": request, + "protocol_path": protocol_path.relative_to(repo_root).as_posix(), + "protocol_sha256": _protocol_digest(protocol_path), + "yfinance_version": importlib.metadata.version("yfinance"), + "terms_urls": [YFINANCE_TERMS, YAHOO_TERMS], + "raw_data_committed": False, + "local_file": output_path.relative_to(repo_root).as_posix(), + "input_sha256": _sha256(output_path), + **diagnostics, + } + metadata_path.write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + records.append(record) + return records + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--protocol", + type=Path, + default=Path("paper/expansion/protocol.json"), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("local_data/expansion"), + ) + parser.add_argument( + "--retrieved-at", + default=datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", + "Z", + ), + ) + args = parser.parse_args() + records = fetch_panels( + args.protocol.resolve(), + args.output_dir, + retrieved_at=args.retrieved_at, + ) + for record in records: + print( + f"{record['panel']}: {record['complete_rows']} rows, " + f"SHA-256 {record['input_sha256']}" + ) + print("Raw observations remain local and untracked.") + print("Provider terms and publication rights are not certified by this script.") + + +if __name__ == "__main__": + main() diff --git a/scripts/release_expansion_artifacts.sh b/scripts/release_expansion_artifacts.sh new file mode 100755 index 0000000..cdc8592 --- /dev/null +++ b/scripts/release_expansion_artifacts.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +python_bin="${PYTHON_BIN:-${repo_root}/.venv/bin/python}" +panel_argument="${1:-${repo_root}/local_data/expansion}" + +if [[ ! -x "${python_bin}" ]]; then + printf '%s\n' "Python environment not found: ${python_bin}" >&2 + exit 1 +fi +if ! git -C "${repo_root}" diff --quiet || \ + ! git -C "${repo_root}" diff --cached --quiet; then + printf '%s\n' \ + "commit tracked source changes before releasing expansion artifacts" >&2 + exit 1 +fi + +panel_dir="$( + "${python_bin}" -c \ + 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve())' \ + "${panel_argument}" +)" +if [[ ! -d "${panel_dir}" ]]; then + printf '%s\n' "expansion panel directory not found: ${panel_dir}" >&2 + exit 1 +fi +for panel in us_sector_etfs country_equity_etfs; do + for suffix in csv metadata.json; do + if [[ ! -f "${panel_dir}/${panel}.${suffix}" ]]; then + printf '%s\n' "missing expansion input: ${panel_dir}/${panel}.${suffix}" >&2 + exit 1 + fi + done +done + +current_commit="$(git -C "${repo_root}" rev-parse HEAD)" +reviewed_manifest="${repo_root}/paper/expansion/results/manifest.json" +release_source_paths=( + quantcortex + schemas/canonical_target_tape.schema.json + pyproject.toml + poetry.lock + paper/preregistration.md + paper/expansion/protocol.json + scripts/fetch_expansion_data.py + scripts/release_expansion_artifacts.sh + scripts/run_expansion_experiments.py +) + +if [[ -n "${QUANTCORTEX_EXPANSION_GENERATED_AT:-}" ]]; then + source_commit="${current_commit}" + generated_at="${QUANTCORTEX_EXPANSION_GENERATED_AT}" +elif [[ -f "${reviewed_manifest}" ]]; then + reviewed_source_commit="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["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 ! git -C "${repo_root}" cat-file -e "${reviewed_source_commit}^{commit}" \ + >/dev/null 2>&1; then + printf '%s\n' "reviewed expansion source commit is unavailable" >&2 + exit 1 + fi + if ! git -C "${repo_root}" diff --quiet \ + "${reviewed_source_commit}" "${current_commit}" -- \ + "${release_source_paths[@]}"; then + printf '%s\n' \ + "QUANTCORTEX_EXPANSION_GENERATED_AT is required for changed release source" >&2 + exit 1 + fi + source_commit="${reviewed_source_commit}" + generated_at="${reviewed_generated_at}" +else + printf '%s\n' \ + "QUANTCORTEX_EXPANSION_GENERATED_AT is required for the first release" >&2 + exit 1 +fi + +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/quantcortex-expansion-release.XXXXXX")" +source_worktree="${temporary_root}/source" +generated_output="${temporary_root}/generated" + +cleanup() { + git -C "${repo_root}" worktree remove --force "${source_worktree}" \ + >/dev/null 2>&1 || true + rm -rf "${temporary_root}" +} +trap cleanup EXIT + +git -C "${repo_root}" worktree add --detach "${source_worktree}" "${source_commit}" \ + >/dev/null + +( + cd "${source_worktree}" + MPLCONFIGDIR="${temporary_root}/matplotlib" \ + PYTHONPATH="${source_worktree}" \ + "${python_bin}" scripts/run_expansion_experiments.py \ + --protocol "${source_worktree}/paper/expansion/protocol.json" \ + --panel-dir "${panel_dir}" \ + --output-dir "${generated_output}" \ + --generated-at "${generated_at}" +) + +"${python_bin}" - \ + "${generated_output}/results/manifest.json" \ + "${source_commit}" <<'PY' +import hashlib +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +expected_commit = sys.argv[2] +git = manifest["git"] +if git["source_commit"] != expected_commit: + raise SystemExit("manifest source commit does not match the release commit") +if git["tracked_worktree_clean_at_start"] is not True: + raise SystemExit("manifest did not record a clean source worktree") +root = manifest_path.parents[1] +for relative, expected in manifest["artifacts"].items(): + artifact = root / relative + actual = hashlib.sha256(artifact.read_bytes()).hexdigest() + if actual != expected: + raise SystemExit(f"artifact digest mismatch: {relative}") +PY + +rm -rf "${repo_root}/paper/expansion/results" \ + "${repo_root}/paper/expansion/figures" +cp -R "${generated_output}/results" "${repo_root}/paper/expansion/results" +cp -R "${generated_output}/figures" "${repo_root}/paper/expansion/figures" + +printf '%s\n' "released expansion artifacts from source commit ${source_commit}" +printf '%s\n' "manifest timestamp: ${generated_at}" diff --git a/scripts/release_paper_artifacts.sh b/scripts/release_paper_artifacts.sh index f2f8a96..9bfa7b1 100755 --- a/scripts/release_paper_artifacts.sh +++ b/scripts/release_paper_artifacts.sh @@ -95,13 +95,18 @@ else poetry.lock scripts/build_paper.sh scripts/generate_report.py + scripts/fetch_expansion_data.py + scripts/release_expansion_artifacts.sh scripts/release_paper_artifacts.sh + scripts/run_expansion_experiments.py scripts/run_paper_experiments.py paper/main.tex paper/anonymous.tex paper/checklist.tex paper/references.bib paper/neurips_2026.sty + paper/preregistration.md + paper/expansion/protocol.json ) if ! git -C "${repo_root}" diff --quiet \ "${reviewed_source_commit}" "${current_commit}" -- \ @@ -222,6 +227,30 @@ rm -rf "${source_worktree}/paper/results" "${source_worktree}/paper/figures" cp -R "${generated_output}/results" "${source_worktree}/paper/results" cp -R "${generated_output}/figures" "${source_worktree}/paper/figures" +expansion_manifest="${repo_root}/paper/expansion/results/manifest.json" +if [[ ! -f "${expansion_manifest}" ]]; then + printf '%s\n' "reviewed expansion manifest not found: ${expansion_manifest}" >&2 + exit 1 +fi +expansion_source_commit="$( + "${python_bin}" -c \ + 'import json, sys; print(json.load(open(sys.argv[1]))["git"]["source_commit"])' \ + "${expansion_manifest}" +)" +if [[ "${expansion_source_commit}" != "${source_commit}" ]]; then + printf '%s\n' \ + "expansion artifacts do not match the paper source commit" \ + "paper source: ${source_commit}" \ + "expansion source: ${expansion_source_commit}" >&2 + exit 1 +fi +rm -rf "${source_worktree}/paper/expansion/results" \ + "${source_worktree}/paper/expansion/figures" +cp -R "${repo_root}/paper/expansion/results" \ + "${source_worktree}/paper/expansion/results" +cp -R "${repo_root}/paper/expansion/figures" \ + "${source_worktree}/paper/expansion/figures" + ( cd "${source_worktree}" PYTHON_BIN="${python_bin}" \ diff --git a/scripts/run_expansion_experiments.py b/scripts/run_expansion_experiments.py new file mode 100644 index 0000000..55b3e4a --- /dev/null +++ b/scripts/run_expansion_experiments.py @@ -0,0 +1,1340 @@ +#!/usr/bin/env python3 +"""Run the frozen multi-panel evaluation-contract expansion.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import platform +import shutil +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path + +import numpy as np +import pandas as pd + +os.environ.setdefault("LOKY_MAX_CPU_COUNT", "1") + +from quantcortex.backtest.conformance import ( + target_tape_to_payload, + weights_to_target_tape, +) +from quantcortex.backtest.metrics.plotting import ( + BRIGHT_COLORS, + INK, + MUTED_INK, + NEGATIVE_RED, + REFERENCE_BLUE, + SPINE, + add_panel_label, + apply_plot_style, + style_axis, +) +from quantcortex.data.local_csv import load_price_matrix, sha256_file +from quantcortex.research.expansion import ( + FROZEN_PROTOCOL_COMMIT, + FROZEN_PROTOCOL_SHA256, + bootstrap_metric_difference, + cross_sectional_momentum_targets, + exposure_matched_comparator_targets, + invalid_same_close_result, + learned_gbrt_targets, + monthly_decision_dates, + performance_metrics, + run_engine, + short_term_reversal_targets, + time_series_momentum_targets, + validate_price_panel, +) + +VARIANTS = ( + "baseline", + "same_close", + "zero_cash", + "zero_cost", + "costed_comparator", + "vectorized", +) +SWITCHES = { + "same_close_minus_baseline": ("same_close", "baseline"), + "zero_cash_minus_baseline": ("zero_cash", "baseline"), + "zero_cost_minus_baseline": ("zero_cost", "baseline"), + "strategy_minus_costed_comparator": ("baseline", "costed_comparator"), + "vectorized_minus_event": ("vectorized", "baseline"), +} +STRATEGY_LABELS = { + "ts_momentum": "TS momentum", + "cross_sectional_momentum": "Cross-sectional momentum", + "short_term_reversal": "Short-term reversal", + "learned_gbrt": "Learned GBRT", +} +SWITCH_LABELS = { + "same_close_minus_baseline": "Same-close - next-bar", + "zero_cash_minus_baseline": "Zero cash - SHV cash", + "zero_cost_minus_baseline": "Zero cost - 13 bp cost", + "strategy_minus_costed_comparator": "Strategy - costed comparator", + "vectorized_minus_event": "Vectorized - event-driven", +} +PANEL_LABELS = { + "us_sector_etfs": "U.S. sector ETFs", + "country_equity_etfs": "Country equity ETFs", +} +PROVIDER_NAME = "Yahoo Finance via yfinance" +TERMS_URLS = ( + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html", +) +SOURCE_FILES = ( + "scripts/fetch_expansion_data.py", + "scripts/release_expansion_artifacts.sh", + "scripts/run_expansion_experiments.py", + "paper/preregistration.md", + "paper/expansion/protocol.json", + "schemas/canonical_target_tape.schema.json", + "pyproject.toml", + "poetry.lock", +) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _canonical_json_digest(value: object) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _parse_aware_timestamp(value: str, *, name: str) -> datetime: + if not isinstance(value, str): + raise TypeError(f"{name} must be an ISO-8601 string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{name} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ValueError(f"{name} must include a UTC offset") + return parsed + + +def _threadpool_environment() -> list[dict[str, object]]: + """Return stable BLAS/OpenMP metadata without machine-specific paths.""" + from threadpoolctl import threadpool_info + + keys = ( + "user_api", + "internal_api", + "prefix", + "version", + "threading_layer", + "architecture", + "num_threads", + ) + return [ + {key: entry[key] for key in keys if key in entry} + for entry in threadpool_info() + ] + + +def _git_metadata(repo_root: Path) -> dict[str, str | bool]: + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + tracked_status = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ).stdout + return { + "source_commit": commit, + "tracked_worktree_clean_at_start": not tracked_status.strip(), + } + + +def _source_tree_manifest(repo_root: Path) -> dict[str, object]: + relative_paths = set(SOURCE_FILES) + relative_paths.update( + path.relative_to(repo_root).as_posix() + for path in (repo_root / "quantcortex").rglob("*.py") + if path.is_file() + ) + files = {} + digest = hashlib.sha256() + for relative in sorted(relative_paths): + path = repo_root / relative + if not path.is_file(): + raise ValueError(f"source fingerprint is missing {relative}") + content_digest = _sha256(path) + files[relative] = content_digest + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(content_digest.encode("ascii")) + digest.update(b"\n") + return { + "sha256": digest.hexdigest(), + "file_count": len(files), + "files": files, + } + + +def _load_protocol(path: Path) -> dict[str, object]: + if _sha256(path) != FROZEN_PROTOCOL_SHA256: + raise ValueError("expansion protocol differs from the pre-data freeze") + protocol = json.loads(path.read_text(encoding="utf-8")) + if protocol.get("schema_version") != 1: + raise ValueError("unsupported expansion protocol version") + if protocol.get("status") != ( + "repository_frozen_prospective_not_externally_registered" + ): + raise ValueError("expansion protocol is not frozen") + if protocol.get("historical_case_confirmatory") is not False: + raise ValueError("historical case must remain non-confirmatory") + return protocol + + +def _load_panel( + repo_root: Path, + panel_dir: Path, + panel_name: str, + risky_symbols: list[str], + protocol: dict[str, object], +) -> tuple[pd.DataFrame, dict[str, object]]: + data = protocol["data"] + cash_symbol = data["cash_proxy"] + csv_path = panel_dir / f"{panel_name}.csv" + metadata_path = panel_dir / f"{panel_name}.metadata.json" + if not csv_path.is_file() or not metadata_path.is_file(): + raise ValueError(f"missing local panel or metadata for {panel_name}") + try: + relative_csv = csv_path.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + relative_csv = None + if relative_csv is not None: + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", "--", relative_csv], + cwd=repo_root, + check=False, + capture_output=True, + ) + if tracked.returncode == 0: + raise ValueError(f"raw panel must not be tracked: {csv_path}") + if tracked.returncode != 1: + raise RuntimeError(f"could not determine tracking status for {csv_path}") + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + expected_protocol_digest = _sha256(repo_root / "paper/expansion/protocol.json") + if metadata.get("schema_version") != 1: + raise ValueError(f"unsupported metadata version for {panel_name}") + if metadata.get("panel") != panel_name: + raise ValueError(f"metadata panel mismatch for {panel_name}") + if metadata.get("protocol_sha256") != expected_protocol_digest: + raise ValueError(f"metadata protocol digest mismatch for {panel_name}") + if metadata.get("input_sha256") != sha256_file(csv_path): + raise ValueError(f"input digest mismatch for {panel_name}") + if metadata.get("raw_data_committed") is not False: + raise ValueError(f"metadata must mark raw {panel_name} data uncommitted") + symbols = [*risky_symbols, cash_symbol] + if metadata.get("symbols") != symbols: + raise ValueError(f"metadata symbol mismatch for {panel_name}") + if metadata.get("request") != data["provider_request"]: + raise ValueError(f"metadata request mismatch for {panel_name}") + if metadata.get("protocol_path") != "paper/expansion/protocol.json": + raise ValueError(f"metadata protocol path mismatch for {panel_name}") + if metadata.get("provider") != PROVIDER_NAME: + raise ValueError(f"metadata provider mismatch for {panel_name}") + if metadata.get("terms_urls") != list(TERMS_URLS): + raise ValueError(f"metadata terms URLs mismatch for {panel_name}") + if not isinstance(metadata.get("yfinance_version"), str) or not metadata[ + "yfinance_version" + ].strip(): + raise ValueError(f"metadata provider version is missing for {panel_name}") + if metadata.get("provider_terms_independently_verified") is not False: + raise ValueError(f"metadata terms status mismatch for {panel_name}") + _parse_aware_timestamp(metadata.get("retrieved_at"), name="retrieved_at") + prices = load_price_matrix( + csv_path, + symbols=symbols, + start=data["start"], + end=data["evaluation_end"], + max_ffill=None, + require_complete=True, + ) + validated = validate_price_panel( + prices, + risky_symbols=risky_symbols, + cash_symbol=cash_symbol, + ) + evaluation = validated.loc[data["evaluation_start"] : data["evaluation_end"]] + pre_evaluation_sessions = int( + (validated.index < pd.Timestamp(data["evaluation_start"])).sum() + ) + pre_evaluation_months = int( + validated.index[ + validated.index < pd.Timestamp(data["evaluation_start"]) + ].to_period("M").nunique() + ) + if pre_evaluation_sessions < data["minimum_pre_evaluation_sessions"]: + raise ValueError(f"panel {panel_name} has insufficient pre-evaluation history") + expected_months = pd.period_range( + pd.Timestamp(data["evaluation_start"]).to_period("M"), + pd.Timestamp(data["evaluation_end"]).to_period("M"), + freq="M", + ) + missing_months = expected_months.difference(evaluation.index.to_period("M").unique()) + if len(missing_months): + raise ValueError( + f"panel {panel_name} is missing evaluation months: " + + ", ".join(str(month) for month in missing_months) + ) + expected_metadata = { + "complete_rows": len(validated), + "evaluation_sessions": len(evaluation), + "first_date": validated.index[0].date().isoformat(), + "last_date": validated.index[-1].date().isoformat(), + "pre_evaluation_months": pre_evaluation_months, + "pre_evaluation_sessions": pre_evaluation_sessions, + } + for key, expected in expected_metadata.items(): + if metadata.get(key) != expected: + raise ValueError(f"metadata {key} mismatch for {panel_name}") + provider_rows = metadata.get("provider_rows") + dropped_rows = metadata.get("dropped_incomplete_rows") + missing_by_symbol = metadata.get("missing_by_symbol") + if ( + isinstance(provider_rows, bool) + or not isinstance(provider_rows, int) + or isinstance(dropped_rows, bool) + or not isinstance(dropped_rows, int) + or provider_rows != len(validated) + dropped_rows + or dropped_rows < 0 + ): + raise ValueError(f"metadata provider-row accounting mismatch for {panel_name}") + if not isinstance(missing_by_symbol, dict) or set(missing_by_symbol) != set(symbols): + raise ValueError(f"metadata missingness symbols mismatch for {panel_name}") + if any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in missing_by_symbol.values() + ): + raise ValueError(f"metadata missingness counts are invalid for {panel_name}") + if sum(missing_by_symbol.values()) < dropped_rows: + raise ValueError(f"metadata missingness accounting mismatch for {panel_name}") + return validated, metadata + + +def _strategy_targets( + prices: pd.DataFrame, + risky_symbols: list[str], + protocol: dict[str, object], +) -> tuple[dict[str, dict[str, pd.DataFrame]], list[dict[str, object]]]: + data = protocol["data"] + strategies = protocol["strategies"] + all_decisions = monthly_decision_dates(prices.index, end=data["evaluation_end"]) + evaluation_decisions = monthly_decision_dates( + prices.index, + start=data["evaluation_start"], + end=data["evaluation_end"], + ) + targets: dict[str, dict[str, pd.DataFrame]] = { + "ts_momentum": { + "deterministic": time_series_momentum_targets( + prices, + symbols=risky_symbols, + decisions=evaluation_decisions, + lookback=strategies["ts_momentum"]["lookback_sessions"], + ) + }, + "cross_sectional_momentum": { + "deterministic": cross_sectional_momentum_targets( + prices, + symbols=risky_symbols, + decisions=evaluation_decisions, + lookback_start=strategies["cross_sectional_momentum"][ + "lookback_start_sessions" + ], + skip_recent=strategies["cross_sectional_momentum"][ + "skip_recent_sessions" + ], + selection_count=strategies["cross_sectional_momentum"][ + "selection_count" + ], + ) + }, + "short_term_reversal": { + "deterministic": short_term_reversal_targets( + prices, + symbols=risky_symbols, + decisions=evaluation_decisions, + lookback=strategies["short_term_reversal"]["lookback_sessions"], + selection_count=strategies["short_term_reversal"][ + "selection_count" + ], + ) + }, + "learned_gbrt": {}, + } + learned_diagnostics = [] + learned_config = strategies["learned_gbrt"] + for seed in learned_config["seeds"]: + result = learned_gbrt_targets( + prices, + symbols=risky_symbols, + all_decisions=all_decisions, + evaluation_decisions=evaluation_decisions, + config=learned_config, + seed=seed, + ) + targets["learned_gbrt"][str(seed)] = result.weights + learned_diagnostics.append( + { + "seed": int(seed), + "first_training_rows": int(result.training_rows.iloc[0]), + "minimum_training_rows": int(result.training_rows.min()), + "maximum_training_rows": int(result.training_rows.max()), + "minimum_training_months": int(result.training_months.min()), + "maximum_training_months": int(result.training_months.max()), + } + ) + return targets, learned_diagnostics + + +def _evaluation_index(prices: pd.DataFrame, protocol: dict[str, object]) -> pd.DatetimeIndex: + data = protocol["data"] + index = prices.index[ + (prices.index >= pd.Timestamp(data["evaluation_start"])) + & (prices.index <= pd.Timestamp(data["evaluation_end"])) + ] + if index.empty: + raise ValueError("evaluation window contains no observations") + return pd.DatetimeIndex(index) + + +def _target_hash(weights: pd.DataFrame) -> tuple[str, int]: + tape = weights_to_target_tape(weights, max_gross=1.0) + payload = target_tape_to_payload( + tape, + max_gross=1.0, + expected_symbols=list(weights.columns), + ) + return _canonical_json_digest(payload), int(len(payload["records"])) + + +def _run_target_variant( + weights: pd.DataFrame, + risky_prices: pd.DataFrame, + cash_returns: pd.Series, + *, + cost_rate: float, +) -> dict[str, object]: + zero_cash = pd.Series(0.0, index=cash_returns.index, name="zero_cash") + comparator_targets = exposure_matched_comparator_targets(weights) + return { + "baseline": run_engine( + weights, + risky_prices, + cash_returns, + cost_rate=cost_rate, + engine="event_driven", + ), + "same_close": invalid_same_close_result( + weights, + risky_prices, + cash_returns, + cost_rate=cost_rate, + ), + "zero_cash": run_engine( + weights, + risky_prices, + zero_cash, + cost_rate=cost_rate, + engine="event_driven", + ), + "zero_cost": run_engine( + weights, + risky_prices, + cash_returns, + cost_rate=0.0, + engine="event_driven", + ), + "costed_comparator": run_engine( + comparator_targets, + risky_prices, + cash_returns, + cost_rate=cost_rate, + engine="event_driven", + ), + "vectorized": run_engine( + weights, + risky_prices, + cash_returns, + cost_rate=cost_rate, + engine="vectorized", + ), + } + + +def _panel_experiment( + panel_name: str, + prices: pd.DataFrame, + risky_symbols: list[str], + protocol: dict[str, object], +) -> dict[str, object]: + cash_symbol = protocol["data"]["cash_proxy"] + cost_rate = protocol["execution"]["cost_per_one_way_gross_notional"] + risky_prices = prices.loc[:, risky_symbols] + cash_returns = prices[cash_symbol].pct_change(fill_method=None).fillna(0.0) + cash_returns.name = cash_symbol + evaluation_index = _evaluation_index(prices, protocol) + targets, learned_diagnostics = _strategy_targets( + prices, + risky_symbols, + protocol, + ) + metric_rows = [] + engine_rows = [] + target_rows = [] + variant_series: dict[str, dict[str, dict[str, pd.Series]]] = {} + for strategy, seed_targets in targets.items(): + variant_series[strategy] = {} + for seed, weights in seed_targets.items(): + target_digest, record_count = _target_hash(weights) + target_rows.append( + { + "panel": panel_name, + "strategy": strategy, + "seed": seed, + "sha256": target_digest, + "decision_count": int(len(weights)), + "record_count": record_count, + "symbols": list(weights.columns), + } + ) + results = _run_target_variant( + weights, + risky_prices, + cash_returns, + cost_rate=cost_rate, + ) + variant_series[strategy][seed] = {} + for variant in VARIANTS: + result = results[variant] + metrics = performance_metrics(result, cash_returns, evaluation_index) + metric_rows.append( + { + "panel": panel_name, + "strategy": strategy, + "seed": seed, + "variant": variant, + **metrics, + } + ) + variant_series[strategy][seed][variant] = result.returns.reindex( + evaluation_index + ) + event_returns = variant_series[strategy][seed]["baseline"] + vectorized_returns = variant_series[strategy][seed]["vectorized"] + engine_rows.append( + { + "panel": panel_name, + "strategy": strategy, + "seed": seed, + "observations": int(len(evaluation_index)), + "max_absolute_daily_return_difference": float( + (event_returns - vectorized_returns).abs().max() + ), + "final_wealth_difference": float( + (1.0 + vectorized_returns).prod() + - (1.0 + event_returns).prod() + ), + "event_return_sha256": _series_digest(event_returns), + "vectorized_return_sha256": _series_digest(vectorized_returns), + } + ) + return { + "metrics": pd.DataFrame(metric_rows), + "engine": pd.DataFrame(engine_rows), + "targets": target_rows, + "series": variant_series, + "cash": cash_returns.reindex(evaluation_index), + "learned_diagnostics": learned_diagnostics, + } + + +def _series_digest(series: pd.Series) -> str: + payload = pd.DataFrame( + { + "date": series.index.strftime("%Y-%m-%d"), + "return": series.to_numpy(dtype=float), + } + ).to_csv(index=False, float_format="%.17g") + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _family_summary(metrics: pd.DataFrame) -> pd.DataFrame: + numeric = [ + column + for column in metrics.select_dtypes(include=[np.number]).columns + if column not in {"observations", "family_size"} + ] + grouped = metrics.groupby(["panel", "strategy", "variant"], sort=True) + summary = grouped[numeric].mean().reset_index() + summary["observations"] = grouped["observations"].min().to_numpy() + summary["family_size"] = grouped.size().to_numpy() + return summary.loc[ + :, + [ + "panel", + "strategy", + "variant", + "family_size", + "observations", + *numeric, + ], + ] + + +def _contract_effects( + panel_results: dict[str, dict[str, object]], + protocol: dict[str, object], +) -> pd.DataFrame: + uncertainty = protocol["uncertainty"] + block_lengths = [ + uncertainty["primary_block_sessions"], + *uncertainty["sensitivity_block_sessions"], + ] + rows = [] + for panel_name, panel in panel_results.items(): + for strategy, seeds in panel["series"].items(): + ordered_seeds = sorted( + seeds, + key=lambda value: int(value) if value.isdigit() else -1, + ) + for switch, (lhs_variant, rhs_variant) in SWITCHES.items(): + lhs = [seeds[seed][lhs_variant] for seed in ordered_seeds] + rhs = [seeds[seed][rhs_variant] for seed in ordered_seeds] + for block_length in block_lengths: + estimate = bootstrap_metric_difference( + lhs, + rhs, + panel["cash"], + block_length=block_length, + replications=uncertainty["replications"], + seed=uncertainty["seed"], + ) + rows.append( + { + "panel": panel_name, + "strategy": strategy, + "switch": switch, + **estimate, + } + ) + return pd.DataFrame(rows) + + +def _rank_reversals(summary: pd.DataFrame) -> pd.DataFrame: + rows = [] + for panel, panel_frame in summary.groupby("panel"): + baseline = panel_frame.loc[panel_frame["variant"] == "baseline"] + baseline_order = _metric_order(baseline) + for variant in VARIANTS: + variant_frame = panel_frame.loc[panel_frame["variant"] == variant] + order = _metric_order(variant_frame) + for strategy in sorted(baseline_order): + rows.append( + { + "panel": panel, + "variant": variant, + "strategy": strategy, + "baseline_rank": baseline_order[strategy], + "variant_rank": order[strategy], + "rank_change": baseline_order[strategy] - order[strategy], + } + ) + return pd.DataFrame(rows) + + +def _metric_order(frame: pd.DataFrame) -> dict[str, int]: + values = { + row.strategy: float(row.cash_excess_sharpe) + for row in frame.itertuples(index=False) + } + ordered = sorted(values, key=lambda strategy: (-values[strategy], strategy)) + return {strategy: rank for rank, strategy in enumerate(ordered, start=1)} + + +def _write_csv(frame: pd.DataFrame, path: Path) -> None: + frame.to_csv(path, index=False, float_format="%.12g", lineterminator="\n") + + +def _write_generated_values( + *, + summary: pd.DataFrame, + effects: pd.DataFrame, + engine: pd.DataFrame, + metrics: pd.DataFrame, + ranks: pd.DataFrame, + data_records: list[dict[str, object]], + path: Path, +) -> None: + """Write paper-facing LaTeX values from the aggregate result tables.""" + + def macro(name: str, value: str | int) -> str: + return f"\\newcommand{{\\{name}}}{{{value}}}" + + def percent(value: float) -> str: + return f"{value * 100.0:.2f}\\%" + + data_by_panel = {str(record["panel"]): record for record in data_records} + if set(data_by_panel) != set(PANEL_LABELS): + raise ValueError("generated values require one provenance record per panel") + input_digests = {} + for panel, record in data_by_panel.items(): + digest = record.get("input_sha256") + if not isinstance(digest, str) or len(digest) != 64: + raise ValueError(f"invalid input digest for {panel}") + try: + int(digest, 16) + except ValueError as exc: + raise ValueError(f"invalid input digest for {panel}") from exc + input_digests[panel] = digest + + baseline = summary.loc[summary["variant"] == "baseline"].copy() + baseline_rows = [] + for panel in PANEL_LABELS: + frame = baseline.loc[baseline["panel"] == panel].set_index("strategy") + for strategy in STRATEGY_LABELS: + row = frame.loc[strategy] + baseline_rows.append( + " & ".join( + [ + PANEL_LABELS[panel], + STRATEGY_LABELS[strategy], + percent(float(row["annualized_arithmetic_return"])), + f"{float(row['cash_excess_sharpe']):.2f}", + percent(float(row["cagr"])), + percent(float(row["max_drawdown"])), + ] + ) + + r" \\" + ) + + primary = effects.loc[effects["block_length"] == 21].copy() + effect_rows = [] + for switch in SWITCHES: + frame = primary.loc[primary["switch"] == switch] + return_below = int((frame["annualized_mean_ci_95_upper"] < 0.0).sum()) + return_above = int((frame["annualized_mean_ci_95_lower"] > 0.0).sum()) + sharpe_below = int((frame["sharpe_ci_95_upper"] < 0.0).sum()) + sharpe_above = int((frame["sharpe_ci_95_lower"] > 0.0).sum()) + count = int(len(frame)) + effect_rows.append( + " & ".join( + [ + SWITCH_LABELS[switch], + f"{return_below}/{count - return_below - return_above}/{return_above}", + f"{sharpe_below}/{count - sharpe_below - sharpe_above}/{sharpe_above}", + ] + ) + + r" \\" + ) + + learned = metrics.loc[ + (metrics["strategy"] == "learned_gbrt") + & (metrics["variant"] == "baseline") + ] + seed_ranges = {} + for panel in PANEL_LABELS: + values = learned.loc[ + learned["panel"] == panel, + "cash_excess_sharpe", + ] + seed_ranges[panel] = f"{values.min():.2f}--{values.max():.2f}" + + cost_effect = primary.loc[primary["switch"] == "zero_cost_minus_baseline"] + same_close = primary.loc[ + primary["switch"] == "same_close_minus_baseline" + ] + zero_cash = primary.loc[primary["switch"] == "zero_cash_minus_baseline"] + rank_changes = { + variant: int( + ( + ranks.loc[ranks["variant"] == variant, "rank_change"] + != 0 + ).sum() + ) + for variant in ("same_close", "zero_cost", "costed_comparator") + } + lines = [ + "% Generated by scripts/run_expansion_experiments.py; do not edit.", + macro("ExpansionPanelCount", len(PANEL_LABELS)), + macro("ExpansionFamilyCount", len(STRATEGY_LABELS)), + macro("ExpansionFamilyPanelCount", len(baseline)), + macro( + "ExpansionPositiveBaselineSharpeCount", + int((baseline["cash_excess_sharpe"] > 0.0).sum()), + ), + macro("ExpansionEvaluationSessions", f"{int(baseline['observations'].min()):,}"), + macro("ExpansionBootstrapReplications", f"{int(primary['replications'].min()):,}"), + macro("ExpansionProtocolDigest", FROZEN_PROTOCOL_SHA256), + macro("ExpansionSectorInputDigest", input_digests["us_sector_etfs"]), + macro( + "ExpansionCountryInputDigest", + input_digests["country_equity_etfs"], + ), + macro("ExpansionBaselineRows", "\n".join(baseline_rows)), + macro("ExpansionEffectRows", "\n".join(effect_rows)), + macro( + "ExpansionCostEffectRange", + ( + f"{cost_effect['annualized_mean_difference'].min() * 100.0:.2f}--" + f"{cost_effect['annualized_mean_difference'].max() * 100.0:.2f}" + " percentage points" + ), + ), + macro( + "ExpansionSameCloseBelowCount", + int((same_close["annualized_mean_ci_95_upper"] < 0.0).sum()), + ), + macro( + "ExpansionSameCloseOverlapCount", + int( + ( + (same_close["annualized_mean_ci_95_lower"] <= 0.0) + & (same_close["annualized_mean_ci_95_upper"] >= 0.0) + ).sum() + ), + ), + macro( + "ExpansionZeroCashBelowCount", + int((zero_cash["annualized_mean_ci_95_upper"] < 0.0).sum()), + ), + macro( + "ExpansionZeroCashOverlapCount", + int( + ( + (zero_cash["annualized_mean_ci_95_lower"] <= 0.0) + & (zero_cash["annualized_mean_ci_95_upper"] >= 0.0) + ).sum() + ), + ), + macro( + "ExpansionEngineMaxDailyBp", + f"{engine['max_absolute_daily_return_difference'].max() * 10_000.0:.2f}", + ), + macro( + "ExpansionEngineMaxWealthGap", + percent(float(engine["final_wealth_difference"].abs().max())), + ), + macro("ExpansionSectorSeedRange", seed_ranges["us_sector_etfs"]), + macro("ExpansionCountrySeedRange", seed_ranges["country_equity_etfs"]), + macro("ExpansionSameCloseRankChanges", rank_changes["same_close"]), + macro("ExpansionZeroCostRankChanges", rank_changes["zero_cost"]), + macro( + "ExpansionComparatorRankChanges", + rank_changes["costed_comparator"], + ), + ] + path.write_text("\n".join(lines) + "\n", encoding="ascii") + + +def _plot_baseline(summary: pd.DataFrame, output: Path) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + apply_plot_style() + baseline = summary.loc[summary["variant"] == "baseline"] + fig, axes = plt.subplots(1, 2, figsize=(10.2, 4.2), sharex=True, sharey=True) + colors = dict( + zip( + STRATEGY_LABELS, + BRIGHT_COLORS[: len(STRATEGY_LABELS)], + strict=True, + ) + ) + annotation_style = { + ("country_equity_etfs", "learned_gbrt"): { + "xytext": (-6, -7), + "ha": "right", + }, + ("country_equity_etfs", "ts_momentum"): { + "xytext": (6, -1), + "ha": "left", + }, + } + for panel_index, panel in enumerate(PANEL_LABELS): + axis = axes[panel_index] + frame = baseline.loc[baseline["panel"] == panel] + for row in frame.itertuples(index=False): + axis.scatter( + row.annualized_arithmetic_return * 100.0, + row.cash_excess_sharpe, + color=colors[row.strategy], + s=45, + zorder=3, + ) + annotation = annotation_style.get( + (panel, row.strategy), + {"xytext": (5, 5), "ha": "left"}, + ) + axis.annotate( + STRATEGY_LABELS[row.strategy], + (row.annualized_arithmetic_return * 100.0, row.cash_excess_sharpe), + xytext=annotation["xytext"], + textcoords="offset points", + ha=annotation["ha"], + fontsize=7.5, + ) + axis.axhline(0.0, color=SPINE, linewidth=0.8) + axis.axvline(0.0, color=SPINE, linewidth=0.8) + axis.set_title(PANEL_LABELS[panel]) + axis.set_xlabel("Annualized arithmetic return (%)") + style_axis(axis) + add_panel_label(axis, chr(ord("a") + panel_index)) + axes[0].set_ylabel("Cash-excess Sharpe") + fig.suptitle("Frozen-strategy performance after costs", color=INK, y=1.01) + fig.tight_layout() + _save_figure(fig, output / "baseline_performance") + plt.close(fig) + + +def _plot_effects(effects: pd.DataFrame, output: Path, *, metric: str) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.lines import Line2D + + apply_plot_style() + primary = effects.loc[effects["block_length"] == 21].copy() + if metric == "return": + estimate = "annualized_mean_difference" + lower = "annualized_mean_ci_95_lower" + upper = "annualized_mean_ci_95_upper" + scale = 100.0 + label = "Annualized arithmetic return difference (pp)" + stem = "contract_effects_return" + elif metric == "sharpe": + estimate = "sharpe_difference" + lower = "sharpe_ci_95_lower" + upper = "sharpe_ci_95_upper" + scale = 1.0 + label = "Cash-excess Sharpe difference" + stem = "contract_effects_sharpe" + else: + raise ValueError("metric must be 'return' or 'sharpe'") + fig, axes = plt.subplots(1, 2, figsize=(10.6, 8.5), sharex=True, sharey=True) + ordered_pairs: list[tuple[str, str]] = [] + effect_positions: list[int] = [] + tick_positions: list[int] = [] + tick_labels: list[str] = [] + heading_indexes: list[int] = [] + cursor = 0 + for strategy in STRATEGY_LABELS: + heading_indexes.append(len(tick_positions)) + tick_positions.append(cursor) + tick_labels.append(STRATEGY_LABELS[strategy]) + cursor += 1 + for switch in SWITCHES: + ordered_pairs.append((strategy, switch)) + effect_positions.append(cursor) + tick_positions.append(cursor) + tick_labels.append(SWITCH_LABELS[switch]) + cursor += 1 + cursor += 1 + for panel_index, panel in enumerate(PANEL_LABELS): + axis = axes[panel_index] + frame = primary.loc[primary["panel"] == panel].set_index( + ["strategy", "switch"] + ) + y = np.asarray(effect_positions) + values = np.array([frame.loc[pair, estimate] for pair in ordered_pairs]) * scale + lows = np.array([frame.loc[pair, lower] for pair in ordered_pairs]) * scale + highs = np.array([frame.loc[pair, upper] for pair in ordered_pairs]) * scale + categories = np.where( + lows > 0.0, + "above", + np.where(highs < 0.0, "below", "overlap"), + ) + axis.hlines(y, lows, highs, color=MUTED_INK, linewidth=1.0, zorder=2) + for category, color, marker in ( + ("above", REFERENCE_BLUE, "^"), + ("overlap", MUTED_INK, "o"), + ("below", NEGATIVE_RED, "v"), + ): + mask = categories == category + axis.scatter( + values[mask], + y[mask], + color=color, + marker=marker, + s=27, + zorder=3, + ) + axis.axvline(0.0, color=SPINE, linewidth=0.9) + axis.set_title(PANEL_LABELS[panel]) + axis.set_yticks(tick_positions) + if panel_index == 0: + axis.set_yticklabels(tick_labels) + for index in heading_indexes: + axis.get_yticklabels()[index].set_fontweight("bold") + else: + axis.tick_params(labelleft=False) + for position in effect_positions: + axis.axhline(position, color="#D9DEE5", linewidth=0.65, zorder=0) + style_axis(axis, grid=None) + add_panel_label(axis, chr(ord("a") + panel_index)) + axes[0].invert_yaxis() + handles = [ + Line2D( + [], + [], + color=color, + marker=marker, + linestyle="none", + markersize=5, + label=legend_label, + ) + for color, marker, legend_label in ( + (REFERENCE_BLUE, "^", "95% interval above zero"), + (MUTED_INK, "o", "95% interval overlaps zero"), + (NEGATIVE_RED, "v", "95% interval below zero"), + ) + ] + fig.suptitle( + "One-switch effects with 21-session block-bootstrap intervals", + color=INK, + y=0.995, + ) + fig.legend( + handles=handles, + loc="upper center", + bbox_to_anchor=(0.5, 0.965), + ncol=3, + ) + fig.supxlabel(label, y=0.012) + fig.tight_layout(rect=(0.0, 0.035, 1.0, 0.93)) + _save_figure(fig, output / stem) + plt.close(fig) + + +def _plot_engine_conformance(engine: pd.DataFrame, output: Path) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + apply_plot_style() + frame = ( + engine.groupby(["panel", "strategy"])[ + "max_absolute_daily_return_difference" + ] + .max() + .unstack("strategy") + .reindex(index=list(PANEL_LABELS), columns=list(STRATEGY_LABELS)) + * 10_000.0 + ) + fig, axis = plt.subplots(figsize=(7.4, 3.4)) + values = frame.to_numpy() + image = axis.imshow(values, cmap="Blues", aspect="auto") + contrast_threshold = float(np.nanmin(values) + 0.58 * np.ptp(values)) + for row in range(frame.shape[0]): + for column in range(frame.shape[1]): + value = frame.iloc[row, column] + axis.text( + column, + row, + f"{value:.2f}", + ha="center", + va="center", + fontsize=8, + color="white" if value >= contrast_threshold else INK, + ) + axis.set_xticks( + np.arange(frame.shape[1]), + [STRATEGY_LABELS[name] for name in frame.columns], + rotation=20, + ha="right", + ) + axis.set_yticks( + np.arange(frame.shape[0]), + [PANEL_LABELS[name] for name in frame.index], + ) + axis.set_title("Maximum absolute daily return difference between engines (bp)") + fig.colorbar(image, ax=axis, label="Basis points") + fig.tight_layout() + _save_figure(fig, output / "engine_conformance") + plt.close(fig) + + +def _plot_learned_seeds(metrics: pd.DataFrame, output: Path) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + apply_plot_style() + frame = metrics.loc[ + (metrics["strategy"] == "learned_gbrt") + & (metrics["variant"] == "baseline") + ].copy() + fig, axes = plt.subplots(1, 2, figsize=(9.4, 3.8), sharey=True) + for panel_index, panel in enumerate(PANEL_LABELS): + axis = axes[panel_index] + selected = frame.loc[frame["panel"] == panel].sort_values("seed") + axis.scatter( + selected["seed"].astype(int), + selected["cash_excess_sharpe"], + color=REFERENCE_BLUE, + s=30, + zorder=3, + ) + family_mean = float(selected["cash_excess_sharpe"].mean()) + axis.axhline( + family_mean, + color=MUTED_INK, + linewidth=0.9, + linestyle="--", + label="Seed mean", + ) + axis.axhline(0.0, color=SPINE, linewidth=0.8) + axis.set_title(PANEL_LABELS[panel]) + axis.set_xlabel("Frozen random seed") + axis.set_xticks(selected["seed"].astype(int)) + style_axis(axis) + add_panel_label(axis, chr(ord("a") + panel_index)) + axes[1].legend(loc="lower right") + axes[0].set_ylabel("Cash-excess Sharpe") + fig.suptitle("Learned-model seed sensitivity", color=INK) + fig.tight_layout() + _save_figure(fig, output / "learned_seed_sensitivity") + plt.close(fig) + + +def _save_figure(fig, path_without_suffix: Path) -> None: + fig.savefig( + path_without_suffix.with_suffix(".png"), + dpi=180, + bbox_inches="tight", + facecolor="white", + ) + fig.savefig( + path_without_suffix.with_suffix(".pdf"), + bbox_inches="tight", + facecolor="white", + metadata={"CreationDate": None, "ModDate": None}, + ) + + +def _artifact_manifest(root: Path) -> dict[str, str]: + artifacts = {} + for directory in (root / "results", root / "figures"): + for path in sorted(directory.rglob("*")): + if path.is_file() and path.name != "manifest.json": + artifacts[path.relative_to(root).as_posix()] = _sha256(path) + return artifacts + + +def run_expansion( + *, + repo_root: Path, + protocol_path: Path, + panel_dir: Path, + output_dir: Path, + generated_at: str, +) -> dict[str, object]: + """Run every frozen panel and write aggregate artifacts.""" + generated_timestamp = _parse_aware_timestamp(generated_at, name="generated_at") + git = _git_metadata(repo_root) + if git["source_commit"] == "unavailable" or not git[ + "tracked_worktree_clean_at_start" + ]: + raise ValueError("commit tracked source changes before running expansion") + protocol = _load_protocol(protocol_path) + panel_results: dict[str, dict[str, object]] = {} + data_records = [] + for panel_name, risky_symbols in protocol["panels"].items(): + prices, metadata = _load_panel( + repo_root, + panel_dir, + panel_name, + list(risky_symbols), + protocol, + ) + retrieved_timestamp = _parse_aware_timestamp( + metadata["retrieved_at"], + name="retrieved_at", + ) + if retrieved_timestamp > generated_timestamp: + raise ValueError(f"panel {panel_name} was retrieved after generation") + panel_results[panel_name] = _panel_experiment( + panel_name, + prices, + list(risky_symbols), + protocol, + ) + data_records.append( + { + key: value + for key, value in metadata.items() + if key != "local_file" + } + ) + + metrics = pd.concat( + [result["metrics"] for result in panel_results.values()], + ignore_index=True, + ) + summary = _family_summary(metrics) + effects = _contract_effects(panel_results, protocol) + engine = pd.concat( + [result["engine"] for result in panel_results.values()], + ignore_index=True, + ) + ranks = _rank_reversals(summary) + targets = [ + row + for result in panel_results.values() + for row in result["targets"] + ] + learned_diagnostics = [ + {"panel": panel, **row} + for panel, result in panel_results.items() + for row in result["learned_diagnostics"] + ] + + with tempfile.TemporaryDirectory(prefix="quantcortex-expansion-") as temp: + generated = Path(temp) + result_dir = generated / "results" + figure_dir = generated / "figures" + result_dir.mkdir(parents=True) + figure_dir.mkdir(parents=True) + _write_csv(metrics, result_dir / "seed_variant_metrics.csv") + _write_csv(summary, result_dir / "family_summary.csv") + _write_csv(effects, result_dir / "contract_effects.csv") + _write_csv(engine, result_dir / "engine_conformance.csv") + _write_csv(ranks, result_dir / "rank_reversals.csv") + _write_csv( + pd.DataFrame(learned_diagnostics), + result_dir / "learned_fit_diagnostics.csv", + ) + _write_generated_values( + summary=summary, + effects=effects, + engine=engine, + metrics=metrics, + ranks=ranks, + data_records=data_records, + path=result_dir / "generated_values.tex", + ) + (result_dir / "target_tape_hashes.json").write_text( + json.dumps(targets, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + (result_dir / "data_provenance.json").write_text( + json.dumps(data_records, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + _plot_baseline(summary, figure_dir) + _plot_effects(effects, figure_dir, metric="return") + _plot_effects(effects, figure_dir, metric="sharpe") + _plot_engine_conformance(engine, figure_dir) + _plot_learned_seeds(metrics, figure_dir) + manifest = { + "schema_version": 1, + "generated_at": generated_at, + "protocol": { + "path": protocol_path.relative_to(repo_root).as_posix(), + "sha256": _sha256(protocol_path), + "freeze_commit": FROZEN_PROTOCOL_COMMIT, + "status": protocol["status"], + }, + "git": git, + "source_tree": _source_tree_manifest(repo_root), + "environment": { + "python": platform.python_version(), + "platform": platform.platform(), + "numpy": importlib.metadata.version("numpy"), + "pandas": importlib.metadata.version("pandas"), + "scikit_learn": importlib.metadata.version("scikit-learn"), + "matplotlib": importlib.metadata.version("matplotlib"), + "threadpoolctl": importlib.metadata.version("threadpoolctl"), + "threadpools": _threadpool_environment(), + }, + "data": data_records, + "counts": { + "panels": len(panel_results), + "strategy_families": len(STRATEGY_LABELS), + "seed_variant_rows": int(len(metrics)), + "contract_effect_rows": int(len(effects)), + "target_tapes": int(len(targets)), + }, + "artifacts": _artifact_manifest(generated), + } + (result_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + for name in ("results", "figures"): + destination = output_dir / name + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(generated / name, destination) + return manifest + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--protocol", + type=Path, + default=Path("paper/expansion/protocol.json"), + ) + parser.add_argument( + "--panel-dir", + type=Path, + default=Path("local_data/expansion"), + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path("paper/expansion"), + ) + parser.add_argument("--generated-at", required=True) + args = parser.parse_args() + repo_root = Path(__file__).resolve().parent.parent + manifest = run_expansion( + repo_root=repo_root, + protocol_path=args.protocol.resolve(), + panel_dir=args.panel_dir.resolve(), + output_dir=args.output_dir.resolve(), + generated_at=args.generated_at, + ) + print( + f"wrote {len(manifest['artifacts'])} aggregate expansion artifacts " + f"from source commit {manifest['git']['source_commit']}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_expansion_artifacts.py b/tests/test_expansion_artifacts.py new file mode 100644 index 0000000..cf8d632 --- /dev/null +++ b/tests/test_expansion_artifacts.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +from datetime import datetime +from pathlib import Path + +import numpy as np +import pandas as pd + +from quantcortex.research.expansion import ( + FROZEN_PROTOCOL_COMMIT, + FROZEN_PROTOCOL_SHA256, +) +from scripts.run_expansion_experiments import _source_tree_manifest + +REPO_ROOT = Path(__file__).resolve().parent.parent +EXPANSION_ROOT = REPO_ROOT / "paper" / "expansion" +RESULT_ROOT = EXPANSION_ROOT / "results" + +EXPECTED_ROWS = { + "contract_effects.csv": 120, + "engine_conformance.csv": 16, + "family_summary.csv": 48, + "learned_fit_diagnostics.csv": 10, + "rank_reversals.csv": 48, + "seed_variant_metrics.csv": 96, +} +EXPECTED_ARTIFACTS = { + *(f"results/{name}" for name in EXPECTED_ROWS), + "results/data_provenance.json", + "results/generated_values.tex", + "results/target_tape_hashes.json", + *( + f"figures/{stem}.{suffix}" + for stem in ( + "baseline_performance", + "contract_effects_return", + "contract_effects_sharpe", + "engine_conformance", + "learned_seed_sensitivity", + ) + for suffix in ("pdf", "png") + ), +} + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _aware_timestamp(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + assert parsed.tzinfo is not None + assert parsed.utcoffset() is not None + return parsed + + +def test_expansion_manifest_binds_clean_source_inputs_and_artifacts(): + manifest = json.loads((RESULT_ROOT / "manifest.json").read_text(encoding="utf-8")) + + assert manifest["schema_version"] == 1 + generated_at = _aware_timestamp(manifest["generated_at"]) + protocol = manifest["protocol"] + assert protocol == { + "freeze_commit": FROZEN_PROTOCOL_COMMIT, + "path": "paper/expansion/protocol.json", + "sha256": FROZEN_PROTOCOL_SHA256, + "status": "repository_frozen_prospective_not_externally_registered", + } + assert _sha256(EXPANSION_ROOT / "protocol.json") == FROZEN_PROTOCOL_SHA256 + + git = manifest["git"] + source_commit = git["source_commit"] + assert len(source_commit) == 40 + int(source_commit, 16) + assert git["tracked_worktree_clean_at_start"] is True + subprocess.run( + ["git", "merge-base", "--is-ancestor", source_commit, "HEAD"], + cwd=REPO_ROOT, + check=True, + ) + + source_tree = manifest["source_tree"] + assert source_tree["file_count"] == len(source_tree["files"]) + assert source_tree == _source_tree_manifest(REPO_ROOT) + subprocess.run( + ["git", "diff", "--quiet", source_commit, "--", *source_tree["files"]], + cwd=REPO_ROOT, + check=True, + ) + for required in ( + "paper/preregistration.md", + "scripts/fetch_expansion_data.py", + "scripts/release_expansion_artifacts.sh", + "scripts/run_expansion_experiments.py", + "schemas/canonical_target_tape.schema.json", + ): + assert required in source_tree["files"] + + environment = manifest["environment"] + assert environment["threadpoolctl"] + assert isinstance(environment["threadpools"], list) + + artifacts = manifest["artifacts"] + assert set(artifacts) == EXPECTED_ARTIFACTS + for relative_path, expected_digest in artifacts.items(): + artifact = EXPANSION_ROOT / relative_path + assert artifact.is_file(), relative_path + assert _sha256(artifact) == expected_digest, relative_path + if artifact.suffix == ".pdf": + assert artifact.read_bytes().startswith(b"%PDF-") + if artifact.suffix == ".png": + assert artifact.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + data = manifest["data"] + assert json.loads( + (RESULT_ROOT / "data_provenance.json").read_text(encoding="utf-8") + ) == data + assert len(data) == 2 + for panel in data: + retrieved_at = _aware_timestamp(panel["retrieved_at"]) + assert retrieved_at <= generated_at + assert panel["protocol_sha256"] == FROZEN_PROTOCOL_SHA256 + assert panel["provider_terms_independently_verified"] is False + assert panel["raw_data_committed"] is False + assert panel["dropped_incomplete_rows"] == 0 + assert panel["complete_rows"] == 3018 + assert panel["pre_evaluation_sessions"] == 1007 + assert panel["evaluation_sessions"] == 2011 + assert set(panel["missing_by_symbol"].values()) == {0} + assert len(panel["input_sha256"]) == 64 + int(panel["input_sha256"], 16) + + assert manifest["counts"] == { + "contract_effect_rows": 120, + "panels": 2, + "seed_variant_rows": 96, + "strategy_families": 4, + "target_tapes": 16, + } + + +def test_expansion_tables_and_target_hashes_are_complete_and_finite(): + for name, expected_rows in EXPECTED_ROWS.items(): + frame = pd.read_csv(RESULT_ROOT / name) + assert len(frame) == expected_rows, name + assert not frame.isna().any(axis=None), name + numeric = frame.select_dtypes(include=[np.number]) + assert np.isfinite(numeric.to_numpy()).all(), name + + summary = pd.read_csv(RESULT_ROOT / "family_summary.csv") + assert set(summary["panel"]) == {"us_sector_etfs", "country_equity_etfs"} + assert set(summary["strategy"]) == { + "cross_sectional_momentum", + "learned_gbrt", + "short_term_reversal", + "ts_momentum", + } + assert set(summary["variant"]) == { + "baseline", + "costed_comparator", + "same_close", + "vectorized", + "zero_cash", + "zero_cost", + } + + effects = pd.read_csv(RESULT_ROOT / "contract_effects.csv") + assert set(effects["block_length"]) == {5, 21, 63} + assert set(effects["replications"]) == {5000} + assert (effects["annualized_mean_ci_95_lower"] <= effects["annualized_mean_ci_95_upper"]).all() + assert (effects["sharpe_ci_95_lower"] <= effects["sharpe_ci_95_upper"]).all() + + targets = json.loads( + (RESULT_ROOT / "target_tape_hashes.json").read_text(encoding="utf-8") + ) + assert len(targets) == 16 + assert len({row["sha256"] for row in targets}) == 16 + for row in targets: + assert row["decision_count"] == 96 + assert row["symbols"] == sorted(row["symbols"]) + assert row["record_count"] == row["decision_count"] * len(row["symbols"]) + assert len(row["sha256"]) == 64 + int(row["sha256"], 16) + + generated = (RESULT_ROOT / "generated_values.tex").read_text(encoding="ascii") + for expected in ( + r"\newcommand{\ExpansionPanelCount}{2}", + r"\newcommand{\ExpansionFamilyCount}{4}", + r"\newcommand{\ExpansionFamilyPanelCount}{8}", + r"\newcommand{\ExpansionBootstrapReplications}{5,000}", + rf"\newcommand{{\ExpansionProtocolDigest}}{{{FROZEN_PROTOCOL_SHA256}}}", + r"\newcommand{\ExpansionSectorInputDigest}{c5947c4ca6ad6d21ad834c8f344dcdd07acc59ba411e3dcb2202a2413642b2f9}", + r"\newcommand{\ExpansionCountryInputDigest}{870fa926b5080378c65bd629b9959bd73b16391dded5720c894c0f35558132a9}", + r"\newcommand{\ExpansionBaselineRows}", + r"\newcommand{\ExpansionEffectRows}", + ): + assert expected in generated diff --git a/tests/test_paper_artifacts.py b/tests/test_paper_artifacts.py index ba0523b..1eb73bb 100644 --- a/tests/test_paper_artifacts.py +++ b/tests/test_paper_artifacts.py @@ -253,6 +253,7 @@ def test_paper_source_and_reviewed_pdf_are_published(): assert "\\usepackage[preprint]{neurips_2026}" in main assert "\\usepackage{orcidlink}" in main assert "\\input{results/generated_values}" in main + assert "\\input{expansion/results/generated_values}" in main assert "Executable Evaluation Contracts" in main assert "Kevin Lee\\,\\orcidlink{0009-0004-0388-9260}" in main assert "University of California, Los Angeles" in main @@ -260,6 +261,8 @@ def test_paper_source_and_reviewed_pdf_are_published(): assert "\\input{checklist}" in main assert "\\PaperInputDigest" in main assert "{bootstrap_robustness.pdf}" in main + assert "{contract_effects_return.pdf}" in main + assert "{baseline_performance.pdf}" in main assert "target-exposure comparator" in main assert "actual_input_digest" in release_script assert "expected_input_digest" in release_script @@ -340,13 +343,23 @@ def test_paper_source_and_reviewed_pdf_are_published(): "checklist.tex", "references.bib", "neurips_2026.sty", + "preregistration.md", "results/generated_values.tex", + "results/manifest.json", + "expansion/protocol.json", + "expansion/results/generated_values.tex", + "expansion/results/manifest.json", "figures/accounting_summary.pdf", "figures/audit_protocol.pdf", "figures/bootstrap_robustness.pdf", "figures/engine_comparison.pdf", "figures/return_attribution_and_protocol_switches.pdf", "figures/sensitivity_and_ablation.pdf", + "expansion/figures/baseline_performance.pdf", + "expansion/figures/contract_effects_return.pdf", + "expansion/figures/contract_effects_sharpe.pdf", + "expansion/figures/engine_conformance.pdf", + "expansion/figures/learned_seed_sensitivity.pdf", } == set(source_entries) for relative_path, expected_digest in source_entries.items(): assert _sha256(PAPER_ROOT / relative_path) == expected_digest diff --git a/tests/test_repository_data_policy.py b/tests/test_repository_data_policy.py index 017039a..6212aa5 100644 --- a/tests/test_repository_data_policy.py +++ b/tests/test_repository_data_policy.py @@ -12,6 +12,11 @@ "data/sample/rotation_prices.csv", "quantcortex/data/sample/rotation_prices.csv", ] +RAW_MARKET_DATA_SIGNATURES = ( + {"date", "QQQ", "VGT", "GLD", "TLT", "SPY", "VIG", "SHV"}, + {"date", "XLB", "XLE", "XLF", "XLI", "XLK", "XLP", "XLU", "XLV", "XLY", "SHV"}, + {"date", "EWA", "EWC", "EWG", "EWH", "EWJ", "EWL", "EWP", "EWQ", "EWS", "EWU", "SHV"}, +) PUBLISHED_CHARTS = { "allocation_and_exposure.png", "drawdown.png", @@ -35,6 +40,7 @@ "local_data/README.md", "docs/architecture.md", "docs/production-readiness.md", + "paper/COMPUTE.md", "paper/README.md", ] @@ -54,14 +60,13 @@ def test_redistributed_market_data_is_absent(): "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: + if any(signature <= header for signature in RAW_MARKET_DATA_SIGNATURES): leaked.append(relative_path) assert not leaked, f"tracked raw paper price matrices detected: {leaked}" diff --git a/tests/test_research_expansion.py b/tests/test_research_expansion.py new file mode 100644 index 0000000..d73f8af --- /dev/null +++ b/tests/test_research_expansion.py @@ -0,0 +1,655 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from quantcortex.research.expansion import ( + FROZEN_PROTOCOL_COMMIT, + FROZEN_PROTOCOL_SHA256, + bootstrap_metric_difference, + cross_sectional_momentum_targets, + exposure_matched_comparator_targets, + invalid_same_close_result, + learned_gbrt_targets, + monthly_decision_dates, + performance_metrics, + run_engine, + short_term_reversal_targets, + time_series_momentum_targets, + validate_price_panel, +) +from scripts.fetch_expansion_data import _adjusted_close, _validate_panel +from scripts.run_expansion_experiments import ( + PANEL_LABELS, + STRATEGY_LABELS, + SWITCHES, + _family_summary, + _load_panel, + _load_protocol, + _plot_baseline, + _plot_effects, + _plot_engine_conformance, + _plot_learned_seeds, + _rank_reversals, + _source_tree_manifest, + _write_generated_values, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _trend_panel(periods: int = 340) -> pd.DataFrame: + index = pd.bdate_range("2020-01-02", periods=periods) + time = np.arange(periods, dtype=float) + return pd.DataFrame( + { + "A": np.exp(0.001 * time), + "B": np.exp(-0.001 * time), + "C": np.ones(periods), + "D": np.exp(0.001 * time), + "CASH": np.exp(0.0001 * time), + }, + index=index, + ) + + +def _learned_config() -> dict[str, object]: + return { + "label_horizon_sessions": 21, + "feature_return_windows": [5, 21, 63, 126, 252], + "feature_volatility_windows": [21, 63], + "training_decision_months": 60, + "minimum_training_decision_months": 24, + "n_estimators": 10, + "learning_rate": 0.03, + "max_depth": 2, + "min_samples_leaf": 5, + "subsample": 0.8, + "selection_count": 3, + } + + +def test_panel_loader_validates_published_metadata(tmp_path): + protocol_path = REPO_ROOT / "paper" / "expansion" / "protocol.json" + protocol = json.loads(protocol_path.read_text(encoding="utf-8")) + dates = pd.bdate_range("2024-01-02", periods=30) + protocol["data"] = { + **protocol["data"], + "start": dates[0].date().isoformat(), + "evaluation_start": dates[10].date().isoformat(), + "evaluation_end": dates[-1].date().isoformat(), + "minimum_pre_evaluation_sessions": 5, + } + panel = pd.DataFrame( + { + "A": np.linspace(100.0, 110.0, len(dates)), + "B": np.linspace(90.0, 105.0, len(dates)), + "SHV": np.linspace(100.0, 100.5, len(dates)), + }, + index=dates, + ) + panel.index.name = "date" + csv_path = tmp_path / "fixture_panel.csv" + metadata_path = tmp_path / "fixture_panel.metadata.json" + panel.to_csv(csv_path, float_format="%.17g") + pre_evaluation = dates < dates[10] + metadata = { + "schema_version": 1, + "panel": "fixture_panel", + "symbols": ["A", "B", "SHV"], + "provider": "Yahoo Finance via yfinance", + "provider_terms_independently_verified": False, + "retrieved_at": "2026-06-18T22:37:51Z", + "request": protocol["data"]["provider_request"], + "protocol_path": "paper/expansion/protocol.json", + "protocol_sha256": hashlib.sha256(protocol_path.read_bytes()).hexdigest(), + "yfinance_version": "1.4.1", + "terms_urls": [ + "https://ranaroussi.github.io/yfinance/", + "https://legal.yahoo.com/us/en/yahoo/terms/otos/index.html", + ], + "raw_data_committed": False, + "input_sha256": hashlib.sha256(csv_path.read_bytes()).hexdigest(), + "provider_rows": len(panel), + "complete_rows": len(panel), + "dropped_incomplete_rows": 0, + "missing_by_symbol": {"A": 0, "B": 0, "SHV": 0}, + "pre_evaluation_sessions": int(pre_evaluation.sum()), + "pre_evaluation_months": int(dates[pre_evaluation].to_period("M").nunique()), + "evaluation_sessions": int((~pre_evaluation).sum()), + "first_date": dates[0].date().isoformat(), + "last_date": dates[-1].date().isoformat(), + } + metadata_path.write_text( + json.dumps(metadata, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + loaded, published = _load_panel( + REPO_ROOT, + tmp_path, + "fixture_panel", + ["A", "B"], + protocol, + ) + pd.testing.assert_frame_equal(loaded, panel, check_freq=False) + assert published == metadata + + tampered = copy.deepcopy(metadata) + tampered["provider_rows"] += 1 + metadata_path.write_text( + json.dumps(tampered, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="provider-row accounting"): + _load_panel(REPO_ROOT, tmp_path, "fixture_panel", ["A", "B"], protocol) + + +def test_monthly_schedule_and_price_validation_are_strict(): + prices = _trend_panel() + validated = validate_price_panel( + prices, + risky_symbols=["A", "B", "C", "D"], + cash_symbol="CASH", + ) + decisions = monthly_decision_dates(validated.index, start="2020-03-10") + + assert decisions[0] == pd.Timestamp("2020-03-10") + assert decisions.to_period("M").is_unique + assert list(validated.columns) == ["A", "B", "C", "D", "CASH"] + + +def test_rule_targets_follow_frozen_selection_and_tie_breaks(): + prices = _trend_panel() + decision = pd.DatetimeIndex([prices.index[300]]) + symbols = ["A", "B", "C", "D"] + + time_series = time_series_momentum_targets( + prices, + symbols=symbols, + decisions=decision, + ) + cross_sectional = cross_sectional_momentum_targets( + prices, + symbols=symbols, + decisions=decision, + selection_count=1, + ) + + assert time_series.iloc[0].to_dict() == { + "A": 0.5, + "B": 0.0, + "C": 0.0, + "D": 0.5, + } + assert cross_sectional.iloc[0].to_dict() == { + "A": 1.0, + "B": 0.0, + "C": 0.0, + "D": 0.0, + } + + +def test_reversal_does_not_renormalize_unused_exposure(): + index = pd.bdate_range("2024-01-02", periods=8) + prices = pd.DataFrame( + { + "A": [100, 101, 102, 103, 104, 105, 106, 107], + "B": [100, 100, 100, 100, 100, 90, 90, 90], + "C": [100] * 8, + "D": [100, 101, 101, 101, 101, 101, 101, 101], + }, + index=index, + ) + targets = short_term_reversal_targets( + prices, + symbols=["A", "B", "C", "D"], + decisions=pd.DatetimeIndex([index[-1]]), + selection_count=3, + ) + + assert targets.iloc[0].to_dict() == { + "A": 0.0, + "B": 1.0 / 3.0, + "C": 0.0, + "D": 0.0, + } + assert np.isclose(targets.iloc[0].sum(), 1.0 / 3.0) + + +def test_learned_targets_are_deterministic_and_ignore_future_prices(): + index = pd.bdate_range("2014-01-02", periods=1_500) + rng = np.random.default_rng(7) + innovations = rng.normal( + loc=np.array([0.0003, 0.0001, -0.0001, 0.0002]), + scale=np.array([0.008, 0.009, 0.007, 0.01]), + size=(len(index), 4), + ) + prices = pd.DataFrame( + np.exp(np.cumsum(innovations, axis=0)) * 100.0, + index=index, + columns=["A", "B", "C", "D"], + ) + all_decisions = monthly_decision_dates(index) + evaluation = all_decisions[-12:] + cutoff = evaluation[3] + + first = learned_gbrt_targets( + prices, + symbols=list(prices.columns), + all_decisions=all_decisions, + evaluation_decisions=evaluation, + config=_learned_config(), + seed=11, + ) + repeated = learned_gbrt_targets( + prices, + symbols=list(prices.columns), + all_decisions=all_decisions, + evaluation_decisions=evaluation, + config=_learned_config(), + seed=11, + ) + perturbed = prices.copy() + perturbed.loc[perturbed.index > cutoff, "A"] *= 4.0 + future_changed = learned_gbrt_targets( + perturbed, + symbols=list(prices.columns), + all_decisions=all_decisions, + evaluation_decisions=evaluation[:4], + config=_learned_config(), + seed=11, + ) + + pd.testing.assert_frame_equal(first.weights, repeated.weights) + pd.testing.assert_series_equal(first.weights.loc[cutoff], future_changed.weights.loc[cutoff]) + assert (first.training_months >= 24).all() + assert (first.weights.sum(axis=1) <= 1.0 + 1e-12).all() + assert (first.weights >= 0.0).all(axis=None) + + +def test_same_close_diagnostic_moves_the_first_earned_return_backward(): + index = pd.bdate_range("2024-01-02", periods=5) + prices = pd.DataFrame({"A": [100.0, 110.0, 121.0, 133.1, 146.41]}, index=index) + cash = pd.Series(0.0, index=index) + weights = pd.DataFrame({"A": [1.0]}, index=pd.DatetimeIndex([index[1]])) + + causal = run_engine( + weights, + prices, + cash, + cost_rate=0.0, + engine="event_driven", + ) + invalid = invalid_same_close_result( + weights, + prices, + cash, + cost_rate=0.0, + ) + + assert np.isclose(causal.returns.loc[index[1]], 0.0) + assert np.isclose(causal.returns.loc[index[2]], 0.0) + assert np.isclose(causal.returns.loc[index[3]], 0.10) + assert np.isclose(invalid.returns.loc[index[1]], 0.10) + + +def test_exposure_matched_comparator_preserves_residual_cash(): + index = pd.DatetimeIndex(["2024-01-02", "2024-02-01"]) + weights = pd.DataFrame( + [[0.6, 0.0, 0.0], [0.0, 0.0, 0.0]], + index=index, + columns=["A", "B", "C"], + ) + + comparator = exposure_matched_comparator_targets(weights) + + np.testing.assert_allclose(comparator.iloc[0], [0.2, 0.2, 0.2]) + np.testing.assert_allclose(comparator.iloc[1], [0.0, 0.0, 0.0]) + + +def test_exposure_matched_comparator_rejects_short_targets(): + weights = pd.DataFrame( + [[0.6, -0.1, 0.0]], + index=pd.DatetimeIndex(["2024-01-02"]), + columns=["A", "B", "C"], + ) + + with pytest.raises(ValueError, match="long-only"): + exposure_matched_comparator_targets(weights) + + +def test_bootstrap_family_effect_is_reproducible_and_matches_point_estimates(): + index = pd.bdate_range("2024-01-02", periods=12) + cash = pd.Series(np.linspace(0.0001, 0.0002, len(index)), index=index) + lhs = [ + pd.Series(np.linspace(-0.01, 0.02, len(index)), index=index), + pd.Series(np.linspace(-0.008, 0.018, len(index)), index=index), + ] + rhs = [ + pd.Series(np.linspace(-0.006, 0.011, len(index)), index=index), + pd.Series(np.linspace(-0.004, 0.010, len(index)), index=index), + ] + + first = bootstrap_metric_difference( + lhs, + rhs, + cash, + block_length=3, + replications=250, + seed=42, + ) + second = bootstrap_metric_difference( + lhs, + rhs, + cash, + block_length=3, + replications=250, + seed=42, + ) + + assert first == second + expected = np.mean( + [ + (left - right).mean() * 252.0 + for left, right in zip(lhs, rhs, strict=True) + ] + ) + assert np.isclose(first["annualized_mean_difference"], expected) + assert first["family_size"] == 2 + + +def test_bootstrap_point_estimate_uses_the_joint_family_intersection(): + dates = pd.bdate_range("2024-01-02", periods=4) + lhs = [ + pd.Series([1.0, 0.01, 0.03], index=dates[:3]), + pd.Series([0.02, 0.04, 2.0], index=dates[1:]), + ] + rhs = [ + pd.Series([0.0, 0.0, 0.0], index=dates[:3]), + pd.Series([0.0, 0.0, 0.0], index=dates[1:]), + ] + cash = pd.Series([0.001, 0.002, 0.0015, 0.0025], index=dates) + + estimate = bootstrap_metric_difference( + lhs, + rhs, + cash, + block_length=2, + replications=100, + seed=42, + ) + + expected = np.mean([0.02, 0.03]) * 252.0 + assert estimate["observations"] == 2 + assert np.isclose(estimate["annualized_mean_difference"], expected) + + +def test_performance_metrics_use_shv_excess_returns(): + prices = _trend_panel(periods=30) + risky = prices[["A"]] + cash = prices["CASH"].pct_change(fill_method=None).fillna(0.0) + weights = pd.DataFrame({"A": [1.0]}, index=pd.DatetimeIndex([prices.index[0]])) + result = run_engine( + weights, + risky, + cash, + cost_rate=0.0, + engine="event_driven", + ) + metrics = performance_metrics(result, cash, prices.index) + + assert metrics["observations"] == len(prices) + assert metrics["annualized_arithmetic_return"] > 0.0 + assert metrics["cash_excess_sharpe"] > 0.0 + + +def test_machine_protocol_matches_frozen_model_and_panel_choices(): + protocol_path = REPO_ROOT / "paper" / "expansion" / "protocol.json" + protocol = _load_protocol(protocol_path) + + assert protocol["status"] == ( + "repository_frozen_prospective_not_externally_registered" + ) + assert protocol["historical_case_confirmatory"] is False + assert list(protocol["panels"]) == [ + "us_sector_etfs", + "country_equity_etfs", + ] + learned = protocol["strategies"]["learned_gbrt"] + assert learned["estimator"] == ( + "sklearn.ensemble.GradientBoostingRegressor" + ) + assert learned["seeds"] == [11, 29, 47, 71, 97] + assert protocol["execution"]["cost_per_one_way_gross_notional"] == 0.0013 + assert protocol["uncertainty"] == { + "method": "joint circular block bootstrap", + "replications": 5000, + "primary_block_sessions": 21, + "sensitivity_block_sessions": [5, 63], + "seed": 20260618, + "interval": "two-sided 95 percent percentile", + "annualized_arithmetic_return": "252 times daily arithmetic mean", + "sharpe": ( + "sqrt(252) times mean daily strategy-minus-SHV return divided by " + "sample standard deviation" + ), + } + assert FROZEN_PROTOCOL_COMMIT == ( + "4018f4063f46889f41d6981db5a71079e1dbd713" + ) + assert FROZEN_PROTOCOL_SHA256 == ( + "e49e41a12a19fa5404a573ba5e21eb8a2888e616985f8c610d9652866923315c" + ) + + +def test_provider_adjusted_close_selection_and_complete_intersection(): + index = pd.bdate_range("2017-01-02", "2018-03-30") + columns = pd.MultiIndex.from_product( + [["Adj Close", "Close"], ["A", "B", "SHV"]] + ) + values = np.tile(np.arange(1, len(columns) + 1, dtype=float), (len(index), 1)) + download = pd.DataFrame(values, index=index, columns=columns) + download.loc[index[5], ("Adj Close", "B")] = np.nan + + adjusted = _adjusted_close(download, ["A", "B", "SHV"]) + complete, diagnostics = _validate_panel( + adjusted, + evaluation_start="2018-01-02", + evaluation_end="2018-03-30", + minimum_pre_evaluation_sessions=200, + minimum_mature_training_months=12, + ) + + assert list(complete.columns) == ["A", "B", "SHV"] + assert index[5] not in complete.index + assert diagnostics["dropped_incomplete_rows"] == 1 + assert diagnostics["missing_by_symbol"]["B"] == 1 + + +def test_provider_panel_validation_rejects_a_missing_evaluation_month(): + index = pd.bdate_range("2016-01-04", "2018-03-30") + frame = pd.DataFrame(100.0, index=index, columns=["A", "B", "SHV"]) + frame = frame.loc[frame.index.to_period("M") != pd.Period("2018-02", freq="M")] + + with pytest.raises(ValueError, match="missing evaluation months: 2018-02"): + _validate_panel( + frame, + evaluation_start="2018-01-02", + evaluation_end="2018-03-30", + minimum_pre_evaluation_sessions=252, + minimum_mature_training_months=24, + ) + + +def _plot_fixture_frames() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + summary_rows = [] + metric_rows = [] + engine_rows = [] + for panel_index, panel in enumerate(PANEL_LABELS): + for strategy_index, strategy in enumerate(STRATEGY_LABELS): + for variant_index, variant in enumerate( + [ + "baseline", + "same_close", + "zero_cash", + "zero_cost", + "costed_comparator", + "vectorized", + ] + ): + summary_rows.append( + { + "panel": panel, + "strategy": strategy, + "variant": variant, + "family_size": 1, + "observations": 100, + "annualized_arithmetic_return": ( + 0.01 * (strategy_index + 1) - 0.002 * variant_index + ), + "cash_excess_sharpe": ( + 0.2 * strategy_index - 0.03 * variant_index + ), + "cagr": 0.008 * (strategy_index + 1) - 0.001 * variant_index, + "max_drawdown": -0.05 * (strategy_index + 1), + } + ) + for seed in [11, 29, 47, 71, 97]: + metric_rows.append( + { + "panel": panel, + "strategy": "learned_gbrt", + "seed": str(seed), + "variant": "baseline", + "cash_excess_sharpe": 0.01 * seed - panel_index, + } + ) + engine_rows.append( + { + "panel": panel, + "strategy": strategy, + "max_absolute_daily_return_difference": ( + 0.00001 * (strategy_index + 1) + ), + "final_wealth_difference": 0.001 * (strategy_index + 1), + } + ) + return ( + pd.DataFrame(summary_rows), + pd.DataFrame(metric_rows), + pd.DataFrame(engine_rows), + ) + + +def test_family_summary_and_rank_reversals_are_deterministic(): + summary, _, _ = _plot_fixture_frames() + seed_metrics = pd.concat([summary.assign(seed="11"), summary.assign(seed="29")]) + + aggregated = _family_summary(seed_metrics) + ranks = _rank_reversals(aggregated) + + assert (aggregated["family_size"] == 2).all() + assert len(ranks) == len(PANEL_LABELS) * 6 * len(STRATEGY_LABELS) + baseline = ranks.loc[ranks["variant"] == "baseline"] + assert (baseline["rank_change"] == 0).all() + + +def test_all_expansion_figures_render_from_complete_tables(tmp_path): + summary, learned_metrics, engine = _plot_fixture_frames() + effect_rows = [] + for panel in PANEL_LABELS: + for strategy in STRATEGY_LABELS: + for switch_index, switch in enumerate(SWITCHES): + value = (switch_index - 2) * 0.002 + effect_rows.append( + { + "panel": panel, + "strategy": strategy, + "switch": switch, + "block_length": 21, + "annualized_mean_difference": value, + "annualized_mean_ci_95_lower": value - 0.01, + "annualized_mean_ci_95_upper": value + 0.01, + "sharpe_difference": value * 10.0, + "sharpe_ci_95_lower": value * 10.0 - 0.1, + "sharpe_ci_95_upper": value * 10.0 + 0.1, + } + ) + effects = pd.DataFrame(effect_rows) + + _plot_baseline(summary, tmp_path) + _plot_effects(effects, tmp_path, metric="return") + _plot_effects(effects, tmp_path, metric="sharpe") + _plot_engine_conformance(engine, tmp_path) + _plot_learned_seeds(learned_metrics, tmp_path) + + assert len(list(tmp_path.glob("*.png"))) == 5 + assert len(list(tmp_path.glob("*.pdf"))) == 5 + assert all(path.stat().st_size > 1_000 for path in tmp_path.iterdir()) + + +def test_expansion_latex_values_are_derived_from_aggregate_tables(tmp_path): + summary, learned_metrics, engine = _plot_fixture_frames() + effects = [] + for panel in PANEL_LABELS: + for strategy in STRATEGY_LABELS: + for switch_index, switch in enumerate(SWITCHES): + value = (switch_index - 2) * 0.01 + effects.append( + { + "panel": panel, + "strategy": strategy, + "switch": switch, + "block_length": 21, + "replications": 5000, + "annualized_mean_difference": value, + "annualized_mean_ci_95_lower": value - 0.001, + "annualized_mean_ci_95_upper": value + 0.001, + "sharpe_ci_95_lower": value - 0.001, + "sharpe_ci_95_upper": value + 0.001, + } + ) + ranks = _rank_reversals(summary) + path = tmp_path / "generated_values.tex" + + _write_generated_values( + summary=summary, + effects=pd.DataFrame(effects), + engine=engine, + metrics=learned_metrics, + ranks=ranks, + data_records=[ + {"panel": "us_sector_etfs", "input_sha256": "a" * 64}, + {"panel": "country_equity_etfs", "input_sha256": "b" * 64}, + ], + path=path, + ) + + generated = path.read_text(encoding="ascii") + assert "\\newcommand{\\ExpansionPanelCount}{2}" in generated + assert "\\newcommand{\\ExpansionFamilyCount}{4}" in generated + assert "\\newcommand{\\ExpansionBootstrapReplications}{5,000}" in generated + assert f"\\newcommand{{\\ExpansionProtocolDigest}}{{{FROZEN_PROTOCOL_SHA256}}}" in generated + assert f"\\newcommand{{\\ExpansionSectorInputDigest}}{{{'a' * 64}}}" in generated + assert f"\\newcommand{{\\ExpansionCountryInputDigest}}{{{'b' * 64}}}" in generated + assert "U.S. sector ETFs & TS momentum" in generated + assert "95%" not in generated + + +def test_expansion_source_fingerprint_includes_contract_and_operational_sources(): + manifest = _source_tree_manifest(REPO_ROOT) + + assert "scripts/fetch_expansion_data.py" in manifest["files"] + assert "scripts/release_expansion_artifacts.sh" in manifest["files"] + assert "scripts/run_expansion_experiments.py" in manifest["files"] + assert "paper/preregistration.md" in manifest["files"] + assert "paper/expansion/protocol.json" in manifest["files"] + assert "schemas/canonical_target_tape.schema.json" in manifest["files"] + assert len(manifest["sha256"]) == 64