diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2b15e95..3648a7f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,57 +3,154 @@ name: CI on: push: branches: [main] - tags: ["*"] pull_request: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + JULIA_PKG_PRECOMPILE_AUTO: "0" + jobs: - test: - name: Julia ${{ matrix.version }} - ${{ matrix.os }} - runs-on: ${{ matrix.os }} + base: + name: base Julia ${{ matrix.version }} / ${{ matrix.dependency-set }} + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - version: - - "1.10" # supported floor (LTS) - - "1" # latest stable - # macOS runners queue slowly on PRs; keep Linux + Windows on every PR - # (full correctness coverage) and add macOS only on pushes to main/tags. - os: ${{ (github.event_name == 'pull_request' && fromJSON('["ubuntu-latest","windows-latest"]')) || fromJSON('["ubuntu-latest","macOS-latest","windows-latest"]') }} + include: + - version: "1.10.12" + dependency-set: minimum + - version: "1.10.12" + dependency-set: current + - version: "1.12.7" + dependency-set: minimum + - version: "1.12.7" + dependency-set: current steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v2 with: version: ${{ matrix.version }} - uses: julia-actions/cache@v2 - - uses: julia-actions/julia-buildpkg@v1 - - uses: julia-actions/julia-runtest@v1 - # Coverage scans *all* of src/, including legacy files not yet - # reconstructed (they have invalid-escape docstrings / parse errors). - # Keep it non-fatal until those files are wired in; the test step above - # is the gate. Re-enable strict coverage once src/ fully parses. - - uses: julia-actions/julia-processcoverage@v1 - continue-on-error: true - - uses: codecov/codecov-action@v4 - continue-on-error: true + - name: Run base tests and optional-extension fallback checks + env: + RISKLABAI_TEST_DEEP_BSDE: "0" + RISKLABAI_DEPENDENCY_SET: ${{ matrix.dependency-set }} + run: | + julia --startup-file=no -e ' + using Pkg + Pkg.activate(; temp = true) + Pkg.develop(path = pwd()) + if ENV["RISKLABAI_DEPENDENCY_SET"] == "minimum" + Pkg.add([ + Pkg.PackageSpec(name = "Clustering", version = v"0.15.8"), + Pkg.PackageSpec(name = "Combinatorics", version = v"1.1.0"), + Pkg.PackageSpec(name = "DataFrames", version = v"1.8.2"), + Pkg.PackageSpec(name = "DecisionTree", version = v"0.12.4"), + Pkg.PackageSpec(name = "Distributions", version = v"0.25.128"), + Pkg.PackageSpec(name = "HypothesisTests", version = v"0.11.8"), + Pkg.PackageSpec(name = "SpecialFunctions", version = v"2.8.0"), + ]) + else + Pkg.add([ + Pkg.PackageSpec(name = "Clustering"), + Pkg.PackageSpec(name = "Combinatorics"), + Pkg.PackageSpec(name = "DataFrames"), + Pkg.PackageSpec(name = "DecisionTree"), + Pkg.PackageSpec(name = "Distributions"), + Pkg.PackageSpec(name = "HypothesisTests"), + Pkg.PackageSpec(name = "SpecialFunctions"), + ]) + end + using RiskLabAI + include(joinpath(pwd(), "test", "runtests.jl")) + ' + + deep-bsde: + name: Deep-BSDE Julia ${{ matrix.version }} / ${{ matrix.dependency-set }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - version: "1.10.12" + dependency-set: minimum + - version: "1.10.12" + dependency-set: current + - version: "1.12.7" + dependency-set: minimum + - version: "1.12.7" + dependency-set: current + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 with: - files: lcov.info + version: ${{ matrix.version }} + - uses: julia-actions/cache@v2 + - name: Run tests with the weak dependencies loaded + env: + RISKLABAI_TEST_DEEP_BSDE: "1" + RISKLABAI_DEPENDENCY_SET: ${{ matrix.dependency-set }} + run: | + julia --startup-file=no -e ' + using Pkg + Pkg.activate(; temp = true) + Pkg.develop(path = pwd()) + if ENV["RISKLABAI_DEPENDENCY_SET"] == "minimum" + Pkg.add([ + Pkg.PackageSpec(name = "Clustering", version = v"0.15.8"), + Pkg.PackageSpec(name = "Combinatorics", version = v"1.1.0"), + Pkg.PackageSpec(name = "DataFrames", version = v"1.8.2"), + Pkg.PackageSpec(name = "DecisionTree", version = v"0.12.4"), + Pkg.PackageSpec(name = "Distributions", version = v"0.25.128"), + Pkg.PackageSpec(name = "HypothesisTests", version = v"0.11.8"), + Pkg.PackageSpec(name = "SpecialFunctions", version = v"2.8.0"), + Pkg.PackageSpec(name = "Lux", version = v"1.31.4"), + Pkg.PackageSpec(name = "Optimisers", version = v"0.4.7"), + Pkg.PackageSpec(name = "Zygote", version = v"0.7.11"), + ]) + else + Pkg.add([ + Pkg.PackageSpec(name = "Clustering"), + Pkg.PackageSpec(name = "Combinatorics"), + Pkg.PackageSpec(name = "DataFrames"), + Pkg.PackageSpec(name = "DecisionTree"), + Pkg.PackageSpec(name = "Distributions"), + Pkg.PackageSpec(name = "HypothesisTests"), + Pkg.PackageSpec(name = "SpecialFunctions"), + Pkg.PackageSpec(name = "Lux"), + Pkg.PackageSpec(name = "Optimisers"), + Pkg.PackageSpec(name = "Zygote"), + ]) + end + using Lux, Optimisers, Zygote, RiskLabAI + include(joinpath(pwd(), "test", "runtests.jl")) + ' format: - name: JuliaFormatter + name: JuliaFormatter 2.9.0 runs-on: ubuntu-latest - # Enforced on the reconstructed code only. Legacy src/ subdirs that are not - # yet ported can contain parse errors, so they are excluded until wired in. steps: - uses: actions/checkout@v4 - uses: julia-actions/setup-julia@v2 with: - version: "1" - - run: | - julia -e 'using Pkg; Pkg.add(name="JuliaFormatter", version="2.9.0") - using JuliaFormatter - format(["src/Utils", "src/Data", "src/Backtest", "test"]; verbose=true) || exit(0)' - git diff --exit-code + version: "1.12.7" + - name: Verify deterministic formatting + run: | + julia --startup-file=no -e ' + using Pkg + Pkg.activate(; temp = true) + Pkg.add(PackageSpec(name = "JuliaFormatter", version = v"2.9.0")) + using JuliaFormatter + paths = [ + "src/CausalFactorAnalysis", + "test/causal_factor_analysis", + "test/runtests.jl", + ] + format(paths; overwrite = false, verbose = true) || exit(1) + ' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..587ef53 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022-2026, Hamid Arian +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/PACKAGE_FILES.json b/PACKAGE_FILES.json new file mode 100644 index 0000000..56743b6 --- /dev/null +++ b/PACKAGE_FILES.json @@ -0,0 +1,281 @@ +{ + "schema_version": 1, + "document_type": "RISKLABAI_JULIA_INTENDED_PACKAGE_FILES", + "product": "RiskLabAI", + "language": "Julia", + "version": "1.0.0", + "artifact_types": [ + "julia_package_tree" + ], + "candidate_file_count": 89, + "candidate_files": [ + ".github/workflows/CI.yml", + "LICENSE", + "PACKAGE_FILES.json", + "PACKAGE_IDENTITY.json", + "PACKAGE_INSPECTION.md", + "PUBLIC_API.json", + "Project.toml", + "README.md", + "RELEASE_NOTES_NEXT_MAJOR.md", + "TEST_INVENTORY.json", + "docs/causal_factor_analysis.md", + "docs/compatibility.md", + "examples/causal_factor_analysis_quickstart.jl", + "ext/RiskLabAIDeepBSDEExt.jl", + "src/Backtest/Backtest.jl", + "src/Backtest/BacktestStatistics.jl", + "src/Backtest/BacktestSyntheticData.jl", + "src/Backtest/BetSizing.jl", + "src/Backtest/MultipleTesting.jl", + "src/Backtest/OUTradingRules.jl", + "src/Backtest/ProbabilisticSharpeRatio.jl", + "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "src/Backtest/StrategyRisk.jl", + "src/Backtest/TestSetOverfitting.jl", + "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/Optimizer.jl", + "src/CausalFactorAnalysis/Protocol.jl", + "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/TreatmentEffects.jl", + "src/Cluster/Cluster.jl", + "src/Cluster/Clustering.jl", + "src/Data/Data.jl", + "src/Data/Denoise/Denoising.jl", + "src/Data/Differentiation/Differentiation.jl", + "src/Data/Distance/DistanceMetric.jl", + "src/Data/Labeling/FinancialLabels.jl", + "src/Data/Labeling/Labeling.jl", + "src/Data/Structures/abstract_bars.jl", + "src/Data/Structures/abstract_imbalance_bars.jl", + "src/Data/Structures/abstract_information_driven_bars.jl", + "src/Data/Structures/abstract_run_bars.jl", + "src/Data/Structures/imbalance_bars.jl", + "src/Data/Structures/run_bars.jl", + "src/Data/Structures/standard_bars.jl", + "src/Data/Structures/time_bars.jl", + "src/Data/Structures/types.jl", + "src/Data/SyntheticData/SyntheticData.jl", + "src/Data/Weights/SampleWeight.jl", + "src/Ensemble/BaggingAccuracy.jl", + "src/Ensemble/Ensemble.jl", + "src/Features/EntropyFeatures.jl", + "src/Features/FeatureImportance.jl", + "src/Features/Features.jl", + "src/Features/MicrostructuralFeatures.jl", + "src/Features/StructuralBreaks.jl", + "src/Optimization/Hedging.jl", + "src/Optimization/HierarchicalRiskParity.jl", + "src/Optimization/NestedClusteredOptimization.jl", + "src/Optimization/Optimization.jl", + "src/Pde/DeepBSDESolver.jl", + "src/Pde/Equations.jl", + "src/Pde/Pde.jl", + "src/RiskLabAI.jl", + "src/Utils/Utils.jl", + "src/Utils/constants.jl", + "src/Utils/ewma.jl", + "src/Utils/field_inheritance.jl", + "src/Validation/CrossValScore.jl", + "src/Validation/CrossValidators.jl", + "src/Validation/HyperParameterTuning.jl", + "src/Validation/PathAdaptiveCPCV.jl", + "src/Validation/PathBaggedCPCV.jl", + "src/Validation/Validation.jl", + "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_optimizer.jl", + "test/causal_factor_analysis/test_protocol.jl", + "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_treatment_effects.jl", + "test/fixtures/pelt_x1.txt", + "test/fixtures/pelt_x2.txt", + "test/fixtures/pelt_x3.txt", + "test/fixtures/vrsadf_y.txt", + "test/preserved_runtests.jl", + "test/runtests.jl" + ], + "local_control_files_excluded_from_artifacts": [ + "PACKAGE_IDENTITY.json" + ], + "release_source_file_count": 88, + "release_source_files": [ + ".github/workflows/CI.yml", + "LICENSE", + "PACKAGE_FILES.json", + "PACKAGE_INSPECTION.md", + "PUBLIC_API.json", + "Project.toml", + "README.md", + "RELEASE_NOTES_NEXT_MAJOR.md", + "TEST_INVENTORY.json", + "docs/causal_factor_analysis.md", + "docs/compatibility.md", + "examples/causal_factor_analysis_quickstart.jl", + "ext/RiskLabAIDeepBSDEExt.jl", + "src/Backtest/Backtest.jl", + "src/Backtest/BacktestStatistics.jl", + "src/Backtest/BacktestSyntheticData.jl", + "src/Backtest/BetSizing.jl", + "src/Backtest/MultipleTesting.jl", + "src/Backtest/OUTradingRules.jl", + "src/Backtest/ProbabilisticSharpeRatio.jl", + "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "src/Backtest/StrategyRisk.jl", + "src/Backtest/TestSetOverfitting.jl", + "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/Optimizer.jl", + "src/CausalFactorAnalysis/Protocol.jl", + "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/TreatmentEffects.jl", + "src/Cluster/Cluster.jl", + "src/Cluster/Clustering.jl", + "src/Data/Data.jl", + "src/Data/Denoise/Denoising.jl", + "src/Data/Differentiation/Differentiation.jl", + "src/Data/Distance/DistanceMetric.jl", + "src/Data/Labeling/FinancialLabels.jl", + "src/Data/Labeling/Labeling.jl", + "src/Data/Structures/abstract_bars.jl", + "src/Data/Structures/abstract_imbalance_bars.jl", + "src/Data/Structures/abstract_information_driven_bars.jl", + "src/Data/Structures/abstract_run_bars.jl", + "src/Data/Structures/imbalance_bars.jl", + "src/Data/Structures/run_bars.jl", + "src/Data/Structures/standard_bars.jl", + "src/Data/Structures/time_bars.jl", + "src/Data/Structures/types.jl", + "src/Data/SyntheticData/SyntheticData.jl", + "src/Data/Weights/SampleWeight.jl", + "src/Ensemble/BaggingAccuracy.jl", + "src/Ensemble/Ensemble.jl", + "src/Features/EntropyFeatures.jl", + "src/Features/FeatureImportance.jl", + "src/Features/Features.jl", + "src/Features/MicrostructuralFeatures.jl", + "src/Features/StructuralBreaks.jl", + "src/Optimization/Hedging.jl", + "src/Optimization/HierarchicalRiskParity.jl", + "src/Optimization/NestedClusteredOptimization.jl", + "src/Optimization/Optimization.jl", + "src/Pde/DeepBSDESolver.jl", + "src/Pde/Equations.jl", + "src/Pde/Pde.jl", + "src/RiskLabAI.jl", + "src/Utils/Utils.jl", + "src/Utils/constants.jl", + "src/Utils/ewma.jl", + "src/Utils/field_inheritance.jl", + "src/Validation/CrossValScore.jl", + "src/Validation/CrossValidators.jl", + "src/Validation/HyperParameterTuning.jl", + "src/Validation/PathAdaptiveCPCV.jl", + "src/Validation/PathBaggedCPCV.jl", + "src/Validation/Validation.jl", + "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_optimizer.jl", + "test/causal_factor_analysis/test_protocol.jl", + "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_treatment_effects.jl", + "test/fixtures/pelt_x1.txt", + "test/fixtures/pelt_x2.txt", + "test/fixtures/pelt_x3.txt", + "test/fixtures/vrsadf_y.txt", + "test/preserved_runtests.jl", + "test/runtests.jl" + ], + "runtime_source_file_count": 63, + "runtime_source_files": [ + "src/Backtest/Backtest.jl", + "src/Backtest/BacktestStatistics.jl", + "src/Backtest/BacktestSyntheticData.jl", + "src/Backtest/BetSizing.jl", + "src/Backtest/MultipleTesting.jl", + "src/Backtest/OUTradingRules.jl", + "src/Backtest/ProbabilisticSharpeRatio.jl", + "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "src/Backtest/StrategyRisk.jl", + "src/Backtest/TestSetOverfitting.jl", + "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "src/BetSize/BetSizing.jl", + "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "src/CausalFactorAnalysis/FactorMirage.jl", + "src/CausalFactorAnalysis/GraphIdentification.jl", + "src/CausalFactorAnalysis/Optimizer.jl", + "src/CausalFactorAnalysis/Protocol.jl", + "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "src/CausalFactorAnalysis/TreatmentEffects.jl", + "src/Cluster/Cluster.jl", + "src/Cluster/Clustering.jl", + "src/Data/Data.jl", + "src/Data/Denoise/Denoising.jl", + "src/Data/Differentiation/Differentiation.jl", + "src/Data/Distance/DistanceMetric.jl", + "src/Data/Labeling/FinancialLabels.jl", + "src/Data/Labeling/Labeling.jl", + "src/Data/Structures/abstract_bars.jl", + "src/Data/Structures/abstract_imbalance_bars.jl", + "src/Data/Structures/abstract_information_driven_bars.jl", + "src/Data/Structures/abstract_run_bars.jl", + "src/Data/Structures/imbalance_bars.jl", + "src/Data/Structures/run_bars.jl", + "src/Data/Structures/standard_bars.jl", + "src/Data/Structures/time_bars.jl", + "src/Data/Structures/types.jl", + "src/Data/SyntheticData/SyntheticData.jl", + "src/Data/Weights/SampleWeight.jl", + "src/Ensemble/BaggingAccuracy.jl", + "src/Ensemble/Ensemble.jl", + "src/Features/EntropyFeatures.jl", + "src/Features/FeatureImportance.jl", + "src/Features/Features.jl", + "src/Features/MicrostructuralFeatures.jl", + "src/Features/StructuralBreaks.jl", + "src/Optimization/Hedging.jl", + "src/Optimization/HierarchicalRiskParity.jl", + "src/Optimization/NestedClusteredOptimization.jl", + "src/Optimization/Optimization.jl", + "src/Pde/DeepBSDESolver.jl", + "src/Pde/Equations.jl", + "src/Pde/Pde.jl", + "src/RiskLabAI.jl", + "src/Utils/Utils.jl", + "src/Utils/constants.jl", + "src/Utils/ewma.jl", + "src/Utils/field_inheritance.jl", + "src/Validation/CrossValScore.jl", + "src/Validation/CrossValidators.jl", + "src/Validation/HyperParameterTuning.jl", + "src/Validation/PathAdaptiveCPCV.jl", + "src/Validation/PathBaggedCPCV.jl", + "src/Validation/Validation.jl" + ], + "test_file_count": 12, + "test_files": [ + "test/causal_factor_analysis/test_factor_mirage.jl", + "test/causal_factor_analysis/test_graph_identification.jl", + "test/causal_factor_analysis/test_optimizer.jl", + "test/causal_factor_analysis/test_protocol.jl", + "test/causal_factor_analysis/test_specification_diagnostics.jl", + "test/causal_factor_analysis/test_treatment_effects.jl", + "test/fixtures/pelt_x1.txt", + "test/fixtures/pelt_x2.txt", + "test/fixtures/pelt_x3.txt", + "test/fixtures/vrsadf_y.txt", + "test/preserved_runtests.jl", + "test/runtests.jl" + ], + "wheel_python_source_members": [], + "generated_metadata_members": { + "policy": "Generated package-manager metadata is not pre-enumerated by filename here; the future artifact inspector must validate its normalized field set and reject undeclared payload files." + } +} diff --git a/PACKAGE_INSPECTION.md b/PACKAGE_INSPECTION.md new file mode 100644 index 0000000..973ff9b --- /dev/null +++ b/PACKAGE_INSPECTION.md @@ -0,0 +1,48 @@ +# Future Julia package inspection specification + +This specification applies only after a separate human authorization creates a +temporary RiskLabAI 1.0.0 package tree. It does not authorize registration, +installation, upload, publication, tagging, or release. + +## Inputs and membership + +The inspector must bind the final source bytes and compare the package tree +with `PACKAGE_FILES.json`, `PUBLIC_API.json`, `TEST_INVENTORY.json`, and +`Project.toml`. Reject absolute or traversing paths, case-fold or Unicode +normalization collisions, duplicate paths, links, reparse points, special +files, caches, compiled output, manifests, credentials, or any undeclared +member. + +The inspected tree must contain exactly the 88 declared RiskLabAI 1.0.0 release +members, including the governed `.github/workflows/CI.yml`. Its package name +and existing UUID must be unchanged. The canonical BSD-3-Clause license, +maintainer and contact, repository, issue, documentation, and version records +must agree with the reviewed metadata contract. + +## Dependency and extension behavior + +`Project.toml` must expose only the approved required dependencies and standard +libraries. Lux, Optimisers, and Zygote must remain weak dependencies loaded +only through `RiskLabAIDeepBSDEExt`. TimeSeries and any undeclared dependency +must be absent. All dependencies and weak dependencies must have bounded +compatibility entries. + +## Runtime checks + +In fresh Julia 1.10.12 and 1.12.7 environments, verify the exact package origin, +`Base.pkgversion(RiskLabAI) == v"1.0.0"`, the 148 root exports, all module +exports in `PUBLIC_API.json`, and the complete 57-feature causal namespace. +Run the exact package-scoped test inventory at the approved dependency floors +and current versions. In every temporary test environment, explicitly add all +seven required external dependencies before running tests. Run base lanes with +only the `Test` target and verify the four extension-absence/fallback +assertions. Repeat all lanes with Lux, Optimisers, and Zygote explicitly +installed and loaded, then verify the four numerical Deep-BSDE assertions. + +## Final review + +Scan every member name and every decoded public text member against the release +hygiene denylist. Record the tree hash, member hashes, resolved dependency +versions, module origins, test results, and every deviation. Any deviation +keeps the package rejected. Human registration, release, publication, and +version-control authority remain separate decisions after inspection. diff --git a/PUBLIC_API.json b/PUBLIC_API.json new file mode 100644 index 0000000..57576f3 --- /dev/null +++ b/PUBLIC_API.json @@ -0,0 +1,1052 @@ +{ + "schema_version": 1, + "document_type": "RISKLABAI_JULIA_PUBLIC_API_INVENTORY", + "product": "RiskLabAI", + "language": "Julia", + "version": "1.0.0", + "package_uuid": "a72881da-fdaa-49c1-8962-99caf4ccfee8", + "inventory_rule": "Exported bindings observed from the exact source modules in an isolated supported Julia environment.", + "root_export_count": 148, + "root_exports": [ + "BlackScholesBarenblatt", + "CausalFactorAnalysis", + "CombinatorialPurgedCV", + "Dollar", + "ExpectedImbalanceBars", + "ExpectedRunBars", + "FixedImbalanceBars", + "FixedRunBars", + "HJBLQ", + "KFoldCV", + "PricingDefaultRisk", + "PricingDiffRate", + "PurgedKFoldCV", + "StandardBars", + "Tick", + "TimeBars", + "Volume", + "WalkForwardCV", + "adaptive_probability_of_backtest_overfitting", + "alpha_estimates", + "average_bet_sizes", + "avg_active_signals", + "backtest_paths", + "bagged_probability_of_backtest_overfitting", + "bagging_classifier_accuracy", + "bagging_evaluate_schemes", + "bekker_parkinson_volatility_estimates", + "benchmark_sharpe_ratio", + "benjamini_hochberg_yekutieli_adjusted_p_values", + "bet_size_sigmoid", + "bet_timing", + "beta_estimates", + "bin_frequency", + "binomial_sharpe_ratio", + "calculate_bootstrap_accuracy", + "calculate_hhi", + "calculate_hhi_concentration", + "calculate_holding_period", + "calculate_strategy_risk", + "calculate_weighted_tau", + "cluster_k_means_base", + "cluster_k_means_top", + "cluster_variance", + "compute_beta", + "compute_drawdowns_time_under_water", + "compute_sigmoid_width", + "conditional_expected_drawdown", + "conditional_predictive_impact", + "construct_bars_from_data", + "corwin_schultz_estimator", + "covariance_to_correlation", + "cross_val_score", + "cv_split", + "discrete_signal", + "distance_corr", + "edge_estimator", + "estimate_volatility_regimes", + "estimated_sharpe_ratio_z_statistics", + "ewma", + "expected_max_sharpe_ratio", + "failure_probability", + "feature_importance_mda", + "feature_importance_mdi", + "feature_importance_sfi", + "fit_bagging", + "fit_ornstein_uhlenbeck", + "gamma_estimates", + "generateSignal", + "generate_max_sharpe_ratios", + "generate_signal", + "get_bsadf_sequence", + "get_bsadf_statistic", + "get_bubble_episodes", + "get_expanding_window_adf", + "get_gsadf_statistic", + "get_n_splits", + "get_optimal_portfolio_weights", + "get_optimal_portfolio_weights_nco", + "get_sadf_sequence", + "get_test_dataset", + "grid_search_cv", + "haircut_sharpe_ratios", + "hit_upper_probability", + "holm_adjusted_p_values", + "hrp", + "implied_precision", + "inverse_price", + "inverse_variance_weights", + "kontoyiannis_entropy", + "lag_dataframe", + "lempel_ziv_entropy", + "limit_price", + "longest_match_length", + "lplz_sharpe_inference", + "make_new_outputs", + "mdi_plus_importance", + "mean_exit_time", + "mean_std_error", + "mix_gaussians", + "moving_block_bootstrap_indices", + "mp_avg_active_signals", + "nercome_denoised_covariance", + "newey_west_automatic_lag", + "newey_west_long_run_variance", + "optimal_ou_trading_rule", + "orthogonal_features", + "ou_rule_metrics", + "pca_weights", + "pde_driver", + "pde_hamiltonian", + "pde_sample", + "pde_sigma", + "pde_terminal", + "pelt_change_points", + "performance_evaluation", + "plug_in_entropy_estimator", + "prepare_data", + "probabilistic_sharpe_ratio", + "probabilityOfBacktestOverfitting", + "probability_bet_size", + "probability_mass_function", + "probability_of_backtest_overfitting", + "psy_minimum_window", + "quasi_diagonal", + "random_block_correlation", + "random_block_covariance", + "random_covariance_sub", + "random_search_cv", + "recursive_bisection", + "shannon_entropy", + "sharpe_difference_test", + "sharpe_ratio", + "sharpe_ratio_influence_function", + "sharpe_ratio_p_values", + "sharpe_ratio_trials", + "sigma_estimates", + "silhouette_samples", + "simulate_psy_critical_values", + "solve_deep_bsde", + "strategy_bet_sizing", + "strategy_type1_error_probability", + "strategy_type2_error_probability", + "synthetic_back_testing", + "target_position", + "target_sharpe_ratio_symbolic", + "theta_for_type2_error", + "theta_from_half_life", + "volatility_robust_sadf" + ], + "module_export_surfaces": [ + { + "module": "Backtest", + "export_count": 55, + "exports": [ + "average_bet_sizes", + "avg_active_signals", + "benchmark_sharpe_ratio", + "benjamini_hochberg_yekutieli_adjusted_p_values", + "bet_size_sigmoid", + "bet_timing", + "bin_frequency", + "binomial_sharpe_ratio", + "calculate_hhi", + "calculate_hhi_concentration", + "calculate_holding_period", + "calculate_strategy_risk", + "compute_drawdowns_time_under_water", + "compute_sigmoid_width", + "conditional_expected_drawdown", + "discrete_signal", + "estimated_sharpe_ratio_z_statistics", + "expected_max_sharpe_ratio", + "failure_probability", + "fit_ornstein_uhlenbeck", + "generate_max_sharpe_ratios", + "generate_signal", + "haircut_sharpe_ratios", + "hit_upper_probability", + "holm_adjusted_p_values", + "implied_precision", + "inverse_price", + "limit_price", + "lplz_sharpe_inference", + "mean_exit_time", + "mean_std_error", + "mix_gaussians", + "mp_avg_active_signals", + "newey_west_automatic_lag", + "newey_west_long_run_variance", + "optimal_ou_trading_rule", + "ou_rule_metrics", + "performance_evaluation", + "probabilistic_sharpe_ratio", + "probability_bet_size", + "probability_of_backtest_overfitting", + "sharpe_difference_test", + "sharpe_ratio", + "sharpe_ratio_influence_function", + "sharpe_ratio_p_values", + "sharpe_ratio_trials", + "stationary_std", + "strategy_bet_sizing", + "strategy_type1_error_probability", + "strategy_type2_error_probability", + "synthetic_back_testing", + "target_position", + "target_sharpe_ratio_symbolic", + "theta_for_type2_error", + "theta_from_half_life" + ] + }, + { + "module": "CausalFactorAnalysis", + "export_count": 57, + "exports": [ + "BackdoorAdjustmentEvidence", + "BacktestStage", + "CausalAdjustmentSetStage", + "CausalDAG", + "CausalDiscoveryStage", + "CausalExplanatoryAndPredictivePowerStage", + "CausalFactorProtocolReport", + "CausalPortfolioConstructionStage", + "ColliderCoefficients", + "ColliderDiagnostics", + "DSeparationEvidence", + "DifferenceInDifferencesEstimate", + "EventHorizon", + "FoldEvidence", + "FrontdoorAdjustmentEvidence", + "InstrumentEvidence", + "MultipleTestingAdjustmentsStage", + "NodeRoleEvidence", + "PathEvidence", + "PopulationRegressionDiagnostics", + "RegressionDiagnostics", + "SpecificationExperimentResult", + "SpecificationPopulationResult", + "StrategyPerformance", + "TreatmentEffectDecomposition", + "ValidationEvidence", + "VariableSelectionStage", + "average_treatment_effect", + "backdoor_adjusted_average_treatment_effect", + "backdoor_adjusted_expectation", + "causal_role_evidence", + "check_backdoor_adjustment_set", + "check_frontdoor_adjustment_set", + "check_instrument", + "collider_factor_return", + "collider_forecast_return", + "collider_model_diagnostics", + "collider_overcontrolled_coefficients", + "collider_population_diagnostics", + "collider_specification_experiment", + "confounded_mediator_population_diagnostics", + "confounded_mediator_specification_experiment", + "confounder_factor_return", + "confounder_forecast_return", + "confounder_undercontrolled_coefficient", + "d_separation", + "difference_in_differences", + "fork_population_diagnostics", + "fork_specification_experiment", + "frontdoor_adjusted_probability", + "linear_instrumental_variable_effect", + "minimal_backdoor_adjustment_sets", + "minimal_frontdoor_adjustment_sets", + "minimum_variance_factor_weights", + "randomized_mean_difference", + "treatment_effect_decomposition", + "validate_causal_factor_protocol" + ] + }, + { + "module": "Cluster", + "export_count": 8, + "exports": [ + "cluster_k_means_base", + "cluster_k_means_top", + "covariance_to_correlation", + "make_new_outputs", + "random_block_correlation", + "random_block_covariance", + "random_covariance_sub", + "silhouette_samples" + ] + }, + { + "module": "Data", + "export_count": 65, + "exports": [ + "AbstractBars", + "Dollar", + "ExpectedImbalanceBars", + "ExpectedRunBars", + "FixedImbalanceBars", + "FixedRunBars", + "Metric", + "StandardBars", + "Tick", + "TimeBars", + "Volume", + "adaptive_differencing_order", + "adaptive_fractional_difference", + "align_params_length", + "bar_construction_condition", + "calculate_average_uniqueness", + "calculate_cross_entropy", + "calculate_distance", + "calculate_kullback_leibler_divergence", + "calculate_mutual_information", + "calculate_number_of_bins", + "calculate_t_value_linear_regression", + "calculate_time_decay", + "calculate_variation_of_information", + "calculate_variation_of_information_extended", + "calculate_weights_ffd", + "calculate_weights_std", + "compute_log_returns", + "construct_bars_from_data", + "corr_to_cov", + "cov_to_corr", + "cusum_filter_events_dynamic_threshold", + "daily_volatility_with_log_returns", + "denoise_cov", + "denoised_corr", + "distance_correlation", + "drift_volatility_burst", + "expand_label_for_meta_labeling", + "find_max_eval", + "find_optimal_ffd", + "find_trend_using_trend_scanning", + "form_block_matrix", + "form_true_matrix", + "fractional_difference_fixed", + "fractional_difference_std", + "fractionally_differentiated_log_price", + "generate_prices_from_regimes", + "heston_merton_log_returns", + "ksg_mutual_information", + "marcenko_pastur_pdf", + "meta_events", + "meta_labeling", + "nercome_denoised_covariance", + "optimal_portfolio", + "optimal_portfolio_denoised", + "parallel_generate_prices", + "pca", + "random_cov", + "rescaled_range_hurst", + "sample_weight_absolute_return_meta_labeling", + "simulates_cov_mu", + "symmetric_cusum_filter", + "triple_barrier", + "vertical_barrier", + "wavelet_variance_hurst" + ] + }, + { + "module": "Ensemble", + "export_count": 4, + "exports": [ + "bagging_classifier_accuracy", + "bagging_evaluate_schemes", + "calculate_bootstrap_accuracy", + "fit_bagging" + ] + }, + { + "module": "Features", + "export_count": 37, + "exports": [ + "alpha_estimates", + "bekker_parkinson_volatility_estimates", + "beta_estimates", + "calculate_weighted_tau", + "compute_beta", + "conditional_predictive_impact", + "corwin_schultz_estimator", + "edge_estimator", + "feature_importance_mda", + "feature_importance_mdi", + "feature_importance_sfi", + "gamma_estimates", + "get_bsadf_sequence", + "get_bsadf_statistic", + "get_bubble_episodes", + "get_expanding_window_adf", + "get_gsadf_statistic", + "get_sadf_sequence", + "get_test_dataset", + "grassberger_entropy", + "kontoyiannis_entropy", + "lag_dataframe", + "lempel_ziv_entropy", + "longest_match_length", + "mdi_plus_importance", + "miller_madow_entropy", + "nsb_entropy", + "orthogonal_features", + "pelt_change_points", + "plug_in_entropy_estimator", + "prepare_data", + "probability_mass_function", + "psy_minimum_window", + "shannon_entropy", + "sigma_estimates", + "simulate_psy_critical_values", + "volatility_robust_sadf" + ] + }, + { + "module": "Optimization", + "export_count": 9, + "exports": [ + "cluster_variance", + "distance_corr", + "get_optimal_portfolio_weights", + "get_optimal_portfolio_weights_nco", + "hrp", + "inverse_variance_weights", + "pca_weights", + "quasi_diagonal", + "recursive_bisection" + ] + }, + { + "module": "Pde", + "export_count": 11, + "exports": [ + "BlackScholesBarenblatt", + "Equation", + "HJBLQ", + "PricingDefaultRisk", + "PricingDiffRate", + "pde_driver", + "pde_hamiltonian", + "pde_sample", + "pde_sigma", + "pde_terminal", + "solve_deep_bsde" + ] + }, + { + "module": "RiskLabAI", + "export_count": 148, + "exports": [ + "BlackScholesBarenblatt", + "CausalFactorAnalysis", + "CombinatorialPurgedCV", + "Dollar", + "ExpectedImbalanceBars", + "ExpectedRunBars", + "FixedImbalanceBars", + "FixedRunBars", + "HJBLQ", + "KFoldCV", + "PricingDefaultRisk", + "PricingDiffRate", + "PurgedKFoldCV", + "StandardBars", + "Tick", + "TimeBars", + "Volume", + "WalkForwardCV", + "adaptive_probability_of_backtest_overfitting", + "alpha_estimates", + "average_bet_sizes", + "avg_active_signals", + "backtest_paths", + "bagged_probability_of_backtest_overfitting", + "bagging_classifier_accuracy", + "bagging_evaluate_schemes", + "bekker_parkinson_volatility_estimates", + "benchmark_sharpe_ratio", + "benjamini_hochberg_yekutieli_adjusted_p_values", + "bet_size_sigmoid", + "bet_timing", + "beta_estimates", + "bin_frequency", + "binomial_sharpe_ratio", + "calculate_bootstrap_accuracy", + "calculate_hhi", + "calculate_hhi_concentration", + "calculate_holding_period", + "calculate_strategy_risk", + "calculate_weighted_tau", + "cluster_k_means_base", + "cluster_k_means_top", + "cluster_variance", + "compute_beta", + "compute_drawdowns_time_under_water", + "compute_sigmoid_width", + "conditional_expected_drawdown", + "conditional_predictive_impact", + "construct_bars_from_data", + "corwin_schultz_estimator", + "covariance_to_correlation", + "cross_val_score", + "cv_split", + "discrete_signal", + "distance_corr", + "edge_estimator", + "estimate_volatility_regimes", + "estimated_sharpe_ratio_z_statistics", + "ewma", + "expected_max_sharpe_ratio", + "failure_probability", + "feature_importance_mda", + "feature_importance_mdi", + "feature_importance_sfi", + "fit_bagging", + "fit_ornstein_uhlenbeck", + "gamma_estimates", + "generateSignal", + "generate_max_sharpe_ratios", + "generate_signal", + "get_bsadf_sequence", + "get_bsadf_statistic", + "get_bubble_episodes", + "get_expanding_window_adf", + "get_gsadf_statistic", + "get_n_splits", + "get_optimal_portfolio_weights", + "get_optimal_portfolio_weights_nco", + "get_sadf_sequence", + "get_test_dataset", + "grid_search_cv", + "haircut_sharpe_ratios", + "hit_upper_probability", + "holm_adjusted_p_values", + "hrp", + "implied_precision", + "inverse_price", + "inverse_variance_weights", + "kontoyiannis_entropy", + "lag_dataframe", + "lempel_ziv_entropy", + "limit_price", + "longest_match_length", + "lplz_sharpe_inference", + "make_new_outputs", + "mdi_plus_importance", + "mean_exit_time", + "mean_std_error", + "mix_gaussians", + "moving_block_bootstrap_indices", + "mp_avg_active_signals", + "nercome_denoised_covariance", + "newey_west_automatic_lag", + "newey_west_long_run_variance", + "optimal_ou_trading_rule", + "orthogonal_features", + "ou_rule_metrics", + "pca_weights", + "pde_driver", + "pde_hamiltonian", + "pde_sample", + "pde_sigma", + "pde_terminal", + "pelt_change_points", + "performance_evaluation", + "plug_in_entropy_estimator", + "prepare_data", + "probabilistic_sharpe_ratio", + "probabilityOfBacktestOverfitting", + "probability_bet_size", + "probability_mass_function", + "probability_of_backtest_overfitting", + "psy_minimum_window", + "quasi_diagonal", + "random_block_correlation", + "random_block_covariance", + "random_covariance_sub", + "random_search_cv", + "recursive_bisection", + "shannon_entropy", + "sharpe_difference_test", + "sharpe_ratio", + "sharpe_ratio_influence_function", + "sharpe_ratio_p_values", + "sharpe_ratio_trials", + "sigma_estimates", + "silhouette_samples", + "simulate_psy_critical_values", + "solve_deep_bsde", + "strategy_bet_sizing", + "strategy_type1_error_probability", + "strategy_type2_error_probability", + "synthetic_back_testing", + "target_position", + "target_sharpe_ratio_symbolic", + "theta_for_type2_error", + "theta_from_half_life", + "volatility_robust_sadf" + ] + }, + { + "module": "Utils", + "export_count": 33, + "exports": [ + "@field_inherit", + "BUY_TICKS_NUMBER", + "CLOSE_PRICE", + "CUMULATIVE_BUY_THETA", + "CUMULATIVE_BUY_VOLUME", + "CUMULATIVE_DOLLAR", + "CUMULATIVE_SELL_THETA", + "CUMULATIVE_SELL_VOLUME", + "CUMULATIVE_THETA", + "CUMULATIVE_TICKS", + "CUMULATIVE_VOLUME", + "DATE_TIME", + "EXPECTED_BUY_IMBALANCE", + "EXPECTED_BUY_TICKS_PROPORTION", + "EXPECTED_IMBALANCE", + "EXPECTED_IMBALANCE_WINDOW", + "EXPECTED_SELL_IMBALANCE", + "EXPECTED_TICKS_NUMBER", + "HIGH_PRICE", + "LOW_PRICE", + "N_PREVIOUS_BARS_FOR_EXPECTED_N_TICKS_ESTIMATION", + "N_TICKS_ON_BAR_FORMATION", + "OPEN_PRICE", + "PREVIOUS_BARS_BUY_TICKS_PROPORTIONS_LIST", + "PREVIOUS_BARS_N_TICKS_LIST", + "PREVIOUS_TICK_IMBALANCES_BUY_LIST", + "PREVIOUS_TICK_IMBALANCES_LIST", + "PREVIOUS_TICK_IMBALANCES_SELL_LIST", + "PREVIOUS_TICK_RULE", + "THRESHOLD", + "TICK_NUMBER", + "TIMESTAMP", + "ewma" + ] + }, + { + "module": "Validation", + "export_count": 16, + "exports": [ + "CombinatorialPurgedCV", + "KFoldCV", + "PurgedKFoldCV", + "WalkForwardCV", + "adaptive_probability_of_backtest_overfitting", + "backtest_paths", + "bagged_probability_of_backtest_overfitting", + "cross_val_score", + "cv_split", + "deflated_sharpe_gate", + "estimate_volatility_regimes", + "get_n_splits", + "grid_search_cv", + "leakage_aware_hpo", + "moving_block_bootstrap_indices", + "random_search_cv" + ] + } + ], + "runtime_source_file_count": 63, + "runtime_sources": [ + { + "path": "src/Backtest/Backtest.jl", + "size": 3320, + "sha256": "2BD5FD263B4BE056A9846A915E475A33014CF356FDD9D2201ED6003D99126118" + }, + { + "path": "src/Backtest/BacktestStatistics.jl", + "size": 14179, + "sha256": "7889FFE3811AEDD52700441510AD10845D0C8CF0E107EA33089A56A438242B85" + }, + { + "path": "src/Backtest/BacktestSyntheticData.jl", + "size": 2657, + "sha256": "B5A098CCA8B057A4A480FF38E9D4CB9AD02BB8C2C2E5D41E65E8BFE8244ABB66" + }, + { + "path": "src/Backtest/BetSizing.jl", + "size": 8349, + "sha256": "AD2C6BD4359D48A9A9520779C09B8EE2B5540D50C2D9E730994F239C0D809B5F" + }, + { + "path": "src/Backtest/MultipleTesting.jl", + "size": 5639, + "sha256": "87BD4DF58DD7E8372736589CB8920C4FC2500630854DB8699F72B2D29363CD73" + }, + { + "path": "src/Backtest/OUTradingRules.jl", + "size": 9537, + "sha256": "342CBEB471511D198A2A02EA4F0832C826BE9AB45FD6A0D5835B9B317903B135" + }, + { + "path": "src/Backtest/ProbabilisticSharpeRatio.jl", + "size": 7792, + "sha256": "95745305045077CBF34D98D2BDA501114797776587C4EFA195BBD4C3AB3176C8" + }, + { + "path": "src/Backtest/ProbabilityOfBacktestOverfitting.jl", + "size": 3865, + "sha256": "84AC8ADBF512A80A69DBD5D4836F31249A09B3BC1EB5AFFD9C43F8702C825523" + }, + { + "path": "src/Backtest/StrategyRisk.jl", + "size": 7067, + "sha256": "308E5968BA23DBCAC94BF6DA99B1871A947AEBFEB579F99346C130A93E57FBF1" + }, + { + "path": "src/Backtest/TestSetOverfitting.jl", + "size": 6199, + "sha256": "6BCAD71DB9AB6D2A33B582F19C7A78D783C34DA602D99AA35588BF64006391B8" + }, + { + "path": "src/Backtests/ProbabilityOfBacktestOverfitting.jl", + "size": 2736, + "sha256": "8DE0E7C5A69B99A175F0C2667EACAC1C877DDA91DB5524223118F3C5D22EEEC6" + }, + { + "path": "src/BetSize/BetSizing.jl", + "size": 13711, + "sha256": "6B56738C2BB16CDF04F2DA440E5D20599CB6FD529B35B15529069BC0948AF907" + }, + { + "path": "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", + "size": 2089, + "sha256": "2CEDE65173AE33C337A826AED7D0BD3C7105622A60E17071C4E472985B47F4BA" + }, + { + "path": "src/CausalFactorAnalysis/FactorMirage.jl", + "size": 8659, + "sha256": "DE328CEDA83C160CC166F8770F446BCD082B66DDE99511221E30095374F133A2" + }, + { + "path": "src/CausalFactorAnalysis/GraphIdentification.jl", + "size": 29966, + "sha256": "D0734576F874404FCE5C6244FE4E9AF1F4FF3C07BBFEE58D43098E76FD3121FB" + }, + { + "path": "src/CausalFactorAnalysis/Optimizer.jl", + "size": 5001, + "sha256": "1896FC5BCD0E43691EAB233E86300872D54BD68ECBFE125A88CB5AD736B7E506" + }, + { + "path": "src/CausalFactorAnalysis/Protocol.jl", + "size": 57203, + "sha256": "95DB2ABAA05609F210F77B478457A75800020EAA85806E90018D78C247964E50" + }, + { + "path": "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "size": 6811, + "sha256": "3FDB30F7D87DA1F94DC5234268E48AF646C069C4B9D5E78E89B033644C4CB93B" + }, + { + "path": "src/CausalFactorAnalysis/TreatmentEffects.jl", + "size": 8446, + "sha256": "23BD47E0B88E8C73C0BA362CDDC3FE6B84F989CF590F0E4AF7410A765344FD4F" + }, + { + "path": "src/Cluster/Cluster.jl", + "size": 777, + "sha256": "1370F63F6936C27FF5928B2DCAF887570A32999ADD6DBDA6B87A68749F23D9CB" + }, + { + "path": "src/Cluster/Clustering.jl", + "size": 11078, + "sha256": "5B71F12566C47A9014007A4B6A845F0D338D7342C00AE96B3DF78965E5B8D8DC" + }, + { + "path": "src/Data/Data.jl", + "size": 3554, + "sha256": "F9A08E3A514DADCBC14F556B40D2D07046D06AF6C9CE0D670A0C013F793771B7" + }, + { + "path": "src/Data/Denoise/Denoising.jl", + "size": 12655, + "sha256": "D789E3F4BAF1E6DCE8A710C46E81270A656FEF920346FB9232A5DC198ED8E327" + }, + { + "path": "src/Data/Differentiation/Differentiation.jl", + "size": 13402, + "sha256": "9CE4CCDDBBF77E57FD8865D99B6C6FAADD8C0239350AA33C15A1E45C4A99E461" + }, + { + "path": "src/Data/Distance/DistanceMetric.jl", + "size": 11754, + "sha256": "753B8DC76011FBE82810E7FB98EA30F2D38DDB5226DD95F92B840C170F66E245" + }, + { + "path": "src/Data/Labeling/FinancialLabels.jl", + "size": 2921, + "sha256": "00646787E0D55B3CF59B8CF575030B4008154BF6BA917F48B1C4822FF4A23F4E" + }, + { + "path": "src/Data/Labeling/Labeling.jl", + "size": 9735, + "sha256": "D487F3ED3B0CDEF04C40DF7D57724FC0220D16AB3144FB1C28889799AC1F34FD" + }, + { + "path": "src/Data/Structures/abstract_bars.jl", + "size": 4774, + "sha256": "646E7D7C75BB595752BCC0F7C433E6C951EC908D70471A099A45B26015A5D87C" + }, + { + "path": "src/Data/Structures/abstract_imbalance_bars.jl", + "size": 4580, + "sha256": "13984D66AF1F7746F8138E8F47C4402BD3802E7C198F39DCF5490295D76938F4" + }, + { + "path": "src/Data/Structures/abstract_information_driven_bars.jl", + "size": 2340, + "sha256": "572B41B860A867197E0FF732E27329F22D2AAA38751642E62700DC2D11987D31" + }, + { + "path": "src/Data/Structures/abstract_run_bars.jl", + "size": 6878, + "sha256": "82CA0C4087DD54B3E314F7D16A9146557AA97800953FFC26D1002310258D2EFE" + }, + { + "path": "src/Data/Structures/imbalance_bars.jl", + "size": 3125, + "sha256": "A2887D6B3CBA1A9BF71CA7581E2E13E2BD82854D5B39F9A268B1924D40F77DE6" + }, + { + "path": "src/Data/Structures/run_bars.jl", + "size": 2978, + "sha256": "420900A27ABED792A063AC043B6825FF5DBB150B8E4018BC341C0DED78A51847" + }, + { + "path": "src/Data/Structures/standard_bars.jl", + "size": 2402, + "sha256": "B693ECF7F95AEDBF3CD49AD9C14E1E7D70CA4B85591121443E01F4A557C9D637" + }, + { + "path": "src/Data/Structures/time_bars.jl", + "size": 4006, + "sha256": "EFB7FA94FA17DD236BD40DE2C1528F0D5355314C83CA7C1076036726A1A33A70" + }, + { + "path": "src/Data/Structures/types.jl", + "size": 871, + "sha256": "BF7A371208DE55E389A9643069A77D6422BBDE327053241C18EA6B5DB5CB0618" + }, + { + "path": "src/Data/SyntheticData/SyntheticData.jl", + "size": 14074, + "sha256": "90A2D56DEBA678D06C893EC450E31DF40DAC18C639712A156A49E53A37608AE4" + }, + { + "path": "src/Data/Weights/SampleWeight.jl", + "size": 5700, + "sha256": "28778ED250F4F770F873418DB240DE547E1B2FA3530D40B2AD94F7972C396642" + }, + { + "path": "src/Ensemble/BaggingAccuracy.jl", + "size": 5870, + "sha256": "C0291FAFDF479C23F3030814746C9D7771B7BE5748607ED8BBC41D4363D33AF5" + }, + { + "path": "src/Ensemble/Ensemble.jl", + "size": 479, + "sha256": "BDB8AB43A20557E97427BBDCFC9CE68C5BAA7993B59001A500C781DE96239ECD" + }, + { + "path": "src/Features/EntropyFeatures.jl", + "size": 13866, + "sha256": "8B187C8174B2D7DFDC1218F8BC137047B1FBEB015000F8C755A2B3F86A3AD6DA" + }, + { + "path": "src/Features/FeatureImportance.jl", + "size": 20944, + "sha256": "59125DD8F1AB71F2620DC8512C838994FC903585906E37D6BE542457130A638D" + }, + { + "path": "src/Features/Features.jl", + "size": 2062, + "sha256": "D0E2A8CF861445ED8811A05E2AAA615EAE71B71F551A8561FF43756D2F685772" + }, + { + "path": "src/Features/MicrostructuralFeatures.jl", + "size": 9364, + "sha256": "92E38DD1EB3470B315D809B8B5E382B0FA021E7B9D73B53329605089637CF3D9" + }, + { + "path": "src/Features/StructuralBreaks.jl", + "size": 28647, + "sha256": "DF46E7A17CCFA18718BA3545EDC7D9AB5A19A83DCAC4E6968C9F117AE2F4342F" + }, + { + "path": "src/Optimization/Hedging.jl", + "size": 1542, + "sha256": "5D53049AB53DA667DAB50C8A85C0C4AEC8B72FB173EF10492CE29CAC2DD4C4CF" + }, + { + "path": "src/Optimization/HierarchicalRiskParity.jl", + "size": 5366, + "sha256": "D183F89F5630B7A90698B0C5E8FD4B12BE4C2CB35B63584D3E68BE228E66DF3F" + }, + { + "path": "src/Optimization/NestedClusteredOptimization.jl", + "size": 3203, + "sha256": "606C77D03C188329A2D34A47B66E30F9EF071830F759FCAF3212174F0C75A040" + }, + { + "path": "src/Optimization/Optimization.jl", + "size": 1046, + "sha256": "F3F2B5BF989A38B2597212B189D46B3F15822EC70D0A10B88665B8232749B111" + }, + { + "path": "src/Pde/DeepBSDESolver.jl", + "size": 4156, + "sha256": "98713B61985616C1B75A75AEEFDFC04EB098CCE8B113DC82C30E66C290F5CE2B" + }, + { + "path": "src/Pde/Equations.jl", + "size": 6691, + "sha256": "E9FDB03662BFA332071DFEE0B5EAD08EB9083013394D2A467E4E74850619D9F8" + }, + { + "path": "src/Pde/Pde.jl", + "size": 1150, + "sha256": "83EC54078F611573F5BDDAAAB1A196FDBCF5BDF60441D73F15A5DFACC8960318" + }, + { + "path": "src/RiskLabAI.jl", + "size": 9192, + "sha256": "F1568851BCEA6C59D24D2BD3362E91A74A1FA2EA96CDFDB10E25B8D80BE2600F" + }, + { + "path": "src/Utils/constants.jl", + "size": 1878, + "sha256": "4375C297224F43D46DCBB3C48CDE48C03E0411003A8EB7BE0F7043D21D0EA1C0" + }, + { + "path": "src/Utils/ewma.jl", + "size": 603, + "sha256": "1859782577AB7D54D2ABDEA9E2D55E27E1A5085DF9CAC4747BD07F531D802B50" + }, + { + "path": "src/Utils/field_inheritance.jl", + "size": 1354, + "sha256": "1B49243A727EF52DD281E5C99833F4628D11E171A362DD93F0569A29BBC68615" + }, + { + "path": "src/Utils/Utils.jl", + "size": 1422, + "sha256": "00040198C01FAA14E1382615E45FB9C4134E811358873ACAADE6395300AF6757" + }, + { + "path": "src/Validation/CrossValidators.jl", + "size": 11066, + "sha256": "7CD01DAA03DCE65A3FD8FD63A659CA49F41C065EBCB57D74A7143D04A02EF274" + }, + { + "path": "src/Validation/CrossValScore.jl", + "size": 2838, + "sha256": "0B201D8E86ED57E48739263D24B5D6E15A63920AF57F25677A0A01BAC26F1D75" + }, + { + "path": "src/Validation/HyperParameterTuning.jl", + "size": 9470, + "sha256": "1A01C2640FB08BEC1DA80418148F7A049ABCEEA0D807FA4732FAA2B049A6FC7B" + }, + { + "path": "src/Validation/PathAdaptiveCPCV.jl", + "size": 7574, + "sha256": "A206D2CA42A7F531923A190081882696D4D686C8E89006FA93D4EA7C422F2415" + }, + { + "path": "src/Validation/PathBaggedCPCV.jl", + "size": 4064, + "sha256": "009ACA2A790988505C04A6260C69EAC458961E2CB3200EE9B5AD94C02231F591" + }, + { + "path": "src/Validation/Validation.jl", + "size": 1729, + "sha256": "8D77D7E5FDC0C523C1832A749EB945AB1651DD02CBA839170372DC1262AC04C1" + } + ], + "causal_factor_analysis": { + "export_count": 57, + "ordered_exports": [ + "BackdoorAdjustmentEvidence", + "BacktestStage", + "CausalAdjustmentSetStage", + "CausalDAG", + "CausalDiscoveryStage", + "CausalExplanatoryAndPredictivePowerStage", + "CausalFactorProtocolReport", + "CausalPortfolioConstructionStage", + "ColliderCoefficients", + "ColliderDiagnostics", + "DSeparationEvidence", + "DifferenceInDifferencesEstimate", + "EventHorizon", + "FoldEvidence", + "FrontdoorAdjustmentEvidence", + "InstrumentEvidence", + "MultipleTestingAdjustmentsStage", + "NodeRoleEvidence", + "PathEvidence", + "PopulationRegressionDiagnostics", + "RegressionDiagnostics", + "SpecificationExperimentResult", + "SpecificationPopulationResult", + "StrategyPerformance", + "TreatmentEffectDecomposition", + "ValidationEvidence", + "VariableSelectionStage", + "average_treatment_effect", + "backdoor_adjusted_average_treatment_effect", + "backdoor_adjusted_expectation", + "causal_role_evidence", + "check_backdoor_adjustment_set", + "check_frontdoor_adjustment_set", + "check_instrument", + "collider_factor_return", + "collider_forecast_return", + "collider_model_diagnostics", + "collider_overcontrolled_coefficients", + "collider_population_diagnostics", + "collider_specification_experiment", + "confounded_mediator_population_diagnostics", + "confounded_mediator_specification_experiment", + "confounder_factor_return", + "confounder_forecast_return", + "confounder_undercontrolled_coefficient", + "d_separation", + "difference_in_differences", + "fork_population_diagnostics", + "fork_specification_experiment", + "frontdoor_adjusted_probability", + "linear_instrumental_variable_effect", + "minimal_backdoor_adjustment_sets", + "minimal_frontdoor_adjustment_sets", + "minimum_variance_factor_weights", + "randomized_mean_difference", + "treatment_effect_decomposition", + "validate_causal_factor_protocol" + ], + "ordered_export_sha256": "A6012DFB954F4BB26BE57762E879BA770D30A912B6685174821BC123770E3F9A" + } +} diff --git a/Project.toml b/Project.toml index 4955ee7..864e8b4 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "RiskLabAI" uuid = "a72881da-fdaa-49c1-8962-99caf4ccfee8" -version = "0.6.2" -authors = ["RiskLab AI "] +version = "1.0.0" +authors = ["Hamid Arian "] [deps] Clustering = "aaaa29a8-35af-508c-8bc3-b662a17a0fe5" @@ -12,31 +12,34 @@ DecisionTree = "7806a523-6efd-50cb-b5f6-3fa6f1930dbb" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" HypothesisTests = "09f84164-cd44-5f33-b23f-e6b0d136a0d5" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -Lux = "b2108857-7c20-44ae-9111-449ecde12c47" -Optimisers = "3bd65402-5787-11e9-1adc-39752487f4e2" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" + +[weakdeps] +Lux = "b2108857-7c20-44ae-9111-449ecde12c47" +Optimisers = "3bd65402-5787-11e9-1adc-39752487f4e2" Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" +[extensions] +RiskLabAIDeepBSDEExt = ["Lux", "Optimisers", "Zygote"] + [compat] -Clustering = "0.15" -Combinatorics = "1" -DataFrames = "1" +Clustering = "0.15.8" +Combinatorics = "1.1" +DataFrames = "1.8" Dates = "1" -DecisionTree = "0.12" -Distributions = "0.25" -HypothesisTests = "0.11" +DecisionTree = "0.12.4" +Distributions = "0.25.128" +HypothesisTests = "0.11.8" LinearAlgebra = "1" Lux = "1.31.4" Optimisers = "0.4.7" Random = "1" -SpecialFunctions = "2" +SpecialFunctions = "2.8" Statistics = "1" -TimeSeries = "0.20 - 0.24" Zygote = "0.7.11" -julia = "1.10" +julia = "~1.10.12, ~1.12.7" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/README.md b/README.md index 498c582..e765bdc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,56 @@ -# RiskLabAI.jl -Financial AI with Julia +# RiskLabAI 1.0.0 pre-package source + +Status: **local-only and blocked**. + +This directory is the source-only Julia continuity root for the next major +RiskLabAI release. It is the same RiskLabAI product and preserves package UUID +`a72881da-fdaa-49c1-8962-99caf4ccfee8`; it is not a separate package. + +The `src` directory preserves all 56 source paths captured from the current +public Julia `main` baseline and adds seven causal-factor source files. No prior +source path or non-causal module has been deleted. Fifty-five baseline source +files remain byte-identical; the root module differs only by the additive +`CausalFactorAnalysis` include and export. + +The causal module exposes the same 57 public concepts as the completed Python +namespace. Its 138 focused tests pass on Julia 1.10.12 LTS and Julia 1.12.7 +stable at both dependency endpoints, including the independent four-node graph +oracles. All 508 non-deep preserved assertions also pass in each lane, and the +four deep-BSDE assertions pass with the optional extension enabled. + +The static `Project.toml` contract now records the existing package UUID, +version `1.0.0`, supported Julia lines, exact required dependencies, and the +optional `deep_bsde` extension. No package tree has been registered, installed, +published, or released. The full runtime matrix, documentation, API inventory, +12-file package-scoped test inventory, and intended package file list are +complete. Artifact inspection and human-controlled version-control, +registration, publication, and release decisions remain blocked, and the +recorded source-conflict exclusions remain binding. + +## Confirmed identity and stewardship + +- Product and package name: `RiskLabAI` +- Julia package UUID: `a72881da-fdaa-49c1-8962-99caf4ccfee8` +- Intended license: BSD-3-Clause +- Rights holder and public maintainer: Hamid Arian +- Copyright: 2022-2026 +- Contact: arian@risklab.ai +- Repository: https://github.com/RiskLabAI/RiskLabAI.jl +- Issues: https://github.com/RiskLabAI/RiskLabAI.jl/issues +- Documentation: https://github.com/RiskLabAI/RiskLabAI.jl#readme + +The rights holder has confirmed permission to publish and license every file +admitted to the clean public package. This confirmation does not clear +artifact-inspection or human-authorization gates. + +See `PACKAGE_IDENTITY.json` for the fail-closed machine-readable state. + +The complete Julia contract is recorded in `Project.toml`, `PUBLIC_API.json`, +`TEST_INVENTORY.json`, and `PACKAGE_FILES.json`. Future package-tree checks are +specified in `PACKAGE_INSPECTION.md`; that document grants no registry or +release authority. + +The 57-concept contract is documented in `docs/causal_factor_analysis.md`, and +runtime details are in `docs/compatibility.md`. A small deterministic example +is in `examples/causal_factor_analysis_quickstart.jl`, and the blocked +next-major summary is in `RELEASE_NOTES_NEXT_MAJOR.md`. diff --git a/RELEASE_NOTES_NEXT_MAJOR.md b/RELEASE_NOTES_NEXT_MAJOR.md new file mode 100644 index 0000000..6390bbc --- /dev/null +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -0,0 +1,30 @@ +# RiskLabAI Julia next-major release notes — blocked draft + +Target version: **1.0.0**. The owner approved this exact version, but assigning +it does not authorize registration, publication, or release. + +The next major Julia release preserves the prior public repository source +modules and adds the verified 57-concept `RiskLabAI.CausalFactorAnalysis` +module under the existing RiskLabAI package UUID. + +The supported runtime policy is Julia 1.10 LTS and Julia 1.12 stable at their +tested latest patches. TimeSeries is removed as an unused requirement. Lux, +Optimisers, and Zygote move behind the optional `deep_bsde` extension; the +base library remains usable without that stack. The deep solver uses a +consistent Float64 numerical path. + +The test target now keeps weak dependencies out of the base environment. Base +lanes verify the explicit fallback, while separate Julia 1.10.12 and 1.12.7 +extension lanes install the weak dependencies and run the numerical solver +tests. Every minimum and current lane explicitly adds the seven required base +dependencies to its temporary test environment; extension lanes additionally +add Lux, Optimisers, and Zygote. The governed workflow also pins JuliaFormatter +2.9.0 for the clean causal source, causal tests, and test entry point. + +No source-conflicted causal result is admitted. The static Project metadata, +63-file source inventory, 12-file package-scoped test inventory, public API +inventory, documentation, examples, dependency extension, governed CI +workflow, and 88-file intended package-tree list are frozen. No package +artifact has been created. Future artifact inspection and separate human +authorization for version-control, registration, publication, and release +remain blocked. diff --git a/TEST_INVENTORY.json b/TEST_INVENTORY.json new file mode 100644 index 0000000..7d3b8a0 --- /dev/null +++ b/TEST_INVENTORY.json @@ -0,0 +1,93 @@ +{ + "schema_version": 1, + "document_type": "RISKLABAI_JULIA_TEST_INVENTORY", + "product": "RiskLabAI", + "language": "Julia", + "version": "1.0.0", + "runner": "Pkg.test base lanes / explicit weak-dependency Deep-BSDE lanes / Test", + "file_count": 12, + "verified_assertions_per_required_lane": { + "preserved_non_deep": 508, + "causal_factor_analysis": 138, + "lane_specific_deep_bsde_or_fallback": 4, + "total": 650 + }, + "base_lane_count": 4, + "deep_bsde_lane_count": 4, + "required_lane_count": 8, + "required_lane_assertion_total": 5200, + "files": [ + { + "path": "test/causal_factor_analysis/test_factor_mirage.jl", + "size": 2829, + "sha256": "84B194306913AFE850F6E4B56761491AEA2B13F180BFF24FD83BB20FE10151D7", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_graph_identification.jl", + "size": 18944, + "sha256": "B15F623E6F474B706F3AA00AB69D693C0F496494DB0E537D824F3B429363D22F", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_optimizer.jl", + "size": 3424, + "sha256": "C3DD2CB2F226D9C8ABF6C3E645929007BC811419321E10181A5CF8C5AAAC6ECB", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_protocol.jl", + "size": 11071, + "sha256": "5916A06D7D186DA6A74FA0FD23DC7EDD29BA75BACCBA92CA53E3AB94C201109D", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_specification_diagnostics.jl", + "size": 1974, + "sha256": "0BBB85F99A06450D9940FBFC152C0F239159DA6CC4AB75D6D5A34490E23B717E", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_treatment_effects.jl", + "size": 2220, + "sha256": "1D47C23D447AB8C50B8BD013AA6FA3ACA2A577924FFC0D17656BAA9B95CBCAEF", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/fixtures/pelt_x1.txt", + "size": 4560, + "sha256": "D91CCA3A9AC511AD7E57690342491639C2221B7D6971A84D48C06142DCBF7EBF", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/pelt_x2.txt", + "size": 3898, + "sha256": "A6ED3EC032127997915CC490713622C466060B09A196DB6E64B71E75C1F3A9D2", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/pelt_x3.txt", + "size": 6071, + "sha256": "4ED138F86A26DBEAA424E52656286EFE0ECEF8EC9F5901F4F39B6653348F91CA", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/vrsadf_y.txt", + "size": 2225, + "sha256": "15174F32C7EA4FCF7A158C80C47ABDFB25B1DEFBEDD2DA0B1103B6B2AFF33AD5", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/preserved_runtests.jl", + "size": 72649, + "sha256": "72BE89B073A52697AC6BD39E9DA67717FEC832B81875E6317C2ABF98C4071896", + "group": "PRESERVED_LIBRARY" + }, + { + "path": "test/runtests.jl", + "size": 2657, + "sha256": "FAB90A2D9E23A34918915BE579A46B00FC2493346BECC85012B9C78EF31F4DC8", + "group": "RUNNER" + } + ] +} diff --git a/docs/causal_factor_analysis.md b/docs/causal_factor_analysis.md new file mode 100644 index 0000000..e860fac --- /dev/null +++ b/docs/causal_factor_analysis.md @@ -0,0 +1,48 @@ +# Causal factor analysis + +The public causal API implements the source-consistent analytical scope of *Causal Factor Investing* and exposes 57 concepts. The Julia namespace is `RiskLabAI.CausalFactorAnalysis`; the matching Python namespace is `RiskLabAI.causal_factor_analysis`. + +## Public surface + +| Group | Records and functions | +|---|---| +| Optimizer (1) | `minimum_variance_factor_weights` | +| Factor-mirage analytics (10) | `StrategyPerformance`, `ColliderCoefficients`, `ColliderDiagnostics`, `confounder_undercontrolled_coefficient`, `confounder_factor_return`, `confounder_forecast_return`, `collider_overcontrolled_coefficients`, `collider_factor_return`, `collider_forecast_return`, `collider_model_diagnostics` | +| Protocol (12) | `EventHorizon`, `FoldEvidence`, `ValidationEvidence`, `VariableSelectionStage`, `CausalDiscoveryStage`, `CausalAdjustmentSetStage`, `CausalExplanatoryAndPredictivePowerStage`, `CausalPortfolioConstructionStage`, `BacktestStage`, `MultipleTestingAdjustmentsStage`, `CausalFactorProtocolReport`, `validate_causal_factor_protocol` | +| Graph identification (14) | `CausalDAG`, `PathEvidence`, `DSeparationEvidence`, `BackdoorAdjustmentEvidence`, `FrontdoorAdjustmentEvidence`, `InstrumentEvidence`, `NodeRoleEvidence`, `d_separation`, `check_backdoor_adjustment_set`, `minimal_backdoor_adjustment_sets`, `check_frontdoor_adjustment_set`, `minimal_frontdoor_adjustment_sets`, `check_instrument`, `causal_role_evidence` | +| Treatment effects (10) | `DifferenceInDifferencesEstimate`, `TreatmentEffectDecomposition`, `average_treatment_effect`, `backdoor_adjusted_average_treatment_effect`, `backdoor_adjusted_expectation`, `difference_in_differences`, `frontdoor_adjusted_probability`, `linear_instrumental_variable_effect`, `randomized_mean_difference`, `treatment_effect_decomposition` | +| Specification diagnostics (10) | `PopulationRegressionDiagnostics`, `RegressionDiagnostics`, `SpecificationExperimentResult`, `SpecificationPopulationResult`, `collider_population_diagnostics`, `collider_specification_experiment`, `confounded_mediator_population_diagnostics`, `confounded_mediator_specification_experiment`, `fork_population_diagnostics`, `fork_specification_experiment` | + +## Behavioral contract + +The optimizer requires a symmetric positive-definite covariance matrix and a full-column-rank exposure matrix. It rejects singular or numerically unresolved constraints rather than adding hidden regularization. + +Graph routines operate on a caller-supplied acyclic graph with an explicit observed-node set. They provide path evidence for d-separation, back-door, front-door, and instrument checks. These are graphical criteria, not causal-discovery procedures, positivity proofs, empirical instrument-strength tests, or claims that the supplied graph is true. Inclusion-minimal adjustment sets are not necessarily variance-optimal or minimum-cost sets. + +Treatment-effect functions evaluate explicit identification formulas from caller-supplied interventional, conditional, or grouped quantities. They do not infer that the identification assumptions hold in observed data. + +Protocol records are immutable evidence containers. The validator checks the declared seven-stage structure and fail-closed evidence rules; it does not execute empirical research or certify the truth of a declaration. + +Specification diagnostics expose exact population identities and deterministic experiments for fork, collider, and confounded-mediator structures. They are mathematical diagnostics rather than automated model selection. + +## Cross-language behavior + +Python returns immutable tuple-backed evidence records and NumPy arrays; Julia returns immutable structs and Julia vectors or matrices. Python raises `ValueError` for invalid analytical inputs; Julia raises `ArgumentError`. Canonical node and set ordering, graphical criteria, result fields, resource limits, numerical tolerances, and estimands otherwise match. + +Both implementations are tested against independent analytical results and graph oracles. Agreement between the languages is additional evidence, never the sole correctness test. + +## Exclusions + +Three later-source results remain excluded because their sources conflict: `CFA-CONF-01`, `CFA-COLL-01`, and `MIRAGE-COLL-SHIFT-01`. The clean namespace does not delegate to the excluded legacy causal implementation. + +See `../examples/causal_factor_analysis_quickstart.jl` for a deterministic example and `compatibility.md` for the tested runtime policy. + +The exact exported names, module surfaces, and source hashes are frozen in +`../PUBLIC_API.json`. The causal tests and their hashes are part of the exact +package-scoped inventory in `../TEST_INVENTORY.json`. + +The exported name set is exactly the same 57-name set as Python. Python keeps +its approved `__all__` order, while this Julia inventory uses canonical lexical +order; Julia export order is not a behavioral contract. Both language-native +digests and the canonical name-set digest are recorded in the blocked +pre-package contract. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..774b00d --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,100 @@ +# Julia runtime and dependency support + +Status: **matrix verified; release blocked**. + +This policy applies to the blocked RiskLabAI `1.0.0` release candidate. The +assigned version does not authorize package registration, artifact creation, +publication, or release. + +## Supported Julia policy + +RiskLabAI supports the maintained LTS and stable Julia lines tested for this +release: + +- Julia 1.10.12 LTS; +- Julia 1.12.7 stable. + +Julia 1.11 is best-effort rather than a supported lane. If the upstream stable +line changes before release metadata is frozen, the new stable line must +replace 1.12 in the matrix and pass again. + +Both official runtime archives were verified against their published SHA-256 +checksums before testing. + +## Required and optional dependencies + +The required external dependency floors are: + +- Clustering 0.15.8; +- Combinatorics 1.1.0; +- DataFrames 1.8.2; +- DecisionTree 0.12.4; +- Distributions 0.25.128; +- HypothesisTests 0.11.8; +- SpecialFunctions 2.8.0. + +The current-compatible lanes resolved Distributions 0.25.131 and +SpecialFunctions 2.9.0; the other required packages remained at their stated +floors. Dates, LinearAlgebra, Random, and Statistics are required standard +libraries. + +Lux, Optimisers, and Zygote are isolated behind the optional `deep_bsde` +capability. The tested minimums are Lux 1.31.4, Optimisers 0.4.7, and Zygote +0.7.11. Current lanes used Lux 1.31.4, Optimisers 0.4.9, and Zygote 0.7.12. +The base source loads without these packages. Calling `solve_deep_bsde` without +the extension raises a direct dependency error. + +The governed base test target contains only `Test`; it does not promote weak +dependencies into required dependencies. Base lanes verify four explicit +extension-absence and fallback assertions. Separate `deep_bsde` lanes install +and load Lux, Optimisers, and Zygote before running the four numerical solver +assertions. Every CI lane creates a temporary test environment and adds all +seven required external packages as direct dependencies at either their exact +floors or current compatible versions. Extension lanes additionally add the +three weak dependencies. This keeps direct imports in the preserved tests +independent of incidental manifest state. + +TimeSeries is not a required dependency. No preserved source behavior uses it, +and the complete base library passes without it. + +## Matrix evidence + +Four runtime/dependency combinations were exercised: Julia 1.10.12 and 1.12.7, +each with the minimum and current-compatible required dependency sets. Every +combination was run once as a base lane and once as a `deep_bsde` extension +lane, for eight required executions. + +In every lane: + +- the isolated base source loaded without Lux, Optimisers, or Zygote and passed + all 4 fallback assertions; +- all 138 frozen causal-factor tests passed; +- all 508 non-deep assertions in the 45 preserved test sets passed; +- all 4 deep-BSDE assertions passed with the optional stack enabled. + +That is 650 passing assertions per execution and 5,200 across the four base and +four extension executions. The causal suite includes independent analytical +and exhaustive graph oracles; it does not use Python-Julia agreement as its +sole correctness test. + +The CI workflow keeps Julia 1.10.12 LTS and Julia 1.12.7 stable as explicit +base and extension jobs. Both minimum and current jobs construct their +temporary test environments explicitly. JuliaFormatter 2.9.0 checks only the +independently maintained causal source, causal tests, and test entry point; +preserved source remains governed by the complete analytical test suite. + +The deep solver now converts Lux parameters and state to Float64, matching the +PDE state and eliminating a mixed-precision fallback present in every original +lane. This changes neither the public API nor the estimand. + +The causal public design remains exactly 57 concepts and matches the Python +namespace. No excluded legacy causal implementation was used as a correctness +authority. + +## Remaining release gates + +`Project.toml`, the public surface, package-scoped tests, documentation, source +allowlists, and governed CI workflow are complete. Local source loading and +temporary test environments retained no release artifact and performed no +registration. Artifact inspection and separate human authorization for +version-control, registration, publication, and release remain outstanding. diff --git a/examples/causal_factor_analysis_quickstart.jl b/examples/causal_factor_analysis_quickstart.jl new file mode 100644 index 0000000..6736b7a --- /dev/null +++ b/examples/causal_factor_analysis_quickstart.jl @@ -0,0 +1,32 @@ +"""Small, deterministic examples for the frozen causal-factor API.""" + +using RiskLabAI +using RiskLabAI.CausalFactorAnalysis +using LinearAlgebra: Diagonal + +@assert Base.pkgversion(RiskLabAI) == v"1.0.0" + +covariance = Matrix(Diagonal([1.0, 2.0, 4.0])) +factor_exposures = [1.0 0.0; 0.0 1.0; 1.0 1.0] +target_exposures = [0.0, 1.0] +weights = minimum_variance_factor_weights( + covariance, + factor_exposures, + target_exposures, +) +@assert isapprox(weights, [-2.0 / 7.0, 5.0 / 7.0, 2.0 / 7.0]) + +effect = average_treatment_effect(3.5, 1.25) +@assert effect == 2.25 + +dag = CausalDAG( + ("T", "U", "Y"), + (("U", "T"), ("U", "Y"), ("T", "Y")), + ("T", "U", "Y"), +) +@assert !check_backdoor_adjustment_set(dag, "T", "Y").admissible +@assert check_backdoor_adjustment_set(dag, "T", "Y", ("U",)).admissible + +println("minimum-variance weights: ", weights) +println("average treatment effect: ", effect) +println("back-door adjustment set: (\"U\",)") diff --git a/ext/RiskLabAIDeepBSDEExt.jl b/ext/RiskLabAIDeepBSDEExt.jl new file mode 100644 index 0000000..bbf94d5 --- /dev/null +++ b/ext/RiskLabAIDeepBSDEExt.jl @@ -0,0 +1,7 @@ +module RiskLabAIDeepBSDEExt + +using RiskLabAI + +include(joinpath(@__DIR__, "..", "src", "Pde", "DeepBSDESolver.jl")) + +end # module RiskLabAIDeepBSDEExt diff --git a/src/CausalFactorAnalysis/CausalFactorAnalysis.jl b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl new file mode 100644 index 0000000..e0d054f --- /dev/null +++ b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl @@ -0,0 +1,72 @@ +module CausalFactorAnalysis + +using LinearAlgebra +using Random +using Statistics + +include("TreatmentEffects.jl") +include("SpecificationDiagnostics.jl") +include("FactorMirage.jl") +include("Optimizer.jl") +include("GraphIdentification.jl") +include("Protocol.jl") + +export BackdoorAdjustmentEvidence, + BacktestStage, + CausalDAG, + CausalAdjustmentSetStage, + CausalDiscoveryStage, + CausalExplanatoryAndPredictivePowerStage, + CausalFactorProtocolReport, + CausalPortfolioConstructionStage, + ColliderCoefficients, + ColliderDiagnostics, + DSeparationEvidence, + DifferenceInDifferencesEstimate, + EventHorizon, + FoldEvidence, + FrontdoorAdjustmentEvidence, + InstrumentEvidence, + MultipleTestingAdjustmentsStage, + NodeRoleEvidence, + PathEvidence, + PopulationRegressionDiagnostics, + RegressionDiagnostics, + SpecificationExperimentResult, + SpecificationPopulationResult, + StrategyPerformance, + TreatmentEffectDecomposition, + ValidationEvidence, + VariableSelectionStage, + average_treatment_effect, + backdoor_adjusted_average_treatment_effect, + backdoor_adjusted_expectation, + causal_role_evidence, + check_backdoor_adjustment_set, + check_frontdoor_adjustment_set, + check_instrument, + collider_factor_return, + collider_forecast_return, + collider_model_diagnostics, + collider_overcontrolled_coefficients, + collider_population_diagnostics, + collider_specification_experiment, + confounded_mediator_population_diagnostics, + confounded_mediator_specification_experiment, + confounder_factor_return, + confounder_forecast_return, + confounder_undercontrolled_coefficient, + d_separation, + difference_in_differences, + fork_population_diagnostics, + fork_specification_experiment, + frontdoor_adjusted_probability, + linear_instrumental_variable_effect, + minimal_backdoor_adjustment_sets, + minimal_frontdoor_adjustment_sets, + minimum_variance_factor_weights, + randomized_mean_difference, + treatment_effect_decomposition, + validate_causal_factor_protocol + +end diff --git a/src/CausalFactorAnalysis/FactorMirage.jl b/src/CausalFactorAnalysis/FactorMirage.jl new file mode 100644 index 0000000..2210c70 --- /dev/null +++ b/src/CausalFactorAnalysis/FactorMirage.jl @@ -0,0 +1,219 @@ +struct StrategyPerformance + correct::Float64 + misspecified::Float64 +end + +struct ColliderCoefficients + beta_hat::Float64 + theta_hat::Float64 +end + +struct ColliderDiagnostics + residual_variance::Float64 + outcome_variance::Float64 + correct_r_squared::Float64 + overcontrolled_r_squared::Float64 + correct_adjusted_r_squared::Float64 + overcontrolled_adjusted_r_squared::Float64 + correct_beta_variance::Float64 + overcontrolled_beta_variance::Float64 + collider_coefficient_variance::Float64 + correct_beta_t_statistic::Float64 + overcontrolled_beta_t_statistic::Float64 + collider_t_statistic::Float64 + adjusted_r_squared_prefers_overcontrolled::Bool + absolute_beta_t_prefers_overcontrolled::Bool +end + +function _finite_output(name::AbstractString, value) + result = try + Float64(value) + catch + throw(ArgumentError("$name cannot be represented as a finite Float64")) + end + isfinite(result) || + throw(ArgumentError("$name cannot be represented as a finite Float64")) + return result +end + +_exact_fraction(name, value) = Rational{BigInt}(_finite_real(name, value)) + +function _coefficient_times_square_root(name, coefficient, radicand) + iszero(coefficient) && return 0.0 + result = try + setprecision(BigFloat, 256) do + Float64(BigFloat(coefficient) * sqrt(BigFloat(radicand))) + end + catch + throw(ArgumentError("$name cannot be represented as a finite Float64")) + end + isfinite(result) || + throw(ArgumentError("$name cannot be represented as a finite Float64")) + return result +end + +_confounder_coefficient(beta, gamma, delta) = beta + gamma * delta / (1 + delta * delta) + +function _collider_coefficients(beta, gamma, delta) + denominator = 1 + gamma * gamma + return (beta - delta * gamma) / denominator, gamma / denominator +end + +function confounder_undercontrolled_coefficient(beta, gamma, delta) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + return _finite_output( + "undercontrolled coefficient", + _confounder_coefficient(beta_value, gamma_value, delta_value), + ) +end + +function confounder_factor_return( + x, + z, + beta, + gamma, + delta_estimated; + delta_realized = nothing, +) + x_value = _exact_fraction("x", x) + z_value = _exact_fraction("z", z) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + estimated = _exact_fraction("delta_estimated", delta_estimated) + realized = + delta_realized === nothing ? estimated : + _exact_fraction("delta_realized", delta_realized) + + correct_signal = x_value * beta_value + z_value * gamma_value + estimated_coefficient = _confounder_coefficient(beta_value, gamma_value, estimated) + realized_coefficient = _confounder_coefficient(beta_value, gamma_value, realized) + return StrategyPerformance( + _finite_output("correct factor return", correct_signal * correct_signal), + _finite_output( + "undercontrolled factor return", + x_value * x_value * estimated_coefficient * realized_coefficient, + ), + ) +end + +function confounder_forecast_return(beta, gamma, delta_estimated; delta_realized = nothing) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + estimated = _exact_fraction("delta_estimated", delta_estimated) + realized = + delta_realized === nothing ? estimated : + _exact_fraction("delta_realized", delta_realized) + + estimated_coefficient = _confounder_coefficient(beta_value, gamma_value, estimated) + realized_coefficient = _confounder_coefficient(beta_value, gamma_value, realized) + second_signal = beta_value * realized + gamma_value + return StrategyPerformance( + _finite_output( + "correct forecast return", + beta_value * beta_value + second_signal * second_signal, + ), + _finite_output( + "undercontrolled forecast return", + (1 + realized * realized) * estimated_coefficient * realized_coefficient, + ), + ) +end + +function collider_overcontrolled_coefficients(beta, gamma, delta) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + beta_hat, theta_hat = _collider_coefficients(beta_value, gamma_value, delta_value) + return ColliderCoefficients( + _finite_output("overcontrolled beta coefficient", beta_hat), + _finite_output("collider coefficient", theta_hat), + ) +end + +function collider_factor_return(x, collider_proxy, beta, gamma, delta) + x_value = _exact_fraction("x", x) + proxy_value = _exact_fraction("collider_proxy", collider_proxy) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + + beta_x = beta_value * x_value + denominator = 1 + gamma_value * gamma_value + overcontrolled_signal = + (beta_value - delta_value * gamma_value) * x_value + gamma_value * proxy_value + return StrategyPerformance( + _finite_output("correct collider factor return", beta_x * beta_x), + _finite_output( + "overcontrolled factor return", + beta_x * overcontrolled_signal / denominator, + ), + ) +end + +function collider_forecast_return(beta, gamma, delta) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + denominator = 1 + gamma_value * gamma_value + return StrategyPerformance( + _finite_output("correct collider forecast return", beta_value * beta_value), + _finite_output( + "overcontrolled forecast return", + beta_value * (beta_value - gamma_value * delta_value) / denominator, + ), + ) +end + +function collider_model_diagnostics(beta, gamma, delta, n_observations) + beta_value = _exact_fraction("beta", beta) + gamma_value = _exact_fraction("gamma", gamma) + delta_value = _exact_fraction("delta", delta) + n_observations isa Bool && throw(ArgumentError("n_observations must be an integer")) + n_observations isa Integer || throw(ArgumentError("n_observations must be an integer")) + n_integer = BigInt(n_observations) + n_integer > 3 || throw(ArgumentError("n_observations must be greater than 3")) + n_value = n_integer // BigInt(1) + + gamma_denominator = 1 + gamma_value * gamma_value + outcome_variance = 1 + beta_value * beta_value + residual_variance = 1 / gamma_denominator + correct_r_squared = 1 - 1 / outcome_variance + overcontrolled_r_squared = 1 - 1 / (gamma_denominator * outcome_variance) + correct_adjusted_r_squared = + 1 - (((n_integer - 1) // (n_integer - 2)) / outcome_variance) + overcontrolled_adjusted_r_squared = + 1 - ((n_integer - 1) // (n_integer - 3)) / (gamma_denominator * outcome_variance) + + diagnostic_sum = + (beta_value * gamma_value + delta_value)^2 + gamma_value * gamma_value + 1 + correct_beta_variance = 1 / n_value + overcontrolled_beta_variance = diagnostic_sum / (n_value * gamma_denominator^2) + collider_coefficient_variance = 1 / (n_value * gamma_denominator^2) + overcontrolled_numerator = beta_value - delta_value * gamma_value + + return ColliderDiagnostics( + _finite_output("residual variance", residual_variance), + _finite_output("outcome variance", outcome_variance), + _finite_output("correct R-squared", correct_r_squared), + _finite_output("overcontrolled R-squared", overcontrolled_r_squared), + _finite_output("correct adjusted R-squared", correct_adjusted_r_squared), + _finite_output( + "overcontrolled adjusted R-squared", + overcontrolled_adjusted_r_squared, + ), + _finite_output("correct beta variance", correct_beta_variance), + _finite_output("overcontrolled beta variance", overcontrolled_beta_variance), + _finite_output("collider coefficient variance", collider_coefficient_variance), + _coefficient_times_square_root("correct beta t-statistic", beta_value, n_value), + _coefficient_times_square_root( + "overcontrolled beta t-statistic", + overcontrolled_numerator, + n_value / diagnostic_sum, + ), + _coefficient_times_square_root("collider t-statistic", gamma_value, n_value), + gamma_value^2 * (n_integer - 3) > 1, + overcontrolled_numerator^2 > beta_value^2 * diagnostic_sum, + ) +end diff --git a/src/CausalFactorAnalysis/GraphIdentification.jl b/src/CausalFactorAnalysis/GraphIdentification.jl new file mode 100644 index 0000000..fb4f9d2 --- /dev/null +++ b/src/CausalFactorAnalysis/GraphIdentification.jl @@ -0,0 +1,889 @@ +const _DEFAULT_MAX_PATHS = 10_000 +const _DEFAULT_MAX_PATH_STATES = 100_000 +const _DEFAULT_MAX_CANDIDATES = 65_536 + +struct CausalDAG + nodes::Tuple{Vararg{String}} + directed_edges::Tuple{Vararg{Tuple{String,String}}} + observed_nodes::Tuple{Vararg{String}} + + function CausalDAG( + nodes::Tuple{Vararg{String}}, + directed_edges::Tuple{Vararg{Tuple{String,String}}}, + observed_nodes::Tuple{Vararg{String}}, + ::Val{:validated}, + ) + return new(nodes, directed_edges, observed_nodes) + end +end + +struct PathEvidence + nodes::Tuple{Vararg{String}} + colliders::Tuple{Vararg{String}} + noncolliders::Tuple{Vararg{String}} + conditioned_noncolliders::Tuple{Vararg{String}} + activated_colliders::Tuple{Vararg{String}} + blocking_colliders::Tuple{Vararg{String}} + is_open::Bool + is_directed_from_left::Bool + is_backdoor_from_left::Bool +end + +struct DSeparationEvidence + left::Tuple{Vararg{String}} + right::Tuple{Vararg{String}} + conditioned::Tuple{Vararg{String}} + separated::Bool + paths::Tuple{Vararg{PathEvidence}} +end + +struct BackdoorAdjustmentEvidence + treatment::String + outcome::String + adjustment_set::Tuple{Vararg{String}} + all_observed::Bool + endpoints_in_set::Tuple{Vararg{String}} + descendants_in_set::Tuple{Vararg{String}} + open_backdoor_paths::Tuple{Vararg{PathEvidence}} + admissible::Bool +end + +struct FrontdoorAdjustmentEvidence + treatment::String + outcome::String + mediator_set::Tuple{Vararg{String}} + all_observed::Bool + endpoints_in_set::Tuple{Vararg{String}} + directed_path_exists::Bool + unintercepted_directed_paths::Tuple{Vararg{PathEvidence}} + open_treatment_mediator_backdoor_paths::Tuple{Vararg{PathEvidence}} + open_mediator_outcome_backdoor_paths_given_treatment::Tuple{Vararg{PathEvidence}} + graphically_admissible::Bool + positivity_required::Bool +end + +struct InstrumentEvidence + instrument::String + treatment::String + outcome::String + conditioned::Tuple{Vararg{String}} + all_observed::Bool + conditioning_disjoint::Bool + conditioning_unaffected_by_treatment::Bool + pearl_relevance::Bool + pearl_exclusion_exogeneity::Bool + pearl_graphical::Bool + cfi_direct_relevance::Bool + cfi_full_mediation::Bool + cfi_exogeneity::Bool + cfi_simple::Bool +end + +struct NodeRoleEvidence + node::String + treatment::String + outcome::String + collider_on_paths::Tuple + noncollider_on_paths::Tuple + mediator_on_directed_paths::Tuple + backdoor_noncollider_on_paths::Tuple + common_cause_paths::Tuple + descendant_of_treatment::Bool + ancestor_of_outcome::Bool +end + +function _node(value, subject::AbstractString) + value isa AbstractString || + throw(ArgumentError("$subject must be a string node identifier")) + result = String(value) + isempty(strip(result)) && throw(ArgumentError("$subject must be nonblank")) + return result +end + +function _node_tuple(value, subject::AbstractString; allow_empty::Bool) + value isa AbstractString && + throw(ArgumentError("$subject must be a collection, not a string")) + items = try + collect(value) + catch + throw(ArgumentError("$subject must be an iterable collection")) + end + nodes = String[_node(item, "$subject member") for item in items] + allow_empty || !isempty(nodes) || throw(ArgumentError("$subject must be nonempty")) + length(unique(nodes)) == length(nodes) || + throw(ArgumentError("$subject must not contain duplicate nodes")) + return Tuple(sort(nodes)) +end + +function _edge_tuple(value) + value isa AbstractString && throw(ArgumentError("directed_edges must be a collection")) + items = try + collect(value) + catch + throw(ArgumentError("directed_edges must be an iterable collection")) + end + edges = Tuple{String,String}[] + for raw_edge in items + raw_edge isa Tuple || + raw_edge isa AbstractVector || + throw(ArgumentError("each directed edge must be an ordered two-node sequence")) + length(raw_edge) == 2 || + throw(ArgumentError("each directed edge must contain two nodes")) + push!( + edges, + ( + _node(raw_edge[1], "directed-edge source"), + _node(raw_edge[2], "directed-edge target"), + ), + ) + end + length(unique(edges)) == length(edges) || + throw(ArgumentError("directed_edges must not contain duplicate edges")) + return Tuple(sort(edges)) +end + +function _children(nodes, edges) + result = Dict(node => Set{String}() for node in nodes) + for (source, target) in edges + push!(result[source], target) + end + return result +end + +function _parents(nodes, edges) + result = Dict(node => Set{String}() for node in nodes) + for (source, target) in edges + push!(result[target], source) + end + return result +end + +function _acyclic(nodes, edges) + children = _children(nodes, edges) + indegree = Dict(node => 0 for node in nodes) + for (_, target) in edges + indegree[target] += 1 + end + ready = sort([node for node in nodes if indegree[node] == 0]) + visited = 0 + while !isempty(ready) + node = pop!(ready) + visited += 1 + for child in sort(collect(children[node]); rev = true) + indegree[child] -= 1 + indegree[child] == 0 && push!(ready, child) + end + end + return visited == length(nodes) +end + +function CausalDAG(nodes, directed_edges, observed_nodes) + normalized_nodes = _node_tuple(nodes, "nodes"; allow_empty = false) + normalized_edges = _edge_tuple(directed_edges) + observed = _node_tuple(observed_nodes, "observed_nodes"; allow_empty = true) + node_set = Set(normalized_nodes) + issubset(Set(observed), node_set) || + throw(ArgumentError("observed_nodes must be a subset of nodes")) + for (source, target) in normalized_edges + source in node_set && target in node_set || + throw(ArgumentError("every directed edge endpoint must be a graph node")) + source != target || throw(ArgumentError("directed self-loops are not admissible")) + end + _acyclic(normalized_nodes, normalized_edges) || + throw(ArgumentError("directed_edges must define an acyclic graph")) + return CausalDAG(normalized_nodes, normalized_edges, observed, Val(:validated)) +end + +function _endpoints(dag::CausalDAG, treatment, outcome) + treatment_node = _node(treatment, "treatment") + outcome_node = _node(outcome, "outcome") + treatment_node != outcome_node || + throw(ArgumentError("treatment and outcome must be distinct")) + treatment_node in dag.nodes && outcome_node in dag.nodes || + throw(ArgumentError("treatment and outcome must be known graph nodes")) + observed = Set(dag.observed_nodes) + treatment_node in observed && outcome_node in observed || + throw(ArgumentError("treatment and outcome must both be observed")) + return treatment_node, outcome_node +end + +function _known_nodes(dag, value, subject; allow_empty) + nodes = _node_tuple(value, subject; allow_empty = allow_empty) + issubset(Set(nodes), Set(dag.nodes)) || + throw(ArgumentError("$subject must contain only known graph nodes")) + return nodes +end + +function _limit(value, subject) + value === nothing && return nothing + value isa Bool && throw(ArgumentError("$subject must be positive or nothing")) + value isa Integer || throw(ArgumentError("$subject must be positive or nothing")) + value >= 1 || throw(ArgumentError("$subject must be positive")) + return Int(value) +end + +function _descendants(dag::CausalDAG, node::String, edges = dag.directed_edges) + children = _children(dag.nodes, edges) + visited = Set{String}() + pending = collect(children[node]) + while !isempty(pending) + current = pop!(pending) + current in visited && continue + push!(visited, current) + append!(pending, children[current]) + end + return visited +end + +function _ancestors(dag::CausalDAG, node::String) + parents = _parents(dag.nodes, dag.directed_edges) + visited = Set{String}() + pending = collect(parents[node]) + while !isempty(pending) + current = pop!(pending) + current in visited && continue + push!(visited, current) + append!(pending, parents[current]) + end + return visited +end + +_without_outgoing(edges, nodes) = Tuple(edge for edge in edges if !(edge[1] in Set(nodes))) +_without_incoming(edges, nodes) = Tuple(edge for edge in edges if !(edge[2] in Set(nodes))) + +function _has_directed_path(dag, left, right; edges = dag.directed_edges, blocked = ()) + blocked_nodes = Set(blocked) + if left in blocked_nodes || right in blocked_nodes + return false + end + children = _children(dag.nodes, edges) + pending = String[left] + visited = Set{String}() + while !isempty(pending) + node = pop!(pending) + node == right && return true + node in visited && continue + push!(visited, node) + append!( + pending, + [ + child for + child in children[node] if !(child in visited) && !(child in blocked_nodes) + ], + ) + end + return false +end + +function _is_d_separated(dag, left, right, conditioned; edges = dag.directed_edges) + parents = _parents(dag.nodes, edges) + relevant = union(Set(left), Set(right), Set(conditioned)) + pending = collect(relevant) + while !isempty(pending) + node = pop!(pending) + for parent in parents[node] + if !(parent in relevant) + push!(relevant, parent) + push!(pending, parent) + end + end + end + moral = Dict(node => Set{String}() for node in relevant) + for (source, target) in edges + if source in relevant && target in relevant + push!(moral[source], target) + push!(moral[target], source) + end + end + for child in relevant + child_parents = sort([parent for parent in parents[child] if parent in relevant]) + for first_index in eachindex(child_parents) + for second_index = (first_index+1):length(child_parents) + first = child_parents[first_index] + second = child_parents[second_index] + push!(moral[first], second) + push!(moral[second], first) + end + end + end + blocked = Set(conditioned) + targets = Set(right) + reachable = [node for node in left if !(node in blocked)] + visited = Set{String}() + while !isempty(reachable) + node = pop!(reachable) + node in targets && return false + node in visited && continue + push!(visited, node) + append!( + reachable, + [ + neighbor for + neighbor in moral[node] if !(neighbor in blocked) && !(neighbor in visited) + ], + ) + end + return true +end + +function _simple_paths( + dag, + left, + right; + edges = dag.directed_edges, + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + adjacency = Dict(node => Set{String}() for node in dag.nodes) + for (source, target) in edges + push!(adjacency[source], target) + push!(adjacency[target], source) + end + paths = Tuple[] + states = 0 + for start in left, finish in right + stack = Any[(start, (start,), Set((start,)))] + while !isempty(stack) + states += 1 + max_path_states !== nothing && + states > max_path_states && + throw(ArgumentError("complete path search exceeds max_path_states")) + node, path, visited = pop!(stack) + if node == finish + push!(paths, path) + max_paths !== nothing && + length(paths) > max_paths && + throw(ArgumentError("complete path evidence exceeds max_paths")) + continue + end + for neighbor in sort(collect(adjacency[node]); rev = true) + if !(neighbor in visited) + push!( + stack, + (neighbor, (path..., neighbor), union(visited, (neighbor,))), + ) + end + end + end + end + sort!(paths; by = path -> (length(path), path)) + return Tuple(paths) +end + +function _path_evidence(dag, path, conditioned; edges = dag.directed_edges) + edge_set = Set(edges) + conditioned_set = Set(conditioned) + colliders = String[] + noncolliders = String[] + for index = 2:(length(path)-1) + previous, node, following = path[index-1], path[index], path[index+1] + if (previous, node) in edge_set && (following, node) in edge_set + push!(colliders, node) + else + push!(noncolliders, node) + end + end + activated = String[] + blocking = String[] + for collider in colliders + family = union(Set((collider,)), _descendants(dag, collider, edges)) + if !isempty(intersect(family, conditioned_set)) + push!(activated, collider) + else + push!(blocking, collider) + end + end + conditioned_noncolliders = + Tuple(node for node in noncolliders if node in conditioned_set) + is_directed = all(index -> (path[index], path[index+1]) in edge_set, 1:(length(path)-1)) + is_backdoor = length(path) > 1 && (path[2], path[1]) in edge_set + return PathEvidence( + path, + Tuple(colliders), + Tuple(noncolliders), + conditioned_noncolliders, + Tuple(activated), + Tuple(blocking), + isempty(conditioned_noncolliders) && isempty(blocking), + is_directed, + is_backdoor, + ) +end + +function _d_separation( + dag, + left, + right, + conditioned; + edges = dag.directed_edges, + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + paths = Tuple( + _path_evidence(dag, path, conditioned; edges = edges) for path in _simple_paths( + dag, + left, + right; + edges = edges, + max_paths = max_paths, + max_path_states = max_path_states, + ) + ) + return DSeparationEvidence( + left, + right, + conditioned, + all(path -> !path.is_open, paths), + paths, + ) +end + +function d_separation( + dag::CausalDAG, + left, + right, + conditioned = (); + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + left_nodes = _known_nodes(dag, left, "left"; allow_empty = false) + right_nodes = _known_nodes(dag, right, "right"; allow_empty = false) + conditioned_nodes = _known_nodes(dag, conditioned, "conditioned"; allow_empty = true) + path_limit = _limit(max_paths, "max_paths") + state_limit = _limit(max_path_states, "max_path_states") + isempty(intersect(Set(left_nodes), Set(right_nodes))) && + isempty(intersect(Set(left_nodes), Set(conditioned_nodes))) && + isempty(intersect(Set(right_nodes), Set(conditioned_nodes))) || + throw(ArgumentError("left, right, and conditioned must be pairwise disjoint")) + return _d_separation( + dag, + left_nodes, + right_nodes, + conditioned_nodes; + max_paths = path_limit, + max_path_states = state_limit, + ) +end + +function check_backdoor_adjustment_set( + dag::CausalDAG, + treatment, + outcome, + adjustment_set = (); + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + adjustment = _known_nodes(dag, adjustment_set, "adjustment_set"; allow_empty = true) + path_limit = _limit(max_paths, "max_paths") + state_limit = _limit(max_path_states, "max_path_states") + observed = Set(dag.observed_nodes) + endpoints = Tuple( + sort(collect(intersect(Set(adjustment), Set((treatment_node, outcome_node))))), + ) + descendants = + Tuple(sort(collect(intersect(Set(adjustment), _descendants(dag, treatment_node))))) + edges = _without_outgoing(dag.directed_edges, (treatment_node,)) + separation = _d_separation( + dag, + (treatment_node,), + (outcome_node,), + adjustment; + edges = edges, + max_paths = path_limit, + max_path_states = state_limit, + ) + open_paths = Tuple(path for path in separation.paths if path.is_open) + all_observed = issubset(Set(adjustment), observed) + admissible = + all_observed && isempty(endpoints) && isempty(descendants) && isempty(open_paths) + return BackdoorAdjustmentEvidence( + treatment_node, + outcome_node, + adjustment, + all_observed, + endpoints, + descendants, + open_paths, + admissible, + ) +end + +function _inclusion_minimal_sets(valid_sets) + ordered = sort(collect(valid_sets); by = item -> (length(item), item)) + result = Tuple[] + for candidate in ordered + candidate_set = Set(candidate) + any(existing -> issubset(Set(existing), candidate_set), result) || + push!(result, candidate) + end + return Tuple(result) +end + +mutable struct _SearchFrame + next_index::Int + evaluated::Bool +end + +function _search_minimal_sets(candidates, is_valid, max_candidates) + valid_sets = Tuple[] + selected = String[] + frames = _SearchFrame[_SearchFrame(1, false)] + work = 0 + while !isempty(frames) + frame = frames[end] + candidate = Tuple(selected) + if !frame.evaluated + work += 1 + length(candidate) + max_candidates !== nothing && + work > max_candidates && + throw(ArgumentError("exact minimal-set search exceeds max_candidates")) + candidate_set = Set(candidate) + if any(existing -> issubset(Set(existing), candidate_set), valid_sets) + pop!(frames) + !isempty(selected) && pop!(selected) + continue + end + if is_valid(candidate) + isempty(candidate) && return ((),) + push!(valid_sets, candidate) + pop!(frames) + pop!(selected) + continue + end + frame.evaluated = true + continue + end + if frame.next_index > length(candidates) + pop!(frames) + !isempty(selected) && pop!(selected) + continue + end + index = frame.next_index + frame.next_index += 1 + push!(selected, candidates[index]) + push!(frames, _SearchFrame(index + 1, false)) + end + return _inclusion_minimal_sets(valid_sets) +end + +function minimal_backdoor_adjustment_sets( + dag::CausalDAG, + treatment, + outcome; + max_candidates = _DEFAULT_MAX_CANDIDATES, +) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + candidate_limit = _limit(max_candidates, "max_candidates") + descendants = _descendants(dag, treatment_node) + candidates = Tuple( + node for node in dag.observed_nodes if + !(node in (treatment_node, outcome_node)) && !(node in descendants) + ) + edges = _without_outgoing(dag.directed_edges, (treatment_node,)) + is_valid(candidate) = + _is_d_separated(dag, (treatment_node,), (outcome_node,), candidate; edges = edges) + return _search_minimal_sets(candidates, is_valid, candidate_limit) +end + +function _directed_path_tuples( + dag, + left, + right; + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + children = _children(dag.nodes, dag.directed_edges) + parents = _parents(dag.nodes, dag.directed_edges) + can_reach_right = Set((right,)) + pending = String[right] + while !isempty(pending) + node = pop!(pending) + for parent in parents[node] + if !(parent in can_reach_right) + push!(can_reach_right, parent) + push!(pending, parent) + end + end + end + paths = Tuple[] + stack = Any[(left, (left,))] + states = 0 + while !isempty(stack) + states += 1 + max_path_states !== nothing && + states > max_path_states && + throw(ArgumentError("directed-path search exceeds max_path_states")) + node, path = pop!(stack) + if node == right + push!(paths, path) + max_paths !== nothing && + length(paths) > max_paths && + throw(ArgumentError("directed-path evidence exceeds max_paths")) + continue + end + for child in sort(collect(children[node]); rev = true) + child in can_reach_right && push!(stack, (child, (path..., child))) + end + end + sort!(paths; by = path -> (length(path), path)) + return Tuple(paths) +end + +function _directed_paths(dag, left, right; max_paths, max_path_states) + return Tuple( + _path_evidence(dag, path, ()) for path in _directed_path_tuples( + dag, + left, + right; + max_paths = max_paths, + max_path_states = max_path_states, + ) + ) +end + +function check_frontdoor_adjustment_set( + dag::CausalDAG, + treatment, + outcome, + mediator_set; + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + mediators = _known_nodes(dag, mediator_set, "mediator_set"; allow_empty = true) + path_limit = _limit(max_paths, "max_paths") + state_limit = _limit(max_path_states, "max_path_states") + observed = Set(dag.observed_nodes) + endpoints = + Tuple(sort(collect(intersect(Set(mediators), Set((treatment_node, outcome_node)))))) + directed_paths = _directed_paths( + dag, + treatment_node, + outcome_node; + max_paths = path_limit, + max_path_states = state_limit, + ) + unintercepted = Tuple( + path for path in directed_paths if + isempty(intersect(Set(path.nodes[2:(end-1)]), Set(mediators))) + ) + if !isempty(mediators) && isempty(endpoints) + treatment_edges = _without_outgoing(dag.directed_edges, (treatment_node,)) + treatment_mediator = _d_separation( + dag, + (treatment_node,), + mediators, + (); + edges = treatment_edges, + max_paths = path_limit, + max_path_states = state_limit, + ) + mediator_edges = _without_outgoing(dag.directed_edges, mediators) + mediator_outcome = _d_separation( + dag, + mediators, + (outcome_node,), + (treatment_node,); + edges = mediator_edges, + max_paths = path_limit, + max_path_states = state_limit, + ) + open_treatment_mediator = + Tuple(path for path in treatment_mediator.paths if path.is_open) + open_mediator_outcome = + Tuple(path for path in mediator_outcome.paths if path.is_open) + else + open_treatment_mediator = () + open_mediator_outcome = () + end + all_observed = issubset(Set(mediators), observed) + directed_path_exists = !isempty(directed_paths) + admissible = + !isempty(mediators) && + all_observed && + isempty(endpoints) && + directed_path_exists && + isempty(unintercepted) && + isempty(open_treatment_mediator) && + isempty(open_mediator_outcome) + return FrontdoorAdjustmentEvidence( + treatment_node, + outcome_node, + mediators, + all_observed, + endpoints, + directed_path_exists, + unintercepted, + open_treatment_mediator, + open_mediator_outcome, + admissible, + true, + ) +end + +function minimal_frontdoor_adjustment_sets( + dag::CausalDAG, + treatment, + outcome; + max_candidates = _DEFAULT_MAX_CANDIDATES, +) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + candidate_limit = _limit(max_candidates, "max_candidates") + _has_directed_path(dag, treatment_node, outcome_node) || return () + candidates = Tuple( + node for node in dag.observed_nodes if !(node in (treatment_node, outcome_node)) + ) + treatment_edges = _without_outgoing(dag.directed_edges, (treatment_node,)) + function is_valid(candidate) + isempty(candidate) && return false + _has_directed_path(dag, treatment_node, outcome_node; blocked = candidate) && + return false + _is_d_separated(dag, (treatment_node,), candidate, (); edges = treatment_edges) || + return false + mediator_edges = _without_outgoing(dag.directed_edges, candidate) + return _is_d_separated( + dag, + candidate, + (outcome_node,), + (treatment_node,); + edges = mediator_edges, + ) + end + return _search_minimal_sets(candidates, is_valid, candidate_limit) +end + +function check_instrument(dag::CausalDAG, instrument, treatment, outcome, conditioned = ()) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + instrument_node = _node(instrument, "instrument") + instrument_node in dag.nodes || + throw(ArgumentError("instrument must be a known graph node")) + !(instrument_node in (treatment_node, outcome_node)) || + throw(ArgumentError("instrument, treatment, and outcome must be distinct")) + controls = _known_nodes(dag, conditioned, "conditioned"; allow_empty = true) + forbidden = Set((instrument_node, treatment_node, outcome_node)) + conditioning_disjoint = isempty(intersect(Set(controls), forbidden)) + observed = Set(dag.observed_nodes) + all_observed = issubset(union(forbidden, Set(controls)), observed) + unaffected = isempty(intersect(Set(controls), _descendants(dag, treatment_node))) + if conditioning_disjoint + treatment_edges = _without_incoming(dag.directed_edges, (treatment_node,)) + pearl_relevance = + !_is_d_separated(dag, (instrument_node,), (treatment_node,), controls) + pearl_exclusion = _is_d_separated( + dag, + (instrument_node,), + (outcome_node,), + controls; + edges = treatment_edges, + ) + else + pearl_relevance = false + pearl_exclusion = false + end + pearl_graphical = + all_observed && + conditioning_disjoint && + unaffected && + pearl_relevance && + pearl_exclusion + + edge_set = Set(dag.directed_edges) + cfi_direct = (instrument_node, treatment_node) in edge_set + instrument_outcome_path = _has_directed_path(dag, instrument_node, outcome_node) + bypasses_treatment = + _has_directed_path(dag, instrument_node, outcome_node; blocked = (treatment_node,)) + cfi_full_mediation = instrument_outcome_path && !bypasses_treatment + instrument_edges = _without_outgoing(dag.directed_edges, (instrument_node,)) + cfi_exogeneity = _is_d_separated( + dag, + (instrument_node,), + (outcome_node,), + (); + edges = instrument_edges, + ) + cfi_simple = + instrument_node in observed && cfi_direct && cfi_full_mediation && cfi_exogeneity + return InstrumentEvidence( + instrument_node, + treatment_node, + outcome_node, + controls, + all_observed, + conditioning_disjoint, + unaffected, + pearl_relevance, + pearl_exclusion, + pearl_graphical, + cfi_direct, + cfi_full_mediation, + cfi_exogeneity, + cfi_simple, + ) +end + +function causal_role_evidence( + dag::CausalDAG, + treatment, + outcome, + node; + max_paths = _DEFAULT_MAX_PATHS, + max_path_states = _DEFAULT_MAX_PATH_STATES, +) + treatment_node, outcome_node = _endpoints(dag, treatment, outcome) + role_node = _node(node, "node") + role_node in dag.nodes || throw(ArgumentError("node must be a known graph node")) + !(role_node in (treatment_node, outcome_node)) || + throw(ArgumentError("node must differ from treatment and outcome")) + path_limit = _limit(max_paths, "max_paths") + state_limit = _limit(max_path_states, "max_path_states") + paths = Tuple( + _path_evidence(dag, path, ()) for path in _simple_paths( + dag, + (treatment_node,), + (outcome_node,); + max_paths = path_limit, + max_path_states = state_limit, + ) + ) + collider_paths = Tuple(path.nodes for path in paths if role_node in path.colliders) + noncollider_paths = + Tuple(path.nodes for path in paths if role_node in path.noncolliders) + mediator_paths = Tuple( + path.nodes for path in paths if + path.is_directed_from_left && role_node in path.nodes[2:(end-1)] + ) + backdoor_paths = Tuple( + path.nodes for + path in paths if path.is_backdoor_from_left && role_node in path.noncolliders + ) + edge_set = Set(dag.directed_edges) + common_cause_paths = Tuple[] + for path in paths + role_node in path.noncolliders || continue + index = findfirst(==(role_node), path.nodes) + directed_to_treatment = all( + position -> (path.nodes[position], path.nodes[position-1]) in edge_set, + index:-1:2, + ) + directed_to_outcome = all( + position -> (path.nodes[position], path.nodes[position+1]) in edge_set, + index:(length(path.nodes)-1), + ) + directed_to_treatment && + directed_to_outcome && + push!(common_cause_paths, path.nodes) + end + return NodeRoleEvidence( + role_node, + treatment_node, + outcome_node, + collider_paths, + noncollider_paths, + mediator_paths, + backdoor_paths, + Tuple(common_cause_paths), + role_node in _descendants(dag, treatment_node), + role_node in _ancestors(dag, outcome_node), + ) +end diff --git a/src/CausalFactorAnalysis/Optimizer.jl b/src/CausalFactorAnalysis/Optimizer.jl new file mode 100644 index 0000000..374670a --- /dev/null +++ b/src/CausalFactorAnalysis/Optimizer.jl @@ -0,0 +1,109 @@ +const _SYMMETRY_RTOL = 1.0e-10 +const _SYMMETRY_ATOL = 1.0e-12 +const _FEASIBILITY_RTOL = 1.0e-10 +const _FEASIBILITY_ATOL = 1.0e-12 + +function _real_matrix(name::AbstractString, values) + values isa AbstractMatrix || throw(ArgumentError("$name must be a numeric matrix")) + all(value -> value isa Real, values) || + throw(ArgumentError("$name must be real-valued")) + result = try + Matrix{Float64}(values) + catch + throw(ArgumentError("$name must be a numeric matrix")) + end + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return result +end + +function _real_vector(name::AbstractString, values) + values isa AbstractVector || throw(ArgumentError("$name must be a numeric vector")) + all(value -> value isa Real, values) || + throw(ArgumentError("$name must be real-valued")) + result = try + Vector{Float64}(values) + catch + throw(ArgumentError("$name must be a numeric vector")) + end + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return result +end + +function minimum_variance_factor_weights(covariance, factor_exposures, target_exposures) + covariance_array = _real_matrix("covariance", covariance) + exposure_array = _real_matrix("factor_exposures", factor_exposures) + target_array = _real_vector("target_exposures", target_exposures) + + n_assets, covariance_columns = size(covariance_array) + n_assets > 0 && covariance_columns == n_assets || + throw(ArgumentError("covariance must be a nonempty square matrix")) + size(exposure_array, 1) == n_assets || throw( + ArgumentError("factor_exposures must have the same number of rows as covariance"), + ) + n_factors = size(exposure_array, 2) + n_factors > 0 || throw(ArgumentError("factor_exposures must contain a factor column")) + n_factors <= n_assets || + throw(ArgumentError("factor_exposures cannot have more columns than rows")) + length(target_array) == n_factors || throw( + ArgumentError("target_exposures length must equal the number of factor columns"), + ) + + covariance_scale = maximum(abs, covariance_array) + covariance_scale > 0.0 || throw(ArgumentError("covariance must be positive definite")) + scaled_covariance = covariance_array / covariance_scale + isapprox( + scaled_covariance, + transpose(scaled_covariance); + rtol = _SYMMETRY_RTOL, + atol = _SYMMETRY_ATOL, + ) || throw(ArgumentError("covariance must be symmetric")) + scaled_covariance = 0.5 .* scaled_covariance .+ 0.5 .* transpose(scaled_covariance) + covariance_cholesky = try + cholesky(Symmetric(scaled_covariance); check = true) + catch + throw(ArgumentError("covariance must be positive definite")) + end + + maximum_exposures = vec(maximum(abs.(exposure_array); dims = 1)) + all(value -> value > 0.0, maximum_exposures) || + throw(ArgumentError("factor_exposures must have full column rank")) + target_scales = abs.(target_array) ./ (floatmax(Float64) / 2.0) + constraint_scales = max.(maximum_exposures, target_scales) + scaled_exposures = exposure_array ./ transpose(constraint_scales) + scaled_targets = target_array ./ constraint_scales + any((target_array .!= 0.0) .& (scaled_targets .== 0.0)) && throw( + ArgumentError("factor constraints could not be represented at numerical precision"), + ) + all(isfinite, scaled_exposures) && all(isfinite, scaled_targets) || throw( + ArgumentError("factor constraints could not be represented at numerical precision"), + ) + + lower = covariance_cholesky.L + whitened_exposures = lower \ scaled_exposures + all(isfinite, whitened_exposures) || + throw(ArgumentError("factor_exposures produce non-finite whitened values")) + rank(whitened_exposures) == n_factors || + throw(ArgumentError("factor_exposures must have full column rank")) + if n_factors == n_assets + weights = transpose(scaled_exposures) \ scaled_targets + else + factorization = qr(whitened_exposures) + orthonormal_basis = Matrix(factorization.Q)[:, 1:n_factors] + triangular_factor = Matrix(factorization.R)[1:n_factors, 1:n_factors] + transformed_targets = transpose(triangular_factor) \ scaled_targets + whitened_weights = orthonormal_basis * transformed_targets + weights = transpose(lower) \ whitened_weights + end + all(isfinite, weights) || + throw(ArgumentError("the allocation produced non-finite weights")) + + achieved_exposures = transpose(scaled_exposures) * weights + residuals = abs.(achieved_exposures - scaled_targets) + tolerances = map(scaled_targets) do target + target != 0.0 ? _FEASIBILITY_RTOL * abs(target) : _FEASIBILITY_ATOL + end + all(isfinite, achieved_exposures) && all(residuals .<= tolerances) || throw( + ArgumentError("factor constraints could not be resolved to numerical precision"), + ) + return Vector{Float64}(weights) +end diff --git a/src/CausalFactorAnalysis/Protocol.jl b/src/CausalFactorAnalysis/Protocol.jl new file mode 100644 index 0000000..33cfc54 --- /dev/null +++ b/src/CausalFactorAnalysis/Protocol.jl @@ -0,0 +1,1551 @@ +const _STAGE_NAMES = ( + "Variable Selection", + "Causal Discovery", + "Causal Adjustment Set", + "Causal Explanatory and Predictive Power", + "Causal Portfolio Construction", + "Backtest", + "Multiple Testing Adjustments", +) + +const _VARIABLE_SELECTION_METHODS = Set(( + "MUTUAL_INFORMATION", + "SHAPLEY_VALUES", + "MEAN_DECREASE_IMPURITY", + "PERMUTATION_FEATURE_IMPORTANCE", +)) +const _RESEARCH_PURPOSES = Set(("CAUSAL_ATTRIBUTION", "RISK_PREMIA_HARVESTING")) +const _DISCOVERY_METHODS = Set(( + "PC", + "LINGAM", + "ECONOMIC_REASONING", + "EX_ANTE_VIEWS", + "OBSERVED_OUTCOMES", + "PEER_REVIEWED_ASSUMPTIONS", + "DOMAIN_EXPERTISE", +)) +const _ADJUSTMENT_METHODS = + Set(("BACKDOOR_ADJUSTMENT", "FRONT_DOOR_ADJUSTMENT", "INSTRUMENTAL_VARIABLES")) +const _VALIDATION_METHODS = Set(( + "PURGED_CROSS_VALIDATION", + "WALK_FORWARD", + "RESAMPLING", + "COMBINATORIAL_PURGED_CROSS_VALIDATION", +)) +const _PURGED_VALIDATION_METHODS = + Set(("PURGED_CROSS_VALIDATION", "COMBINATORIAL_PURGED_CROSS_VALIDATION")) +const _METRICS_BY_TASK = Dict( + "PROBABILITY" => Set(("LOG_LOSS", "BRIER_SCORE")), + "RANKING" => Set(( + "ROC_CURVE", + "PRECISION_RECALL_CURVE", + "MEAN_RECIPROCAL_RANK", + "CLASSIFICATION_ACCURACY", + )), + "RETURN_SIZE" => Set(("MEAN_SQUARED_ERROR", "R_SQUARED", "SPEARMAN_CORRELATION")), +) +const _PORTFOLIO_METHODS = Set(( + "POSITION_SIZING", + "EXPOSURE_CONTROL", + "ECONOMIC_RATIONALE", + "FRAGILITY_STRESS_TEST", + "TRANSACTION_COST_OPTIMIZATION", + "TRANSFER_COEFFICIENT", +)) +const _BACKTEST_METHODS = Set(( + "WALK_FORWARD", + "RESAMPLING", + "COMBINATORIAL_PURGED_CROSS_VALIDATION", + "MONTE_CARLO", +)) +const _MULTIPLE_TESTING_METHODS = + Set(("HOLM", "HOCHBERG", "BENJAMINI_HOCHBERG", "DEFLATED_SHARPE_RATIO")) +const _P_VALUE_METHODS = Set(("HOLM", "HOCHBERG", "BENJAMINI_HOCHBERG")) +const _REFIT_COMPONENTS = + Set(("VARIABLE_SELECTION", "CAUSAL_ESTIMATION", "PORTFOLIO_CONSTRUCTION")) + +Base.@kwdef struct EventHorizon + observation_id::String + start::Real + stop::Real +end + +Base.@kwdef struct FoldEvidence + fold_id::String + path_id::String + train_ids::Tuple{Vararg{String}} + test_ids::Tuple{Vararg{String}} + fit_ids::Tuple{Vararg{String}} + prediction_ids::Tuple{Vararg{String}} + refit_id::String + refit_components::Tuple{Vararg{String}} +end + +Base.@kwdef struct ValidationEvidence + method_labels::Tuple{Vararg{String}} + event_horizons::Tuple{Vararg{EventHorizon}} + folds::Tuple{Vararg{FoldEvidence}} + embargo::Real = 0.0 +end + +Base.@kwdef struct VariableSelectionStage + purpose::String + selected_variables::Tuple{Vararg{String}} + method_labels::Tuple{Vararg{String}} + overlapping_returns::Bool + strong_time_dependence::Bool + validation::Union{Nothing,ValidationEvidence} +end + +Base.@kwdef struct CausalDiscoveryStage + method_labels::Tuple{Vararg{String}} + graph_id::String + graph_nodes::Tuple{Vararg{String}} + directed_edges::Tuple{Vararg{Tuple{String,String}}} + graph_kind::String + ambiguous_edges::Tuple{Vararg{Tuple{String,String}}} + assumptions::Tuple{Vararg{String}} +end + +Base.@kwdef struct CausalAdjustmentSetStage + treatment::String + outcome::String + method_label::String + identified::Bool + admissible_adjustment_sets::Tuple + selected_adjustment_set::Tuple{Vararg{String}} + confounders::Tuple{Vararg{String}} + descendants::Tuple{Vararg{String}} + mediators::Tuple{Vararg{String}} + colliders::Tuple{Vararg{String}} + instruments::Tuple{Vararg{String}} + control_justifications::Tuple + open_backdoor_paths::Tuple{Vararg{String}} + frontdoor_criteria_satisfied::Union{Nothing,Bool} = nothing + instrument_relevance_satisfied::Union{Nothing,Bool} = nothing + instrument_exclusion_satisfied::Union{Nothing,Bool} = nothing + instrument_exogeneity_satisfied::Union{Nothing,Bool} = nothing +end + +Base.@kwdef struct CausalExplanatoryAndPredictivePowerStage + task_types::Tuple{Vararg{String}} + estimator_label::String + explanatory_metric_labels::Tuple{Vararg{String}} + predictive_metric_labels::Tuple{Vararg{String}} + explanatory_evidence_id::Union{Nothing,String} + predictive_evidence_id::Union{Nothing,String} + naive_benchmark_id::String + validation::ValidationEvidence + multiclass_encoding::Union{Nothing,String} = nothing + averaging_method::Union{Nothing,String} = nothing +end + +Base.@kwdef struct CausalPortfolioConstructionStage + method_labels::Tuple{Vararg{String}} + causal_exposures::Tuple{Vararg{String}} + controlled_unintended_exposures::Tuple{Vararg{String}} + cost_model_id::String + constraint_set_id::String + economic_rationale::String + fragility_scenarios::Tuple{Vararg{String}} + transfer_coefficient::Real +end + +Base.@kwdef struct BacktestStage + method_labels::Tuple{Vararg{String}} + trial_family_id::String + declared_trial_ids::Tuple{Vararg{String}} + validation::Union{Nothing,ValidationEvidence} = nothing + monte_carlo_dgp::Union{Nothing,String} = nothing +end + +Base.@kwdef struct MultipleTestingAdjustmentsStage + method_labels::Tuple{Vararg{String}} + trial_family_id::String + declared_trial_ids::Tuple{Vararg{String}} + family_partitions::Tuple + alpha::Real + backtests_independent::Bool + p_value_estimator::Union{Nothing,String} = nothing + time_dependence_model::Union{Nothing,String} = nothing + p_value_inputs_assume_independence::Union{Nothing,Bool} = nothing + sharpe_variance::Union{Nothing,Real} = nothing + effective_trials::Union{Nothing,Real} = nothing + sample_length::Union{Nothing,Integer} = nothing + skewness::Union{Nothing,Real} = nothing + kurtosis::Union{Nothing,Real} = nothing + selection_bias_evidence_id::Union{Nothing,String} = nothing +end + +struct CausalFactorProtocolReport + trial_family_id::String + declared_trial_ids::Tuple{Vararg{String}} + variable_selection::VariableSelectionStage + causal_discovery::CausalDiscoveryStage + causal_adjustment_set::CausalAdjustmentSetStage + causal_explanatory_and_predictive_power::CausalExplanatoryAndPredictivePowerStage + causal_portfolio_construction::CausalPortfolioConstructionStage + backtest::BacktestStage + multiple_testing_adjustments::MultipleTestingAdjustmentsStage + source_oracle::String + source_locator::String + + function CausalFactorProtocolReport( + trial_family_id::String, + declared_trial_ids::Tuple{Vararg{String}}, + variable_selection::VariableSelectionStage, + causal_discovery::CausalDiscoveryStage, + causal_adjustment_set::CausalAdjustmentSetStage, + causal_explanatory_and_predictive_power::CausalExplanatoryAndPredictivePowerStage, + causal_portfolio_construction::CausalPortfolioConstructionStage, + backtest::BacktestStage, + multiple_testing_adjustments::MultipleTestingAdjustmentsStage, + ::Val{:validated}, + ) + return new( + trial_family_id, + declared_trial_ids, + variable_selection, + causal_discovery, + causal_adjustment_set, + causal_explanatory_and_predictive_power, + causal_portfolio_construction, + backtest, + multiple_testing_adjustments, + "PROTOCOL-01", + "PDF pages 18-22 (journal pages 27-31), Exhibit 10", + ) + end +end + +function CausalFactorProtocolReport(; + trial_family_id, + declared_trial_ids, + variable_selection, + causal_discovery, + causal_adjustment_set, + causal_explanatory_and_predictive_power, + causal_portfolio_construction, + backtest, + multiple_testing_adjustments, +) + return CausalFactorProtocolReport( + String(trial_family_id), + Tuple(declared_trial_ids), + variable_selection, + causal_discovery, + causal_adjustment_set, + causal_explanatory_and_predictive_power, + causal_portfolio_construction, + backtest, + multiple_testing_adjustments, + Val(:validated), + ) +end + +function Base.getproperty(report::CausalFactorProtocolReport, name::Symbol) + if name === :stage_names + return _STAGE_NAMES + elseif name === :stages + return ( + (_STAGE_NAMES[1], getfield(report, :variable_selection)), + (_STAGE_NAMES[2], getfield(report, :causal_discovery)), + (_STAGE_NAMES[3], getfield(report, :causal_adjustment_set)), + (_STAGE_NAMES[4], getfield(report, :causal_explanatory_and_predictive_power)), + (_STAGE_NAMES[5], getfield(report, :causal_portfolio_construction)), + (_STAGE_NAMES[6], getfield(report, :backtest)), + (_STAGE_NAMES[7], getfield(report, :multiple_testing_adjustments)), + ) + end + return getfield(report, name) +end + +function Base.propertynames(::CausalFactorProtocolReport, private::Bool = false) + fields = fieldnames(CausalFactorProtocolReport) + return private ? (fields..., :stages, :stage_names) : (fields..., :stages, :stage_names) +end + +function _add_protocol_error!(errors::Vector{String}, message::String) + message in errors || push!(errors, message) + return nothing +end + +_nonblank(value) = value isa AbstractString && !isempty(strip(value)) + +function _finite_protocol_real(value) + value isa Bool && return false + value isa Real || return false + result = try + Float64(value) + catch + return false + end + return isfinite(result) +end + +function _validate_string_tuple( + value, + stage::String, + subject::String, + errors::Vector{String}; + allow_empty::Bool = false, +) + if !(value isa Tuple) + _add_protocol_error!(errors, "$stage: $subject must be a tuple.") + return false + end + if !allow_empty && isempty(value) + _add_protocol_error!(errors, "$stage: $subject must not be empty.") + return false + end + if !all(_nonblank, value) + _add_protocol_error!(errors, "$stage: $subject contains an invalid identifier.") + return false + end + if length(unique(value)) != length(value) + _add_protocol_error!(errors, "$stage: $subject contains duplicate identifiers.") + return false + end + return true +end + +function _validate_labels( + value, + allowed, + stage::String, + subject::String, + errors::Vector{String}; + allow_empty::Bool = false, +) + valid = _validate_string_tuple(value, stage, subject, errors; allow_empty = allow_empty) + if valid && !issubset(Set(value), allowed) + _add_protocol_error!(errors, "$stage: $subject contains a non-source label.") + return false + end + return valid +end + +function _validate_event_horizons(evidence, stage, errors) + isempty(evidence.event_horizons) && begin + _add_protocol_error!(errors, "$stage: event horizons must be nonempty.") + return Dict{String,Tuple{Float64,Float64}}() + end + result = Dict{String,Tuple{Float64,Float64}}() + for horizon in evidence.event_horizons + if !_nonblank(horizon.observation_id) + _add_protocol_error!( + errors, + "$stage: an event horizon has an invalid identifier.", + ) + continue + end + if haskey(result, horizon.observation_id) + _add_protocol_error!( + errors, + "$stage: event-horizon identifiers must be unique.", + ) + continue + end + if !_finite_protocol_real(horizon.start) || !_finite_protocol_real(horizon.stop) + _add_protocol_error!( + errors, + "$stage: event-horizon bounds must be finite numbers.", + ) + continue + end + start = Float64(horizon.start) + stop = Float64(horizon.stop) + if !(start < stop) + _add_protocol_error!( + errors, + "$stage: every event horizon must have positive length.", + ) + continue + end + result[horizon.observation_id] = (start, stop) + end + return result +end + +_intervals_overlap(left, right) = left[1] < right[2] && right[1] < left[2] + +function _has_any_overlap(horizons) + intervals = collect(values(horizons)) + for left_index in eachindex(intervals) + for right_index = (left_index+1):length(intervals) + _intervals_overlap(intervals[left_index], intervals[right_index]) && return true + end + end + return false +end + +function _validate_fold_evidence( + evidence::ValidationEvidence, + stage::String, + errors::Vector{String}; + required_refit_components, +) + _validate_labels( + evidence.method_labels, + _VALIDATION_METHODS, + stage, + "validation method labels", + errors, + ) + horizon_map = _validate_event_horizons(evidence, stage, errors) + if !_finite_protocol_real(evidence.embargo) || Float64(evidence.embargo) < 0.0 + _add_protocol_error!(errors, "$stage: embargo must be a finite nonnegative number.") + embargo = 0.0 + else + embargo = Float64(evidence.embargo) + end + + if isempty(evidence.folds) + _add_protocol_error!(errors, "$stage: validation folds must be nonempty.") + return horizon_map + end + + fold_ids = Set{String}() + refit_training_sets = Dict{String,Set{String}}() + path_predictions = Set{Tuple{String,String}}() + path_test_ids = Dict{String,Set{String}}() + path_partitions = Dict{String,Vector{Tuple}}() + nonresampled_test_ids = Set{String}() + known_ids = Set(keys(horizon_map)) + validation_methods = Set(evidence.method_labels) + + for fold in evidence.folds + if !_nonblank(fold.fold_id) || !_nonblank(fold.path_id) + _add_protocol_error!( + errors, + "$stage: fold and path identifiers must be nonblank.", + ) + elseif fold.fold_id in fold_ids + _add_protocol_error!(errors, "$stage: fold identifiers must be unique.") + else + push!(fold_ids, fold.fold_id) + end + _nonblank(fold.refit_id) || _add_protocol_error!( + errors, + "$stage: every fold must record a refit identifier.", + ) + components_valid = _validate_labels( + fold.refit_components, + _REFIT_COMPONENTS, + stage, + "refit components", + errors, + ) + if components_valid && + !issubset(required_refit_components, Set(fold.refit_components)) + _add_protocol_error!( + errors, + "$stage: every fold must refit all required pipeline components.", + ) + end + + train_valid = + _validate_string_tuple(fold.train_ids, stage, "training identifiers", errors) + test_valid = + _validate_string_tuple(fold.test_ids, stage, "test identifiers", errors) + fit_valid = _validate_string_tuple(fold.fit_ids, stage, "fit identifiers", errors) + prediction_valid = _validate_string_tuple( + fold.prediction_ids, + stage, + "prediction identifiers", + errors, + ) + train_valid && test_valid && fit_valid && prediction_valid || continue + + train_set = Set(fold.train_ids) + test_set = Set(fold.test_ids) + fit_set = Set(fold.fit_ids) + prediction_set = Set(fold.prediction_ids) + !isempty(intersect(train_set, test_set)) && _add_protocol_error!( + errors, + "$stage: training and test membership must be disjoint.", + ) + if fit_set != train_set || !isempty(intersect(fit_set, test_set)) + _add_protocol_error!( + errors, + "$stage: every fold must refit on its complete training membership only.", + ) + end + prediction_set == test_set || _add_protocol_error!( + errors, + "$stage: predictions must match the test membership.", + ) + issubset(union(train_set, test_set, fit_set, prediction_set), known_ids) || + _add_protocol_error!( + errors, + "$stage: every fold identifier must have an event horizon.", + ) + + if !isempty( + intersect(validation_methods, Set(("PURGED_CROSS_VALIDATION", "WALK_FORWARD"))), + ) + !isempty(intersect(nonresampled_test_ids, test_set)) && _add_protocol_error!( + errors, + "$stage: ordinary temporal validation cannot duplicate test predictions.", + ) + union!(nonresampled_test_ids, intersect(test_set, known_ids)) + end + + if _nonblank(fold.refit_id) + previous = get(refit_training_sets, fold.refit_id, nothing) + previous !== nothing && + previous != train_set && + _add_protocol_error!( + errors, + "$stage: a refit identifier cannot represent different training sets.", + ) + refit_training_sets[fold.refit_id] = copy(train_set) + end + + if _nonblank(fold.path_id) + get!(path_test_ids, fold.path_id, Set{String}()) + get!(path_partitions, fold.path_id, Tuple[]) + push!(path_partitions[fold.path_id], Tuple(sort(collect(test_set)))) + for observation_id in fold.test_ids + contribution = (fold.path_id, observation_id) + contribution in path_predictions && _add_protocol_error!( + errors, + "$stage: a path cannot contain duplicate test contributions.", + ) + push!(path_predictions, contribution) + push!(path_test_ids[fold.path_id], observation_id) + end + end + + issubset(union(train_set, test_set), known_ids) || continue + for train_id in fold.train_ids, test_id in fold.test_ids + train_horizon = horizon_map[train_id] + test_horizon = horizon_map[test_id] + _intervals_overlap(train_horizon, test_horizon) && _add_protocol_error!( + errors, + "$stage: train and test event horizons must be purged.", + ) + if embargo > 0.0 && + train_horizon[1] >= test_horizon[2] && + train_horizon[1] < test_horizon[2] + embargo + _add_protocol_error!( + errors, + "$stage: post-test embargo observations must be excluded.", + ) + end + end + if "WALK_FORWARD" in validation_methods && !isempty(train_set) && !isempty(test_set) + latest_train_stop = maximum(horizon_map[item][2] for item in train_set) + earliest_test_start = minimum(horizon_map[item][1] for item in test_set) + latest_train_stop > earliest_test_start && _add_protocol_error!( + errors, + "$stage: walk-forward training must precede the test interval.", + ) + end + end + + if "COMBINATORIAL_PURGED_CROSS_VALIDATION" in validation_methods + length(path_test_ids) >= 2 || + _add_protocol_error!(errors, "$stage: CPCV requires multiple complete paths.") + if !isempty(known_ids) && any(ids -> ids != known_ids, values(path_test_ids)) + _add_protocol_error!( + errors, + "$stage: every CPCV path must predict each declared observation once.", + ) + end + signatures = [Tuple(sort(partition)) for partition in values(path_partitions)] + length(unique(signatures)) == length(signatures) || _add_protocol_error!( + errors, + "$stage: CPCV paths must have distinct test partitions.", + ) + end + if "PURGED_CROSS_VALIDATION" in validation_methods && + !isempty(known_ids) && + nonresampled_test_ids != known_ids + _add_protocol_error!( + errors, + "$stage: purged cross-validation must predict every observation once.", + ) + end + return horizon_map +end + +function _protocol_adjacency(nodes, edges) + result = Dict(node => Set{String}() for node in nodes) + for (source, target) in edges + if haskey(result, source) && haskey(result, target) + push!(result[source], target) + end + end + return result +end + +function _protocol_acyclic(nodes, edges) + adjacency = _protocol_adjacency(nodes, edges) + indegree = Dict(node => 0 for node in nodes) + for targets in values(adjacency), target in targets + indegree[target] += 1 + end + ready = [node for node in nodes if indegree[node] == 0] + visited = 0 + while !isempty(ready) + node = pop!(ready) + visited += 1 + for target in adjacency[node] + indegree[target] -= 1 + indegree[target] == 0 && push!(ready, target) + end + end + return visited == length(nodes) +end + +function _protocol_reachable(adjacency, start) + visited = Set{String}() + pending = collect(get(adjacency, start, Set{String}())) + while !isempty(pending) + node = pop!(pending) + node in visited && continue + push!(visited, node) + append!(pending, get(adjacency, node, Set{String}())) + end + return visited +end + +function _protocol_reachable_avoiding(adjacency, start, blocked) + start in blocked && return Set{String}() + visited = Set{String}() + pending = [node for node in get(adjacency, start, Set{String}()) if !(node in blocked)] + while !isempty(pending) + node = pop!(pending) + if node in visited || node in blocked + continue + end + push!(visited, node) + append!( + pending, + [ + target for + target in get(adjacency, node, Set{String}()) if !(target in blocked) + ], + ) + end + return visited +end + +function _protocol_d_separated(edges, left, right, conditioned) + nodes = union(Set((left, right)), Set(conditioned)) + for (source, target) in edges + push!(nodes, source) + push!(nodes, target) + end + parents = Dict(node => Set{String}() for node in nodes) + for (source, target) in edges + push!(parents[target], source) + end + ancestors = union(Set((left, right)), Set(conditioned)) + pending = collect(ancestors) + while !isempty(pending) + node = pop!(pending) + for parent in parents[node] + if !(parent in ancestors) + push!(ancestors, parent) + push!(pending, parent) + end + end + end + moral = Dict(node => Set{String}() for node in ancestors) + for (source, target) in edges + if source in ancestors && target in ancestors + push!(moral[source], target) + push!(moral[target], source) + end + end + for child in ancestors + child_parents = collect(intersect(parents[child], ancestors)) + for first_index in eachindex(child_parents) + for second_index = (first_index+1):length(child_parents) + first = child_parents[first_index] + second = child_parents[second_index] + push!(moral[first], second) + push!(moral[second], first) + end + end + end + if left in conditioned || right in conditioned + return true + end + visited = Set(conditioned) + pending = String[left] + while !isempty(pending) + node = pop!(pending) + node == right && return false + node in visited && continue + push!(visited, node) + append!(pending, [item for item in moral[node] if !(item in visited)]) + end + return true +end + +function _validate_discovery(stage, selected_variables, outcome, errors) + label = "Stage 2" + _validate_labels( + stage.method_labels, + _DISCOVERY_METHODS, + label, + "causal-discovery method labels", + errors, + ) + _nonblank(stage.graph_id) || + _add_protocol_error!(errors, "$label: graph identifier must be nonblank.") + nodes_valid = _validate_string_tuple(stage.graph_nodes, label, "graph nodes", errors) + nodes = nodes_valid ? Set(stage.graph_nodes) : Set{String}() + if nodes_valid && nodes != union(selected_variables, Set((outcome,))) + _add_protocol_error!( + errors, + "$label: graph nodes must match the selected variables and outcome.", + ) + end + edges_valid = true + if length(unique(stage.directed_edges)) != length(stage.directed_edges) + _add_protocol_error!(errors, "$label: directed edges contain duplicates.") + edges_valid = false + end + for (source, target) in stage.directed_edges + if !(source in nodes && target in nodes) + _add_protocol_error!( + errors, + "$label: every directed edge must use graph nodes.", + ) + edges_valid = false + end + if source == target + _add_protocol_error!(errors, "$label: directed self-loops are not admissible.") + edges_valid = false + end + end + if edges_valid && !isempty(nodes) && !_protocol_acyclic(nodes, stage.directed_edges) + _add_protocol_error!(errors, "$label: the resolved graph must be acyclic.") + end + stage.graph_kind == "DAG" || + _add_protocol_error!(errors, "$label: the accepted graph must be a resolved DAG.") + isempty(stage.ambiguous_edges) || _add_protocol_error!( + errors, + "$label: identification-relevant ambiguity must be resolved.", + ) + _validate_string_tuple(stage.assumptions, label, "graph assumptions", errors) + safe_edges = edges_valid ? stage.directed_edges : () + return nodes, _protocol_adjacency(nodes, safe_edges), safe_edges +end + +function _validate_adjustment_sets(value, stage, errors) + if !(value isa Tuple) || isempty(value) + _add_protocol_error!(errors, "$stage: admissible adjustment sets must be nonempty.") + return Set{Set{String}}(), false + end + result = Set{Set{String}}() + valid = true + for adjustment_set in value + if !_validate_string_tuple( + adjustment_set, + stage, + "an admissible adjustment set", + errors; + allow_empty = true, + ) + valid = false + continue + end + frozen = Set(adjustment_set) + if frozen in result + _add_protocol_error!( + errors, + "$stage: admissible adjustment sets must be distinct.", + ) + valid = false + end + push!(result, frozen) + end + return result, valid +end + +function _validate_justifications(value, stage, errors) + result = Dict{String,String}() + for item in value + if !(item isa Tuple) || + length(item) != 2 || + !_nonblank(item[1]) || + !_nonblank(item[2]) + _add_protocol_error!(errors, "$stage: a control justification is invalid.") + continue + end + haskey(result, item[1]) && _add_protocol_error!( + errors, + "$stage: every control must have one justification.", + ) + result[item[1]] = item[2] + end + return result +end + +function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, errors) + label = "Stage 3" + treatment = _nonblank(stage.treatment) ? stage.treatment : "" + outcome = _nonblank(stage.outcome) ? stage.outcome : "" + if !_nonblank(stage.treatment) || !_nonblank(stage.outcome) + _add_protocol_error!(errors, "$label: treatment and outcome must be nonblank.") + elseif treatment == outcome + _add_protocol_error!(errors, "$label: treatment and outcome must be distinct.") + end + treatment in graph_nodes && outcome in graph_nodes || + _add_protocol_error!(errors, "$label: treatment and outcome must be graph nodes.") + stage.method_label in _ADJUSTMENT_METHODS || + _add_protocol_error!(errors, "$label: adjustment method is not source-listed.") + stage.identified || + _add_protocol_error!(errors, "$label: the causal effect must be identified.") + + selected_valid = _validate_string_tuple( + stage.selected_adjustment_set, + label, + "selected adjustment set", + errors; + allow_empty = true, + ) + admissible_sets, _ = + _validate_adjustment_sets(stage.admissible_adjustment_sets, label, errors) + selected = selected_valid ? Set(stage.selected_adjustment_set) : Set{String}() + if selected_valid && !(selected in admissible_sets) + _add_protocol_error!( + errors, + "$label: the selected adjustment set must be declared admissible.", + ) + end + + role_names = Dict{Symbol,Set{String}}() + for field_name in (:confounders, :descendants, :mediators, :colliders, :instruments) + value = getproperty(stage, field_name) + valid = _validate_string_tuple( + value, + label, + String(field_name), + errors; + allow_empty = true, + ) + role_names[field_name] = valid ? Set(value) : Set{String}() + if valid && !issubset(Set(value), graph_nodes) + _add_protocol_error!( + errors, + "$label: every declared causal role must be a graph node.", + ) + end + if valid && !isempty(intersect(Set((treatment, outcome)), Set(value))) + _add_protocol_error!( + errors, + "$label: treatment and outcome cannot be role variables.", + ) + end + end + + computed_descendants = _protocol_reachable(adjacency, treatment) + delete!(computed_descendants, outcome) + role_names[:descendants] == computed_descendants || _add_protocol_error!( + errors, + "$label: declared descendants must exactly match the graph.", + ) + for mediator in role_names[:mediators] + if !(mediator in computed_descendants) || + !(outcome in _protocol_reachable(adjacency, mediator)) + _add_protocol_error!( + errors, + "$label: declared mediators must lie on a causal path.", + ) + end + end + incoming = Dict(node => 0 for node in graph_nodes) + for targets in values(adjacency), target in targets + incoming[target] += 1 + end + any(node -> get(incoming, node, 0) < 2, role_names[:colliders]) && _add_protocol_error!( + errors, + "$label: declared colliders must have converging arrows.", + ) + outcome in _protocol_reachable(adjacency, treatment) || _add_protocol_error!( + errors, + "$label: the graph must retain a treatment-to-outcome path.", + ) + + graph_confounders = Set( + node for node in setdiff(graph_nodes, Set((treatment, outcome))) if + treatment in _protocol_reachable(adjacency, node) && + outcome in _protocol_reachable_avoiding(adjacency, node, Set((treatment,))) + ) + role_names[:confounders] == graph_confounders || _add_protocol_error!( + errors, + "$label: declared confounders must match graph-implied common causes.", + ) + graph_colliders = Set( + node for + (node, degree) in incoming if degree >= 2 && !(node in (treatment, outcome)) + ) + role_names[:colliders] == graph_colliders || _add_protocol_error!( + errors, + "$label: declared colliders must match graph-implied converging nodes.", + ) + graph_mediators = Set( + node for node in setdiff(graph_nodes, Set((treatment, outcome))) if + node in _protocol_reachable(adjacency, treatment) && + outcome in _protocol_reachable(adjacency, node) + ) + role_names[:mediators] == graph_mediators || _add_protocol_error!( + errors, + "$label: declared mediators must match graph-implied causal-path nodes.", + ) + isempty(intersect(role_names[:confounders], role_names[:instruments])) || + _add_protocol_error!( + errors, + "$label: a variable cannot be both a confounder and an instrument.", + ) + + instrument_edges = Tuple(edge for edge in directed_edges if edge[1] != treatment) + for instrument in role_names[:instruments] + treatment in _protocol_reachable_avoiding(adjacency, instrument, selected) || + _add_protocol_error!( + errors, + "$label: every declared instrument must be relevant in the graph.", + ) + outcome in _protocol_reachable_avoiding(adjacency, instrument, Set((treatment,))) && + _add_protocol_error!( + errors, + "$label: every declared instrument must satisfy graph-level exclusion.", + ) + _protocol_d_separated(instrument_edges, instrument, outcome, selected) || + _add_protocol_error!( + errors, + "$label: every declared instrument must be graph-separated from outcome shocks.", + ) + end + + forbidden_controls = union( + computed_descendants, + role_names[:descendants], + role_names[:mediators], + role_names[:colliders], + role_names[:instruments], + Set((treatment, outcome)), + ) + isempty(intersect(selected, forbidden_controls)) || _add_protocol_error!( + errors, + "$label: ordinary controls cannot include descendants, mediators, colliders, or instruments.", + ) + for candidate in admissible_sets + issubset(candidate, graph_nodes) || _add_protocol_error!( + errors, + "$label: every declared admissible set must use graph nodes.", + ) + isempty(intersect(candidate, forbidden_controls)) || _add_protocol_error!( + errors, + "$label: no declared admissible set may contain a descendant, mediator, collider, or instrument.", + ) + if stage.method_label == "BACKDOOR_ADJUSTMENT" && + !_protocol_backdoor_d_separated(directed_edges, treatment, outcome, candidate) + _add_protocol_error!( + errors, + "$label: every declared admissible set must block graph-implied backdoor paths.", + ) + end + end + issubset(selected, graph_nodes) || + _add_protocol_error!(errors, "$label: every selected control must be a graph node.") + justifications = _validate_justifications(stage.control_justifications, label, errors) + Set(keys(justifications)) == selected || _add_protocol_error!( + errors, + "$label: every selected control needs one justification.", + ) + _validate_string_tuple( + stage.open_backdoor_paths, + label, + "open backdoor paths", + errors; + allow_empty = true, + ) + isempty(stage.open_backdoor_paths) || + _add_protocol_error!(errors, "$label: no backdoor path may remain open.") + + if stage.method_label != "FRONT_DOOR_ADJUSTMENT" && + stage.frontdoor_criteria_satisfied !== nothing + _add_protocol_error!( + errors, + "$label: front-door evidence requires the front-door method.", + ) + end + instrument_premises = ( + stage.instrument_relevance_satisfied, + stage.instrument_exclusion_satisfied, + stage.instrument_exogeneity_satisfied, + ) + if stage.method_label != "INSTRUMENTAL_VARIABLES" && + any(value -> value !== nothing, instrument_premises) + _add_protocol_error!( + errors, + "$label: instrumental-variable premises require the IV method.", + ) + end + + if stage.method_label == "BACKDOOR_ADJUSTMENT" + _protocol_backdoor_d_separated(directed_edges, treatment, outcome, selected) || + _add_protocol_error!( + errors, + "$label: selected controls must block every graph-implied backdoor path.", + ) + elseif stage.method_label == "FRONT_DOOR_ADJUSTMENT" + if isempty(role_names[:mediators]) || stage.frontdoor_criteria_satisfied !== true + _add_protocol_error!( + errors, + "$label: front-door criteria must be explicitly satisfied.", + ) + elseif outcome in + _protocol_reachable_avoiding(adjacency, treatment, role_names[:mediators]) + _add_protocol_error!( + errors, + "$label: front-door mediators must intercept every directed causal path.", + ) + end + for mediator in role_names[:mediators] + if !_protocol_backdoor_d_separated( + directed_edges, + treatment, + mediator, + selected, + ) || + !_protocol_backdoor_d_separated( + directed_edges, + mediator, + outcome, + union(Set((treatment,)), selected), + ) + _add_protocol_error!( + errors, + "$label: graph-implied front-door backdoor criteria must hold.", + ) + end + end + elseif stage.method_label == "INSTRUMENTAL_VARIABLES" + if isempty(role_names[:instruments]) || + any(value -> value !== true, instrument_premises) + _add_protocol_error!( + errors, + "$label: instrumental-variable premises must all hold.", + ) + end + end + return nothing +end + +function validate_causal_factor_protocol(report::CausalFactorProtocolReport) + errors = String[] + _nonblank(report.trial_family_id) || + _add_protocol_error!(errors, "Protocol: trial-family identifier must be nonblank.") + trials_valid = _validate_string_tuple( + report.declared_trial_ids, + "Protocol", + "declared trial identifiers", + errors, + ) + + selection = report.variable_selection + label = "Stage 1" + selection.purpose in _RESEARCH_PURPOSES || + _add_protocol_error!(errors, "$label: research purpose is not source-listed.") + selected_valid = _validate_string_tuple( + selection.selected_variables, + label, + "selected variables", + errors, + ) + selected_variables = selected_valid ? Set(selection.selected_variables) : Set{String}() + selection_methods_valid = _validate_labels( + selection.method_labels, + _VARIABLE_SELECTION_METHODS, + label, + "variable-selection method labels", + errors, + ) + selection_methods = + selection_methods_valid ? Set(selection.method_labels) : Set{String}() + if selection.validation === nothing + selection_horizons = Dict{String,Tuple{Float64,Float64}}() + if selection.overlapping_returns || + selection.strong_time_dependence || + "PERMUTATION_FEATURE_IMPORTANCE" in selection_methods + _add_protocol_error!( + errors, + "$label: the declared selection design requires temporal validation.", + ) + end + else + selection_horizons = _validate_fold_evidence( + selection.validation, + label, + errors; + required_refit_components = Set(("VARIABLE_SELECTION",)), + ) + actual_overlap = _has_any_overlap(selection_horizons) + selection.overlapping_returns == actual_overlap || _add_protocol_error!( + errors, + "$label: overlap declaration must match event horizons.", + ) + validation_methods = Set(selection.validation.method_labels) + if actual_overlap && + isempty(intersect(validation_methods, _PURGED_VALIDATION_METHODS)) + _add_protocol_error!( + errors, + "$label: overlapping returns require purged validation.", + ) + end + if selection.strong_time_dependence && ( + !_finite_protocol_real(selection.validation.embargo) || + Float64(selection.validation.embargo) <= 0.0 + ) + _add_protocol_error!( + errors, + "$label: strong time dependence requires an embargo.", + ) + end + end + + adjustment = report.causal_adjustment_set + safe_outcome = _nonblank(adjustment.outcome) ? adjustment.outcome : "" + graph_nodes, graph_adjacency, graph_edges = _validate_discovery( + report.causal_discovery, + selected_variables, + safe_outcome, + errors, + ) + _validate_adjustment(adjustment, graph_nodes, graph_adjacency, graph_edges, errors) + + stage4 = report.causal_explanatory_and_predictive_power + label = "Stage 4" + task_types_valid = _validate_labels( + stage4.task_types, + Set(keys(_METRICS_BY_TASK)), + label, + "task types", + errors, + ) + allowed_metrics = Set{String}() + if task_types_valid + for task_type in stage4.task_types + union!(allowed_metrics, _METRICS_BY_TASK[task_type]) + end + end + _nonblank(stage4.estimator_label) || + _add_protocol_error!(errors, "$label: estimator label must be nonblank.") + explanatory_valid = _validate_labels( + stage4.explanatory_metric_labels, + allowed_metrics, + label, + "explanatory metric labels", + errors; + allow_empty = true, + ) + predictive_valid = _validate_labels( + stage4.predictive_metric_labels, + allowed_metrics, + label, + "predictive metric labels", + errors; + allow_empty = true, + ) + has_explanatory = explanatory_valid && !isempty(stage4.explanatory_metric_labels) + has_predictive = predictive_valid && !isempty(stage4.predictive_metric_labels) + has_explanatory || + has_predictive || + _add_protocol_error!(errors, "$label: at least one performance aspect is required.") + reported_metrics = union( + explanatory_valid ? Set(stage4.explanatory_metric_labels) : Set{String}(), + predictive_valid ? Set(stage4.predictive_metric_labels) : Set{String}(), + ) + if task_types_valid + for task_type in stage4.task_types + isempty(intersect(reported_metrics, _METRICS_BY_TASK[task_type])) && + _add_protocol_error!( + errors, + "$label: every declared task needs a compatible metric.", + ) + end + end + if has_explanatory && !_nonblank(stage4.explanatory_evidence_id) + _add_protocol_error!(errors, "$label: explanatory evidence identifier is required.") + elseif !has_explanatory && stage4.explanatory_evidence_id !== nothing + _add_protocol_error!( + errors, + "$label: explanatory evidence must match reported metrics.", + ) + end + if has_predictive && !_nonblank(stage4.predictive_evidence_id) + _add_protocol_error!(errors, "$label: predictive evidence identifier is required.") + elseif !has_predictive && stage4.predictive_evidence_id !== nothing + _add_protocol_error!( + errors, + "$label: predictive evidence must match reported metrics.", + ) + end + if has_explanatory && + has_predictive && + stage4.explanatory_evidence_id == stage4.predictive_evidence_id + _add_protocol_error!( + errors, + "$label: explanatory and predictive evidence must be separate.", + ) + end + if selection.purpose == "CAUSAL_ATTRIBUTION" && !has_explanatory + _add_protocol_error!( + errors, + "$label: causal attribution requires explanatory evidence.", + ) + end + if selection.purpose == "RISK_PREMIA_HARVESTING" && !has_predictive + _add_protocol_error!( + errors, + "$label: risk-premia harvesting requires predictive evidence.", + ) + end + _nonblank(stage4.naive_benchmark_id) || + _add_protocol_error!(errors, "$label: a naive benchmark identifier is required.") + if stage4.multiclass_encoding !== nothing || stage4.averaging_method !== nothing + stage4.multiclass_encoding == "ONE_VS_REST" || _add_protocol_error!( + errors, + "$label: multiclass encoding is not source-listed.", + ) + stage4.averaging_method in ("MICRO", "MACRO", "WEIGHTED", "SAMPLES") || + _add_protocol_error!( + errors, + "$label: multiclass averaging is not source-listed.", + ) + isempty(intersect(Set(stage4.task_types), Set(("PROBABILITY", "RANKING")))) && + _add_protocol_error!( + errors, + "$label: multiclass settings require a classification task.", + ) + end + stage4_horizons = _validate_fold_evidence( + stage4.validation, + label, + errors; + required_refit_components = Set(("VARIABLE_SELECTION", "CAUSAL_ESTIMATION")), + ) + isempty(intersect(Set(stage4.validation.method_labels), _PURGED_VALIDATION_METHODS)) && + _add_protocol_error!(errors, "$label: purged cross-validation is required.") + if !isempty(selection_horizons) && selection_horizons != stage4_horizons + _add_protocol_error!(errors, "$label: event horizons must match Stage 1.") + end + if selection.strong_time_dependence && ( + !_finite_protocol_real(stage4.validation.embargo) || + Float64(stage4.validation.embargo) <= 0.0 + ) + _add_protocol_error!(errors, "$label: strong time dependence requires an embargo.") + end + + portfolio = report.causal_portfolio_construction + label = "Stage 5" + portfolio_labels_valid = _validate_labels( + portfolio.method_labels, + _PORTFOLIO_METHODS, + label, + "portfolio-construction method labels", + errors, + ) + if portfolio_labels_valid && Set(portfolio.method_labels) != _PORTFOLIO_METHODS + _add_protocol_error!( + errors, + "$label: all source portfolio considerations are required.", + ) + end + causal_valid = _validate_string_tuple( + portfolio.causal_exposures, + label, + "causal exposures", + errors, + ) + neutral_valid = _validate_string_tuple( + portfolio.controlled_unintended_exposures, + label, + "controlled unintended exposures", + errors; + allow_empty = true, + ) + causal_exposures = causal_valid ? Set(portfolio.causal_exposures) : Set{String}() + neutral_exposures = + neutral_valid ? Set(portfolio.controlled_unintended_exposures) : Set{String}() + if causal_valid && !issubset(causal_exposures, graph_nodes) + _add_protocol_error!(errors, "$label: causal exposures must be graph variables.") + end + if causal_valid && any( + exposure -> !(safe_outcome in _protocol_reachable(graph_adjacency, exposure)), + causal_exposures, + ) + _add_protocol_error!( + errors, + "$label: every causal exposure must have a directed path to the outcome.", + ) + end + if neutral_valid && !issubset(neutral_exposures, graph_nodes) + _add_protocol_error!( + errors, + "$label: controlled unintended exposures must be graph variables.", + ) + end + safe_treatment = _nonblank(adjustment.treatment) ? adjustment.treatment : "" + safe_treatment in causal_exposures || _add_protocol_error!( + errors, + "$label: the target causal factor must drive position sizing.", + ) + isempty(intersect(causal_exposures, neutral_exposures)) || _add_protocol_error!( + errors, + "$label: causal and neutralized exposures must be disjoint.", + ) + issubset(Set(adjustment.colliders), neutral_exposures) || + _add_protocol_error!(errors, "$label: declared colliders must be neutralized.") + for (value, subject) in ( + (portfolio.cost_model_id, "cost model identifier"), + (portfolio.constraint_set_id, "constraint-set identifier"), + (portfolio.economic_rationale, "economic rationale"), + ) + _nonblank(value) || + _add_protocol_error!(errors, "$label: $subject must be nonblank.") + end + _validate_string_tuple( + portfolio.fragility_scenarios, + label, + "causal-fragility scenarios", + errors, + ) + if !_finite_protocol_real(portfolio.transfer_coefficient) || + !(-1.0 <= Float64(portfolio.transfer_coefficient) <= 1.0) + _add_protocol_error!( + errors, + "$label: transfer coefficient must be finite and between minus one and one.", + ) + end + + backtest = report.backtest + label = "Stage 6" + backtest_labels_valid = _validate_labels( + backtest.method_labels, + _BACKTEST_METHODS, + label, + "backtest method labels", + errors, + ) + backtest.trial_family_id == report.trial_family_id || _add_protocol_error!( + errors, + "$label: trial-family identifier must match the report.", + ) + backtest.declared_trial_ids == report.declared_trial_ids || _add_protocol_error!( + errors, + "$label: declared trials must match the report exactly.", + ) + non_monte_carlo = + backtest_labels_valid ? + setdiff(Set(backtest.method_labels), Set(("MONTE_CARLO",))) : Set{String}() + if !isempty(non_monte_carlo) + if backtest.validation === nothing + _add_protocol_error!( + errors, + "$label: temporal backtests require validation evidence.", + ) + else + _validate_fold_evidence( + backtest.validation, + label, + errors; + required_refit_components = _REFIT_COMPONENTS, + ) + if selection.strong_time_dependence && ( + !_finite_protocol_real(backtest.validation.embargo) || + Float64(backtest.validation.embargo) <= 0.0 + ) + _add_protocol_error!( + errors, + "$label: strong time dependence requires a backtest embargo.", + ) + end + issubset(non_monte_carlo, Set(backtest.validation.method_labels)) || + _add_protocol_error!( + errors, + "$label: backtest and validation method labels must agree.", + ) + end + elseif backtest.validation !== nothing + _add_protocol_error!( + errors, + "$label: Monte Carlo-only evidence cannot carry temporal folds.", + ) + end + if backtest_labels_valid && "MONTE_CARLO" in backtest.method_labels + _nonblank(backtest.monte_carlo_dgp) || + _add_protocol_error!(errors, "$label: Monte Carlo requires an explicit DGP.") + elseif backtest.monte_carlo_dgp !== nothing + _add_protocol_error!( + errors, + "$label: a Monte Carlo DGP requires the Monte Carlo method.", + ) + end + + multiple_testing = report.multiple_testing_adjustments + label = "Stage 7" + methods_valid = _validate_labels( + multiple_testing.method_labels, + _MULTIPLE_TESTING_METHODS, + label, + "multiple-testing method labels", + errors, + ) + multiple_testing.trial_family_id == report.trial_family_id || _add_protocol_error!( + errors, + "$label: trial-family identifier must match the report.", + ) + multiple_testing.declared_trial_ids == report.declared_trial_ids || + _add_protocol_error!( + errors, + "$label: declared trials must match the report exactly.", + ) + + covered_trials = Set{String}() + partition_ids = Set{String}() + if isempty(multiple_testing.family_partitions) + _add_protocol_error!(errors, "$label: family partitions must be nonempty.") + else + for partition in multiple_testing.family_partitions + if !(partition isa Tuple) || length(partition) != 2 || !_nonblank(partition[1]) + _add_protocol_error!(errors, "$label: a family partition is invalid.") + continue + end + partition[1] in partition_ids && _add_protocol_error!( + errors, + "$label: family partition identifiers must be unique.", + ) + push!(partition_ids, partition[1]) + _validate_string_tuple( + partition[2], + label, + "partition trial identifiers", + errors, + ) || continue + for trial_id in partition[2] + trial_id in covered_trials && _add_protocol_error!( + errors, + "$label: family partitions must be disjoint.", + ) + push!(covered_trials, trial_id) + end + end + if trials_valid && covered_trials != Set(report.declared_trial_ids) + _add_protocol_error!( + errors, + "$label: family partitions must cover declared trials exactly.", + ) + end + end + if !_finite_protocol_real(multiple_testing.alpha) || + !(0.0 < Float64(multiple_testing.alpha) < 1.0) + _add_protocol_error!( + errors, + "$label: alpha must be a finite number between zero and one.", + ) + end + multiple_testing.backtests_independent && + _add_protocol_error!(errors, "$label: backtest dependence must be represented.") + + methods = methods_valid ? Set(multiple_testing.method_labels) : Set{String}() + if !isempty(intersect(methods, _P_VALUE_METHODS)) + _nonblank(multiple_testing.p_value_estimator) || + _add_protocol_error!(errors, "$label: p-value estimator must be declared.") + _nonblank(multiple_testing.time_dependence_model) || _add_protocol_error!( + errors, + "$label: time dependence treatment must be declared.", + ) + multiple_testing.p_value_inputs_assume_independence === false || + _add_protocol_error!( + errors, + "$label: p-value inputs must explicitly reject independence assumptions.", + ) + elseif any( + value -> value !== nothing, + ( + multiple_testing.p_value_estimator, + multiple_testing.time_dependence_model, + multiple_testing.p_value_inputs_assume_independence, + ), + ) + _add_protocol_error!(errors, "$label: p-value inputs require a p-value correction.") + end + if "DEFLATED_SHARPE_RATIO" in methods + trial_count = length(report.declared_trial_ids) + if !_finite_protocol_real(multiple_testing.sharpe_variance) || + Float64(multiple_testing.sharpe_variance) <= 0.0 + _add_protocol_error!( + errors, + "$label: positive Sharpe-ratio variance is required.", + ) + end + if !_finite_protocol_real(multiple_testing.effective_trials) || + !(1.0 <= Float64(multiple_testing.effective_trials) < trial_count) + _add_protocol_error!( + errors, + "$label: effective trials must be below total trials.", + ) + end + if multiple_testing.sample_length === nothing || + multiple_testing.sample_length isa Bool || + Int(multiple_testing.sample_length) <= 1 + _add_protocol_error!( + errors, + "$label: sample length must be an integer above one.", + ) + end + _finite_protocol_real(multiple_testing.skewness) || + _add_protocol_error!(errors, "$label: skewness must be finite.") + if !_finite_protocol_real(multiple_testing.kurtosis) || + Float64(multiple_testing.kurtosis) < 1.0 + _add_protocol_error!( + errors, + "$label: Pearson kurtosis must be finite and at least one.", + ) + elseif _finite_protocol_real(multiple_testing.skewness) && + abs(Float64(multiple_testing.skewness)) > + sqrt(Float64(multiple_testing.kurtosis) - 1.0) + _add_protocol_error!( + errors, + "$label: skewness and Pearson kurtosis must satisfy the moment inequality.", + ) + end + _nonblank(multiple_testing.selection_bias_evidence_id) || + _add_protocol_error!(errors, "$label: selection-bias evidence is required.") + elseif any( + value -> value !== nothing, + ( + multiple_testing.sharpe_variance, + multiple_testing.effective_trials, + multiple_testing.sample_length, + multiple_testing.skewness, + multiple_testing.kurtosis, + multiple_testing.selection_bias_evidence_id, + ), + ) + _add_protocol_error!( + errors, + "$label: Sharpe-correction inputs require the deflated Sharpe ratio.", + ) + end + + isempty(errors) || + throw(ArgumentError("Causal factor protocol is invalid. " * join(errors, " "))) + return report +end + + + +function _protocol_backdoor_d_separated(edges, exposure, outcome, conditioned) + backdoor_edges = Tuple(edge for edge in edges if edge[1] != exposure) + return _protocol_d_separated(backdoor_edges, exposure, outcome, conditioned) +end diff --git a/src/CausalFactorAnalysis/SpecificationDiagnostics.jl b/src/CausalFactorAnalysis/SpecificationDiagnostics.jl new file mode 100644 index 0000000..bcd168b --- /dev/null +++ b/src/CausalFactorAnalysis/SpecificationDiagnostics.jl @@ -0,0 +1,187 @@ +struct PopulationRegressionDiagnostics + coefficient_names::Tuple{Vararg{String}} + coefficients::Tuple{Vararg{Float64}} + r_squared::Float64 +end + +struct RegressionDiagnostics + coefficient_names::Tuple{Vararg{String}} + coefficients::Tuple{Vararg{Float64}} + standard_errors::Tuple{Vararg{Float64}} + t_statistics::Tuple{Vararg{Float64}} + r_squared::Float64 + adjusted_r_squared::Float64 + residual_variance::Float64 + n_observations::Int +end + +struct SpecificationPopulationResult + structure::String + conditioning_variable_role::String + unconditioned::PopulationRegressionDiagnostics + conditioned::PopulationRegressionDiagnostics +end + +struct SpecificationExperimentResult + structure::String + conditioning_variable_role::String + unconditioned::RegressionDiagnostics + conditioned::RegressionDiagnostics +end + +const _MAX_SAMPLE_SIZE = 1_000_000 + +function _sample_size(value) + value isa Bool && throw(ArgumentError("sample_size must be an integer")) + value isa Integer || throw(ArgumentError("sample_size must be an integer")) + result = Int(value) + result > 3 || throw(ArgumentError("sample_size must be greater than 3")) + result <= _MAX_SAMPLE_SIZE || + throw(ArgumentError("sample_size must not exceed $_MAX_SAMPLE_SIZE")) + return result +end + +function _random_seed(value) + value isa Bool && throw(ArgumentError("seed must be an integer")) + value isa Integer || throw(ArgumentError("seed must be an integer")) + 0 <= value <= typemax(UInt32) || + throw(ArgumentError("seed must be between 0 and 2^32 - 1")) + return UInt32(value) +end + +function _population_regression(names, coefficients, r_squared) + return PopulationRegressionDiagnostics( + Tuple(String.(names)), + Tuple(Float64.(coefficients)), + Float64(r_squared), + ) +end + +function _ols_diagnostics(outcome::Vector{Float64}, regressors) + n_observations = length(outcome) + columns = Any[ones(Float64, n_observations)] + names = String["intercept"] + for (name, values) in regressors + length(values) == n_observations || + throw(ArgumentError("regressor length must equal outcome length")) + push!(columns, values) + push!(names, String(name)) + end + design = hcat(columns...) + n_parameters = size(design, 2) + n_observations > n_parameters || + throw(ArgumentError("the regression requires positive residual degrees of freedom")) + rank(design) == n_parameters || + throw(ArgumentError("the generated regression design is rank deficient")) + + coefficients = design \ outcome + fitted = design * coefficients + residuals = outcome - fitted + residual_sum_squares = dot(residuals, residuals) + centered = outcome .- mean(outcome) + total_sum_squares = dot(centered, centered) + isfinite(total_sum_squares) && total_sum_squares > 0.0 || + throw(ArgumentError("the generated outcome must have positive finite variance")) + + residual_degrees_of_freedom = n_observations - n_parameters + residual_variance = residual_sum_squares / residual_degrees_of_freedom + coefficient_covariance = residual_variance * inv(transpose(design) * design) + variances = diag(coefficient_covariance) + all(value -> value >= -1.0e-12, variances) || + throw(ArgumentError("the coefficient variance calculation is invalid")) + standard_errors = sqrt.(max.(variances, 0.0)) + t_statistics = map(coefficients, standard_errors) do coefficient, standard_error + standard_error > 0.0 ? coefficient / standard_error : NaN + end + all(isfinite, coefficients) && all(isfinite, standard_errors) || + throw(ArgumentError("the regression diagnostics must be finite")) + + r_squared = 1.0 - residual_sum_squares / total_sum_squares + if -1.0e-12 <= r_squared < 0.0 + r_squared = 0.0 + elseif 1.0 < r_squared <= 1.0 + 1.0e-12 + r_squared = 1.0 + end + adjusted_r_squared = + 1.0 - (1.0 - r_squared) * (n_observations - 1) / residual_degrees_of_freedom + return RegressionDiagnostics( + Tuple(names), + Tuple(Float64.(coefficients)), + Tuple(Float64.(standard_errors)), + Tuple(Float64.(t_statistics)), + Float64(r_squared), + Float64(adjusted_r_squared), + Float64(residual_variance), + n_observations, + ) +end + +function fork_population_diagnostics() + return SpecificationPopulationResult( + "fork", + "confounder", + _population_regression(("intercept", "X"), (0.0, 0.5), 0.25), + _population_regression(("intercept", "X", "Z"), (0.0, 0.0, 1.0), 0.5), + ) +end + +function collider_population_diagnostics() + return SpecificationPopulationResult( + "collider", + "collider", + _population_regression(("intercept", "X"), (0.0, 0.0), 0.0), + _population_regression(("intercept", "X", "Z"), (0.0, -0.5, 0.5), 0.5), + ) +end + +function confounded_mediator_population_diagnostics() + return SpecificationPopulationResult( + "confounded_mediator", + "confounded_mediator", + _population_regression(("intercept", "X"), (0.0, 1.0), 1.0 / 7.0), + _population_regression(("intercept", "X", "Z"), (0.0, -0.5, 1.5), 11.0 / 14.0), + ) +end + +function fork_specification_experiment(sample_size = 5_000, seed = 0) + n_observations = _sample_size(sample_size) + random_state = MersenneTwister(_random_seed(seed)) + z = randn(random_state, n_observations) + x = z + randn(random_state, n_observations) + y = z + randn(random_state, n_observations) + return SpecificationExperimentResult( + "fork", + "confounder", + _ols_diagnostics(y, (("X", x),)), + _ols_diagnostics(y, (("X", x), ("Z", z))), + ) +end + +function collider_specification_experiment(sample_size = 5_000, seed = 0) + n_observations = _sample_size(sample_size) + random_state = MersenneTwister(_random_seed(seed)) + x = randn(random_state, n_observations) + y = randn(random_state, n_observations) + z = x + y + randn(random_state, n_observations) + return SpecificationExperimentResult( + "collider", + "collider", + _ols_diagnostics(y, (("X", x),)), + _ols_diagnostics(y, (("X", x), ("Z", z))), + ) +end + +function confounded_mediator_specification_experiment(sample_size = 5_000, seed = 0) + n_observations = _sample_size(sample_size) + random_state = MersenneTwister(_random_seed(seed)) + x = randn(random_state, n_observations) + w = randn(random_state, n_observations) + z = x + w + randn(random_state, n_observations) + y = z + w + randn(random_state, n_observations) + return SpecificationExperimentResult( + "confounded_mediator", + "confounded_mediator", + _ols_diagnostics(y, (("X", x),)), + _ols_diagnostics(y, (("X", x), ("Z", z))), + ) +end diff --git a/src/CausalFactorAnalysis/TreatmentEffects.jl b/src/CausalFactorAnalysis/TreatmentEffects.jl new file mode 100644 index 0000000..93b98ff --- /dev/null +++ b/src/CausalFactorAnalysis/TreatmentEffects.jl @@ -0,0 +1,221 @@ +struct TreatmentEffectDecomposition + observed_difference::Float64 + average_treatment_effect_on_treated::Float64 + sample_selection_bias::Float64 +end + +struct DifferenceInDifferencesEstimate + treated_change::Float64 + control_change::Float64 + estimate::Float64 +end + +const _PROBABILITY_TOLERANCE = 1.0e-12 + +function _finite_real(name::AbstractString, value) + value isa Bool && throw(ArgumentError("$name must be a real scalar")) + value isa Real || throw(ArgumentError("$name must be a real scalar")) + result = try + Float64(value) + catch + throw(ArgumentError("$name must be a representable real scalar")) + end + isfinite(result) || throw(ArgumentError("$name must be finite")) + return result +end + +function _finite_vector(name::AbstractString, values) + values isa AbstractString && + throw(ArgumentError("$name must be a one-dimensional numeric collection")) + raw = try + collect(values) + catch + throw(ArgumentError("$name must be a one-dimensional numeric collection")) + end + ndims(raw) == 1 && !isempty(raw) || + throw(ArgumentError("$name must be a nonempty numeric vector")) + all(value -> value isa Real && !(value isa Bool), raw) || + throw(ArgumentError("$name must contain real values")) + result = try + Float64.(raw) + catch + throw(ArgumentError("$name must contain representable real values")) + end + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return result +end + +function _probability_vector(name::AbstractString, values) + result = _finite_vector(name, values) + all(value -> 0.0 <= value <= 1.0, result) || + throw(ArgumentError("$name must contain probabilities in [0, 1]")) + isapprox( + sum(result), + 1.0; + rtol = _PROBABILITY_TOLERANCE, + atol = _PROBABILITY_TOLERANCE, + ) || throw(ArgumentError("$name must sum to one")) + return result +end + +function _finite_matrix(name::AbstractString, values) + if values isa AbstractMatrix + all(value -> value isa Real && !(value isa Bool), values) || + throw(ArgumentError("$name must contain real values")) + result = try + Float64.(values) + catch + throw(ArgumentError("$name must contain representable real values")) + end + else + rows = try + collect(values) + catch + throw(ArgumentError("$name must be a two-dimensional numeric collection")) + end + !isempty(rows) || throw(ArgumentError("$name must be nonempty")) + converted = [_finite_vector("$name row", row) for row in rows] + width = length(first(converted)) + all(row -> length(row) == width, converted) || + throw(ArgumentError("$name rows must have equal length")) + result = reduce(vcat, permutedims.(converted)) + end + ndims(result) == 2 || + throw(ArgumentError("$name must be a two-dimensional numeric collection")) + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) + return Matrix{Float64}(result) +end + +function average_treatment_effect(treated_interventional_mean, control_interventional_mean) + treated = _finite_real("treated_interventional_mean", treated_interventional_mean) + control = _finite_real("control_interventional_mean", control_interventional_mean) + return _finite_real("average_treatment_effect", treated - control) +end + +function treatment_effect_decomposition( + observed_treated_mean, + observed_control_mean, + counterfactual_control_mean_for_treated, +) + treated = _finite_real("observed_treated_mean", observed_treated_mean) + control = _finite_real("observed_control_mean", observed_control_mean) + counterfactual = _finite_real( + "counterfactual_control_mean_for_treated", + counterfactual_control_mean_for_treated, + ) + observed = _finite_real("observed_difference", treated - control) + effect = _finite_real("average_treatment_effect_on_treated", treated - counterfactual) + selection = _finite_real("sample_selection_bias", counterfactual - control) + return TreatmentEffectDecomposition(observed, effect, selection) +end + +function randomized_mean_difference(treated_outcomes, control_outcomes) + treated = _finite_vector("treated_outcomes", treated_outcomes) + control = _finite_vector("control_outcomes", control_outcomes) + return _finite_real( + "randomized_mean_difference", + sum(treated) / length(treated) - sum(control) / length(control), + ) +end + +function difference_in_differences( + treated_before, + treated_after, + control_before, + control_after, +) + treated_change = _finite_real( + "treated_change", + _finite_real("treated_after", treated_after) - + _finite_real("treated_before", treated_before), + ) + control_change = _finite_real( + "control_change", + _finite_real("control_after", control_after) - + _finite_real("control_before", control_before), + ) + estimate = _finite_real("difference_in_differences", treated_change - control_change) + return DifferenceInDifferencesEstimate(treated_change, control_change, estimate) +end + +function backdoor_adjusted_expectation(conditional_outcome_means, adjustment_probabilities) + means = _finite_vector("conditional_outcome_means", conditional_outcome_means) + probabilities = + _probability_vector("adjustment_probabilities", adjustment_probabilities) + length(means) == length(probabilities) || throw( + ArgumentError( + "conditional_outcome_means and adjustment_probabilities must have the same length", + ), + ) + return _finite_real("backdoor_adjusted_expectation", dot(means, probabilities)) +end + +function backdoor_adjusted_average_treatment_effect( + treated_conditional_means, + control_conditional_means, + adjustment_probabilities, +) + treated = _finite_vector("treated_conditional_means", treated_conditional_means) + control = _finite_vector("control_conditional_means", control_conditional_means) + probabilities = + _probability_vector("adjustment_probabilities", adjustment_probabilities) + length(treated) == length(control) == length(probabilities) || throw( + ArgumentError( + "treated means, control means, and adjustment probabilities must have the same length", + ), + ) + return _finite_real( + "backdoor_adjusted_average_treatment_effect", + dot(treated - control, probabilities), + ) +end + +function frontdoor_adjusted_probability( + mediator_probabilities_given_treatment, + outcome_probabilities_given_mediator_and_treatment, + treatment_probabilities, +) + mediator_probabilities = _probability_vector( + "mediator_probabilities_given_treatment", + mediator_probabilities_given_treatment, + ) + treatment_weights = + _probability_vector("treatment_probabilities", treatment_probabilities) + conditional = _finite_matrix( + "outcome_probabilities_given_mediator_and_treatment", + outcome_probabilities_given_mediator_and_treatment, + ) + all(value -> 0.0 <= value <= 1.0, conditional) || throw( + ArgumentError( + "outcome_probabilities_given_mediator_and_treatment must contain probabilities in [0, 1]", + ), + ) + size(conditional) == (length(mediator_probabilities), length(treatment_weights)) || + throw( + ArgumentError( + "outcome probability matrix shape must match mediator and treatment state counts", + ), + ) + probability = _finite_real( + "frontdoor_adjusted_probability", + dot(mediator_probabilities, conditional * treatment_weights), + ) + if -_PROBABILITY_TOLERANCE <= probability < 0.0 + return 0.0 + elseif 1.0 < probability <= 1.0 + _PROBABILITY_TOLERANCE + return 1.0 + end + return probability +end + +function linear_instrumental_variable_effect( + outcome_instrument_covariance, + treatment_instrument_covariance, +) + numerator = _finite_real("outcome_instrument_covariance", outcome_instrument_covariance) + denominator = + _finite_real("treatment_instrument_covariance", treatment_instrument_covariance) + denominator == 0.0 && + throw(ArgumentError("treatment_instrument_covariance must be nonzero")) + return _finite_real("instrumental_variable_effect", numerator / denominator) +end diff --git a/src/Pde/DeepBSDESolver.jl b/src/Pde/DeepBSDESolver.jl index f1a4cb6..d2e71ab 100644 --- a/src/Pde/DeepBSDESolver.jl +++ b/src/Pde/DeepBSDESolver.jl @@ -22,6 +22,9 @@ using Lux using Zygote using Optimisers +import RiskLabAI.Pde: Equation, pde_driver, pde_hamiltonian, pde_sample, + pde_terminal, solve_deep_bsde + _act(x) = max(x, zero(x)) # Per-time-step MLP: ReLU after each hidden bias-free linear layer, raw output. @@ -83,8 +86,8 @@ function solve_deep_bsde( net_states = [] for model in models ps, st = Lux.setup(rng, model) - push!(net_params, ps) - push!(net_states, st) + push!(net_params, Lux.f64(ps)) + push!(net_states, Lux.f64(st)) end states = Tuple(net_states) diff --git a/src/Pde/Pde.jl b/src/Pde/Pde.jl index aeca702..09d55ae 100644 --- a/src/Pde/Pde.jl +++ b/src/Pde/Pde.jl @@ -4,16 +4,29 @@ PDE submodule, mirroring the Python `RiskLabAI.pde` sub-package: financial PDEs solved by the Deep BSDE method (Han, Jentzen & E, 2018). -This slice wires the **equations** — the forward-SDE sampler and the BSDE -generator functions. The neural Deep-BSDE solver (which needs a deep-learning -backend) is wired in a follow-up. +The base module wires the **equations** — the forward-SDE sampler and the BSDE +generator functions. The neural Deep-BSDE solver is supplied only by the +optional extension because it requires a deep-learning backend. """ module Pde include("Equations.jl") -# Neural Deep-BSDE solver (Lux.jl backend). -include("DeepBSDESolver.jl") +""" + solve_deep_bsde(args...; kwargs...) + +Solve a PDE with the optional neural Deep-BSDE backend. The base package keeps +the PDE equations available without loading a deep-learning stack. Installing +and loading the `deep_bsde` extension adds the equation-specific method. +""" +function solve_deep_bsde(args...; kwargs...) + throw( + ArgumentError( + "solve_deep_bsde requires the optional deep_bsde extension " * + "(Lux, Optimisers, and Zygote).", + ), + ) +end export Equation, diff --git a/src/RiskLabAI.jl b/src/RiskLabAI.jl index 6666c7e..27467a4 100644 --- a/src/RiskLabAI.jl +++ b/src/RiskLabAI.jl @@ -1,12 +1,14 @@ module RiskLabAI -using LinearAlgebra, DataFrames, TimeSeries, Random +using LinearAlgebra, DataFrames, Random # --------------------------------------------------------------------------- # # Submodules (Phase-2 reconstruction; mirrors the Python sub-package layout). # Wired in one at a time, each green in CI before the next. # `Data` depends on `Utils`, so order matters. # --------------------------------------------------------------------------- # +include("CausalFactorAnalysis/CausalFactorAnalysis.jl") + include("Utils/Utils.jl") using .Utils: ewma @@ -82,6 +84,8 @@ using .Pde: Equation, HJBLQ, BlackScholesBarenblatt, PricingDefaultRisk, Pricing # Top-level exports. # --------------------------------------------------------------------------- # export + # Causal factor analysis + CausalFactorAnalysis, # Utils ewma, # Data.Structures (bars) diff --git a/test/causal_factor_analysis/test_factor_mirage.jl b/test/causal_factor_analysis/test_factor_mirage.jl new file mode 100644 index 0000000..60ee948 --- /dev/null +++ b/test/causal_factor_analysis/test_factor_mirage.jl @@ -0,0 +1,54 @@ +@testset "factor mirage" begin + @test confounder_undercontrolled_coefficient(1.0, 2.0, 3.0) ≈ 1.6 + @test confounder_undercontrolled_coefficient(2.0, 0.0, 4.0) == 2.0 + + factor = confounder_factor_return(1.0, 1.0, 1.0, 3.0, 1.0) + @test factor == StrategyPerformance(16.0, 6.25) + forecast = confounder_forecast_return(1.0, 3.0, 1.0) + @test forecast.correct ≈ 17.0 + @test forecast.misspecified ≈ 12.5 + + coefficients = collider_overcontrolled_coefficients(1.0, 2.0, 3.0) + @test coefficients.beta_hat ≈ -1.0 + @test coefficients.theta_hat ≈ 0.4 + collider_factor = collider_factor_return(2.0, 1.0, 1.0, 2.0, 3.0) + @test collider_factor.correct ≈ 4.0 + @test collider_factor.misspecified ≈ -3.2 + collider_forecast = collider_forecast_return(1.0, 2.0, 3.0) + @test collider_forecast.correct ≈ 1.0 + @test collider_forecast.misspecified ≈ -1.0 + + diagnostics = collider_model_diagnostics(1.0, 2.0, 3.0, 100) + @test diagnostics.residual_variance ≈ 0.2 + @test diagnostics.outcome_variance ≈ 2.0 + @test diagnostics.correct_r_squared ≈ 0.5 + @test diagnostics.overcontrolled_r_squared ≈ 0.9 + + @test_throws ArgumentError confounder_undercontrolled_coefficient(Inf, 1.0, 1.0) + @test_throws ArgumentError collider_model_diagnostics(1.0, 2.0, 3.0, 3) + + @test confounder_undercontrolled_coefficient(0.0, 1.0e308, 1.0e308) ≈ 1.0 rtol = 1.0e-15 + scaled_factor = confounder_factor_return(1.0e200, 0.0, 1.0e-200, 0.0, 1.0) + @test scaled_factor.correct ≈ 1.0 rtol = 1.0e-15 + @test scaled_factor.misspecified ≈ 1.0 rtol = 1.0e-15 + scaled_collider = collider_overcontrolled_coefficients(1.0, 1.0e308, 1.0) + @test scaled_collider.beta_hat ≈ -1.0e-308 rtol = 1.0e-15 + @test scaled_collider.theta_hat ≈ 1.0e-308 rtol = 1.0e-15 + + large_coefficients = collider_overcontrolled_coefficients(1.0e308, 1.0e163, 0.0) + @test large_coefficients.beta_hat ≈ 1.0e-18 rtol = 1.0e-14 + large_diagnostics = collider_model_diagnostics(1.0e154, 1.0e200, 0.0, 4) + @test large_diagnostics.overcontrolled_beta_variance ≈ 2.5e-93 rtol = 1.0e-14 + + latent_factor = collider_factor_return(1.0e208, 0.0, 1.0e-54, 1.0e200, 0.0) + @test latent_factor.correct ≈ 1.0e308 rtol = 1.0e-14 + @test latent_factor.misspecified ≈ 1.0e-92 rtol = 1.0e-14 + latent_forecast = collider_forecast_return(1.0e75, 1.0e200, 0.0) + @test latent_forecast.correct ≈ 1.0e150 rtol = 1.0e-14 + @test latent_forecast.misspecified ≈ 1.0e-250 rtol = 1.0e-14 + + latent_t = collider_model_diagnostics(0.0, 1.0e-200, 1.0e-200, big(10)^200) + @test latent_t.overcontrolled_beta_t_statistic ≈ -1.0e-300 rtol = 1.0e-14 + @test latent_t.absolute_beta_t_prefers_overcontrolled + @test_throws ArgumentError collider_forecast_return(1.0e308, 0.0, 0.0) +end diff --git a/test/causal_factor_analysis/test_graph_identification.jl b/test/causal_factor_analysis/test_graph_identification.jl new file mode 100644 index 0000000..359a6ad --- /dev/null +++ b/test/causal_factor_analysis/test_graph_identification.jl @@ -0,0 +1,505 @@ +function book_dag(nodes, edges; observed = nodes) + return CausalDAG(Tuple(nodes), Tuple(edges), Tuple(observed)) +end + +function oracle_acyclic(nodes, edges) + children = Dict(node => String[] for node in nodes) + indegree = Dict(node => 0 for node in nodes) + for (source, target) in edges + push!(children[source], target) + indegree[target] += 1 + end + pending = [node for node in nodes if indegree[node] == 0] + visited = 0 + while !isempty(pending) + node = pop!(pending) + visited += 1 + for child in children[node] + indegree[child] -= 1 + indegree[child] == 0 && push!(pending, child) + end + end + return visited == length(nodes) +end + +function oracle_four_node_dags() + nodes = ("A", "B", "C", "D") + pairs = (("A", "B"), ("A", "C"), ("A", "D"), ("B", "C"), ("B", "D"), ("C", "D")) + graphs = Tuple[] + for encoding = 0:(3^length(pairs)-1) + states = digits(encoding; base = 3, pad = length(pairs)) + edges = Tuple{String,String}[] + for (pair, state) in zip(pairs, states) + state == 1 && push!(edges, pair) + state == 2 && push!(edges, reverse(pair)) + end + oracle_acyclic(nodes, edges) && push!(graphs, Tuple(sort(edges))) + end + return nodes, Tuple(graphs) +end + +function oracle_descendants(nodes, edges, start) + children = Dict(node => String[] for node in nodes) + for (source, target) in edges + push!(children[source], target) + end + result = Set{String}() + pending = copy(children[start]) + while !isempty(pending) + node = pop!(pending) + node in result && continue + push!(result, node) + append!(pending, children[node]) + end + return result +end + +function oracle_simple_paths(nodes, edges, left, right) + adjacency = Dict(node => String[] for node in nodes) + for (source, target) in edges + push!(adjacency[source], target) + push!(adjacency[target], source) + end + result = Tuple[] + stack = Any[(left, (left,), Set((left,)))] + while !isempty(stack) + node, path, visited = pop!(stack) + if node == right + push!(result, path) + continue + end + for neighbor in adjacency[node] + !(neighbor in visited) && push!( + stack, + (neighbor, (path..., neighbor), union(visited, Set((neighbor,)))), + ) + end + end + return result +end + +function oracle_path_open(nodes, edges, path, conditioned) + edge_set = Set(edges) + conditioned_set = Set(conditioned) + for index = 2:(length(path)-1) + previous, node, following = path[index-1], path[index], path[index+1] + collider = (previous, node) in edge_set && (following, node) in edge_set + if collider + family = union(Set((node,)), oracle_descendants(nodes, edges, node)) + isempty(intersect(family, conditioned_set)) && return false + elseif node in conditioned_set + return false + end + end + return true +end + +function oracle_d_separated(nodes, edges, left, right, conditioned) + return all( + path -> !oracle_path_open(nodes, edges, path, conditioned), + oracle_simple_paths(nodes, edges, left, right), + ) +end + +function oracle_subsets(items) + result = Tuple[] + for mask = 0:(2^length(items)-1) + push!( + result, + Tuple( + items[index] for + index in eachindex(items) if !iszero(mask & (1 << (index - 1))) + ), + ) + end + return result +end + +function oracle_minimal_backdoor_sets(nodes, edges, treatment, outcome) + descendants = oracle_descendants(nodes, edges, treatment) + candidates = Tuple( + node for node in nodes if !(node in (treatment, outcome)) && !(node in descendants) + ) + backdoor_edges = Tuple(edge for edge in edges if edge[1] != treatment) + valid = Tuple[ + candidate for candidate in oracle_subsets(candidates) if + oracle_d_separated(nodes, backdoor_edges, treatment, outcome, candidate) + ] + sort!(valid; by = item -> (length(item), item)) + minimal = Tuple[] + for candidate in valid + candidate_set = Set(candidate) + any(item -> issubset(Set(item), candidate_set), minimal) || + push!(minimal, candidate) + end + return Tuple(minimal) +end + +function oracle_has_directed_path(nodes, edges, left, right; blocked = ()) + blocked_set = Set(blocked) + if left in blocked_set || right in blocked_set + return false + end + children = Dict(node => String[] for node in nodes) + for (source, target) in edges + push!(children[source], target) + end + pending = String[left] + visited = Set{String}() + while !isempty(pending) + node = pop!(pending) + node == right && return true + node in visited && continue + push!(visited, node) + append!( + pending, + [ + child for + child in children[node] if !(child in blocked_set) && !(child in visited) + ], + ) + end + return false +end + +function oracle_sets_d_separated(nodes, edges, left, right, conditioned) + return all( + oracle_d_separated(nodes, edges, left_node, right_node, conditioned) for + left_node in left for right_node in right + ) +end + +function oracle_minimal_frontdoor_sets(nodes, edges, treatment, outcome) + oracle_has_directed_path(nodes, edges, treatment, outcome) || return () + candidates = Tuple(node for node in nodes if !(node in (treatment, outcome))) + treatment_edges = Tuple(edge for edge in edges if edge[1] != treatment) + valid = Tuple[] + for candidate in oracle_subsets(candidates) + isempty(candidate) && continue + oracle_has_directed_path(nodes, edges, treatment, outcome; blocked = candidate) && + continue + oracle_sets_d_separated(nodes, treatment_edges, (treatment,), candidate, ()) || + continue + mediator_edges = Tuple(edge for edge in edges if !(edge[1] in candidate)) + oracle_sets_d_separated( + nodes, + mediator_edges, + candidate, + (outcome,), + (treatment,), + ) || continue + push!(valid, candidate) + end + sort!(valid; by = item -> (length(item), item)) + minimal = Tuple[] + for candidate in valid + candidate_set = Set(candidate) + any(item -> issubset(Set(item), candidate_set), minimal) || + push!(minimal, candidate) + end + return Tuple(minimal) +end + +function oracle_instrument_profile( + nodes, + edges, + instrument, + treatment, + outcome, + conditioned, +) + observed = Set(nodes) + controls = Set(conditioned) + forbidden = Set((instrument, treatment, outcome)) + disjoint = isempty(intersect(controls, forbidden)) + all_observed = issubset(union(forbidden, controls), observed) + unaffected = isempty(intersect(controls, oracle_descendants(nodes, edges, treatment))) + if disjoint + pearl_relevance = + !oracle_d_separated(nodes, edges, instrument, treatment, conditioned) + incoming_removed = Tuple(edge for edge in edges if edge[2] != treatment) + pearl_exclusion = + oracle_d_separated(nodes, incoming_removed, instrument, outcome, conditioned) + else + pearl_relevance = false + pearl_exclusion = false + end + pearl = all_observed && disjoint && unaffected && pearl_relevance && pearl_exclusion + cfi_direct = (instrument, treatment) in Set(edges) + cfi_full_mediation = + oracle_has_directed_path(nodes, edges, instrument, outcome) && + !oracle_has_directed_path(nodes, edges, instrument, outcome; blocked = (treatment,)) + instrument_edges = Tuple(edge for edge in edges if edge[1] != instrument) + cfi_exogeneity = oracle_d_separated(nodes, instrument_edges, instrument, outcome, ()) + cfi_simple = cfi_direct && cfi_full_mediation && cfi_exogeneity + return ( + all_observed, + disjoint, + unaffected, + pearl_relevance, + pearl_exclusion, + pearl, + cfi_direct, + cfi_full_mediation, + cfi_exogeneity, + cfi_simple, + ) +end + +function oracle_role_profile(nodes, edges, treatment, outcome, role_node) + edge_set = Set(edges) + paths = oracle_simple_paths(nodes, edges, treatment, outcome) + sort!(paths; by = path -> (length(path), path)) + collider_paths = Tuple[] + noncollider_paths = Tuple[] + mediator_paths = Tuple[] + backdoor_paths = Tuple[] + common_cause_paths = Tuple[] + for path in paths + colliders = Set{String}() + noncolliders = Set{String}() + for index = 2:(length(path)-1) + previous, node, following = path[index-1], path[index], path[index+1] + if (previous, node) in edge_set && (following, node) in edge_set + push!(colliders, node) + else + push!(noncolliders, node) + end + end + role_node in colliders && push!(collider_paths, path) + role_node in noncolliders && push!(noncollider_paths, path) + directed = + all((path[index], path[index+1]) in edge_set for index = 1:(length(path)-1)) + directed && role_node in path[2:(end-1)] && push!(mediator_paths, path) + (path[2], path[1]) in edge_set && + role_node in noncolliders && + push!(backdoor_paths, path) + if role_node in noncolliders + index = findfirst(==(role_node), path) + to_treatment = all( + (path[position], path[position-1]) in edge_set for position = index:-1:2 + ) + to_outcome = all( + (path[position], path[position+1]) in edge_set for + position = index:(length(path)-1) + ) + to_treatment && to_outcome && push!(common_cause_paths, path) + end + end + return ( + Tuple(collider_paths), + Tuple(noncollider_paths), + Tuple(mediator_paths), + Tuple(backdoor_paths), + Tuple(common_cause_paths), + oracle_has_directed_path(nodes, edges, treatment, role_node), + oracle_has_directed_path(nodes, edges, role_node, outcome), + ) +end + +@testset "graph identification" begin + fork = book_dag(("X", "Y", "Z"), (("Z", "X"), ("Z", "Y"))) + @test !d_separation(fork, ("X",), ("Y",)).separated + @test d_separation(fork, ("X",), ("Y",), ("Z",)).separated + @test minimal_backdoor_adjustment_sets(fork, "X", "Y") == (("Z",),) + + collider = book_dag(("X", "Y", "Z"), (("X", "Z"), ("Y", "Z"))) + @test d_separation(collider, ("X",), ("Y",)).separated + @test !d_separation(collider, ("X",), ("Y",), ("Z",)).separated + + chain = book_dag(("X", "Y", "Z"), (("X", "Z"), ("Z", "Y"))) + @test !d_separation(chain, ("X",), ("Y",)).separated + @test d_separation(chain, ("X",), ("Y",), ("Z",)).separated + @test causal_role_evidence(chain, "X", "Y", "Z").mediator_on_directed_paths == + (("X", "Z", "Y"),) + + frontdoor = book_dag( + ("M", "U", "X", "Y"), + (("U", "X"), ("U", "Y"), ("X", "M"), ("M", "Y")); + observed = ("M", "X", "Y"), + ) + @test minimal_backdoor_adjustment_sets(frontdoor, "X", "Y") == () + @test minimal_frontdoor_adjustment_sets(frontdoor, "X", "Y") == (("M",),) + + factor_edges = (("MOM", "HML"), ("MOM", "PC"), ("HML", "OI"), ("OI", "PC")) + observed_factor_graph = book_dag(("HML", "MOM", "OI", "PC"), factor_edges) + @test minimal_backdoor_adjustment_sets(observed_factor_graph, "HML", "PC") == + (("MOM",),) + latent_factor_graph = + book_dag(("HML", "MOM", "OI", "PC"), factor_edges; observed = ("HML", "OI", "PC")) + @test minimal_backdoor_adjustment_sets(latent_factor_graph, "HML", "PC") == () + @test minimal_frontdoor_adjustment_sets(latent_factor_graph, "HML", "PC") == (("OI",),) + + instrument_graph = + book_dag(("U", "W", "X", "Y"), (("W", "X"), ("X", "Y"), ("U", "X"), ("U", "Y"))) + instrument = check_instrument(instrument_graph, "W", "X", "Y") + @test instrument.pearl_graphical + @test instrument.cfi_simple + + mediator = + book_dag(("W", "X", "Y", "Z"), (("X", "Z"), ("W", "Z"), ("Z", "Y"), ("W", "Y"))) + @test minimal_backdoor_adjustment_sets(mediator, "X", "Y") == ((),) + @test check_backdoor_adjustment_set(mediator, "X", "Y", ()).admissible + @test !check_backdoor_adjustment_set(mediator, "X", "Y", ("Z",)).admissible + + @test_throws ArgumentError CausalDAG(("X", "Y"), (("X", "Y"), ("Y", "X")), ("X", "Y")) + @test_throws ArgumentError d_separation(fork, ("X",), ("X",)) +end + +@testset "exhaustive independent four-node graph oracle" begin + nodes, graphs = oracle_four_node_dags() + @test length(graphs) == 543 + d_separation_queries = 0 + backdoor_queries = 0 + mismatches = Any[] + for edges in graphs + dag = book_dag(nodes, edges) + for treatment in nodes, outcome in nodes + treatment == outcome && continue + remaining = Tuple(node for node in nodes if !(node in (treatment, outcome))) + for conditioned in oracle_subsets(remaining) + expected = oracle_d_separated(nodes, edges, treatment, outcome, conditioned) + actual = d_separation(dag, (treatment,), (outcome,), conditioned).separated + d_separation_queries += 1 + if expected != actual && length(mismatches) < 10 + push!( + mismatches, + ( + :d_separation, + edges, + treatment, + outcome, + conditioned, + expected, + actual, + ), + ) + end + end + expected_sets = oracle_minimal_backdoor_sets(nodes, edges, treatment, outcome) + actual_sets = minimal_backdoor_adjustment_sets(dag, treatment, outcome) + backdoor_queries += 1 + if expected_sets != actual_sets && length(mismatches) < 10 + push!( + mismatches, + (:backdoor, edges, treatment, outcome, expected_sets, actual_sets), + ) + end + end + end + @test d_separation_queries == 26_064 + @test backdoor_queries == 6_516 + @test isempty(mismatches) +end + +@testset "exhaustive independent front-door and instrument oracles" begin + nodes, graphs = oracle_four_node_dags() + frontdoor_queries = 0 + instrument_queries = 0 + mismatches = Any[] + for edges in graphs + dag = book_dag(nodes, edges) + for treatment in nodes, outcome in nodes + treatment == outcome && continue + expected_frontdoor = + oracle_minimal_frontdoor_sets(nodes, edges, treatment, outcome) + actual_frontdoor = minimal_frontdoor_adjustment_sets(dag, treatment, outcome) + frontdoor_queries += 1 + if expected_frontdoor != actual_frontdoor && length(mismatches) < 10 + push!( + mismatches, + ( + :frontdoor, + edges, + treatment, + outcome, + expected_frontdoor, + actual_frontdoor, + ), + ) + end + remaining = Tuple(node for node in nodes if !(node in (treatment, outcome))) + for instrument in remaining + controls = Tuple(node for node in remaining if node != instrument) + for conditioned in oracle_subsets(controls) + expected = oracle_instrument_profile( + nodes, + edges, + instrument, + treatment, + outcome, + conditioned, + ) + actual = + check_instrument(dag, instrument, treatment, outcome, conditioned) + observed = ( + actual.all_observed, + actual.conditioning_disjoint, + actual.conditioning_unaffected_by_treatment, + actual.pearl_relevance, + actual.pearl_exclusion_exogeneity, + actual.pearl_graphical, + actual.cfi_direct_relevance, + actual.cfi_full_mediation, + actual.cfi_exogeneity, + actual.cfi_simple, + ) + instrument_queries += 1 + if expected != observed && length(mismatches) < 10 + push!( + mismatches, + ( + :instrument, + edges, + instrument, + treatment, + outcome, + conditioned, + expected, + observed, + ), + ) + end + end + end + end + end + @test frontdoor_queries == 6_516 + @test instrument_queries == 26_064 + @test isempty(mismatches) +end + +@testset "exhaustive independent path-relative role oracle" begin + nodes, graphs = oracle_four_node_dags() + queries = 0 + mismatches = Any[] + for edges in graphs + dag = book_dag(nodes, edges) + for treatment in nodes, outcome in nodes + treatment == outcome && continue + for role_node in nodes + role_node in (treatment, outcome) && continue + expected = oracle_role_profile(nodes, edges, treatment, outcome, role_node) + actual = causal_role_evidence(dag, treatment, outcome, role_node) + observed = ( + actual.collider_on_paths, + actual.noncollider_on_paths, + actual.mediator_on_directed_paths, + actual.backdoor_noncollider_on_paths, + actual.common_cause_paths, + actual.descendant_of_treatment, + actual.ancestor_of_outcome, + ) + queries += 1 + if expected != observed && length(mismatches) < 10 + push!( + mismatches, + (edges, treatment, outcome, role_node, expected, observed), + ) + end + end + end + end + @test queries == 13_032 + @test isempty(mismatches) +end diff --git a/test/causal_factor_analysis/test_optimizer.jl b/test/causal_factor_analysis/test_optimizer.jl new file mode 100644 index 0000000..2c29901 --- /dev/null +++ b/test/causal_factor_analysis/test_optimizer.jl @@ -0,0 +1,107 @@ +@testset "minimum variance factor weights" begin + covariance = Diagonal([1.0, 2.0, 4.0]) |> Matrix + exposures = [1.0 0.0; 0.0 1.0; 1.0 1.0] + targets = [0.0, 1.0] + weights = minimum_variance_factor_weights(covariance, exposures, targets) + @test weights ≈ [-2.0 / 7.0, 5.0 / 7.0, 2.0 / 7.0] atol = 1.0e-12 + @test transpose(exposures) * weights ≈ targets atol = 1.0e-12 + + @test_throws ArgumentError minimum_variance_factor_weights( + [1.0 0.0; 0.0 0.0], + reshape([1.0, 0.0], 2, 1), + [1.0], + ) + @test_throws ArgumentError minimum_variance_factor_weights( + Matrix{Float64}(I, 2, 2), + [1.0 1.0; 1.0 1.0], + [1.0, 0.0], + ) + + square_exposures = [1.0 1.0; 1.5 1.0] + square_targets = [0.0, 1.0] + expected_square = transpose(square_exposures) \ square_targets + for square_covariance in ([1.0 0.2; 0.2 2.0], [5.0 -1.0; -1.0 0.5]) + @test minimum_variance_factor_weights( + square_covariance, + square_exposures, + square_targets, + ) ≈ expected_square atol = 1.0e-13 + end + + @test minimum_variance_factor_weights( + [2.0 0.5; 0.5 1.0], + reshape([1.0, 1.0], 2, 1), + [1.0], + ) ≈ [0.25, 0.75] atol = 1.0e-14 + @test minimum_variance_factor_weights( + reshape([2.5], 1, 1), + reshape([-4.0], 1, 1), + [3.0], + ) == [-0.75] + + near_collinear = [1.0 1.0; 1.0 1.0 + 1.0e-9] + near_targets = [1.0, 0.0] + near_weights = minimum_variance_factor_weights( + Matrix{Float64}(I, 2, 2), + near_collinear, + near_targets, + ) + @test transpose(near_collinear) * near_weights ≈ near_targets atol = 1.0e-12 + + @test minimum_variance_factor_weights( + reshape([1.0], 1, 1), + reshape([1.0e308], 1, 1), + [1.0], + ) ≈ [1.0e-308] rtol = 1.0e-15 + @test minimum_variance_factor_weights( + Matrix{Float64}(I, 2, 2), + Diagonal([1.0e-200, 1.0e200]) |> Matrix, + [1.0e-200, 1.0e200], + ) == [1.0, 1.0] + @test minimum_variance_factor_weights( + Matrix{Float64}(I, 2, 2), + reshape([1.0e-308, 1.0e-308], 2, 1), + [2.0], + ) ≈ [1.0e308, 1.0e308] rtol = 1.0e-15 + + tiny = nextfloat(0.0) + @test_throws ArgumentError minimum_variance_factor_weights( + reshape([1.0], 1, 1), + reshape([1.0e308], 1, 1), + [tiny], + ) + @test minimum_variance_factor_weights( + reshape([tiny], 1, 1), + reshape([1.0], 1, 1), + [1.0], + ) == [1.0] + + zero_weights = minimum_variance_factor_weights( + Diagonal([1.0, 2.0, 3.0]) |> Matrix, + [1.0 0.0; 0.0 1.0; 1.0 1.0], + [0.0, 0.0], + ) + @test zero_weights == zeros(3) + + baseline_covariance = [2.0 0.25 0.1; 0.25 1.5 -0.2; 0.1 -0.2 1.0] + baseline_exposures = [1.0 0.0; 0.0 1.0; 1.0 1.0] + baseline_targets = [0.2, -0.4] + baseline_weights = minimum_variance_factor_weights( + baseline_covariance, + baseline_exposures, + baseline_targets, + ) + for scale in (1.0e-12, 1.0, 1.0e12) + @test minimum_variance_factor_weights( + scale .* baseline_covariance, + baseline_exposures, + baseline_targets, + ) ≈ baseline_weights rtol = 1.0e-12 atol = 1.0e-12 + end + + @test_throws ArgumentError minimum_variance_factor_weights( + [2.0 0.30001; 0.29999 1.0], + ones(2, 1), + ones(1), + ) +end diff --git a/test/causal_factor_analysis/test_protocol.jl b/test/causal_factor_analysis/test_protocol.jl new file mode 100644 index 0000000..e3db1dc --- /dev/null +++ b/test/causal_factor_analysis/test_protocol.jl @@ -0,0 +1,284 @@ +function protocol_horizons() + return ( + EventHorizon(observation_id = "o1", start = 0.0, stop = 2.0), + EventHorizon(observation_id = "o2", start = 1.0, stop = 3.0), + EventHorizon(observation_id = "o3", start = 4.0, stop = 5.0), + EventHorizon(observation_id = "o4", start = 6.0, stop = 7.0), + EventHorizon(observation_id = "o5", start = 8.0, stop = 9.0), + EventHorizon(observation_id = "o6", start = 10.0, stop = 11.0), + ) +end + +function protocol_fold(fold_id, path_id, train_ids, test_ids, refit_id) + return FoldEvidence( + fold_id = fold_id, + path_id = path_id, + train_ids = train_ids, + test_ids = test_ids, + fit_ids = train_ids, + prediction_ids = test_ids, + refit_id = refit_id, + refit_components = ( + "VARIABLE_SELECTION", + "CAUSAL_ESTIMATION", + "PORTFOLIO_CONSTRUCTION", + ), + ) +end + +function protocol_validation(; method_labels = ("PURGED_CROSS_VALIDATION",)) + return ValidationEvidence( + method_labels = method_labels, + event_horizons = protocol_horizons(), + folds = ( + protocol_fold("f1", "p1", ("o3", "o4", "o5", "o6"), ("o1", "o2"), "r1"), + protocol_fold("f2", "p1", ("o1", "o2", "o5", "o6"), ("o3", "o4"), "r2"), + protocol_fold("f3", "p1", ("o1", "o2", "o3", "o4"), ("o5", "o6"), "r3"), + ), + embargo = 1.0, + ) +end + +function protocol_cpcv_validation() + return ValidationEvidence( + method_labels = ("COMBINATORIAL_PURGED_CROSS_VALIDATION",), + event_horizons = protocol_horizons(), + folds = ( + protocol_fold("p1-f1", "p1", ("o3", "o4", "o5", "o6"), ("o1", "o2"), "p1-r1"), + protocol_fold("p1-f2", "p1", ("o1", "o2", "o5", "o6"), ("o3", "o4"), "p1-r2"), + protocol_fold("p1-f3", "p1", ("o1", "o2", "o3", "o4"), ("o5", "o6"), "p1-r3"), + protocol_fold("p2-f1", "p2", ("o4", "o5", "o6"), ("o1", "o3"), "p2-r1"), + protocol_fold("p2-f2", "p2", ("o3", "o4", "o6"), ("o2", "o5"), "p2-r2"), + protocol_fold("p2-f3", "p2", ("o1", "o2", "o3"), ("o4", "o6"), "p2-r3"), + ), + embargo = 1.0, + ) +end + +function protocol_report(; + variable_selection = nothing, + causal_discovery = nothing, + causal_adjustment_set = nothing, + causal_explanatory_and_predictive_power = nothing, + causal_portfolio_construction = nothing, + backtest = nothing, + multiple_testing_adjustments = nothing, +) + trials = ("strategy-a", "strategy-b", "strategy-c") + family = "preregistered-family" + selection = + variable_selection === nothing ? + VariableSelectionStage( + purpose = "RISK_PREMIA_HARVESTING", + selected_variables = ("X", "Z", "I", "M", "C", "D"), + method_labels = ( + "MUTUAL_INFORMATION", + "SHAPLEY_VALUES", + "MEAN_DECREASE_IMPURITY", + "PERMUTATION_FEATURE_IMPORTANCE", + ), + overlapping_returns = true, + strong_time_dependence = true, + validation = protocol_validation(), + ) : variable_selection + discovery = + causal_discovery === nothing ? + CausalDiscoveryStage( + method_labels = ( + "PC", + "ECONOMIC_REASONING", + "DOMAIN_EXPERTISE", + "OBSERVED_OUTCOMES", + ), + graph_id = "resolved-dag", + graph_nodes = ("X", "Y", "Z", "I", "M", "C", "D"), + directed_edges = ( + ("Z", "X"), + ("Z", "Y"), + ("I", "X"), + ("X", "M"), + ("M", "Y"), + ("X", "Y"), + ("X", "C"), + ("Y", "C"), + ("X", "D"), + ), + graph_kind = "DAG", + ambiguous_edges = (), + assumptions = ( + "Recorded variables are sufficient for the target effect.", + "Directions use temporal and economic restrictions.", + ), + ) : causal_discovery + adjustment = + causal_adjustment_set === nothing ? + CausalAdjustmentSetStage( + treatment = "X", + outcome = "Y", + method_label = "BACKDOOR_ADJUSTMENT", + identified = true, + admissible_adjustment_sets = (("Z",),), + selected_adjustment_set = ("Z",), + confounders = ("Z",), + descendants = ("M", "C", "D"), + mediators = ("M",), + colliders = ("C",), + instruments = ("I",), + control_justifications = (("Z", "Common cause of factor and return."),), + open_backdoor_paths = (), + ) : causal_adjustment_set + performance = + causal_explanatory_and_predictive_power === nothing ? + CausalExplanatoryAndPredictivePowerStage( + task_types = ("RETURN_SIZE",), + estimator_label = "fold-refit causal regression", + explanatory_metric_labels = ("R_SQUARED",), + predictive_metric_labels = ("MEAN_SQUARED_ERROR", "SPEARMAN_CORRELATION"), + explanatory_evidence_id = "explanatory-evidence", + predictive_evidence_id = "predictive-evidence", + naive_benchmark_id = "training-mean-benchmark", + validation = protocol_validation(), + ) : causal_explanatory_and_predictive_power + portfolio = + causal_portfolio_construction === nothing ? + CausalPortfolioConstructionStage( + method_labels = ( + "POSITION_SIZING", + "EXPOSURE_CONTROL", + "ECONOMIC_RATIONALE", + "FRAGILITY_STRESS_TEST", + "TRANSACTION_COST_OPTIMIZATION", + "TRANSFER_COEFFICIENT", + ), + causal_exposures = ("X",), + controlled_unintended_exposures = ("C", "D"), + cost_model_id = "cost-model-v1", + constraint_set_id = "constraints-v1", + economic_rationale = "Positions target the identified effect.", + fragility_scenarios = ("weaken X-to-Y", "perturb Z-to-X"), + transfer_coefficient = 0.82, + ) : causal_portfolio_construction + backtest_stage = + backtest === nothing ? + BacktestStage( + method_labels = ("COMBINATORIAL_PURGED_CROSS_VALIDATION",), + trial_family_id = family, + declared_trial_ids = trials, + validation = protocol_cpcv_validation(), + ) : backtest + corrections = + multiple_testing_adjustments === nothing ? + MultipleTestingAdjustmentsStage( + method_labels = ("HOLM", "DEFLATED_SHARPE_RATIO"), + trial_family_id = family, + declared_trial_ids = trials, + family_partitions = ( + ("primary", ("strategy-a", "strategy-b")), + ("robustness", ("strategy-c",)), + ), + alpha = 0.05, + backtests_independent = false, + p_value_estimator = "dependence-adjusted-estimator", + time_dependence_model = "purged temporal folds", + p_value_inputs_assume_independence = false, + sharpe_variance = 0.04, + effective_trials = 2.0, + sample_length = 252, + skewness = 0.1, + kurtosis = 3.2, + selection_bias_evidence_id = "selection-bias-evidence", + ) : multiple_testing_adjustments + return CausalFactorProtocolReport( + trial_family_id = family, + declared_trial_ids = trials, + variable_selection = selection, + causal_discovery = discovery, + causal_adjustment_set = adjustment, + causal_explanatory_and_predictive_power = performance, + causal_portfolio_construction = portfolio, + backtest = backtest_stage, + multiple_testing_adjustments = corrections, + ) +end + +@testset "causal-factor protocol" begin + report = protocol_report() + @test validate_causal_factor_protocol(report) === report + @test report.stage_names == ( + "Variable Selection", + "Causal Discovery", + "Causal Adjustment Set", + "Causal Explanatory and Predictive Power", + "Causal Portfolio Construction", + "Backtest", + "Multiple Testing Adjustments", + ) + @test first.(report.stages) == report.stage_names + @test report.source_oracle == "PROTOCOL-01" + @test_throws Exception setfield!(report, :trial_family_id, "changed") + + overlap_mismatch = VariableSelectionStage( + purpose = report.variable_selection.purpose, + selected_variables = report.variable_selection.selected_variables, + method_labels = report.variable_selection.method_labels, + overlapping_returns = false, + strong_time_dependence = true, + validation = report.variable_selection.validation, + ) + @test_throws ArgumentError validate_causal_factor_protocol( + protocol_report(variable_selection = overlap_mismatch), + ) + + cyclic = CausalDiscoveryStage( + method_labels = report.causal_discovery.method_labels, + graph_id = "cyclic", + graph_nodes = report.causal_discovery.graph_nodes, + directed_edges = (report.causal_discovery.directed_edges..., ("Y", "X")), + graph_kind = "DAG", + ambiguous_edges = (), + assumptions = report.causal_discovery.assumptions, + ) + @test_throws ArgumentError validate_causal_factor_protocol( + protocol_report(causal_discovery = cyclic), + ) + + invalid_adjustment = CausalAdjustmentSetStage( + treatment = "X", + outcome = "Y", + method_label = "BACKDOOR_ADJUSTMENT", + identified = true, + admissible_adjustment_sets = (("C",),), + selected_adjustment_set = ("C",), + confounders = ("Z",), + descendants = ("M", "C", "D"), + mediators = ("M",), + colliders = ("C",), + instruments = ("I",), + control_justifications = (("C", "Invalid collider control."),), + open_backdoor_paths = (), + ) + @test_throws ArgumentError validate_causal_factor_protocol( + protocol_report(causal_adjustment_set = invalid_adjustment), + ) + + invalid_multiple_testing = MultipleTestingAdjustmentsStage( + method_labels = ("HOLM", "DEFLATED_SHARPE_RATIO"), + trial_family_id = report.trial_family_id, + declared_trial_ids = report.declared_trial_ids, + family_partitions = (("primary", report.declared_trial_ids),), + alpha = 1.0, + backtests_independent = true, + p_value_estimator = "estimator", + time_dependence_model = "model", + p_value_inputs_assume_independence = true, + sharpe_variance = 0.0, + effective_trials = 3.0, + sample_length = 1, + skewness = 2.0, + kurtosis = 1.0, + selection_bias_evidence_id = nothing, + ) + @test_throws ArgumentError validate_causal_factor_protocol( + protocol_report(multiple_testing_adjustments = invalid_multiple_testing), + ) +end diff --git a/test/causal_factor_analysis/test_specification_diagnostics.jl b/test/causal_factor_analysis/test_specification_diagnostics.jl new file mode 100644 index 0000000..56f9ac7 --- /dev/null +++ b/test/causal_factor_analysis/test_specification_diagnostics.jl @@ -0,0 +1,38 @@ +@testset "specification diagnostics" begin + fork = fork_population_diagnostics() + @test fork.unconditioned.coefficients == (0.0, 0.5) + @test fork.conditioned.coefficients == (0.0, 0.0, 1.0) + @test fork.unconditioned.r_squared == 0.25 + @test fork.conditioned.r_squared == 0.5 + + collider = collider_population_diagnostics() + @test collider.unconditioned.coefficients == (0.0, 0.0) + @test collider.conditioned.coefficients == (0.0, -0.5, 0.5) + @test collider.conditioned.r_squared == 0.5 + + mediator = confounded_mediator_population_diagnostics() + @test mediator.unconditioned.coefficients == (0.0, 1.0) + @test mediator.conditioned.coefficients == (0.0, -0.5, 1.5) + @test mediator.unconditioned.r_squared == 1.0 / 7.0 + @test mediator.conditioned.r_squared == 11.0 / 14.0 + + fork_sample = fork_specification_experiment() + @test fork_sample.unconditioned.coefficients[2] ≈ 0.5 atol = 0.06 + @test fork_sample.conditioned.coefficients[2] ≈ 0.0 atol = 0.06 + @test fork_sample.conditioned.coefficients[3] ≈ 1.0 atol = 0.06 + + collider_sample = collider_specification_experiment() + @test collider_sample.unconditioned.coefficients[2] ≈ 0.0 atol = 0.06 + @test collider_sample.conditioned.coefficients[2] ≈ -0.5 atol = 0.06 + @test collider_sample.conditioned.coefficients[3] ≈ 0.5 atol = 0.06 + + mediator_sample = confounded_mediator_specification_experiment() + @test mediator_sample.unconditioned.coefficients[2] ≈ 1.0 atol = 0.08 + @test mediator_sample.conditioned.coefficients[2] ≈ -0.5 atol = 0.08 + @test mediator_sample.conditioned.coefficients[3] ≈ 1.5 atol = 0.08 + + @test fork_specification_experiment(200, 17) == fork_specification_experiment(200, 17) + @test fork_specification_experiment(200, 17) != fork_specification_experiment(200, 18) + @test_throws ArgumentError fork_specification_experiment(3, 0) + @test_throws ArgumentError fork_specification_experiment(10, -1) +end diff --git a/test/causal_factor_analysis/test_treatment_effects.jl b/test/causal_factor_analysis/test_treatment_effects.jl new file mode 100644 index 0000000..103158a --- /dev/null +++ b/test/causal_factor_analysis/test_treatment_effects.jl @@ -0,0 +1,56 @@ +@testset "treatment effects" begin + @test average_treatment_effect(7.25, 2.0) == 5.25 + @test average_treatment_effect(-2, -5) == 3.0 + + decomposition = treatment_effect_decomposition(10.0, 4.0, 6.0) + @test decomposition.observed_difference == 6.0 + @test decomposition.average_treatment_effect_on_treated == 4.0 + @test decomposition.sample_selection_bias == 2.0 + + @test randomized_mean_difference([4.0, 6.0, 8.0], [1.0, 3.0, 5.0]) == 3.0 + did = difference_in_differences(10.0, 15.0, 20.0, 22.0) + @test did.treated_change == 5.0 + @test did.control_change == 2.0 + @test did.estimate == 3.0 + + @test backdoor_adjusted_expectation((0.1, 0.8), (0.75, 0.25)) ≈ 0.275 + @test backdoor_adjusted_average_treatment_effect((0.4, 0.9), (0.1, 0.2), (0.75, 0.25)) ≈ + 0.4 + @test frontdoor_adjusted_probability( + (0.25, 0.75), + ((0.1, 0.5), (0.4, 0.8)), + (0.6, 0.4), + ) ≈ 0.485 + @test linear_instrumental_variable_effect(0.9, 0.3) ≈ 3.0 + @test linear_instrumental_variable_effect(-2.0, 0.5) == -4.0 + + @test_throws ArgumentError average_treatment_effect(true, 0.0) + @test_throws ArgumentError randomized_mean_difference((), (1.0,)) + @test_throws ArgumentError backdoor_adjusted_expectation((1.0,), (0.4, 0.4)) + @test_throws ArgumentError frontdoor_adjusted_probability((1.0,), ((1.2,),), (1.0,)) + @test_throws ArgumentError linear_instrumental_variable_effect(1.0, 0.0) + @test_throws ArgumentError average_treatment_effect( + floatmax(Float64), + -floatmax(Float64), + ) + @test_throws ArgumentError treatment_effect_decomposition( + floatmax(Float64), + -floatmax(Float64), + 0.0, + ) + @test_throws ArgumentError difference_in_differences( + -floatmax(Float64), + floatmax(Float64), + 0.0, + 0.0, + ) + @test_throws ArgumentError backdoor_adjusted_average_treatment_effect( + (floatmax(Float64),), + (-floatmax(Float64),), + (1.0,), + ) + + @test backdoor_adjusted_expectation((2.0, 4.0), (0.5, 0.5 + 5.0e-13)) ≈ 3.0 + 2.0e-12 atol = + 1.0e-14 + @test_throws ArgumentError backdoor_adjusted_expectation((2.0, 4.0), (0.5, 0.50001)) +end diff --git a/test/preserved_runtests.jl b/test/preserved_runtests.jl new file mode 100644 index 0000000..5b7406a --- /dev/null +++ b/test/preserved_runtests.jl @@ -0,0 +1,1936 @@ +using Test +using Dates +using DataFrames +using LinearAlgebra +using Random +using Statistics: mean, cov + +const RISKLABAI_TEST_DEEP_BSDE = get(ENV, "RISKLABAI_TEST_DEEP_BSDE", "0") == "1" + +if RISKLABAI_TEST_DEEP_BSDE + using Lux + using Optimisers + using Zygote +end + +using RiskLabAI + +@testset "RiskLabAI smoke tests" begin + # The package loads and the exported symbols actually exist + # (the old exports referenced undefined, wrongly-cased names). + @test isdefined(RiskLabAI, :probabilityOfBacktestOverfitting) + @test isdefined(RiskLabAI, :generateSignal) + + # discreteSignal: rounds to step size and caps at +/- 1 + s = RiskLabAI.discreteSignal([0.26, 0.94, -1.4], 0.1) + @test s ≈ [0.3, 0.9, -1.0] + + # selectRows: contiguous row blocks for the chosen partitions + @test RiskLabAI.selectRows([1, 3], 2) == [1, 2, 5, 6] +end + +@testset "Utils" begin + # ewma matches the Python RiskLabAI.utils.ewma exactly (same denominator + # 1 + (1-a) + (1-a)^2 + ...). Values computed from both implementations. + result = RiskLabAI.ewma([1.0, 2.0, 3.0, 4.0, 5.0], 3) + expected = [1.0, 1.6666666667, 2.4285714286, 3.2666666667, 4.1612903226] + @test result ≈ expected atol = 1e-9 + + # First element is always the seed value; output length matches input. + @test result[1] == 1.0 + @test length(result) == 5 + + # Constants are defined, with ASCII identifiers. + @test RiskLabAI.Utils.CUMULATIVE_DOLLAR == "Cumulative Dollar Value" + @test isascii(string(:CUMULATIVE_THETA)) + + # The struct-field-inheritance macro is available. + @test isdefined(RiskLabAI.Utils, Symbol("@field_inherit")) +end + +@testset "Data.Structures — standard bars (parity with Python)" begin + # Same fixture as the Python test_standard_bars.py. + # Bar row layout: [date_time, idx, open, high, low, close, volume, + # buy_vol, sell_vol, ticks, dollar, threshold] (1-indexed) + ticks = DataFrame( + date_time = DateTime.([ + "2020-01-01T10:00:00", + "2020-01-01T10:00:01", + "2020-01-01T10:00:02", + "2020-01-01T10:00:03", + "2020-01-01T10:00:04", + "2020-01-01T10:00:05", + "2020-01-01T10:00:06", + ]), + price = [100, 101, 100, 101, 102, 103, 102], + volume = [10, 5, 20, 10, 10, 10, 5], + ) + + # Tick bars, threshold 3 + tb = + RiskLabAI.Data.StandardBars{RiskLabAI.Data.Tick}(bar_type = "tick", threshold = 3.0) + bars = RiskLabAI.Data.construct_bars_from_data(tb; data = ticks) + @test length(bars) == 2 + @test bars[1][1] == DateTime("2020-01-01T10:00:02") + @test bars[1][3] == 100.0 # open + @test bars[1][4] == 101.0 # high + @test bars[1][5] == 100.0 # low + @test bars[1][6] == 100.0 # close + @test bars[1][10] == 3.0 # ticks + @test bars[2][1] == DateTime("2020-01-01T10:00:05") + @test bars[2][3] == 101.0 + @test bars[2][4] == 103.0 + @test bars[2][5] == 101.0 + @test bars[2][6] == 103.0 + @test bars[2][10] == 3.0 + + # Volume bars, threshold 35 + vb = RiskLabAI.Data.StandardBars{RiskLabAI.Data.Volume}( + bar_type = "volume", + threshold = 35.0, + ) + vbars = RiskLabAI.Data.construct_bars_from_data(vb; data = ticks) + @test length(vbars) == 2 + @test vbars[1][7] == 35.0 + @test vbars[2][7] == 35.0 + + # Dollar bars, threshold 3500 + db = RiskLabAI.Data.StandardBars{RiskLabAI.Data.Dollar}( + bar_type = "dollar", + threshold = 3500.0, + ) + dbars = RiskLabAI.Data.construct_bars_from_data(db; data = ticks) + @test length(dbars) == 2 + @test dbars[1][11] == 3505.0 + @test dbars[2][11] == 3570.0 +end + +@testset "Data.Structures — time bars (parity with Python)" begin + # Same fixture as the Python test_time_bars.py (1-second bars). + ticks = DataFrame( + date_time = DateTime.([ + "2020-01-01T10:00:00.100", + "2020-01-01T10:00:00.500", + "2020-01-01T10:00:01.200", + "2020-01-01T10:00:01.800", + "2020-01-01T10:00:02.100", + "2020-01-01T10:00:02.500", + ]), + price = [100, 101, 100, 101, 102, 103], + volume = [10, 5, 20, 10, 10, 10], + ) + + tb = RiskLabAI.Data.TimeBars(resolution_type = "S", resolution_units = 1) + bars = RiskLabAI.Data.construct_bars_from_data(tb; data = ticks) + + @test length(bars) == 2 + @test bars[1][1] == DateTime("2020-01-01T10:00:01") # end time = bucket boundary + @test bars[1][3] == 100.0 # open + @test bars[1][4] == 101.0 # high + @test bars[1][5] == 100.0 # low + @test bars[1][6] == 101.0 # close (previous tick) + @test bars[1][10] == 2.0 # ticks + @test bars[2][1] == DateTime("2020-01-01T10:00:02") + @test bars[2][3] == 100.0 + @test bars[2][4] == 101.0 + @test bars[2][5] == 100.0 + @test bars[2][6] == 101.0 + @test bars[2][10] == 2.0 +end + +@testset "Data.Structures — imbalance bars (parity with Python)" begin + # Same fixture as Python test_imbalance_bars.py. + # Prices 100..103..100 -> tick imbalances [0,1,1,1,-1,-1,-1]. + ticks = DataFrame( + date_time = DateTime.([ + "2020-01-01T10:00:00", + "2020-01-01T10:00:01", + "2020-01-01T10:00:02", + "2020-01-01T10:00:03", + "2020-01-01T10:00:04", + "2020-01-01T10:00:05", + "2020-01-01T10:00:06", + ]), + price = [100, 101, 102, 103, 102, 101, 100], + volume = [10, 10, 10, 10, 10, 10, 10], + ) + + # Fixed E[T] = 2 -> 2 bars with 3 and 4 ticks. + fb = RiskLabAI.Data.FixedImbalanceBars{RiskLabAI.Data.Tick}( + bar_type = "tick_imbalance", + expected_imbalance_window = 10, + initial_estimate_of_expected_n_ticks_in_bar = 2.0, + ) + fbars = RiskLabAI.Data.construct_bars_from_data(fb; data = ticks) + @test length(fbars) == 2 + @test fbars[1][10] == 3.0 # ticks in bar 1 + @test fbars[2][10] == 4.0 # ticks in bar 2 + + # Expected E[T] (EWMA, init 2, window 10) -> 1 bar with 3 ticks. + eb = RiskLabAI.Data.ExpectedImbalanceBars{RiskLabAI.Data.Tick}( + bar_type = "tick_imbalance", + window_size_for_expected_n_ticks_estimation = 10, + expected_imbalance_window = 10, + initial_estimate_of_expected_n_ticks_in_bar = 2.0, + ) + ebars = RiskLabAI.Data.construct_bars_from_data(eb; data = ticks) + @test length(ebars) == 1 + @test ebars[1][10] == 3.0 +end + +@testset "Data.Structures — run bars (parity with Python)" begin + # Same fixture as Python test_run_bars.py. + # Prices 100..103..100 -> buy run then sell run. The threshold stays Inf + # until both buy- and sell-imbalance EWMAs warm up (>= E[T] obs each), so + # the first (and only) bar forms at the last tick with all 7 ticks. + ticks = DataFrame( + date_time = DateTime.([ + "2020-01-01T10:00:00", + "2020-01-01T10:00:01", + "2020-01-01T10:00:02", + "2020-01-01T10:00:03", + "2020-01-01T10:00:04", + "2020-01-01T10:00:05", + "2020-01-01T10:00:06", + ]), + price = [100, 101, 102, 103, 102, 101, 100], + volume = [10, 10, 10, 10, 10, 10, 10], + ) + + # Fixed E[T] = 3 -> 1 bar with 7 ticks. + fb = RiskLabAI.Data.FixedRunBars{RiskLabAI.Data.Tick}( + bar_type = "tick_run", + expected_imbalance_window = 10, + initial_estimate_of_expected_n_ticks_in_bar = 3.0, + ) + fbars = RiskLabAI.Data.construct_bars_from_data(fb; data = ticks) + @test length(fbars) == 1 + @test fbars[1][10] == 7.0 + + # Expected E[T] (init 3) -> same single 7-tick bar on this fixture. + eb = RiskLabAI.Data.ExpectedRunBars{RiskLabAI.Data.Tick}( + bar_type = "tick_run", + window_size_for_expected_n_ticks_estimation = 10, + expected_imbalance_window = 10, + initial_estimate_of_expected_n_ticks_in_bar = 3.0, + ) + ebars = RiskLabAI.Data.construct_bars_from_data(eb; data = ticks) + @test length(ebars) == 1 + @test ebars[1][10] == 7.0 +end + +@testset "Data.Differentiation — fractional differentiation (parity with Python)" begin + D = RiskLabAI.Data + x = [100.0, 101.5, 100.8, 102.3, 101.1, 103.0, 102.5, 104.1, 103.7, 105.0] + + # Weights (reversed, w0 last): exact match to Python calculate_weights_*. + @test D.calculate_weights_std(0.5, 6) ≈ + [-0.02734375, -0.0390625, -0.0625, -0.125, -0.5, 1.0] + @test D.calculate_weights_ffd(0.4, 0.1) ≈ [-0.12, -0.4, 1.0] + + # Fixed-width FFD: width 3 -> first 2 entries NaN, then 8 values. + ffd = D.fractional_difference_fixed(x, 0.4; threshold = 0.1) + @test length(ffd) == 10 + @test all(isnan, ffd[1:2]) + @test ffd[3:end] ≈ [48.2, 49.8, 48.084, 50.284, 49.168, 50.74, 49.76, 51.028] + + # Standard (expanding-window): skip 1 -> first entry NaN, then 9 values. + fds = D.fractional_difference_std(x, 0.5; threshold = 0.01) + @test length(fds) == 10 + @test isnan(fds[1]) + @test fds[2:end] ≈ [ + 51.5, + 37.55, + 32.9625, + 27.1, + 26.66328125, + 23.205078125, + 23.2110351562, + 20.6416259766, + 20.8013458252, + ] + + # ADF-based finders (behavioural: ADF impl differs from statsmodels). + prices = 100.0 .+ 10.0 .* sin.(0.1 .* (1:200)) + res = D.find_optimal_ffd(prices) + @test length(res.d) == 11 + @test all(0.0 .<= res.d .<= 1.0) + @test length(res.p_value) == length(res.d) + + fd = D.fractionally_differentiated_log_price(prices) + @test length(fd) == 200 + @test any(!isnan, fd) +end + +@testset "Data.Differentiation — Adaptive Fractional Differencing (parity with Python)" begin + D = RiskLabAI.Data + n = 512 + incr = [0.5 * sin(0.05t) + 0.3 * cos(0.013t) + 0.2 * sin(0.2t) for t = 0:(n-1)] + price = 100.0 .+ cumsum(incr) + + # Deterministic anchors (R/S Hurst blend) — exact parity with Python's R/S path. + @test D.rescaled_range_hurst(incr) ≈ 0.945557656342456 atol = 1e-9 + @test D.adaptive_differencing_order(incr) ≈ 0.44555765634245603 atol = 1e-9 + # Wavelet component is an optional dependency (not wired) -> NaN, R/S-only blend. + @test isnan(D.wavelet_variance_hurst(incr)) + + res = D.adaptive_fractional_difference(price) + @test res.d_hat ≈ 0.4458675284494259 atol = 1e-8 + # Order/threshold/p-value are behavioural (ADF impl differs from statsmodels): + # structural checks. + @test 0.0 <= res.order <= 1.2 + @test res.threshold in (1e-3, 1e-4, 1e-5) + @test res.adf_pvalue < 0.05 + @test isfinite(res.memory_retained) + @test count(!isnan, res.series) > 400 +end + +@testset "Data.Weights — sample weighting (parity with Python)" begin + D = RiskLabAI.Data + close_index = collect(1:10) + event_start = [1, 3, 6] + event_end = [4, 7, 9] + molecule = [1, 3, 6] + price = [100.0, 101, 103, 102, 104, 103, 105, 106, 104, 107] + + # Concurrency over the affected span. + exp = D.expand_label_for_meta_labeling(close_index, event_start, event_end, molecule) + @test exp.index == collect(1:9) + @test exp.concurrency ≈ [1.0, 1, 2, 2, 1, 2, 2, 1, 1] + + # Average uniqueness from the indicator matrix (events 1-4, 3-7, 6-9). + index_matrix = zeros(10, 3) + index_matrix[1:4, 1] .= 1 + index_matrix[3:7, 2] .= 1 + index_matrix[6:9, 3] .= 1 + @test D.calculate_average_uniqueness(index_matrix) ≈ [0.75, 0.6, 0.75] + + # Absolute-return sample weights (normalised to N=3; NaN first return skipped). + w = D.sample_weight_absolute_return_meta_labeling( + event_start, + event_end, + close_index, + price, + molecule, + ) + @test w ≈ [0.6362107498, 1.2538702552, 1.1099189949] + @test sum(w) ≈ 3.0 + + # Linear time decay (weights assumed chronological). + @test D.calculate_time_decay([1.0, 1.0, 1.0]; clf_last_weight = 0.5) ≈ + [0.6666666667, 0.8333333333, 1.0] + @test D.calculate_time_decay([1.0, 1.0, 1.0]; clf_last_weight = 1.0) ≈ [1.0, 1.0, 1.0] +end + +@testset "Data.Denoise — RMT denoising (parity with Python)" begin + D = RiskLabAI.Data + cov = [4.0 2.0 0.6; 2.0 3.0 0.5; 0.6 0.5 1.0] + + # cov <-> corr round trip (exact parity). + corr = D.cov_to_corr(cov) + @test corr ≈ [ + 1.0 0.5773502692 0.3 + 0.5773502692 1.0 0.2886751346 + 0.3 0.2886751346 1.0 + ] + @test D.corr_to_cov(corr, sqrt.([4.0, 3.0, 1.0])) ≈ cov + + # PCA eigenvalues, descending (exact parity). + evals, evecs = D.pca(corr) + @test evals ≈ [1.7952446957, 0.7822555891, 0.4224997152] + + # Denoised correlation keeping the top factor (sign-invariant reconstruction). + @test D.denoised_corr(evals, evecs, 1) ≈ [ + 1.0 0.437303437 0.362034825 + 0.437303437 1.0 0.3607926887 + 0.362034825 0.3607926887 1.0 + ] + + # Marcenko–Pastur PDF on a small grid (exact parity). + grid, pdf = D.marcenko_pastur_pdf(0.5, 10; num_points = 5) + @test grid ≈ [0.233772234, 0.391886117, 0.55, 0.708113883, 0.866227766] + @test pdf ≈ [0.0, 2.2244409457, 1.8301531674, 1.2310555486, 0.0] + + # GMV portfolio weights (exact parity). + @test D.optimal_portfolio(cov) ≈ [0.0320924262, 0.1463414634, 0.8215661104] + + # denoise_cov is behavioural (KDE fit differs from sklearn): valid output. + denoised = D.denoise_cov(cov, 10.0) + @test size(denoised) == (3, 3) + @test denoised ≈ denoised' # symmetric + @test diag(denoised) ≈ diag(cov) # variances preserved + @test all(isfinite, denoised) +end + +@testset "Data.Denoise — NERCOME sample-split denoiser (Lam 2016)" begin + # NERCOME is a seeded, sample-splitting estimator: the split permutations use + # Julia's RNG, not NumPy's PCG64, so (like the other path-level modules) the + # estimate is reproducible under a given seed but not bit-identical to Python. + # We assert the documented properties and the admitted mechanism (Appraisal 24: + # lower covariance error than MP clipping on a no-gap / non-stationary spectrum). + D = RiskLabAI.Data + + # Structural properties: shape, symmetry, positive-definiteness, reproducibility. + returns = randn(MersenneTwister(7), 30, 6) + est_a = D.nercome_denoised_covariance(returns; random_state = 11) + est_b = D.nercome_denoised_covariance(returns; random_state = 11) + @test size(est_a) == (6, 6) + @test est_a ≈ est_a' # symmetric + @test isposdef(est_a) # positive-definite + @test est_a == est_b # reproducible under a fixed seed + @test_throws ArgumentError D.nercome_denoised_covariance(randn(3, 4)) # needs >= 4 obs + + # Mechanism (verbatim regime, Appraisal 24): on a slowly-decaying spectrum with + # no clean gap and T ~ p, NERCOME recovers the covariance more accurately than MP + # eigenvalue clipping (and than the raw sample covariance). Fully deterministic + # under the fixed RNG seeds below. + relative_frobenius(estimate, truth) = norm(estimate - truth) / norm(truth) + rng = MersenneTwister(123) + p, t = 12, 30 + eigenvalues = [1.4^(-(i - 1)) for i = 1:p] # no bulk / no clean gap + q_orthogonal = Matrix(qr(randn(rng, p, p)).Q) + sigma = q_orthogonal * Diagonal(eigenvalues) * q_orthogonal' + sigma = (sigma + sigma') / 2 + chol = cholesky(sigma).L + nercome_error = Float64[] + clipping_error = Float64[] + raw_error = Float64[] + for rep = 1:20 + sample = Matrix((chol * randn(rng, p, t))') # t x p returns, true cov sigma + sample_cov = cov(sample) + nercome = D.nercome_denoised_covariance(sample; n_splits = 60, random_state = rep) + clipping = D.denoise_cov(sample_cov, t / p) + push!(nercome_error, relative_frobenius(nercome, sigma)) + push!(clipping_error, relative_frobenius(clipping, sigma)) + push!(raw_error, relative_frobenius(sample_cov, sigma)) + end + @test mean(nercome_error) < mean(clipping_error) # the admitted edge + @test mean(nercome_error) < mean(raw_error) # regularizes the raw sample +end + +@testset "Data.Labeling — CUSUM / barriers / meta-labeling (parity with Python)" begin + D = RiskLabAI.Data + dates = DateTime(2020, 1, 1) .+ Day.(0:19) + close = [ + 100.0, + 102, + 101, + 103, + 105, + 104, + 106, + 103, + 101, + 99, + 100, + 102, + 104, + 103, + 105, + 107, + 106, + 108, + 110, + 109, + ] + + # Symmetric CUSUM events. + events = D.symmetric_cusum_filter(dates, close, 3.0) + @test events == + DateTime.(["2020-01-05", "2020-01-09", "2020-01-13", "2020-01-16", "2020-01-19"]) + + # Daily volatility (pandas debiased EWM std; first value NaN). + vol = D.daily_volatility_with_log_returns(dates, close; span = 5) + @test length(vol.index) == 18 + @test vol.index[1] == DateTime("2020-01-03") + @test isnan(vol.volatility[1]) + @test vol.volatility[2] ≈ 0.000137289 atol = 1e-9 + @test vol.volatility[3] ≈ 0.018224119 atol = 1e-9 + + # Vertical barriers (events past the series end are dropped). + vb = D.vertical_barrier(dates, events, 3) + @test vb.event == DateTime.(["2020-01-05", "2020-01-09", "2020-01-13", "2020-01-16"]) + @test vb.barrier == DateTime.(["2020-01-08", "2020-01-12", "2020-01-16", "2020-01-19"]) + + # Triple-barrier meta-events. + target = Dict(vol.index[i] => vol.volatility[i] for i in eachindex(vol.index)) + vbdict = Dict(vb.event[i] => vb.barrier[i] for i in eachindex(vb.event)) + ev = D.meta_events( + dates, + close, + events, + (1.0, 1.0), + target, + 0.0; + vertical_barriers = vbdict, + ) + @test ev.event_start == events + @test ev.base_width ≈ + [0.018224119, 0.0322455982, 0.034249469, 0.0235399705, 0.0182630643] + @test ev.end_time[1:4] == + DateTime.(["2020-01-08", "2020-01-12", "2020-01-16", "2020-01-19"]) + @test ismissing(ev.end_time[5]) + + # Meta-labeling (event 5 dropped: no barrier touch). + ml = D.meta_labeling(ev, dates, close) + @test ml.ret ≈ [-0.0192313619, 0.0098522964, 0.0284379353, 0.0276515313] + @test ml.label == [-1.0, 1.0, 1.0, 1.0] + + # t-value of an OLS fit (matches scipy linregress slope/stderr). + @test D.calculate_t_value_linear_regression([100.0, 102, 101, 103, 105]) ≈ 3.6666666667 atol = + 1e-9 + + # Trend scanning (behavioural shape check). + ts = D.find_trend_using_trend_scanning([dates[1]], dates, close, (3, 6)) + @test nrow(ts) == 1 + @test ts.end_time[1] == DateTime("2020-01-06") + @test ts.trend[1] in (-1.0, 0.0, 1.0) +end + +@testset "Data.Distance — information-theoretic metrics (parity with Python)" begin + D = RiskLabAI.Data + x = [1.0, 2, 3, 4, 5, 6, 7, 8, 9, 10] + y = [2.0, 1, 4, 3, 6, 5, 8, 7, 10, 9] + + # Optimal bin counts (univariate / bivariate). + @test D.calculate_number_of_bins(10) == 3 + @test D.calculate_number_of_bins(100; correlation = 0.5) == 5 + + # Variation of information (fixed bins). + @test D.calculate_variation_of_information(x, y, 3) ≈ 0.763817002 + @test D.calculate_variation_of_information(x, y, 3; norm = true) ≈ 0.5193177863 + + # Mutual information (optimal bins). + @test D.calculate_mutual_information(x, y) ≈ 1.6094379124 + @test D.calculate_mutual_information(x, y; norm = true) ≈ 1.0 + + # KL divergence and cross-entropy. + p = [0.2, 0.3, 0.5] + q = [0.1, 0.4, 0.5] + @test D.calculate_kullback_leibler_divergence(p, q) ≈ 0.0523248144 + @test D.calculate_cross_entropy(p, q) ≈ 1.0819778284 + + # Angular distance matrices. + dep = [1.0 0.5; 0.5 1.0] + @test D.calculate_distance(dep) ≈ [0.0 0.5; 0.5 0.0] + @test D.calculate_distance(dep; metric = "absolute_angular") ≈ [0.0 0.5; 0.5 0.0] +end + +@testset "Data.Distance — KSG MI & distance correlation (parity with Python)" begin + D = RiskLabAI.Data + # Deterministic closed-form data (identical in both languages); the KSG digamma + # formula depends only on the jitter-invariant integer neighbour counts. + n = 400 + x = [sin(0.3t) + 0.2 * cos(0.07t) for t = 0:(n-1)] + z = [cos(0.11t) for t = 0:(n-1)] + y_lin = 0.8 .* x .+ 0.1 .* z + y_non = x .^ 2 .+ 0.05 .* z + y_ind = z + + @test D.ksg_mutual_information(x, y_lin; k = 4, random_state = 0) ≈ 2.0869197729128883 atol = + 1e-9 + @test D.ksg_mutual_information(x, y_non; k = 4, random_state = 0) ≈ 2.1897736141895248 atol = + 1e-9 + @test D.ksg_mutual_information(x, y_ind; k = 4, random_state = 0) ≈ -0.11395325035808046 atol = + 1e-9 + # Strong dependence -> high MI; near-independence -> near-zero (can be slightly negative). + @test D.ksg_mutual_information(x, y_lin) > D.ksg_mutual_information(x, y_ind) + + @test D.distance_correlation(x, y_lin) ≈ 0.9924531334820932 + @test D.distance_correlation(x, y_non) ≈ 0.4503227910220032 + @test D.distance_correlation(x, y_ind) ≈ 0.03608484508267412 + @test D.distance_correlation(x, x) ≈ 1.0 +end + +@testset "Backtest — statistics (parity with Python)" begin + B = RiskLabAI.Backtest + + # sharpe_ratio uses the population std (numpy ddof=0). + returns = [0.01, -0.02, 0.03, 0.005, -0.01, 0.02, 0.015, -0.005] + @test B.sharpe_ratio(returns) ≈ 0.36291502734548026 + @test B.sharpe_ratio(returns; risk_free_rate = 0.001) ≈ 0.29839680026183946 + @test B.sharpe_ratio([0.5, 0.5, 0.5]) == 0.0 + + # bet_timing: position closes / flips, plus the final timestamp. + idx = DateTime.([ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-04", + "2020-01-05", + "2020-01-06", + ]) + positions = [0.0, 1.0, 1.0, 0.0, -1.0, 0.0] + @test B.bet_timing(idx, positions) == DateTime.(["2020-01-04", "2020-01-06"]) + + # average holding period. + hp = B.calculate_holding_period(idx, positions) + @test nrow(hp.holding_periods) == 2 + @test hp.holding_periods.dT ≈ [2.0, 1.0] + @test hp.holding_periods.w ≈ [1.0, 1.0] + @test hp.mean_holding_period ≈ 1.5 + + # Herfindahl–Hirschman index (can exceed 1 for signed returns). + bet_returns = [0.01, -0.02, 0.03, 0.005, -0.01, 0.02, 0.015, -0.005, 0.04, -0.03] + @test B.calculate_hhi(bet_returns) ≈ 1.6060606060606066 + @test B.calculate_hhi(bet_returns[bet_returns .>= 0]) ≈ 0.07083333333333337 + @test B.calculate_hhi(bet_returns[bet_returns .< 0]) ≈ 0.11637080867850098 + @test isnan(B.calculate_hhi([0.1, 0.2])) # <= 2 observations + + # HHI concentration (positive, negative, monthly counts [4, 4, 2]). + weekly = DateTime.([ + "2020-01-05", + "2020-01-12", + "2020-01-19", + "2020-01-26", + "2020-02-02", + "2020-02-09", + "2020-02-16", + "2020-02-23", + "2020-03-01", + "2020-03-08", + ]) + conc = B.calculate_hhi_concentration(weekly, bet_returns) + @test conc.positive ≈ 0.07083333333333337 + @test conc.negative ≈ 0.11637080867850098 + @test conc.time ≈ 0.04000000000000017 + + # drawdowns and time under water. + pidx = DateTime.([ + "2020-01-01", + "2020-01-02", + "2020-01-03", + "2020-01-04", + "2020-01-05", + "2020-01-06", + "2020-01-07", + "2020-01-08", + ]) + pnl = [100.0, 102.0, 101.0, 103.0, 99.0, 98.0, 105.0, 104.0] + + dd = B.compute_drawdowns_time_under_water(pidx, pnl; dollars = true) + @test dd.start == DateTime.(["2020-01-02", "2020-01-04", "2020-01-07"]) + @test dd.drawdown ≈ [1.0, 5.0, 1.0] + @test dd.time_under_water ≈ [1 / 365.25, 2 / 365.25, 1 / 365.25] + + ddp = B.compute_drawdowns_time_under_water(pidx, pnl; dollars = false) + @test ddp.drawdown ≈ [1 - 101 / 102, 1 - 98 / 103, 1 - 104 / 105] + @test ddp.time_under_water ≈ [1 / 365.25, 2 / 365.25, 1 / 365.25] +end + +@testset "Backtest — CED & Sharpe-difference test (parity with Python)" begin + B = RiskLabAI.Backtest + n = 300 + a = [0.001 + 0.012 * sin(0.07t) + 0.004 * cos(0.3t) for t = 0:(n-1)] + b = [0.013 * cos(0.05t) + 0.003 * sin(0.21t) for t = 0:(n-1)] + r = [0.01 * sin(0.05t) + 0.02 * cos(0.011t) - 0.003 for t = 0:(n-1)] + + # Conditional Expected Drawdown (deterministic; exact parity). + @test B.conditional_expected_drawdown(r, 60, 0.90) ≈ 0.7703735604378519 + @test B.conditional_expected_drawdown(r, 60, 0.95) ≈ 0.7746692629744587 + @test B.conditional_expected_drawdown(r, 30, 0.80) ≈ 0.5173307336300851 + @test_throws ArgumentError B.conditional_expected_drawdown(r, 60, 1.5) + + # Naive Sharpe-difference test (deterministic; exact parity). + nv = B.sharpe_difference_test(a, b; method = "naive") + @test nv.delta ≈ 0.1485861269627159 + @test nv.se ≈ 0.0813523963928328 + @test nv.stat ≈ 1.8264505232916093 + @test nv.pvalue ≈ 0.06778242746585597 + @test nv.reject == false + + # Ledoit–Wolf: delta / se (HAC) / stat are deterministic (exact); the bootstrap + # p-value uses Julia's RNG (behavioural -> structural check). + lw = B.sharpe_difference_test( + a, + b; + method = "ledoit_wolf", + n_boot = 500, + random_state = 0, + ) + @test lw.delta ≈ 0.1485861269627159 + @test lw.se ≈ 0.22337469299514048 + @test lw.stat ≈ 0.6651878284437011 + @test 0.0 <= lw.pvalue <= 1.0 + @test_throws ArgumentError B.sharpe_difference_test(a, b; method = "xyz") +end + +@testset "Backtest — closed-form OU trading rules (parity with Python)" begin + B = RiskLabAI.Backtest + th, sg, eg = 0.1, 0.1, 1.0 + + @test B.theta_from_half_life(10.0) ≈ 0.06931471805599453 + @test B.stationary_std(th, sg) ≈ 0.223606797749979 + + # Closed-form first-passage metrics (deterministic; exact parity). + @test B.hit_upper_probability(eg, 2.0, 1.0, th, sg) ≈ 0.9999999999996079 + @test B.mean_exit_time(eg, 2.0, 1.0, th, sg) ≈ 130958.42974177317 rtol = 1e-9 + m = B.ou_rule_metrics(2.0, 1.0, th, sg, eg, 0.0) + @test m.return_rate ≈ 1.5272021846493343e-5 rtol = 1e-9 + @test m.time_scaled_sharpe ≈ 2941.8908064682373 rtol = 1e-9 + @test isnan(B.ou_rule_metrics(-1.0, 1.0, th, sg, eg).hit_probability) # invalid -> bad + + # Optimizer (grid search vs SciPy L-BFGS-B): the objective surface is flat along + # the stop-loss ridge, so the maximized return rate matches even when sl differs. + opt = B.optimal_ou_trading_rule(th, sg, eg) + @test opt.profit_take > 0 && opt.stop_loss > 0 + @test opt.return_rate ≈ 0.0921858063421004 atol = 1e-3 + + # OU fit (AR(1) regression; deterministic; exact parity). + x = zeros(2000) + for t = 2:2000 + x[t] = 0.9 * x[t-1] + 0.1 * sin(0.3 * (t - 1)) + end + f = B.fit_ornstein_uhlenbeck(x) + @test f.theta ≈ 0.044973376784760155 rtol = 1e-9 + @test f.sigma ≈ 0.07109144798150265 rtol = 1e-9 + @test f.rho ≈ 0.9560229338986339 rtol = 1e-9 + @test f.r2 ≈ 0.9130968551707074 rtol = 1e-9 +end + +@testset "Backtest — PSR & test-set overfitting (parity with Python)" begin + B = RiskLabAI.Backtest + + # Probabilistic Sharpe ratio (Φ of the Z-statistic). + psr_sat = B.probabilistic_sharpe_ratio( + 2.5, + 1.0, + 252; + skewness_of_returns = -0.5, + kurtosis_of_returns = 4.0, + ) + @test psr_sat ≈ 1.0 + @test B.probabilistic_sharpe_ratio(1.5, 1.0, 100) ≈ 0.9996784793766524 + psr_z = B.probabilistic_sharpe_ratio(1.5, 1.0, 100; return_test_statistic = true) + @test psr_z ≈ 3.4127787539671264 + # Non-positive denominator: PSR -> 0, Z -> -Inf. + @test B.probabilistic_sharpe_ratio(3.0, 1.0, 100; skewness_of_returns = 2.0) == 0.0 + psr_inf = B.probabilistic_sharpe_ratio( + 3.0, + 1.0, + 100; + skewness_of_returns = 2.0, + return_test_statistic = true, + ) + @test psr_inf == -Inf + + # Benchmark (expected-max) Sharpe ratio. + @test B.benchmark_sharpe_ratio([0.5, 1.2, -0.3, 0.8, 1.5, 0.9]) ≈ 0.7418275166314174 + @test B.benchmark_sharpe_ratio([0.7]) == 0.7 + @test B.benchmark_sharpe_ratio(Float64[]) == 0.0 + + # Expected maximum Sharpe ratio (truncated Euler constant). + @test B.expected_max_sharpe_ratio(10, 0.0, 1.0) ≈ 1.5745983013449716 + @test B.expected_max_sharpe_ratio(50, 0.5, 1.2) ≈ 3.231563712103708 + @test B.expected_max_sharpe_ratio(1, 0.33, 1.0) == 0.33 + @test B.expected_max_sharpe_ratio(0, 0.33, 1.0) == 0.0 + + # Sharpe-ratio Z-statistic. + @test B.estimated_sharpe_ratio_z_statistics(1.5, 100) ≈ 10.238336261901377 + z_skew = B.estimated_sharpe_ratio_z_statistics( + 1.2, + 52; + true_sharpe_ratio = 0.5, + skew = -0.3, + kurt = 4, + ) + @test z_skew ≈ 3.200281749891488 + @test isnan(B.estimated_sharpe_ratio_z_statistics(3.0, 100; skew = 2.0)) + + # Multiple-testing type-1 / type-2 errors. + @test B.strategy_type1_error_probability(1.96) ≈ 0.024997895148220373 + alpha_k = B.strategy_type1_error_probability(1.96; k = 10) + @test alpha_k ≈ 0.22365361940347483 + theta = B.theta_for_type2_error(1.2, 52, 1.0; skew = -0.3, kurt = 4) + @test theta ≈ 4.571831071273555 + @test isnan(B.theta_for_type2_error(3.0, 100, 1.0; skew = 2.0)) + @test B.strategy_type2_error_probability(alpha_k, 10, theta) ≈ 0.004502937106211302 + + # Monte-Carlo helpers are stochastic — check structure with a seeded RNG. + sims = B.generate_max_sharpe_ratios(20, [5, 10], 1.0, 0.0; rng = MersenneTwister(1)) + @test names(sims) == ["max_SR", "n_trials"] + @test nrow(sims) == 40 + @test all(isfinite, sims.max_SR) + + err = B.mean_std_error(20, 3, [5, 10]; rng = MersenneTwister(2)) + @test names(err) == ["n_trials", "meanErr", "stdErr"] + @test nrow(err) == 2 + @test all(isfinite, err.meanErr) +end + +@testset "Backtest — LPLZ HAC Sharpe inference (parity with Python)" begin + B = RiskLabAI.Backtest + r = [ + 0.012, + -0.004, + 0.021, + 0.008, + -0.011, + 0.017, + 0.003, + -0.006, + 0.014, + 0.009, + -0.002, + 0.019, + 0.006, + -0.008, + 0.011, + 0.004, + 0.016, + -0.003, + 0.013, + 0.007, + -0.005, + 0.018, + 0.002, + -0.009, + 0.015, + ] + + @test B.newey_west_automatic_lag(25) == 2 + @test B.newey_west_automatic_lag(240) == 4 + @test B.newey_west_automatic_lag(1000) == 6 + @test B.newey_west_long_run_variance(B.sharpe_ratio_influence_function(r), 2) ≈ + 0.23445542337329167 + @test B.newey_west_long_run_variance(B.sharpe_ratio_influence_function(r), 0) ≈ + 1.1295168114508831 + + res = B.lplz_sharpe_inference(r) + @test res.sharpe_ratio ≈ 0.6037202815610235 + @test res.standard_error ≈ 0.09684119441091 + @test res.test_statistic ≈ 6.234126760140509 + @test res.p_value ≈ 4.5430511104277287e-10 + @test res.significant == true + @test res.lag == 2 + @test res.confidence_interval[1] ≈ 0.41391502829579835 + @test res.confidence_interval[2] ≈ 0.7935255348262487 + + # Custom confidence level, fixed lag, non-zero null. + res2 = B.lplz_sharpe_inference( + r; + confidence_level = 0.99, + lag = 3, + null_sharpe_ratio = 0.1, + ) + @test res2.standard_error ≈ 0.12042504761055403 + @test res2.test_statistic ≈ 4.182853082109785 + @test res2.p_value ≈ 2.8787343639696333e-5 + @test res2.lag == 3 + + # Degenerate (fewer than 3 obs): NaN inference. + short = B.lplz_sharpe_inference([0.1, 0.2]) + @test isnan(short.standard_error) + @test short.lag == 0 + @test short.significant == false +end + +@testset "Backtest — strategy risk (parity with Python)" begin + B = RiskLabAI.Backtest + + # Closed-form binomial variance (Python returns the SymPy expression). + @test B.target_sharpe_ratio_symbolic(0.3, 1.0, -1.0) ≈ 0.84 + + # Implied precision: real root in (0, 1), and NaN when the discriminant < 0. + @test B.implied_precision(0.001, 0.01, 2, 1.0) ≈ 0.11111111111111126 + @test isnan(B.implied_precision(0.02, 0.04, 260, 1.5)) + + # Implied bets-per-year frequency. + @test B.bin_frequency(0.02, 0.04, 0.7, 1.5) ≈ 0.16349480968858132 + @test B.bin_frequency(0.02, 0.04, 0.0, 1.5) == Inf + @test B.bin_frequency(0.02, 0.04, 1.0, 1.5) == Inf + + # Annualised binomial Sharpe ratio (signed Inf when dispersion is zero). + @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 0.7) ≈ 12.90174509339827 + @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 0.0) == -Inf + @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 1.0) == Inf + + # Failure probability (normal-CDF Z-score on observed vs required precision). + rets = [0.01, 0.012, 0.008, -0.001, -0.0012, -0.0008] + @test B.failure_probability(rets, 2, 1.0) ≈ 0.9716202768069931 + # No losing returns -> 0.0. + @test B.failure_probability([0.01, 0.02, 0.03], 260, 1.0) == 0.0 + # Target unachievable (required precision is NaN) -> 1.0. + @test B.failure_probability([0.04, -0.02, 0.03, -0.01], 260, 1.5) == 1.0 + + # Monte-Carlo helpers are stochastic — check structure with a seeded RNG. + trials = B.sharpe_ratio_trials(0.6, 200; rng = MersenneTwister(3)) + @test length(trials) == 3 + @test all(isfinite, trials) + mixed = B.mix_gaussians(0.05, -0.02, 0.01, 0.01, 0.6, 100; rng = MersenneTwister(4)) + @test length(mixed) == 100 + risk = B.calculate_strategy_risk( + 0.05, + -0.02, + 0.01, + 0.01, + 0.6, + 100, + 260, + 1.0; + rng = MersenneTwister(5), + ) + @test 0.0 <= risk <= 1.0 +end + +@testset "Backtest — PBO & synthetic backtesting (parity with Python)" begin + B = RiskLabAI.Backtest + + # Deterministic fixture: rows = observations, cols = strategies. + perf = [sin(0.7t + 1.3i) for t = 1:12, i = 1:4] + metric = (r, rf) -> B.sharpe_ratio(r; risk_free_rate = rf) + + pe = B.performance_evaluation(perf[1:6, :], perf[7:12, :], 4, metric, 0.0) + @test pe[1] == true + @test pe[2] ≈ -0.4054651081081643 + + pbo, logits = B.probability_of_backtest_overfitting(perf; n_partitions = 4) + @test pbo ≈ 1.0 + @test logits ≈ [ + -0.4054651081081643, + -0.4054651081081643, + -1.3862943611198906, + -1.3862943611198906, + -0.4054651081081643, + -0.4054651081081643, + ] + @test_throws ArgumentError B.probability_of_backtest_overfitting(perf; n_partitions = 3) + + # Synthetic backtesting is stochastic — check structure with tiny, seeded params. + results = B.synthetic_back_testing( + 0.0, + 5.0, + 1.0; + n_iteration = 50, + maximum_holding_period = 20, + profit_taking_range = [1.0, 2.0], + stop_loss_range = [1.0, 2.0], + seed = 0, + rng = MersenneTwister(7), + ) + @test length(results) == 4 + @test all(t -> length(t) == 5, results) +end + +@testset "Backtest — bet sizing (parity with Python)" begin + B = RiskLabAI.Backtest + + # Probability bet size: side · (2·Φ(p) - 1). + pbs = B.probability_bet_size([0.55, 0.6, 0.45], [1.0, -1.0, 1.0]) + @test pbs ≈ [0.417680626423, -0.4514937645, 0.347289559424] + + # Concurrent average bet size over numeric dates. + abs_out = B.average_bet_sizes([0, 1, 2, 3, 4], [0.0, 2.0], [2.0, 4.0], [0.5, -0.3]) + @test abs_out ≈ [0.5, 0.5, 0.1, -0.3, -0.3] + + # Discretisation (round to step, cap at ±1). + @test B.discrete_signal([0.26, 0.94, -1.4, 0.05], 0.1) ≈ [0.3, 0.9, -1.0, 0.0] + + # Sigmoid position-sizing family. + @test B.bet_size_sigmoid(2.0, 1.0) ≈ 0.5773502691896258 + @test B.target_position(2.0, 105.0, 100.0, 10) == 9 + @test B.inverse_price(100.0, 2.0, 0.5) ≈ 99.18350341907228 + @test B.inverse_price(100.0, 2.0, 1.0) == 100.0 + @test B.limit_price(5, 2, 100.0, 2.0, 10) ≈ 99.37384680974242 + @test B.compute_sigmoid_width(5.0, 0.5) ≈ 75.0 + @test B.compute_sigmoid_width(5.0, 0.0) == Inf + + # Concurrent active-signal averaging. + starts = [1.0, 2.0, 3.0] + ends = [3.0, 4.0, 5.0] + tp, avg = B.avg_active_signals(starts, ends, [1.0, -0.5, 0.8]) + @test tp == [1.0, 2.0, 3.0, 4.0, 5.0] + @test avg ≈ [1.0, 0.25, 0.15, 0.8, 0.0] + + # A missing end never closes its signal. + avg_missing = + B.mp_avg_active_signals(starts, [missing, 4.0, 5.0], [1.0, -0.5, 0.8], [5.0]) + @test avg_missing ≈ [1.0] + + # End-to-end signal generation (no meta-label side). + gtp, gsig = + B.generate_signal(starts, ends, nothing, [0.6, 0.55, 0.7], [1.0, -1.0, 1.0], 2, 0.1) + @test gtp == [1.0, 2.0, 3.0, 4.0, 5.0] + @test gsig ≈ [0.2, 0.0, 0.1, 0.3, 0.0] +end + +@testset "Backtest — multiple-testing Sharpe haircuts (parity with Python)" begin + B = RiskLabAI.Backtest + sr = [0.25, 0.18, 0.05, 0.02] + T = 120 + + @test B.sharpe_ratio_p_values(sr, T) ≈ [ + 0.0030849496602720805, + 0.024316152257345184, + 0.29194121038518256, + 0.4132903507196785, + ] + + p = B.sharpe_ratio_p_values(sr, T) + @test B.holm_adjusted_p_values(p) ≈ + [0.012339798641, 0.072948456772, 0.58388242077, 0.58388242077] + @test B.benjamini_hochberg_yekutieli_adjusted_p_values(p) ≈ + [0.025707913836, 0.101317301072, 0.810947806626, 0.861021563999] + + h = B.haircut_sharpe_ratios(sr, T; method = "holm") + @test h.significant == [true, false, false, false] + @test h.haircut_sharpe_ratios ≈ + [0.205065615311, 0.132747698412, -0.019337871954, -0.019337871954] + + b = B.haircut_sharpe_ratios(sr, T; method = "bhy") + @test b.haircut_sharpe_ratios ≈ + [0.177826597449, 0.116307178741, -0.080459932637, -0.099039237584] + @test_throws ArgumentError B.haircut_sharpe_ratios(sr, T; method = "xyz") + + # 6-strategy set with ties (tie-handling must give equal adjusted values). + sr2 = [0.30, 0.10, 0.10, 0.22, 0.01, 0.15] + p2 = B.sharpe_ratio_p_values(sr2, 250) + @test B.holm_adjusted_p_values(p2) ≈ [ + 6.304308e-06, + 0.17076944701, + 0.17076944701, + 0.001260545574, + 0.437183530581, + 0.035412131615, + ] + @test B.benjamini_hochberg_yekutieli_adjusted_p_values(p2) ≈ + [1.5445554e-05, 0.16735405807, 0.16735405807, 0.001853001993, 1.0, 0.043379861228] +end + +@testset "Features — entropy (parity with Python)" begin + F = RiskLabAI.Features + m = "11100010011110100" + + @test F.shannon_entropy(m) ≈ 0.9975025463691152 + @test F.shannon_entropy("1111") == 0.0 + @test F.shannon_entropy("") == 0.0 + + @test F.lempel_ziv_entropy(m) ≈ 0.47058823529411764 + + @test F.probability_mass_function(m, 2) == + Dict("11" => 0.3125, "10" => 0.25, "00" => 0.25, "01" => 0.1875) + + @test F.plug_in_entropy_estimator(m, 1) ≈ 0.9975025463691152 + @test F.plug_in_entropy_estimator(m, 2) ≈ 0.9886085007312413 + + @test F.longest_match_length(m, 5, 5) == (2, "0") + # Kontoyiannis H_k is the *averaged* Σ log2(nᵢ)/Lᵢ (de Prado's formula). + @test F.kontoyiannis_entropy(m) ≈ 0.9847738922739608 + @test F.kontoyiannis_entropy(m; window = 5) ≈ 0.8868475362417008 +end + +@testset "Features — bias-corrected entropy (parity with Python)" begin + F = RiskLabAI.Features + m = "11100010011110100" + + # Miller–Madow (plug-in + first-order analytic correction). + @test F.miller_madow_entropy(m, 1) ≈ 1.0399347534540848 + @test F.miller_madow_entropy(m, 2) ≈ 1.0562348307729115 + @test F.miller_madow_entropy(m, 3) ≈ 1.0698448127313827 + + # Grassberger (digamma higher-order correction). + @test F.grassberger_entropy(m, 1) ≈ 1.083745643356587 + @test F.grassberger_entropy(m, 2) ≈ 1.0829481408420427 + @test F.grassberger_entropy(m, 3) ≈ 1.1437749024876813 + + # NSB (Bayesian; needs the true alphabet size). Quadrature matches Python to ~1e-8. + @test F.nsb_entropy(m, 1, 2) ≈ 0.965537224890827 atol = 1e-8 + @test F.nsb_entropy(m, 2, 2) ≈ 0.9600582245715601 atol = 1e-8 + @test F.nsb_entropy(m, 1) ≈ 0.965537224890827 atol = 1e-8 # default alphabet = 2 + + # Larger-alphabet message. + m2 = "abracadabraalakazamabracadabra" + @test F.miller_madow_entropy(m2, 2) ≈ 2.063821752851152 + @test F.grassberger_entropy(m2, 2) ≈ 2.151409069604457 + @test F.nsb_entropy(m2, 2) ≈ 2.3265927148279415 atol = 1e-6 + @test F.nsb_entropy(m2, 2, 7) ≈ 2.2834213811058284 atol = 1e-6 + + # Empty message -> 0. + @test F.miller_madow_entropy("", 1) == 0.0 + @test F.grassberger_entropy("", 1) == 0.0 + @test F.nsb_entropy("", 1, 2) == 0.0 +end + +@testset "Features — microstructural (parity with Python)" begin + F = RiskLabAI.Features + high = [10.0, 11, 12, 11, 13, 12, 14, 13, 15, 14] + low = [9.0, 9.5, 11, 10, 12, 11, 13, 12, 14, 13] + + # Corwin–Schultz β: NaN for the first 3 (rolling-2 then rolling-3 warm-up). + beta = F.beta_estimates(high, low, 3) + @test all(isnan, beta[1:3]) + @test beta[4:end] ≈ [ + 0.026103995125, + 0.020403144632, + 0.015374563434, + 0.014177217155, + 0.012979870876, + 0.012042883073, + 0.01110589527, + ] + + # γ: NaN only for the first point (rolling-2). + gamma = F.gamma_estimates(high, low) + @test isnan(gamma[1]) + @test gamma[2:end] ≈ [ + 0.040268728017, + 0.054575898693, + 0.033241150072, + 0.06883500727, + 0.027907067203, + 0.058159137648, + 0.023762432091, + 0.049793044493, + 0.020477851451, + ] + + # α and spread are floored at 0 for this fixture. + spread = F.corwin_schultz_estimator(high, low, 3) + @test all(isnan, spread[1:3]) + @test spread[4:end] == zeros(7) + + # Bekker–Parkinson volatility. + bp = F.bekker_parkinson_volatility_estimates(high, low, 3) + @test all(isnan, bp[1:3]) + @test bp[4:end] ≈ [ + 0.665889352092, + 0.741771939318, + 0.552081947665, + 0.652306085438, + 0.508261603535, + 0.602526273704, + 0.470915692879, + ] +end + +@testset "Features — EDGE spread estimator (parity with Python)" begin + F = RiskLabAI.Features + # Same 12-bar OHLC fixture as the Python edge.edge_estimator reference. + o = [100.0, 101.2, 100.5, 102.1, 101.8, 103.0, 102.4, 104.1, 103.6, 105.2, 104.8, 106.0] + h = [101.0, 101.9, 101.3, 102.8, 102.5, 103.6, 103.2, 104.7, 104.3, 105.8, 105.5, 106.6] + l = [99.4, 100.6, 99.9, 101.3, 101.0, 102.2, 101.7, 103.4, 102.9, 104.5, 104.0, 105.3] + c = [101.1, 100.7, 101.0, 102.3, 101.6, 103.2, 102.1, 104.4, 103.3, 105.4, 104.4, 106.4] + + @test F.edge_estimator(o, h, l, c) ≈ 0.006962635220907141 + @test F.edge_estimator(o, h, l, c; sign = true) ≈ 0.006962635220907141 + + # Degenerate inputs return NaN (fewer than 3 obs; flat / no-trade bars). + @test isnan(F.edge_estimator([1.0, 2.0], [1.0, 2.0], [1.0, 2.0], [1.0, 2.0])) + flat = fill(100.0, 5) + @test isnan(F.edge_estimator(flat, flat, flat, flat)) + + # Mismatched lengths raise. + @test_throws ArgumentError F.edge_estimator([1.0, 2.0, 3.0], [1.0, 2.0], [1.0], [1.0]) +end + +@testset "Features — structural breaks (parity with Python)" begin + F = RiskLabAI.Features + + # lag_dataframe: columns are lags 0..2 with NaN warm-up. + lagged = F.lag_dataframe([10.0, 11, 12, 13], 2) + @test size(lagged) == (4, 3) + @test lagged[:, 1] == [10.0, 11.0, 12.0, 13.0] + @test all(isnan, lagged[1:1, 2]) + @test lagged[2:4, 2] == [10.0, 11.0, 12.0] + @test all(isnan, lagged[1:2, 3]) + @test lagged[3:4, 3] == [10.0, 11.0] + + # prepare_data ADF design (constant="ct", lags=2): exact diff/shift alignment. + periodic = [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4, 1.35, 1.5, 1.45, 1.6] + y, x, index = F.prepare_data(periodic, "ct", 2) + @test index == [4, 5, 6, 7, 8, 9, 10, 11, 12] + @test y ≈ [0.15, -0.05, 0.15, -0.05, 0.15, -0.05, 0.15, -0.05, 0.15] + @test size(x) == (9, 5) # level, Δl1, Δl2, const, trend + @test x[:, 1] ≈ [1.05, 1.2, 1.15, 1.3, 1.25, 1.4, 1.35, 1.5, 1.45] # lagged level + @test x[:, 4] == ones(9) # constant + @test x[:, 5] == [4.0, 5, 6, 7, 8, 9, 10, 11, 12] # trend + + # OLS β and the ADF t-statistic on a well-conditioned (constant="c") design. + noisy = [1.0, 1.03, 1.01, 1.06, 1.1, 1.08, 1.15, 1.13, 1.2, 1.26, 1.24, 1.33] + yc, xc, _ = F.prepare_data(noisy, "c", 1) + beta_mean, beta_variance = F.compute_beta(yc, xc) + @test beta_mean ≈ [0.14542507467, -0.831800466234, -0.113785422889] + @test beta_mean[1] / sqrt(beta_variance[1, 1]) ≈ 1.007102910535 + + # Backward Supremum ADF and the expanding-window ADF path. + bsadf = F.get_bsadf_statistic(noisy, 6, "c", 1) + @test bsadf.bsadf ≈ 1.007102910535 + adf = F.get_expanding_window_adf(noisy, 6, "c", 1) + @test adf.statistics ≈ + [0.16894098733, 0.437811843619, 1.415912133027, 0.543280756955, 1.007102910535] +end + +@testset "Features — GSADF / BSADF (parity with Python)" begin + F = RiskLabAI.Features + # Same 40-point series as the Python structural_breaks reference (rounded to + # 6 decimals so both languages compute on identical inputs). + y = [ + 0.12573, + -0.006375, + 0.634048, + 0.738948, + 0.203279, + 0.564874, + 1.868874, + 2.815955, + 2.11222, + 0.846798, + 0.223524, + 0.26485, + -2.060181, + -2.278973, + -3.524884, + -4.257151, + -4.80141, + -5.11771, + -4.70608, + -3.663566, + -5.288989, + -4.303964, + -3.388485, + -2.946022, + -2.976168, + -3.587708, + -2.488458, + -0.777045, + 0.946885, + 2.356677, + -4.654213, + -4.863389, + -5.022614, + -4.481768, + -4.267109, + -3.911736, + -4.565565, + -4.695179, + -3.911203, + -2.417772, + ] + + @test F.psy_minimum_window(length(y)) == 12 + nmin = 12 + + bsadf_exp = [ + -0.536910634, + -0.362311837, + 0.34906369, + 0.517036689, + 0.582574434, + 0.520469967, + 0.102659841, + -0.416291845, + -0.031199142, + -0.478681209, + -0.737819991, + -0.838846133, + -0.866177154, + -0.817563944, + -1.000451307, + -0.126785185, + 0.890803186, + 1.384764704, + -1.610888373, + -1.586555274, + -1.569062869, + -1.655578929, + -1.696733029, + -1.700368814, + -1.6264952, + -1.588480421, + -1.618163091, + -1.737832868, + ] + sadf_exp = [ + -0.536910634, + -0.38399068, + 0.263498555, + 0.506163518, + 0.582574434, + 0.520469967, + 0.102659841, + -0.419886094, + -0.031199142, + -0.478681209, + -0.747317284, + -0.851945349, + -0.87885276, + -0.825252719, + -1.016427934, + -1.088760162, + -0.920870397, + -0.626601733, + -1.626225512, + -1.599491827, + -1.579961277, + -1.667177256, + -1.707893951, + -1.763814976, + -1.717976507, + -1.716496223, + -1.820220308, + -1.940406359, + ] + + bs = F.get_bsadf_sequence(y, nmin, "c", 0) + sa = F.get_sadf_sequence(y, nmin, "c", 0) + @test bs.values ≈ bsadf_exp atol = 1e-8 + @test sa.values ≈ sadf_exp atol = 1e-8 + # Endpoints are 1-based positions (nmin+1 .. T). + @test bs.index == collect(13:40) + @test F.get_gsadf_statistic(y, nmin, "c", 0) ≈ 1.3847647044558782 atol = 1e-8 + + # Date-stamping: 1-based positions (Python 0-based labels + 1). + @test F.get_bubble_episodes(bs.index, bs.values, 1.0; min_duration = 1) == [(30, 31)] + @test F.get_bubble_episodes(bs.index, bs.values, 0.5; min_duration = 2) == + [(16, 19), (29, 31)] + + # Critical-value simulator is behavioural (random-walk null, Julia RNG): + # structural checks only. + cv = F.simulate_psy_critical_values( + 40, + nmin; + n_simulations = 50, + rng = MersenneTwister(0), + ) + @test cv.min_sample_length == 12 + @test isfinite(cv.gsadf_global_cv) + @test isfinite(cv.sadf_global_cv) + @test length(cv.bsadf_sequence_cv) == 28 +end + +@testset "Features — volatility-robust SADF (parity with Python)" begin + F = RiskLabAI.Features + fixture(name) = parse.(Float64, readlines(joinpath(@__DIR__, "fixtures", name))) + + # Deterministic explosive-then-collapse log-price path (saved at full Float64 + # precision so Julia and Python compute on the identical input). The observed + # SADF / GSADF sup-ADF statistics are deterministic -> tight parity; the + # wild-bootstrap p-values use Julia's RNG -> behavioural (size/power) checks. + y = fixture("vrsadf_y.txt") + res = F.volatility_robust_sadf(y; n_bootstrap = 199, random_state = 0) + + @test res.sadf ≈ 5.528067986814794 atol = 1e-6 # Python reference + @test res.gsadf ≈ 8.034555882362156 atol = 1e-6 # Python reference + @test 0.0 < res.sadf_pvalue <= 1.0 + @test 0.0 < res.gsadf_pvalue <= 1.0 + # On this explosive series the test rejects (small p-value). + @test res.reject_gsadf + @test res.reject_sadf + + # Power/size contrast: a pure random walk (no bubble) is NOT rejected, and its + # p-values strictly exceed the explosive series' -> the test discriminates. + walk = cumsum(randn(MersenneTwister(42), 150)) + res_walk = F.volatility_robust_sadf(walk; n_bootstrap = 199, random_state = 1) + @test !res_walk.reject_sadf + @test !res_walk.reject_gsadf + @test res_walk.gsadf_pvalue > res.gsadf_pvalue + + # Default min_length follows psy_minimum_window; short series error out. + @test_throws ArgumentError F.volatility_robust_sadf(collect(1.0:5.0)) +end + +@testset "Features — PELT change-points (parity with ruptures)" begin + F = RiskLabAI.Features + fixture(name) = parse.(Float64, readlines(joinpath(@__DIR__, "fixtures", name))) + + # PELT is deterministic, so it reproduces the Python `ruptures` reference indices + # exactly (0-based, matching the Python convention). Fixtures are the identical + # arrays the Python reference was run on, saved at full Float64 precision. + x1 = fixture("pelt_x1.txt") # n=240, mean shift at 120 + x2 = fixture("pelt_x2.txt") # n=200, pure VARIANCE shift at 100 (CUSUM-blind) + x3 = fixture("pelt_x3.txt") # n=320, three change-points (mean+variance) + + @test F.pelt_change_points(x1) == [120] + @test F.pelt_change_points(x2) == [100] + @test F.pelt_change_points(x3; penalty_multiplier = 2.0, min_size = 15, jump = 5) == + [80, 160, 240] + + # Fully explicit deterministic step (no RNG): single change-point at 30. + step = vcat(zeros(30), fill(10.0, 30)) + @test F.pelt_change_points(step; penalty_multiplier = 1.0, min_size = 10, jump = 5) == + [30] + + # Edge cases: too-short series -> empty; flat series -> no change-points. + @test F.pelt_change_points(randn(MersenneTwister(0), 15)) == Int[] + @test F.pelt_change_points(fill(2.0, 100)) == Int[] + @test_throws ArgumentError F.pelt_change_points( + randn(MersenneTwister(0), 100); + model = "rbf", + ) +end + +@testset "Optimization — HRP & hedging (parity with Python)" begin + O = RiskLabAI.Optimization + cov = [0.04 0.006 0.0 0.0; 0.006 0.09 0.0 0.0; 0.0 0.0 0.16 0.012; 0.0 0.0 0.012 0.25] + + # Inverse-variance weights. + @test O.inverse_variance_weights(cov) ≈ + [0.53924505692, 0.239664469742, 0.13481126423, 0.086279209107] + + # Cluster variance (inverse-variance weighted). + @test O.cluster_variance(cov, [1, 2]) ≈ 0.03024852071 + @test O.cluster_variance(cov, [3, 4]) ≈ 0.103271861987 + + # Quasi-diagonal ordering from a SciPy-format linkage matrix (-> 1-based). + link = [0.0 1 0.5 2; 2 3 0.6 2; 4 5 0.9 4] + @test O.quasi_diagonal(link) == [1, 2, 3, 4] + + # Recursive-bisection HRP weights. + @test O.recursive_bisection(cov, [1, 2, 3, 4]) ≈ + [0.535468091151, 0.237985818289, 0.138137860097, 0.088408230462] + + # Correlation distance. + @test O.distance_corr([1.0 0.5; 0.5 1.0]) ≈ [0.0 0.5; 0.5 0.0] + + # PCA hedging: sign-free invariant wᵀ C w = risk_target² · Σρ. + w_min = O.pca_weights(cov) + @test length(w_min) == 4 + @test w_min' * cov * w_min ≈ 1.0 + rd = [0.1, 0.2, 0.3, 0.4] + w_custom = O.pca_weights(cov; risk_distribution = rd, risk_target = 2.0) + @test w_custom' * cov * w_custom ≈ 2.0^2 * sum(rd) +end + +@testset "Cluster — ONC, silhouette & generators" begin + C = RiskLabAI.Cluster + + # silhouette_samples matches scikit-learn exactly (precomputed distance). + x = [0.0, 0.1, 0.2, 1.0, 1.1, 1.3] + dist = [abs(x[i] - x[j]) for i = 1:6, j = 1:6] + sil_two = C.silhouette_samples(dist, [1, 1, 1, 2, 2, 2]) + expected_two = [ + 0.867647058824, + 0.903225806452, + 0.839285714286, + 0.777777777778, + 0.85, + 0.791666666667, + ] + @test sil_two ≈ expected_two atol = 1e-9 + + # A singleton cluster yields silhouette 0 for that point. + sil_one = C.silhouette_samples(dist, [1, 1, 1, 1, 1, 2]) + expected_one = + [0.538461538462, 0.5625, 0.545454545455, -0.571428571429, -0.741935483871, 0.0] + @test sil_one ≈ expected_one atol = 1e-9 + @test sil_one[6] == 0.0 + + # covariance_to_correlation delegates to Data.cov_to_corr. + cov2 = [4.0 1.0; 1.0 9.0] + @test C.covariance_to_correlation(cov2) ≈ [1.0 1/6; 1/6 1.0] + + # Two clearly separated blocks built programmatically (formatter-safe). + corr = fill(0.05, 6, 6) + corr[1:3, 1:3] .= 0.9 + corr[4:6, 4:6] .= 0.85 + corr[diagind(corr)] .= 1.0 + + # k-means base returns a valid partition over all items. + _, clusters, sil = C.cluster_k_means_base(corr; max_clusters = 5, random_state = 1) + @test sort(reduce(vcat, collect(values(clusters)))) == collect(1:6) + @test length(sil) == 6 + @test all(!isempty(v) for v in values(clusters)) + + # ONC top-level also yields a valid partition over all items. + _, clusters_top, sil_top = C.cluster_k_means_top(corr; random_state = 1) + @test sort(reduce(vcat, collect(values(clusters_top)))) == collect(1:6) + @test length(sil_top) == 6 + + # Random block correlation: square, symmetric, unit diagonal. + rbc = C.random_block_correlation(8, 2; random_state = 7) + @test size(rbc) == (8, 8) + @test rbc ≈ rbc' + @test all(abs.(diag(rbc) .- 1.0) .< 1e-9) +end + +@testset "Optimization — hrp() & NCO (parity with Python)" begin + O = RiskLabAI.Optimization + cov = [1.0 0.8 0.0 0.0; 0.8 1.0 0.0 0.0; 0.0 0.0 1.0 0.5; 0.0 0.0 0.5 1.0] + + # Markowitz GMV weights (exact). + gmv = O.get_optimal_portfolio_weights(cov) + @test gmv ≈ [0.227272727273, 0.227272727273, 0.272727272727, 0.272727272727] + @test sum(gmv) ≈ 1.0 + + # Markowitz MVO weights (exact); highest-return asset is overweighted. + mu = [0.1, 0.2, 0.05, 0.1] + mvo = O.get_optimal_portfolio_weights(cov; mu = mu) + @test mvo ≈ [-0.625, 1.25, 0.0, 0.375] + @test sum(mvo) ≈ 1.0 + @test mvo[2] > mvo[1] + + # NCO weights: valid allocation summing to one (behavioural; stochastic k-means). + nco = O.get_optimal_portfolio_weights_nco(cov; number_clusters = 2) + @test length(nco) == 4 + @test sum(nco) ≈ 1.0 + + # HRP weights: long-only allocation in original asset order (behavioural order). + corr = RiskLabAI.Cluster.covariance_to_correlation(cov) + w_hrp = O.hrp(cov, corr) + @test length(w_hrp) == 4 + @test sum(w_hrp) ≈ 1.0 + @test all(w_hrp .> 0) + + # Regression: hrp() must accept a correlation with floating-point asymmetry + # (real sample covariances are not bit-exactly symmetric → hclust would throw + # "Distance matrix should be symmetric" without the symmetrisation guard). + g = randn(MersenneTwister(7), 6, 6) + psd = g * g' + sym_corr = RiskLabAI.Cluster.covariance_to_correlation(psd) + asym_corr = copy(sym_corr) + asym_corr[1, 2] += 1e-12 # break exact symmetry, as FP does + w_sample = O.hrp(psd, asym_corr) + @test length(w_sample) == 6 + @test sum(w_sample) ≈ 1.0 +end + +@testset "Validation — cross-validators (parity with Python)" begin + V = RiskLabAI.Validation + # 10 observations; each label's info range spans two steps ahead. + starts = collect(0:9) + ends = starts .+ 2 + + # Standard K-Fold (no shuffle): contiguous test folds, complementary train. + kfold = V.cv_split(V.KFoldCV(5), 10) + @test kfold[1] == ([3, 4, 5, 6, 7, 8, 9, 10], [1, 2]) + @test kfold[3] == ([1, 2, 3, 4, 7, 8, 9, 10], [5, 6]) + @test length(kfold) == 5 + + # Purged K-Fold with embargo = 0.1 (matches pandas reference exactly). + purged = V.cv_split(V.PurgedKFoldCV(5, starts, ends; embargo = 0.1)) + @test purged[1] == ([6, 7, 8, 9, 10], [1, 2]) + @test purged[2] == ([8, 9, 10], [3, 4]) + @test purged[3] == ([1, 2, 10], [5, 6]) + @test purged[5] == ([1, 2, 3, 4, 5, 6], [9, 10]) + + # CPCV: C(6,2) = 15 splits; first split purged against the combination. + cpcv = V.CombinatorialPurgedCV(6, 2, starts, ends) + @test V.get_n_splits(cpcv) == 15 + splits = V.cv_split(cpcv) + @test length(splits) == 15 + @test splits[1] == ([7, 8, 9, 10], [1, 2, 3, 4]) + # Number of backtest paths = n_test_groups · C / n_splits = 2·15/6 = 5. + @test length(V.backtest_paths(cpcv)) == 5 + + # Walk-Forward with gap = 1: growing train window, walking test fold. + walk = V.cv_split(V.WalkForwardCV(5; gap = 1), 10) + @test walk[1] == (Int[], [1, 2]) + @test walk[2] == ([1], [3, 4]) + @test walk[3] == ([1, 2, 3], [5, 6]) + @test walk[5] == ([1, 2, 3, 4, 5, 6, 7], [9, 10]) +end + +@testset "Validation — path-level Adaptive CPCV (parity with Python)" begin + V = RiskLabAI.Validation + # Closed-form 24×5 performance matrix (identical formula in both languages, so + # no RNG and exact parity; the adaptive PBO is fully deterministic). + P = [0.04 * sin(0.5t + j) + 0.01 * cos(0.2 * t * j) for t = 1:24, j = 1:5] + + @test V.estimate_volatility_regimes(P; n_regimes = 2) == + [1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0] + @test V.estimate_volatility_regimes(P; n_regimes = 3) == + [2, 2, 1, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 2, 2, 1, 0, 0] + + pbo, sel = V.adaptive_probability_of_backtest_overfitting(P; n_partitions = 4) + @test pbo ≈ 0.8887779995564427 + @test sel == 3 # Python 0-based 2 -> Julia 1-based 3 + + pbo2, sel2 = V.adaptive_probability_of_backtest_overfitting( + P; + n_partitions = 4, + target_fraction = 0.5, + n_regimes = 2, + ) + @test pbo2 ≈ 0.777888667110224 + @test sel2 == 5 + + @test_throws ArgumentError V.adaptive_probability_of_backtest_overfitting( + P; + n_partitions = 3, + ) +end + +@testset "Validation — path-level Bagged CPCV (behavioural)" begin + V = RiskLabAI.Validation + P = [0.04 * sin(0.5t + j) + 0.01 * cos(0.2 * t * j) for t = 1:24, j = 1:5] + + # Moving-block bootstrap indices: valid 1-based rows, correct length. + idx = V.moving_block_bootstrap_indices(10, 3, MersenneTwister(1)) + @test length(idx) == 10 + @test all(1 .<= idx .<= 10) + + # Bagging is stochastic (Julia RNG): the bagged PBO is in [0, 1] and equals the + # mean of the per-resample PBOs. + bp, pbos = V.bagged_probability_of_backtest_overfitting( + P; + n_partitions = 4, + n_bag = 15, + rng = MersenneTwister(0), + ) + @test 0.0 <= bp <= 1.0 + @test length(pbos) == 15 + @test bp ≈ sum(pbos) / length(pbos) +end + +@testset "Data.SyntheticData (parity with Python)" begin + S = RiskLabAI.Data + + # form_block_matrix: exact block-diagonal correlation. + fbm = S.form_block_matrix(2, 2, 0.5) + @test fbm == [1.0 0.5 0.0 0.0; 0.5 1.0 0.0 0.0; 0.0 0.0 1.0 0.5; 0.0 0.0 0.5 1.0] + + # drift_volatility_burst: exact drift/vol profiles (midpoint clamp + NaN fill). + drifts, vols = S.drift_volatility_burst(5, 1.0, 2.0, 0.5, 1.0, 0.5, 0.5) + @test drifts ≈ [1.414213562373, 2.0, 0.0, 4.0, 2.828427124746] + @test vols ≈ [0.707106781187, 1.0, 1.0, 2.0, 1.414213562373] + + # compute_log_returns: exact Heston–Merton step given fixed increments. + lr = S.compute_log_returns( + 3, + [0.1, 0.1, 0.2], + [1.0, 1.0, 1.0], + [0.04, 0.04, 0.05], + [0.2, 0.2, 0.2], + [0.5, -0.3, 0.1], + [0.2, 0.1, -0.2], + [0.0, 0.3, 0.0], + [0.0, 1.0, 0.0], + 0.01, + 0.1, + [0.5, 0.5, 0.5], + [0.0, 0.0, 0.0], + [0.1, 0.1, 0.1], + [false, false, true], + ) + @test lr ≈ [0.010775, 0.294711297037, 0.003961067977] + + # align_params_length: scalars broadcast, short vectors padded with last value. + aligned, max_len = + S.align_params_length(Dict("mu" => 0.1, "kappa" => [1.0, 2.0], "theta" => 0.04)) + @test max_len == 2 + @test aligned["mu"] == [0.1, 0.1] + @test aligned["kappa"] == [1.0, 2.0] + @test aligned["theta"] == [0.04, 0.04] + + # Stochastic generators: structural checks (shape, symmetry, positivity). + rc = S.random_cov(5, 3; rng = MersenneTwister(1)) + @test size(rc) == (5, 5) + @test rc ≈ rc' + + mu0, cov0 = S.form_true_matrix(2, 3, 0.5; rng = MersenneTwister(2)) + @test length(mu0) == 6 + @test size(cov0) == (6, 6) + + mu1, cov1 = S.simulates_cov_mu(mu0, cov0, 500; rng = MersenneTwister(3)) + @test length(mu1) == 6 + @test size(cov1) == (6, 6) + + regimes = Dict( + "calm" => Dict( + "mu" => 0.05, + "kappa" => 1.0, + "theta" => 0.04, + "xi" => 0.2, + "rho" => -0.5, + "lam" => 0.1, + "m" => 0.0, + "v" => 0.1, + ), + "turbulent" => Dict( + "mu" => -0.1, + "kappa" => 2.0, + "theta" => 0.1, + "xi" => 0.5, + "rho" => -0.7, + "lam" => 1.0, + "m" => -0.02, + "v" => 0.2, + ), + ) + transition = [0.9 0.1; 0.2 0.8] + prices, regime_path = + S.generate_prices_from_regimes(regimes, transition, 1.0, 50; random_state = 1) + @test length(prices) == 50 + @test all(prices .> 0) + @test length(regime_path) == 50 + + all_prices, all_regimes = + S.parallel_generate_prices(3, regimes, transition, 1.0, 20; random_state = 1) + @test size(all_prices) == (20, 3) + @test size(all_regimes) == (20, 3) +end + +@testset "Features — feature importance (orthogonal & weighted-τ)" begin + F = RiskLabAI.Features + x = [1.0 2.0 3.0; 2.0 1.0 0.0; 3.0 3.0 1.0; 0.0 2.0 2.0; 1.0 0.0 4.0; 2.0 2.0 2.0] + + # orthogonal_features: eigenvalues + cumulative variance exact; columns orthogonal. + orth, eigenvalues, _, cumulative = F.orthogonal_features(x) + @test eigenvalues ≈ [9.4191800225, 3.2941004976, 2.2867194799] + @test cumulative ≈ [0.6279453348, 0.8475520347, 1.0] + @test size(orth) == (6, 3) + gram = orth' * orth + @test maximum(abs.(gram - Diagonal(diag(gram)))) < 1e-8 # uncorrelated components + + # variance_threshold trims the retained component count. + orth2, eig2, _, _ = F.orthogonal_features(x; variance_threshold = 0.80) + @test length(eig2) == 2 + @test size(orth2) == (6, 2) + + # weighted Kendall-τ (wrapper compares importances to 1/ranks, flipping signs). + @test F.calculate_weighted_tau([1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0]) ≈ -1.0 + @test F.calculate_weighted_tau([4.0, 3.0, 2.0, 1.0], [1.0, 2.0, 3.0, 4.0]) ≈ 1.0 + @test F.calculate_weighted_tau([0.5, 0.2, 0.8, 0.1], [1.0, 2.0, 3.0, 4.0]) ≈ 0.2 +end + +@testset "Features — tree feature importance (DecisionTree.jl)" begin + F = RiskLabAI.Features + + # Separable dataset: column 1 informative, column 2 pure noise. + rng = MersenneTwister(42) + n = 200 + y = rand(rng, 0:1, n) + x = hcat(Float64.(y) .+ 0.3 .* randn(rng, n), randn(rng, n)) + + # MDI / MDA / SFI must all rank the informative feature above the noise one. + mdi = F.feature_importance_mdi(x, y; n_trees = 40, random_state = 1) + @test length(mdi.mean) == 2 + @test mdi.mean[1] > mdi.mean[2] + + mda = F.feature_importance_mda(x, y; n_splits = 5, n_trees = 40, random_state = 1) + @test length(mda.mean) == 2 + @test mda.mean[1] > mda.mean[2] + + sfi = F.feature_importance_sfi(x, y; n_splits = 5, n_trees = 40) + @test length(sfi.mean) == 2 + @test sfi.mean[1] > sfi.mean[2] + + # get_test_dataset: shapes and informative/redundant/noise column naming. + xd, yd, names = F.get_test_dataset(; + n_features = 12, + n_informative = 4, + n_redundant = 3, + n_samples = 60, + random_state = 0, + ) + @test size(xd) == (60, 12) + @test length(yd) == 60 + @test length(names) == 12 + @test count(startswith("I_"), names) == 4 + @test count(startswith("R_"), names) == 3 +end + +@testset "Features — MDI+ & CPI (behavioural)" begin + F = RiskLabAI.Features + # col 1 informative, col 2 weakly-informative, col 3 pure noise. + rng = MersenneTwister(7) + n = 300 + y = rand(rng, 0:1, n) + x = hcat( + Float64.(y) .+ 0.5 .* randn(rng, n), + 0.2 .* Float64.(y) .+ randn(rng, n), + randn(rng, n), + ) + + # MDI+ (behavioural): the informative feature scores highest, all non-negative. + imp = F.mdi_plus_importance(x, y; n_trees = 40, random_state = 1) + @test length(imp) == 3 + @test all(imp .>= 0.0) + @test argmax(imp) == 1 + @test imp[1] > imp[3] + + # CPI (behavioural): informative feature has the largest importance and the + # smallest p-value (the calibrated significance test MDA lacks). + cpi = + F.conditional_predictive_impact(x, y; n_splits = 4, n_trees = 40, random_state = 1) + @test length(cpi.importance) == 3 + @test length(cpi.p_value) == 3 + @test argmax(cpi.importance) == 1 + @test cpi.p_value[1] < cpi.p_value[3] + @test all(0.0 .<= cpi.p_value .<= 1.0) +end + +@testset "Ensemble & CV scoring (DecisionTree.jl)" begin + E = RiskLabAI.Ensemble + V = RiskLabAI.Validation + + # Theoretical bagging accuracy: exact (binomial survival function). + @test E.bagging_classifier_accuracy(11, 0.6) ≈ 0.75349813248 + @test E.bagging_classifier_accuracy(101, 0.55) ≈ 0.843755399638 + @test E.bagging_classifier_accuracy(3, 0.7) ≈ 0.784 + @test E.bagging_classifier_accuracy(7, 0.51) ≈ 0.5218662521 + @test_throws ArgumentError E.bagging_classifier_accuracy(10, 0.6) + + # Separable dataset for the behavioural pieces. + rng = MersenneTwister(7) + n = 200 + y = rand(rng, 0:1, n) + x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) + train = 1:150 + test = 151:200 + + schemes = E.bagging_evaluate_schemes( + x[train, :], + y[train], + x[test, :], + y[test]; + n_estimators = 40, + max_samples = 60, + random_state = 1, + ) + @test Set(keys(schemes)) == Set(["uniform", "c_i", "variance"]) + @test all(0.0 <= v <= 1.0 for v in values(schemes)) + @test schemes["uniform"] > 0.6 # informative signal is learnable + + trees, classes = E.fit_bagging( + x[train, :], + y[train]; + n_estimators = 40, + max_samples = 60, + random_state = 1, + ) + values_boot, mean_boot, std_boot = E.calculate_bootstrap_accuracy( + trees, + classes, + x[test, :], + y[test]; + n_bootstraps = 50, + ) + @test length(values_boot) == 50 + @test 0.0 <= mean_boot <= 1.0 + + # cross_val_score over a purged K-Fold and a plain K-Fold. + scores = V.cross_val_score(V.KFoldCV(5), x, y; n_trees = 30, random_state = 1) + @test length(scores) == 5 + @test sum(scores) / length(scores) > 0.7 + + starts = collect(1:n) + purged = V.PurgedKFoldCV(5, starts, starts; embargo = 0.0) + pscores = V.cross_val_score(purged, x, y; n_trees = 30, random_state = 1) + @test length(pscores) == 5 +end + +@testset "Hyper-parameter tuning (DecisionTree.jl)" begin + V = RiskLabAI.Validation + rng = MersenneTwister(11) + n = 200 + y = rand(rng, 0:1, n) + x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) + + grid = Dict(:n_trees => [10, 30], :max_depth => [2, 4]) + + # Grid search: every combination scored; best is recovered and a model refit. + gs = V.grid_search_cv(V.KFoldCV(4), x, y, grid; random_state = 1) + @test length(gs.results) == 4 + @test haskey(gs.best_params, :n_trees) + @test gs.best_score > 0.7 + @test gs.best_score == maximum(score for (_, score) in gs.results) + + # Randomised search: n_iter configurations; best score reported. + rs = V.random_search_cv(V.KFoldCV(4), x, y, grid; n_iter = 3, random_state = 1) + @test length(rs.results) == 3 + @test rs.best_score > 0.7 +end + +@testset "Validation — leakage-aware HPO & DSR gate (parity with Python)" begin + V = RiskLabAI.Validation + # deflated_sharpe_gate is deterministic -> exact parity with Python. + r = [0.01 * sin(0.05t) + 0.02 * cos(0.011t) + 0.0005 for t = 0:499] + g = V.deflated_sharpe_gate(r, 100, 0.05) + @test g.observed_sharpe ≈ -0.13536558416850195 + @test g.benchmark_sharpe ≈ 0.12653014466005713 + @test g.deflated_sharpe ≈ 4.703000079086612e-9 atol = 1e-12 + @test g.passes == false + g2 = V.deflated_sharpe_gate(r, 20, 0.2; threshold = 0.3) + @test g2.benchmark_sharpe ≈ 0.3801415902361039 + @test g2.passes == false + + # leakage-aware HPO (behavioural): purged-CV-scored random search returns a + # valid configuration and finite trial scores. + rng = MersenneTwister(11) + n = 200 + y = rand(rng, 0:1, n) + x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) + starts = collect(1:n) + hpo = V.leakage_aware_hpo( + x, + y, + Dict(:n_trees => [10, 30], :max_depth => [2, 4]); + event_starts = starts, + event_ends = starts, + n_trials = 3, + n_splits = 4, + random_state = 1, + ) + @test hpo.n_trials == 3 + @test haskey(hpo.best_params, :n_trees) + @test length(hpo.trial_scores) <= 3 + @test isfinite(hpo.mean_trial_score) +end + +@testset "Pde — equations (parity with Python)" begin + P = RiskLabAI.Pde + x = [100.0 110.0; 90.0 120.0; 100.0 100.0] + y = reshape([1.0, 2.0, 0.5], 3, 1) + z = [0.1 0.2; 0.3 -0.1; 0.0 0.5] + + hjb = P.HJBLQ(2, 1.0, 4) + @test vec(P.pde_driver(hjb, 0.0, x, y, z)) == zeros(3) + @test vec(P.pde_hamiltonian(hjb, 0.0, x, y, z)) ≈ [0.025, 0.05, 0.125] + @test vec(P.pde_terminal(hjb, 1.0, x)) ≈ [9.3102309548, 9.3281678511, 9.2103903707] + + bsb = P.BlackScholesBarenblatt(2, 1.0, 4) + @test vec(P.pde_driver(bsb, 0.0, x, y, z)) ≈ [0.05, 0.05, 0.05] + @test vec(P.pde_hamiltonian(bsb, 0.0, x, y, z)) ≈ [-0.0375, -0.025, -0.0625] + @test vec(P.pde_terminal(bsb, 1.0, x)) ≈ [22100.0, 22500.0, 20000.0] + + dr = P.PricingDefaultRisk(2, 1.0, 4) + @test vec(P.pde_driver(dr, 0.0, x, y, z)) ≈ [0.0866666667, 0.0866666667, 0.0866666667] + @test vec(P.pde_terminal(dr, 1.0, x)) ≈ [100.0, 90.0, 100.0] + + dfr = P.PricingDiffRate(2, 1.0, 4) + @test vec(P.pde_driver(dfr, 0.0, x, y, z)) ≈ [0.06, 0.04, 0.06] + @test vec(P.pde_hamiltonian(dfr, 0.0, x, y, z)) ≈ [0.0, 0.02, 0.0] + @test vec(P.pde_terminal(dfr, 1.0, x)) ≈ [0.0, 0.0, 0.0] + + # Forward-SDE sampling: shapes and the initial condition. + dw, xs = P.pde_sample(bsb, 16; rng = MersenneTwister(1)) + @test size(dw) == (16, 2, 4) + @test size(xs) == (16, 2, 5) + @test xs[:, :, 1] == repeat([1.0 0.5], 16, 1) +end + +@testset "Pde — optional Deep BSDE extension" begin + P = RiskLabAI.Pde + eq = P.HJBLQ(1, 0.5, 4) + extension = Base.get_extension(RiskLabAI, :RiskLabAIDeepBSDEExt) + + if RISKLABAI_TEST_DEEP_BSDE + extension === nothing && error("Deep-BSDE test extension did not load") + losses, inits = P.solve_deep_bsde( + eq; + hidden_sizes = [8], + iterations = 12, + batch_size = 64, + init_y = 3.0, + learning_rate = 0.02, + rng = MersenneTwister(1), + ) + @test length(losses) == 12 + @test all(isfinite, losses) + @test all(isfinite, inits) + @test minimum(losses) <= losses[1] # training improves on the initial loss + else + @test extension === nothing + failure = try + P.solve_deep_bsde(eq) + nothing + catch caught + caught + end + @test failure isa ArgumentError + message = sprint(showerror, failure) + @test occursin("deep_bsde extension", message) + @test all(occursin(dependency, message) for dependency in ("Lux", "Optimisers", "Zygote")) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 3ab4cd6..fcf85b1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,1910 +1,78 @@ +include("preserved_runtests.jl") + using Test -using Dates -using DataFrames using LinearAlgebra -using Random -using Statistics: mean, cov -using RiskLabAI - -@testset "RiskLabAI smoke tests" begin - # The package loads and the exported symbols actually exist - # (the old exports referenced undefined, wrongly-cased names). - @test isdefined(RiskLabAI, :probabilityOfBacktestOverfitting) - @test isdefined(RiskLabAI, :generateSignal) - - # discreteSignal: rounds to step size and caps at +/- 1 - s = RiskLabAI.discreteSignal([0.26, 0.94, -1.4], 0.1) - @test s ≈ [0.3, 0.9, -1.0] - - # selectRows: contiguous row blocks for the chosen partitions - @test RiskLabAI.selectRows([1, 3], 2) == [1, 2, 5, 6] -end - -@testset "Utils" begin - # ewma matches the Python RiskLabAI.utils.ewma exactly (same denominator - # 1 + (1-a) + (1-a)^2 + ...). Values computed from both implementations. - result = RiskLabAI.ewma([1.0, 2.0, 3.0, 4.0, 5.0], 3) - expected = [1.0, 1.6666666667, 2.4285714286, 3.2666666667, 4.1612903226] - @test result ≈ expected atol = 1e-9 - - # First element is always the seed value; output length matches input. - @test result[1] == 1.0 - @test length(result) == 5 - - # Constants are defined, with ASCII identifiers. - @test RiskLabAI.Utils.CUMULATIVE_DOLLAR == "Cumulative Dollar Value" - @test isascii(string(:CUMULATIVE_THETA)) - - # The struct-field-inheritance macro is available. - @test isdefined(RiskLabAI.Utils, Symbol("@field_inherit")) -end - -@testset "Data.Structures — standard bars (parity with Python)" begin - # Same fixture as the Python test_standard_bars.py. - # Bar row layout: [date_time, idx, open, high, low, close, volume, - # buy_vol, sell_vol, ticks, dollar, threshold] (1-indexed) - ticks = DataFrame( - date_time = DateTime.([ - "2020-01-01T10:00:00", - "2020-01-01T10:00:01", - "2020-01-01T10:00:02", - "2020-01-01T10:00:03", - "2020-01-01T10:00:04", - "2020-01-01T10:00:05", - "2020-01-01T10:00:06", - ]), - price = [100, 101, 100, 101, 102, 103, 102], - volume = [10, 5, 20, 10, 10, 10, 5], - ) - - # Tick bars, threshold 3 - tb = - RiskLabAI.Data.StandardBars{RiskLabAI.Data.Tick}(bar_type = "tick", threshold = 3.0) - bars = RiskLabAI.Data.construct_bars_from_data(tb; data = ticks) - @test length(bars) == 2 - @test bars[1][1] == DateTime("2020-01-01T10:00:02") - @test bars[1][3] == 100.0 # open - @test bars[1][4] == 101.0 # high - @test bars[1][5] == 100.0 # low - @test bars[1][6] == 100.0 # close - @test bars[1][10] == 3.0 # ticks - @test bars[2][1] == DateTime("2020-01-01T10:00:05") - @test bars[2][3] == 101.0 - @test bars[2][4] == 103.0 - @test bars[2][5] == 101.0 - @test bars[2][6] == 103.0 - @test bars[2][10] == 3.0 - - # Volume bars, threshold 35 - vb = RiskLabAI.Data.StandardBars{RiskLabAI.Data.Volume}( - bar_type = "volume", - threshold = 35.0, - ) - vbars = RiskLabAI.Data.construct_bars_from_data(vb; data = ticks) - @test length(vbars) == 2 - @test vbars[1][7] == 35.0 - @test vbars[2][7] == 35.0 - - # Dollar bars, threshold 3500 - db = RiskLabAI.Data.StandardBars{RiskLabAI.Data.Dollar}( - bar_type = "dollar", - threshold = 3500.0, - ) - dbars = RiskLabAI.Data.construct_bars_from_data(db; data = ticks) - @test length(dbars) == 2 - @test dbars[1][11] == 3505.0 - @test dbars[2][11] == 3570.0 -end - -@testset "Data.Structures — time bars (parity with Python)" begin - # Same fixture as the Python test_time_bars.py (1-second bars). - ticks = DataFrame( - date_time = DateTime.([ - "2020-01-01T10:00:00.100", - "2020-01-01T10:00:00.500", - "2020-01-01T10:00:01.200", - "2020-01-01T10:00:01.800", - "2020-01-01T10:00:02.100", - "2020-01-01T10:00:02.500", - ]), - price = [100, 101, 100, 101, 102, 103], - volume = [10, 5, 20, 10, 10, 10], - ) - - tb = RiskLabAI.Data.TimeBars(resolution_type = "S", resolution_units = 1) - bars = RiskLabAI.Data.construct_bars_from_data(tb; data = ticks) - - @test length(bars) == 2 - @test bars[1][1] == DateTime("2020-01-01T10:00:01") # end time = bucket boundary - @test bars[1][3] == 100.0 # open - @test bars[1][4] == 101.0 # high - @test bars[1][5] == 100.0 # low - @test bars[1][6] == 101.0 # close (previous tick) - @test bars[1][10] == 2.0 # ticks - @test bars[2][1] == DateTime("2020-01-01T10:00:02") - @test bars[2][3] == 100.0 - @test bars[2][4] == 101.0 - @test bars[2][5] == 100.0 - @test bars[2][6] == 101.0 - @test bars[2][10] == 2.0 -end - -@testset "Data.Structures — imbalance bars (parity with Python)" begin - # Same fixture as Python test_imbalance_bars.py. - # Prices 100..103..100 -> tick imbalances [0,1,1,1,-1,-1,-1]. - ticks = DataFrame( - date_time = DateTime.([ - "2020-01-01T10:00:00", - "2020-01-01T10:00:01", - "2020-01-01T10:00:02", - "2020-01-01T10:00:03", - "2020-01-01T10:00:04", - "2020-01-01T10:00:05", - "2020-01-01T10:00:06", - ]), - price = [100, 101, 102, 103, 102, 101, 100], - volume = [10, 10, 10, 10, 10, 10, 10], - ) - - # Fixed E[T] = 2 -> 2 bars with 3 and 4 ticks. - fb = RiskLabAI.Data.FixedImbalanceBars{RiskLabAI.Data.Tick}( - bar_type = "tick_imbalance", - expected_imbalance_window = 10, - initial_estimate_of_expected_n_ticks_in_bar = 2.0, - ) - fbars = RiskLabAI.Data.construct_bars_from_data(fb; data = ticks) - @test length(fbars) == 2 - @test fbars[1][10] == 3.0 # ticks in bar 1 - @test fbars[2][10] == 4.0 # ticks in bar 2 - - # Expected E[T] (EWMA, init 2, window 10) -> 1 bar with 3 ticks. - eb = RiskLabAI.Data.ExpectedImbalanceBars{RiskLabAI.Data.Tick}( - bar_type = "tick_imbalance", - window_size_for_expected_n_ticks_estimation = 10, - expected_imbalance_window = 10, - initial_estimate_of_expected_n_ticks_in_bar = 2.0, - ) - ebars = RiskLabAI.Data.construct_bars_from_data(eb; data = ticks) - @test length(ebars) == 1 - @test ebars[1][10] == 3.0 -end - -@testset "Data.Structures — run bars (parity with Python)" begin - # Same fixture as Python test_run_bars.py. - # Prices 100..103..100 -> buy run then sell run. The threshold stays Inf - # until both buy- and sell-imbalance EWMAs warm up (>= E[T] obs each), so - # the first (and only) bar forms at the last tick with all 7 ticks. - ticks = DataFrame( - date_time = DateTime.([ - "2020-01-01T10:00:00", - "2020-01-01T10:00:01", - "2020-01-01T10:00:02", - "2020-01-01T10:00:03", - "2020-01-01T10:00:04", - "2020-01-01T10:00:05", - "2020-01-01T10:00:06", - ]), - price = [100, 101, 102, 103, 102, 101, 100], - volume = [10, 10, 10, 10, 10, 10, 10], - ) - - # Fixed E[T] = 3 -> 1 bar with 7 ticks. - fb = RiskLabAI.Data.FixedRunBars{RiskLabAI.Data.Tick}( - bar_type = "tick_run", - expected_imbalance_window = 10, - initial_estimate_of_expected_n_ticks_in_bar = 3.0, - ) - fbars = RiskLabAI.Data.construct_bars_from_data(fb; data = ticks) - @test length(fbars) == 1 - @test fbars[1][10] == 7.0 - - # Expected E[T] (init 3) -> same single 7-tick bar on this fixture. - eb = RiskLabAI.Data.ExpectedRunBars{RiskLabAI.Data.Tick}( - bar_type = "tick_run", - window_size_for_expected_n_ticks_estimation = 10, - expected_imbalance_window = 10, - initial_estimate_of_expected_n_ticks_in_bar = 3.0, - ) - ebars = RiskLabAI.Data.construct_bars_from_data(eb; data = ticks) - @test length(ebars) == 1 - @test ebars[1][10] == 7.0 -end - -@testset "Data.Differentiation — fractional differentiation (parity with Python)" begin - D = RiskLabAI.Data - x = [100.0, 101.5, 100.8, 102.3, 101.1, 103.0, 102.5, 104.1, 103.7, 105.0] - - # Weights (reversed, w0 last): exact match to Python calculate_weights_*. - @test D.calculate_weights_std(0.5, 6) ≈ - [-0.02734375, -0.0390625, -0.0625, -0.125, -0.5, 1.0] - @test D.calculate_weights_ffd(0.4, 0.1) ≈ [-0.12, -0.4, 1.0] - - # Fixed-width FFD: width 3 -> first 2 entries NaN, then 8 values. - ffd = D.fractional_difference_fixed(x, 0.4; threshold = 0.1) - @test length(ffd) == 10 - @test all(isnan, ffd[1:2]) - @test ffd[3:end] ≈ [48.2, 49.8, 48.084, 50.284, 49.168, 50.74, 49.76, 51.028] - - # Standard (expanding-window): skip 1 -> first entry NaN, then 9 values. - fds = D.fractional_difference_std(x, 0.5; threshold = 0.01) - @test length(fds) == 10 - @test isnan(fds[1]) - @test fds[2:end] ≈ [ - 51.5, - 37.55, - 32.9625, - 27.1, - 26.66328125, - 23.205078125, - 23.2110351562, - 20.6416259766, - 20.8013458252, - ] - - # ADF-based finders (behavioural: ADF impl differs from statsmodels). - prices = 100.0 .+ 10.0 .* sin.(0.1 .* (1:200)) - res = D.find_optimal_ffd(prices) - @test length(res.d) == 11 - @test all(0.0 .<= res.d .<= 1.0) - @test length(res.p_value) == length(res.d) - - fd = D.fractionally_differentiated_log_price(prices) - @test length(fd) == 200 - @test any(!isnan, fd) -end - -@testset "Data.Differentiation — Adaptive Fractional Differencing (parity with Python)" begin - D = RiskLabAI.Data - n = 512 - incr = [0.5 * sin(0.05t) + 0.3 * cos(0.013t) + 0.2 * sin(0.2t) for t = 0:(n-1)] - price = 100.0 .+ cumsum(incr) - - # Deterministic anchors (R/S Hurst blend) — exact parity with Python's R/S path. - @test D.rescaled_range_hurst(incr) ≈ 0.945557656342456 atol = 1e-9 - @test D.adaptive_differencing_order(incr) ≈ 0.44555765634245603 atol = 1e-9 - # Wavelet component is an optional dependency (not wired) -> NaN, R/S-only blend. - @test isnan(D.wavelet_variance_hurst(incr)) - - res = D.adaptive_fractional_difference(price) - @test res.d_hat ≈ 0.4458675284494259 atol = 1e-8 - # Order/threshold/p-value are behavioural (ADF impl differs from statsmodels): - # structural checks. - @test 0.0 <= res.order <= 1.2 - @test res.threshold in (1e-3, 1e-4, 1e-5) - @test res.adf_pvalue < 0.05 - @test isfinite(res.memory_retained) - @test count(!isnan, res.series) > 400 -end - -@testset "Data.Weights — sample weighting (parity with Python)" begin - D = RiskLabAI.Data - close_index = collect(1:10) - event_start = [1, 3, 6] - event_end = [4, 7, 9] - molecule = [1, 3, 6] - price = [100.0, 101, 103, 102, 104, 103, 105, 106, 104, 107] - - # Concurrency over the affected span. - exp = D.expand_label_for_meta_labeling(close_index, event_start, event_end, molecule) - @test exp.index == collect(1:9) - @test exp.concurrency ≈ [1.0, 1, 2, 2, 1, 2, 2, 1, 1] - - # Average uniqueness from the indicator matrix (events 1-4, 3-7, 6-9). - index_matrix = zeros(10, 3) - index_matrix[1:4, 1] .= 1 - index_matrix[3:7, 2] .= 1 - index_matrix[6:9, 3] .= 1 - @test D.calculate_average_uniqueness(index_matrix) ≈ [0.75, 0.6, 0.75] - - # Absolute-return sample weights (normalised to N=3; NaN first return skipped). - w = D.sample_weight_absolute_return_meta_labeling( - event_start, - event_end, - close_index, - price, - molecule, - ) - @test w ≈ [0.6362107498, 1.2538702552, 1.1099189949] - @test sum(w) ≈ 3.0 - - # Linear time decay (weights assumed chronological). - @test D.calculate_time_decay([1.0, 1.0, 1.0]; clf_last_weight = 0.5) ≈ - [0.6666666667, 0.8333333333, 1.0] - @test D.calculate_time_decay([1.0, 1.0, 1.0]; clf_last_weight = 1.0) ≈ [1.0, 1.0, 1.0] -end - -@testset "Data.Denoise — RMT denoising (parity with Python)" begin - D = RiskLabAI.Data - cov = [4.0 2.0 0.6; 2.0 3.0 0.5; 0.6 0.5 1.0] - - # cov <-> corr round trip (exact parity). - corr = D.cov_to_corr(cov) - @test corr ≈ [ - 1.0 0.5773502692 0.3 - 0.5773502692 1.0 0.2886751346 - 0.3 0.2886751346 1.0 - ] - @test D.corr_to_cov(corr, sqrt.([4.0, 3.0, 1.0])) ≈ cov - - # PCA eigenvalues, descending (exact parity). - evals, evecs = D.pca(corr) - @test evals ≈ [1.7952446957, 0.7822555891, 0.4224997152] - - # Denoised correlation keeping the top factor (sign-invariant reconstruction). - @test D.denoised_corr(evals, evecs, 1) ≈ [ - 1.0 0.437303437 0.362034825 - 0.437303437 1.0 0.3607926887 - 0.362034825 0.3607926887 1.0 - ] - - # Marcenko–Pastur PDF on a small grid (exact parity). - grid, pdf = D.marcenko_pastur_pdf(0.5, 10; num_points = 5) - @test grid ≈ [0.233772234, 0.391886117, 0.55, 0.708113883, 0.866227766] - @test pdf ≈ [0.0, 2.2244409457, 1.8301531674, 1.2310555486, 0.0] - - # GMV portfolio weights (exact parity). - @test D.optimal_portfolio(cov) ≈ [0.0320924262, 0.1463414634, 0.8215661104] - - # denoise_cov is behavioural (KDE fit differs from sklearn): valid output. - denoised = D.denoise_cov(cov, 10.0) - @test size(denoised) == (3, 3) - @test denoised ≈ denoised' # symmetric - @test diag(denoised) ≈ diag(cov) # variances preserved - @test all(isfinite, denoised) -end - -@testset "Data.Denoise — NERCOME sample-split denoiser (Lam 2016)" begin - # NERCOME is a seeded, sample-splitting estimator: the split permutations use - # Julia's RNG, not NumPy's PCG64, so (like the other path-level modules) the - # estimate is reproducible under a given seed but not bit-identical to Python. - # We assert the documented properties and the admitted mechanism (Appraisal 24: - # lower covariance error than MP clipping on a no-gap / non-stationary spectrum). - D = RiskLabAI.Data - - # Structural properties: shape, symmetry, positive-definiteness, reproducibility. - returns = randn(MersenneTwister(7), 30, 6) - est_a = D.nercome_denoised_covariance(returns; random_state = 11) - est_b = D.nercome_denoised_covariance(returns; random_state = 11) - @test size(est_a) == (6, 6) - @test est_a ≈ est_a' # symmetric - @test isposdef(est_a) # positive-definite - @test est_a == est_b # reproducible under a fixed seed - @test_throws ArgumentError D.nercome_denoised_covariance(randn(3, 4)) # needs >= 4 obs - - # Mechanism (verbatim regime, Appraisal 24): on a slowly-decaying spectrum with - # no clean gap and T ~ p, NERCOME recovers the covariance more accurately than MP - # eigenvalue clipping (and than the raw sample covariance). Fully deterministic - # under the fixed RNG seeds below. - relative_frobenius(estimate, truth) = norm(estimate - truth) / norm(truth) - rng = MersenneTwister(123) - p, t = 12, 30 - eigenvalues = [1.4^(-(i - 1)) for i = 1:p] # no bulk / no clean gap - q_orthogonal = Matrix(qr(randn(rng, p, p)).Q) - sigma = q_orthogonal * Diagonal(eigenvalues) * q_orthogonal' - sigma = (sigma + sigma') / 2 - chol = cholesky(sigma).L - nercome_error = Float64[] - clipping_error = Float64[] - raw_error = Float64[] - for rep = 1:20 - sample = Matrix((chol * randn(rng, p, t))') # t x p returns, true cov sigma - sample_cov = cov(sample) - nercome = D.nercome_denoised_covariance(sample; n_splits = 60, random_state = rep) - clipping = D.denoise_cov(sample_cov, t / p) - push!(nercome_error, relative_frobenius(nercome, sigma)) - push!(clipping_error, relative_frobenius(clipping, sigma)) - push!(raw_error, relative_frobenius(sample_cov, sigma)) - end - @test mean(nercome_error) < mean(clipping_error) # the admitted edge - @test mean(nercome_error) < mean(raw_error) # regularizes the raw sample -end - -@testset "Data.Labeling — CUSUM / barriers / meta-labeling (parity with Python)" begin - D = RiskLabAI.Data - dates = DateTime(2020, 1, 1) .+ Day.(0:19) - close = [ - 100.0, - 102, - 101, - 103, - 105, - 104, - 106, - 103, - 101, - 99, - 100, - 102, - 104, - 103, - 105, - 107, - 106, - 108, - 110, - 109, - ] - - # Symmetric CUSUM events. - events = D.symmetric_cusum_filter(dates, close, 3.0) - @test events == - DateTime.(["2020-01-05", "2020-01-09", "2020-01-13", "2020-01-16", "2020-01-19"]) - - # Daily volatility (pandas debiased EWM std; first value NaN). - vol = D.daily_volatility_with_log_returns(dates, close; span = 5) - @test length(vol.index) == 18 - @test vol.index[1] == DateTime("2020-01-03") - @test isnan(vol.volatility[1]) - @test vol.volatility[2] ≈ 0.000137289 atol = 1e-9 - @test vol.volatility[3] ≈ 0.018224119 atol = 1e-9 - - # Vertical barriers (events past the series end are dropped). - vb = D.vertical_barrier(dates, events, 3) - @test vb.event == DateTime.(["2020-01-05", "2020-01-09", "2020-01-13", "2020-01-16"]) - @test vb.barrier == DateTime.(["2020-01-08", "2020-01-12", "2020-01-16", "2020-01-19"]) - - # Triple-barrier meta-events. - target = Dict(vol.index[i] => vol.volatility[i] for i in eachindex(vol.index)) - vbdict = Dict(vb.event[i] => vb.barrier[i] for i in eachindex(vb.event)) - ev = D.meta_events( - dates, - close, - events, - (1.0, 1.0), - target, - 0.0; - vertical_barriers = vbdict, - ) - @test ev.event_start == events - @test ev.base_width ≈ - [0.018224119, 0.0322455982, 0.034249469, 0.0235399705, 0.0182630643] - @test ev.end_time[1:4] == - DateTime.(["2020-01-08", "2020-01-12", "2020-01-16", "2020-01-19"]) - @test ismissing(ev.end_time[5]) - - # Meta-labeling (event 5 dropped: no barrier touch). - ml = D.meta_labeling(ev, dates, close) - @test ml.ret ≈ [-0.0192313619, 0.0098522964, 0.0284379353, 0.0276515313] - @test ml.label == [-1.0, 1.0, 1.0, 1.0] - - # t-value of an OLS fit (matches scipy linregress slope/stderr). - @test D.calculate_t_value_linear_regression([100.0, 102, 101, 103, 105]) ≈ 3.6666666667 atol = - 1e-9 - - # Trend scanning (behavioural shape check). - ts = D.find_trend_using_trend_scanning([dates[1]], dates, close, (3, 6)) - @test nrow(ts) == 1 - @test ts.end_time[1] == DateTime("2020-01-06") - @test ts.trend[1] in (-1.0, 0.0, 1.0) -end - -@testset "Data.Distance — information-theoretic metrics (parity with Python)" begin - D = RiskLabAI.Data - x = [1.0, 2, 3, 4, 5, 6, 7, 8, 9, 10] - y = [2.0, 1, 4, 3, 6, 5, 8, 7, 10, 9] - - # Optimal bin counts (univariate / bivariate). - @test D.calculate_number_of_bins(10) == 3 - @test D.calculate_number_of_bins(100; correlation = 0.5) == 5 - - # Variation of information (fixed bins). - @test D.calculate_variation_of_information(x, y, 3) ≈ 0.763817002 - @test D.calculate_variation_of_information(x, y, 3; norm = true) ≈ 0.5193177863 - - # Mutual information (optimal bins). - @test D.calculate_mutual_information(x, y) ≈ 1.6094379124 - @test D.calculate_mutual_information(x, y; norm = true) ≈ 1.0 - - # KL divergence and cross-entropy. - p = [0.2, 0.3, 0.5] - q = [0.1, 0.4, 0.5] - @test D.calculate_kullback_leibler_divergence(p, q) ≈ 0.0523248144 - @test D.calculate_cross_entropy(p, q) ≈ 1.0819778284 - - # Angular distance matrices. - dep = [1.0 0.5; 0.5 1.0] - @test D.calculate_distance(dep) ≈ [0.0 0.5; 0.5 0.0] - @test D.calculate_distance(dep; metric = "absolute_angular") ≈ [0.0 0.5; 0.5 0.0] -end - -@testset "Data.Distance — KSG MI & distance correlation (parity with Python)" begin - D = RiskLabAI.Data - # Deterministic closed-form data (identical in both languages); the KSG digamma - # formula depends only on the jitter-invariant integer neighbour counts. - n = 400 - x = [sin(0.3t) + 0.2 * cos(0.07t) for t = 0:(n-1)] - z = [cos(0.11t) for t = 0:(n-1)] - y_lin = 0.8 .* x .+ 0.1 .* z - y_non = x .^ 2 .+ 0.05 .* z - y_ind = z - - @test D.ksg_mutual_information(x, y_lin; k = 4, random_state = 0) ≈ 2.0869197729128883 atol = - 1e-9 - @test D.ksg_mutual_information(x, y_non; k = 4, random_state = 0) ≈ 2.1897736141895248 atol = - 1e-9 - @test D.ksg_mutual_information(x, y_ind; k = 4, random_state = 0) ≈ -0.11395325035808046 atol = - 1e-9 - # Strong dependence -> high MI; near-independence -> near-zero (can be slightly negative). - @test D.ksg_mutual_information(x, y_lin) > D.ksg_mutual_information(x, y_ind) - - @test D.distance_correlation(x, y_lin) ≈ 0.9924531334820932 - @test D.distance_correlation(x, y_non) ≈ 0.4503227910220032 - @test D.distance_correlation(x, y_ind) ≈ 0.03608484508267412 - @test D.distance_correlation(x, x) ≈ 1.0 -end - -@testset "Backtest — statistics (parity with Python)" begin - B = RiskLabAI.Backtest - - # sharpe_ratio uses the population std (numpy ddof=0). - returns = [0.01, -0.02, 0.03, 0.005, -0.01, 0.02, 0.015, -0.005] - @test B.sharpe_ratio(returns) ≈ 0.36291502734548026 - @test B.sharpe_ratio(returns; risk_free_rate = 0.001) ≈ 0.29839680026183946 - @test B.sharpe_ratio([0.5, 0.5, 0.5]) == 0.0 - - # bet_timing: position closes / flips, plus the final timestamp. - idx = DateTime.([ - "2020-01-01", - "2020-01-02", - "2020-01-03", - "2020-01-04", - "2020-01-05", - "2020-01-06", - ]) - positions = [0.0, 1.0, 1.0, 0.0, -1.0, 0.0] - @test B.bet_timing(idx, positions) == DateTime.(["2020-01-04", "2020-01-06"]) - - # average holding period. - hp = B.calculate_holding_period(idx, positions) - @test nrow(hp.holding_periods) == 2 - @test hp.holding_periods.dT ≈ [2.0, 1.0] - @test hp.holding_periods.w ≈ [1.0, 1.0] - @test hp.mean_holding_period ≈ 1.5 - - # Herfindahl–Hirschman index (can exceed 1 for signed returns). - bet_returns = [0.01, -0.02, 0.03, 0.005, -0.01, 0.02, 0.015, -0.005, 0.04, -0.03] - @test B.calculate_hhi(bet_returns) ≈ 1.6060606060606066 - @test B.calculate_hhi(bet_returns[bet_returns .>= 0]) ≈ 0.07083333333333337 - @test B.calculate_hhi(bet_returns[bet_returns .< 0]) ≈ 0.11637080867850098 - @test isnan(B.calculate_hhi([0.1, 0.2])) # <= 2 observations - - # HHI concentration (positive, negative, monthly counts [4, 4, 2]). - weekly = DateTime.([ - "2020-01-05", - "2020-01-12", - "2020-01-19", - "2020-01-26", - "2020-02-02", - "2020-02-09", - "2020-02-16", - "2020-02-23", - "2020-03-01", - "2020-03-08", - ]) - conc = B.calculate_hhi_concentration(weekly, bet_returns) - @test conc.positive ≈ 0.07083333333333337 - @test conc.negative ≈ 0.11637080867850098 - @test conc.time ≈ 0.04000000000000017 - - # drawdowns and time under water. - pidx = DateTime.([ - "2020-01-01", - "2020-01-02", - "2020-01-03", - "2020-01-04", - "2020-01-05", - "2020-01-06", - "2020-01-07", - "2020-01-08", - ]) - pnl = [100.0, 102.0, 101.0, 103.0, 99.0, 98.0, 105.0, 104.0] - - dd = B.compute_drawdowns_time_under_water(pidx, pnl; dollars = true) - @test dd.start == DateTime.(["2020-01-02", "2020-01-04", "2020-01-07"]) - @test dd.drawdown ≈ [1.0, 5.0, 1.0] - @test dd.time_under_water ≈ [1 / 365.25, 2 / 365.25, 1 / 365.25] - - ddp = B.compute_drawdowns_time_under_water(pidx, pnl; dollars = false) - @test ddp.drawdown ≈ [1 - 101 / 102, 1 - 98 / 103, 1 - 104 / 105] - @test ddp.time_under_water ≈ [1 / 365.25, 2 / 365.25, 1 / 365.25] -end - -@testset "Backtest — CED & Sharpe-difference test (parity with Python)" begin - B = RiskLabAI.Backtest - n = 300 - a = [0.001 + 0.012 * sin(0.07t) + 0.004 * cos(0.3t) for t = 0:(n-1)] - b = [0.013 * cos(0.05t) + 0.003 * sin(0.21t) for t = 0:(n-1)] - r = [0.01 * sin(0.05t) + 0.02 * cos(0.011t) - 0.003 for t = 0:(n-1)] - - # Conditional Expected Drawdown (deterministic; exact parity). - @test B.conditional_expected_drawdown(r, 60, 0.90) ≈ 0.7703735604378519 - @test B.conditional_expected_drawdown(r, 60, 0.95) ≈ 0.7746692629744587 - @test B.conditional_expected_drawdown(r, 30, 0.80) ≈ 0.5173307336300851 - @test_throws ArgumentError B.conditional_expected_drawdown(r, 60, 1.5) - - # Naive Sharpe-difference test (deterministic; exact parity). - nv = B.sharpe_difference_test(a, b; method = "naive") - @test nv.delta ≈ 0.1485861269627159 - @test nv.se ≈ 0.0813523963928328 - @test nv.stat ≈ 1.8264505232916093 - @test nv.pvalue ≈ 0.06778242746585597 - @test nv.reject == false - - # Ledoit–Wolf: delta / se (HAC) / stat are deterministic (exact); the bootstrap - # p-value uses Julia's RNG (behavioural -> structural check). - lw = B.sharpe_difference_test( - a, - b; - method = "ledoit_wolf", - n_boot = 500, - random_state = 0, - ) - @test lw.delta ≈ 0.1485861269627159 - @test lw.se ≈ 0.22337469299514048 - @test lw.stat ≈ 0.6651878284437011 - @test 0.0 <= lw.pvalue <= 1.0 - @test_throws ArgumentError B.sharpe_difference_test(a, b; method = "xyz") -end - -@testset "Backtest — closed-form OU trading rules (parity with Python)" begin - B = RiskLabAI.Backtest - th, sg, eg = 0.1, 0.1, 1.0 - - @test B.theta_from_half_life(10.0) ≈ 0.06931471805599453 - @test B.stationary_std(th, sg) ≈ 0.223606797749979 - - # Closed-form first-passage metrics (deterministic; exact parity). - @test B.hit_upper_probability(eg, 2.0, 1.0, th, sg) ≈ 0.9999999999996079 - @test B.mean_exit_time(eg, 2.0, 1.0, th, sg) ≈ 130958.42974177317 rtol = 1e-9 - m = B.ou_rule_metrics(2.0, 1.0, th, sg, eg, 0.0) - @test m.return_rate ≈ 1.5272021846493343e-5 rtol = 1e-9 - @test m.time_scaled_sharpe ≈ 2941.8908064682373 rtol = 1e-9 - @test isnan(B.ou_rule_metrics(-1.0, 1.0, th, sg, eg).hit_probability) # invalid -> bad - - # Optimizer (grid search vs SciPy L-BFGS-B): the objective surface is flat along - # the stop-loss ridge, so the maximized return rate matches even when sl differs. - opt = B.optimal_ou_trading_rule(th, sg, eg) - @test opt.profit_take > 0 && opt.stop_loss > 0 - @test opt.return_rate ≈ 0.0921858063421004 atol = 1e-3 - - # OU fit (AR(1) regression; deterministic; exact parity). - x = zeros(2000) - for t = 2:2000 - x[t] = 0.9 * x[t-1] + 0.1 * sin(0.3 * (t - 1)) - end - f = B.fit_ornstein_uhlenbeck(x) - @test f.theta ≈ 0.044973376784760155 rtol = 1e-9 - @test f.sigma ≈ 0.07109144798150265 rtol = 1e-9 - @test f.rho ≈ 0.9560229338986339 rtol = 1e-9 - @test f.r2 ≈ 0.9130968551707074 rtol = 1e-9 -end - -@testset "Backtest — PSR & test-set overfitting (parity with Python)" begin - B = RiskLabAI.Backtest - - # Probabilistic Sharpe ratio (Φ of the Z-statistic). - psr_sat = B.probabilistic_sharpe_ratio( - 2.5, - 1.0, - 252; - skewness_of_returns = -0.5, - kurtosis_of_returns = 4.0, - ) - @test psr_sat ≈ 1.0 - @test B.probabilistic_sharpe_ratio(1.5, 1.0, 100) ≈ 0.9996784793766524 - psr_z = B.probabilistic_sharpe_ratio(1.5, 1.0, 100; return_test_statistic = true) - @test psr_z ≈ 3.4127787539671264 - # Non-positive denominator: PSR -> 0, Z -> -Inf. - @test B.probabilistic_sharpe_ratio(3.0, 1.0, 100; skewness_of_returns = 2.0) == 0.0 - psr_inf = B.probabilistic_sharpe_ratio( - 3.0, - 1.0, - 100; - skewness_of_returns = 2.0, - return_test_statistic = true, - ) - @test psr_inf == -Inf - - # Benchmark (expected-max) Sharpe ratio. - @test B.benchmark_sharpe_ratio([0.5, 1.2, -0.3, 0.8, 1.5, 0.9]) ≈ 0.7418275166314174 - @test B.benchmark_sharpe_ratio([0.7]) == 0.7 - @test B.benchmark_sharpe_ratio(Float64[]) == 0.0 - - # Expected maximum Sharpe ratio (truncated Euler constant). - @test B.expected_max_sharpe_ratio(10, 0.0, 1.0) ≈ 1.5745983013449716 - @test B.expected_max_sharpe_ratio(50, 0.5, 1.2) ≈ 3.231563712103708 - @test B.expected_max_sharpe_ratio(1, 0.33, 1.0) == 0.33 - @test B.expected_max_sharpe_ratio(0, 0.33, 1.0) == 0.0 - - # Sharpe-ratio Z-statistic. - @test B.estimated_sharpe_ratio_z_statistics(1.5, 100) ≈ 10.238336261901377 - z_skew = B.estimated_sharpe_ratio_z_statistics( - 1.2, - 52; - true_sharpe_ratio = 0.5, - skew = -0.3, - kurt = 4, - ) - @test z_skew ≈ 3.200281749891488 - @test isnan(B.estimated_sharpe_ratio_z_statistics(3.0, 100; skew = 2.0)) - - # Multiple-testing type-1 / type-2 errors. - @test B.strategy_type1_error_probability(1.96) ≈ 0.024997895148220373 - alpha_k = B.strategy_type1_error_probability(1.96; k = 10) - @test alpha_k ≈ 0.22365361940347483 - theta = B.theta_for_type2_error(1.2, 52, 1.0; skew = -0.3, kurt = 4) - @test theta ≈ 4.571831071273555 - @test isnan(B.theta_for_type2_error(3.0, 100, 1.0; skew = 2.0)) - @test B.strategy_type2_error_probability(alpha_k, 10, theta) ≈ 0.004502937106211302 - - # Monte-Carlo helpers are stochastic — check structure with a seeded RNG. - sims = B.generate_max_sharpe_ratios(20, [5, 10], 1.0, 0.0; rng = MersenneTwister(1)) - @test names(sims) == ["max_SR", "n_trials"] - @test nrow(sims) == 40 - @test all(isfinite, sims.max_SR) - - err = B.mean_std_error(20, 3, [5, 10]; rng = MersenneTwister(2)) - @test names(err) == ["n_trials", "meanErr", "stdErr"] - @test nrow(err) == 2 - @test all(isfinite, err.meanErr) -end - -@testset "Backtest — LPLZ HAC Sharpe inference (parity with Python)" begin - B = RiskLabAI.Backtest - r = [ - 0.012, - -0.004, - 0.021, - 0.008, - -0.011, - 0.017, - 0.003, - -0.006, - 0.014, - 0.009, - -0.002, - 0.019, - 0.006, - -0.008, - 0.011, - 0.004, - 0.016, - -0.003, - 0.013, - 0.007, - -0.005, - 0.018, - 0.002, - -0.009, - 0.015, - ] - - @test B.newey_west_automatic_lag(25) == 2 - @test B.newey_west_automatic_lag(240) == 4 - @test B.newey_west_automatic_lag(1000) == 6 - @test B.newey_west_long_run_variance(B.sharpe_ratio_influence_function(r), 2) ≈ - 0.23445542337329167 - @test B.newey_west_long_run_variance(B.sharpe_ratio_influence_function(r), 0) ≈ - 1.1295168114508831 - - res = B.lplz_sharpe_inference(r) - @test res.sharpe_ratio ≈ 0.6037202815610235 - @test res.standard_error ≈ 0.09684119441091 - @test res.test_statistic ≈ 6.234126760140509 - @test res.p_value ≈ 4.5430511104277287e-10 - @test res.significant == true - @test res.lag == 2 - @test res.confidence_interval[1] ≈ 0.41391502829579835 - @test res.confidence_interval[2] ≈ 0.7935255348262487 - - # Custom confidence level, fixed lag, non-zero null. - res2 = B.lplz_sharpe_inference( - r; - confidence_level = 0.99, - lag = 3, - null_sharpe_ratio = 0.1, - ) - @test res2.standard_error ≈ 0.12042504761055403 - @test res2.test_statistic ≈ 4.182853082109785 - @test res2.p_value ≈ 2.8787343639696333e-5 - @test res2.lag == 3 - - # Degenerate (fewer than 3 obs): NaN inference. - short = B.lplz_sharpe_inference([0.1, 0.2]) - @test isnan(short.standard_error) - @test short.lag == 0 - @test short.significant == false -end - -@testset "Backtest — strategy risk (parity with Python)" begin - B = RiskLabAI.Backtest - - # Closed-form binomial variance (Python returns the SymPy expression). - @test B.target_sharpe_ratio_symbolic(0.3, 1.0, -1.0) ≈ 0.84 - - # Implied precision: real root in (0, 1), and NaN when the discriminant < 0. - @test B.implied_precision(0.001, 0.01, 2, 1.0) ≈ 0.11111111111111126 - @test isnan(B.implied_precision(0.02, 0.04, 260, 1.5)) - - # Implied bets-per-year frequency. - @test B.bin_frequency(0.02, 0.04, 0.7, 1.5) ≈ 0.16349480968858132 - @test B.bin_frequency(0.02, 0.04, 0.0, 1.5) == Inf - @test B.bin_frequency(0.02, 0.04, 1.0, 1.5) == Inf - - # Annualised binomial Sharpe ratio (signed Inf when dispersion is zero). - @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 0.7) ≈ 12.90174509339827 - @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 0.0) == -Inf - @test B.binomial_sharpe_ratio(-0.02, 0.04, 260, 1.0) == Inf - - # Failure probability (normal-CDF Z-score on observed vs required precision). - rets = [0.01, 0.012, 0.008, -0.001, -0.0012, -0.0008] - @test B.failure_probability(rets, 2, 1.0) ≈ 0.9716202768069931 - # No losing returns -> 0.0. - @test B.failure_probability([0.01, 0.02, 0.03], 260, 1.0) == 0.0 - # Target unachievable (required precision is NaN) -> 1.0. - @test B.failure_probability([0.04, -0.02, 0.03, -0.01], 260, 1.5) == 1.0 - - # Monte-Carlo helpers are stochastic — check structure with a seeded RNG. - trials = B.sharpe_ratio_trials(0.6, 200; rng = MersenneTwister(3)) - @test length(trials) == 3 - @test all(isfinite, trials) - mixed = B.mix_gaussians(0.05, -0.02, 0.01, 0.01, 0.6, 100; rng = MersenneTwister(4)) - @test length(mixed) == 100 - risk = B.calculate_strategy_risk( - 0.05, - -0.02, - 0.01, - 0.01, - 0.6, - 100, - 260, - 1.0; - rng = MersenneTwister(5), - ) - @test 0.0 <= risk <= 1.0 -end - -@testset "Backtest — PBO & synthetic backtesting (parity with Python)" begin - B = RiskLabAI.Backtest - - # Deterministic fixture: rows = observations, cols = strategies. - perf = [sin(0.7t + 1.3i) for t = 1:12, i = 1:4] - metric = (r, rf) -> B.sharpe_ratio(r; risk_free_rate = rf) - - pe = B.performance_evaluation(perf[1:6, :], perf[7:12, :], 4, metric, 0.0) - @test pe[1] == true - @test pe[2] ≈ -0.4054651081081643 - - pbo, logits = B.probability_of_backtest_overfitting(perf; n_partitions = 4) - @test pbo ≈ 1.0 - @test logits ≈ [ - -0.4054651081081643, - -0.4054651081081643, - -1.3862943611198906, - -1.3862943611198906, - -0.4054651081081643, - -0.4054651081081643, - ] - @test_throws ArgumentError B.probability_of_backtest_overfitting(perf; n_partitions = 3) - - # Synthetic backtesting is stochastic — check structure with tiny, seeded params. - results = B.synthetic_back_testing( - 0.0, - 5.0, - 1.0; - n_iteration = 50, - maximum_holding_period = 20, - profit_taking_range = [1.0, 2.0], - stop_loss_range = [1.0, 2.0], - seed = 0, - rng = MersenneTwister(7), - ) - @test length(results) == 4 - @test all(t -> length(t) == 5, results) -end - -@testset "Backtest — bet sizing (parity with Python)" begin - B = RiskLabAI.Backtest - - # Probability bet size: side · (2·Φ(p) - 1). - pbs = B.probability_bet_size([0.55, 0.6, 0.45], [1.0, -1.0, 1.0]) - @test pbs ≈ [0.417680626423, -0.4514937645, 0.347289559424] - - # Concurrent average bet size over numeric dates. - abs_out = B.average_bet_sizes([0, 1, 2, 3, 4], [0.0, 2.0], [2.0, 4.0], [0.5, -0.3]) - @test abs_out ≈ [0.5, 0.5, 0.1, -0.3, -0.3] - - # Discretisation (round to step, cap at ±1). - @test B.discrete_signal([0.26, 0.94, -1.4, 0.05], 0.1) ≈ [0.3, 0.9, -1.0, 0.0] - - # Sigmoid position-sizing family. - @test B.bet_size_sigmoid(2.0, 1.0) ≈ 0.5773502691896258 - @test B.target_position(2.0, 105.0, 100.0, 10) == 9 - @test B.inverse_price(100.0, 2.0, 0.5) ≈ 99.18350341907228 - @test B.inverse_price(100.0, 2.0, 1.0) == 100.0 - @test B.limit_price(5, 2, 100.0, 2.0, 10) ≈ 99.37384680974242 - @test B.compute_sigmoid_width(5.0, 0.5) ≈ 75.0 - @test B.compute_sigmoid_width(5.0, 0.0) == Inf - - # Concurrent active-signal averaging. - starts = [1.0, 2.0, 3.0] - ends = [3.0, 4.0, 5.0] - tp, avg = B.avg_active_signals(starts, ends, [1.0, -0.5, 0.8]) - @test tp == [1.0, 2.0, 3.0, 4.0, 5.0] - @test avg ≈ [1.0, 0.25, 0.15, 0.8, 0.0] - - # A missing end never closes its signal. - avg_missing = - B.mp_avg_active_signals(starts, [missing, 4.0, 5.0], [1.0, -0.5, 0.8], [5.0]) - @test avg_missing ≈ [1.0] - - # End-to-end signal generation (no meta-label side). - gtp, gsig = - B.generate_signal(starts, ends, nothing, [0.6, 0.55, 0.7], [1.0, -1.0, 1.0], 2, 0.1) - @test gtp == [1.0, 2.0, 3.0, 4.0, 5.0] - @test gsig ≈ [0.2, 0.0, 0.1, 0.3, 0.0] -end - -@testset "Backtest — multiple-testing Sharpe haircuts (parity with Python)" begin - B = RiskLabAI.Backtest - sr = [0.25, 0.18, 0.05, 0.02] - T = 120 - - @test B.sharpe_ratio_p_values(sr, T) ≈ [ - 0.0030849496602720805, - 0.024316152257345184, - 0.29194121038518256, - 0.4132903507196785, - ] - - p = B.sharpe_ratio_p_values(sr, T) - @test B.holm_adjusted_p_values(p) ≈ - [0.012339798641, 0.072948456772, 0.58388242077, 0.58388242077] - @test B.benjamini_hochberg_yekutieli_adjusted_p_values(p) ≈ - [0.025707913836, 0.101317301072, 0.810947806626, 0.861021563999] - - h = B.haircut_sharpe_ratios(sr, T; method = "holm") - @test h.significant == [true, false, false, false] - @test h.haircut_sharpe_ratios ≈ - [0.205065615311, 0.132747698412, -0.019337871954, -0.019337871954] - - b = B.haircut_sharpe_ratios(sr, T; method = "bhy") - @test b.haircut_sharpe_ratios ≈ - [0.177826597449, 0.116307178741, -0.080459932637, -0.099039237584] - @test_throws ArgumentError B.haircut_sharpe_ratios(sr, T; method = "xyz") - - # 6-strategy set with ties (tie-handling must give equal adjusted values). - sr2 = [0.30, 0.10, 0.10, 0.22, 0.01, 0.15] - p2 = B.sharpe_ratio_p_values(sr2, 250) - @test B.holm_adjusted_p_values(p2) ≈ [ - 6.304308e-06, - 0.17076944701, - 0.17076944701, - 0.001260545574, - 0.437183530581, - 0.035412131615, - ] - @test B.benjamini_hochberg_yekutieli_adjusted_p_values(p2) ≈ - [1.5445554e-05, 0.16735405807, 0.16735405807, 0.001853001993, 1.0, 0.043379861228] -end - -@testset "Features — entropy (parity with Python)" begin - F = RiskLabAI.Features - m = "11100010011110100" - - @test F.shannon_entropy(m) ≈ 0.9975025463691152 - @test F.shannon_entropy("1111") == 0.0 - @test F.shannon_entropy("") == 0.0 - - @test F.lempel_ziv_entropy(m) ≈ 0.47058823529411764 - - @test F.probability_mass_function(m, 2) == - Dict("11" => 0.3125, "10" => 0.25, "00" => 0.25, "01" => 0.1875) - - @test F.plug_in_entropy_estimator(m, 1) ≈ 0.9975025463691152 - @test F.plug_in_entropy_estimator(m, 2) ≈ 0.9886085007312413 - - @test F.longest_match_length(m, 5, 5) == (2, "0") - # Kontoyiannis H_k is the *averaged* Σ log2(nᵢ)/Lᵢ (de Prado's formula). - @test F.kontoyiannis_entropy(m) ≈ 0.9847738922739608 - @test F.kontoyiannis_entropy(m; window = 5) ≈ 0.8868475362417008 -end - -@testset "Features — bias-corrected entropy (parity with Python)" begin - F = RiskLabAI.Features - m = "11100010011110100" - - # Miller–Madow (plug-in + first-order analytic correction). - @test F.miller_madow_entropy(m, 1) ≈ 1.0399347534540848 - @test F.miller_madow_entropy(m, 2) ≈ 1.0562348307729115 - @test F.miller_madow_entropy(m, 3) ≈ 1.0698448127313827 - - # Grassberger (digamma higher-order correction). - @test F.grassberger_entropy(m, 1) ≈ 1.083745643356587 - @test F.grassberger_entropy(m, 2) ≈ 1.0829481408420427 - @test F.grassberger_entropy(m, 3) ≈ 1.1437749024876813 - - # NSB (Bayesian; needs the true alphabet size). Quadrature matches Python to ~1e-8. - @test F.nsb_entropy(m, 1, 2) ≈ 0.965537224890827 atol = 1e-8 - @test F.nsb_entropy(m, 2, 2) ≈ 0.9600582245715601 atol = 1e-8 - @test F.nsb_entropy(m, 1) ≈ 0.965537224890827 atol = 1e-8 # default alphabet = 2 - - # Larger-alphabet message. - m2 = "abracadabraalakazamabracadabra" - @test F.miller_madow_entropy(m2, 2) ≈ 2.063821752851152 - @test F.grassberger_entropy(m2, 2) ≈ 2.151409069604457 - @test F.nsb_entropy(m2, 2) ≈ 2.3265927148279415 atol = 1e-6 - @test F.nsb_entropy(m2, 2, 7) ≈ 2.2834213811058284 atol = 1e-6 - - # Empty message -> 0. - @test F.miller_madow_entropy("", 1) == 0.0 - @test F.grassberger_entropy("", 1) == 0.0 - @test F.nsb_entropy("", 1, 2) == 0.0 -end - -@testset "Features — microstructural (parity with Python)" begin - F = RiskLabAI.Features - high = [10.0, 11, 12, 11, 13, 12, 14, 13, 15, 14] - low = [9.0, 9.5, 11, 10, 12, 11, 13, 12, 14, 13] - - # Corwin–Schultz β: NaN for the first 3 (rolling-2 then rolling-3 warm-up). - beta = F.beta_estimates(high, low, 3) - @test all(isnan, beta[1:3]) - @test beta[4:end] ≈ [ - 0.026103995125, - 0.020403144632, - 0.015374563434, - 0.014177217155, - 0.012979870876, - 0.012042883073, - 0.01110589527, - ] - - # γ: NaN only for the first point (rolling-2). - gamma = F.gamma_estimates(high, low) - @test isnan(gamma[1]) - @test gamma[2:end] ≈ [ - 0.040268728017, - 0.054575898693, - 0.033241150072, - 0.06883500727, - 0.027907067203, - 0.058159137648, - 0.023762432091, - 0.049793044493, - 0.020477851451, - ] - - # α and spread are floored at 0 for this fixture. - spread = F.corwin_schultz_estimator(high, low, 3) - @test all(isnan, spread[1:3]) - @test spread[4:end] == zeros(7) - - # Bekker–Parkinson volatility. - bp = F.bekker_parkinson_volatility_estimates(high, low, 3) - @test all(isnan, bp[1:3]) - @test bp[4:end] ≈ [ - 0.665889352092, - 0.741771939318, - 0.552081947665, - 0.652306085438, - 0.508261603535, - 0.602526273704, - 0.470915692879, - ] -end - -@testset "Features — EDGE spread estimator (parity with Python)" begin - F = RiskLabAI.Features - # Same 12-bar OHLC fixture as the Python edge.edge_estimator reference. - o = [100.0, 101.2, 100.5, 102.1, 101.8, 103.0, 102.4, 104.1, 103.6, 105.2, 104.8, 106.0] - h = [101.0, 101.9, 101.3, 102.8, 102.5, 103.6, 103.2, 104.7, 104.3, 105.8, 105.5, 106.6] - l = [99.4, 100.6, 99.9, 101.3, 101.0, 102.2, 101.7, 103.4, 102.9, 104.5, 104.0, 105.3] - c = [101.1, 100.7, 101.0, 102.3, 101.6, 103.2, 102.1, 104.4, 103.3, 105.4, 104.4, 106.4] - - @test F.edge_estimator(o, h, l, c) ≈ 0.006962635220907141 - @test F.edge_estimator(o, h, l, c; sign = true) ≈ 0.006962635220907141 - - # Degenerate inputs return NaN (fewer than 3 obs; flat / no-trade bars). - @test isnan(F.edge_estimator([1.0, 2.0], [1.0, 2.0], [1.0, 2.0], [1.0, 2.0])) - flat = fill(100.0, 5) - @test isnan(F.edge_estimator(flat, flat, flat, flat)) - - # Mismatched lengths raise. - @test_throws ArgumentError F.edge_estimator([1.0, 2.0, 3.0], [1.0, 2.0], [1.0], [1.0]) -end - -@testset "Features — structural breaks (parity with Python)" begin - F = RiskLabAI.Features - - # lag_dataframe: columns are lags 0..2 with NaN warm-up. - lagged = F.lag_dataframe([10.0, 11, 12, 13], 2) - @test size(lagged) == (4, 3) - @test lagged[:, 1] == [10.0, 11.0, 12.0, 13.0] - @test all(isnan, lagged[1:1, 2]) - @test lagged[2:4, 2] == [10.0, 11.0, 12.0] - @test all(isnan, lagged[1:2, 3]) - @test lagged[3:4, 3] == [10.0, 11.0] - - # prepare_data ADF design (constant="ct", lags=2): exact diff/shift alignment. - periodic = [1.0, 1.1, 1.05, 1.2, 1.15, 1.3, 1.25, 1.4, 1.35, 1.5, 1.45, 1.6] - y, x, index = F.prepare_data(periodic, "ct", 2) - @test index == [4, 5, 6, 7, 8, 9, 10, 11, 12] - @test y ≈ [0.15, -0.05, 0.15, -0.05, 0.15, -0.05, 0.15, -0.05, 0.15] - @test size(x) == (9, 5) # level, Δl1, Δl2, const, trend - @test x[:, 1] ≈ [1.05, 1.2, 1.15, 1.3, 1.25, 1.4, 1.35, 1.5, 1.45] # lagged level - @test x[:, 4] == ones(9) # constant - @test x[:, 5] == [4.0, 5, 6, 7, 8, 9, 10, 11, 12] # trend - - # OLS β and the ADF t-statistic on a well-conditioned (constant="c") design. - noisy = [1.0, 1.03, 1.01, 1.06, 1.1, 1.08, 1.15, 1.13, 1.2, 1.26, 1.24, 1.33] - yc, xc, _ = F.prepare_data(noisy, "c", 1) - beta_mean, beta_variance = F.compute_beta(yc, xc) - @test beta_mean ≈ [0.14542507467, -0.831800466234, -0.113785422889] - @test beta_mean[1] / sqrt(beta_variance[1, 1]) ≈ 1.007102910535 - - # Backward Supremum ADF and the expanding-window ADF path. - bsadf = F.get_bsadf_statistic(noisy, 6, "c", 1) - @test bsadf.bsadf ≈ 1.007102910535 - adf = F.get_expanding_window_adf(noisy, 6, "c", 1) - @test adf.statistics ≈ - [0.16894098733, 0.437811843619, 1.415912133027, 0.543280756955, 1.007102910535] -end - -@testset "Features — GSADF / BSADF (parity with Python)" begin - F = RiskLabAI.Features - # Same 40-point series as the Python structural_breaks reference (rounded to - # 6 decimals so both languages compute on identical inputs). - y = [ - 0.12573, - -0.006375, - 0.634048, - 0.738948, - 0.203279, - 0.564874, - 1.868874, - 2.815955, - 2.11222, - 0.846798, - 0.223524, - 0.26485, - -2.060181, - -2.278973, - -3.524884, - -4.257151, - -4.80141, - -5.11771, - -4.70608, - -3.663566, - -5.288989, - -4.303964, - -3.388485, - -2.946022, - -2.976168, - -3.587708, - -2.488458, - -0.777045, - 0.946885, - 2.356677, - -4.654213, - -4.863389, - -5.022614, - -4.481768, - -4.267109, - -3.911736, - -4.565565, - -4.695179, - -3.911203, - -2.417772, - ] - - @test F.psy_minimum_window(length(y)) == 12 - nmin = 12 - - bsadf_exp = [ - -0.536910634, - -0.362311837, - 0.34906369, - 0.517036689, - 0.582574434, - 0.520469967, - 0.102659841, - -0.416291845, - -0.031199142, - -0.478681209, - -0.737819991, - -0.838846133, - -0.866177154, - -0.817563944, - -1.000451307, - -0.126785185, - 0.890803186, - 1.384764704, - -1.610888373, - -1.586555274, - -1.569062869, - -1.655578929, - -1.696733029, - -1.700368814, - -1.6264952, - -1.588480421, - -1.618163091, - -1.737832868, - ] - sadf_exp = [ - -0.536910634, - -0.38399068, - 0.263498555, - 0.506163518, - 0.582574434, - 0.520469967, - 0.102659841, - -0.419886094, - -0.031199142, - -0.478681209, - -0.747317284, - -0.851945349, - -0.87885276, - -0.825252719, - -1.016427934, - -1.088760162, - -0.920870397, - -0.626601733, - -1.626225512, - -1.599491827, - -1.579961277, - -1.667177256, - -1.707893951, - -1.763814976, - -1.717976507, - -1.716496223, - -1.820220308, - -1.940406359, - ] - - bs = F.get_bsadf_sequence(y, nmin, "c", 0) - sa = F.get_sadf_sequence(y, nmin, "c", 0) - @test bs.values ≈ bsadf_exp atol = 1e-8 - @test sa.values ≈ sadf_exp atol = 1e-8 - # Endpoints are 1-based positions (nmin+1 .. T). - @test bs.index == collect(13:40) - @test F.get_gsadf_statistic(y, nmin, "c", 0) ≈ 1.3847647044558782 atol = 1e-8 - - # Date-stamping: 1-based positions (Python 0-based labels + 1). - @test F.get_bubble_episodes(bs.index, bs.values, 1.0; min_duration = 1) == [(30, 31)] - @test F.get_bubble_episodes(bs.index, bs.values, 0.5; min_duration = 2) == - [(16, 19), (29, 31)] - - # Critical-value simulator is behavioural (random-walk null, Julia RNG): - # structural checks only. - cv = F.simulate_psy_critical_values( - 40, - nmin; - n_simulations = 50, - rng = MersenneTwister(0), - ) - @test cv.min_sample_length == 12 - @test isfinite(cv.gsadf_global_cv) - @test isfinite(cv.sadf_global_cv) - @test length(cv.bsadf_sequence_cv) == 28 -end - -@testset "Features — volatility-robust SADF (parity with Python)" begin - F = RiskLabAI.Features - fixture(name) = parse.(Float64, readlines(joinpath(@__DIR__, "fixtures", name))) - - # Deterministic explosive-then-collapse log-price path (saved at full Float64 - # precision so Julia and Python compute on the identical input). The observed - # SADF / GSADF sup-ADF statistics are deterministic -> tight parity; the - # wild-bootstrap p-values use Julia's RNG -> behavioural (size/power) checks. - y = fixture("vrsadf_y.txt") - res = F.volatility_robust_sadf(y; n_bootstrap = 199, random_state = 0) - - @test res.sadf ≈ 5.528067986814794 atol = 1e-6 # Python reference - @test res.gsadf ≈ 8.034555882362156 atol = 1e-6 # Python reference - @test 0.0 < res.sadf_pvalue <= 1.0 - @test 0.0 < res.gsadf_pvalue <= 1.0 - # On this explosive series the test rejects (small p-value). - @test res.reject_gsadf - @test res.reject_sadf - - # Power/size contrast: a pure random walk (no bubble) is NOT rejected, and its - # p-values strictly exceed the explosive series' -> the test discriminates. - walk = cumsum(randn(MersenneTwister(42), 150)) - res_walk = F.volatility_robust_sadf(walk; n_bootstrap = 199, random_state = 1) - @test !res_walk.reject_sadf - @test !res_walk.reject_gsadf - @test res_walk.gsadf_pvalue > res.gsadf_pvalue - - # Default min_length follows psy_minimum_window; short series error out. - @test_throws ArgumentError F.volatility_robust_sadf(collect(1.0:5.0)) -end - -@testset "Features — PELT change-points (parity with ruptures)" begin - F = RiskLabAI.Features - fixture(name) = parse.(Float64, readlines(joinpath(@__DIR__, "fixtures", name))) - - # PELT is deterministic, so it reproduces the Python `ruptures` reference indices - # exactly (0-based, matching the Python convention). Fixtures are the identical - # arrays the Python reference was run on, saved at full Float64 precision. - x1 = fixture("pelt_x1.txt") # n=240, mean shift at 120 - x2 = fixture("pelt_x2.txt") # n=200, pure VARIANCE shift at 100 (CUSUM-blind) - x3 = fixture("pelt_x3.txt") # n=320, three change-points (mean+variance) - - @test F.pelt_change_points(x1) == [120] - @test F.pelt_change_points(x2) == [100] - @test F.pelt_change_points(x3; penalty_multiplier = 2.0, min_size = 15, jump = 5) == - [80, 160, 240] - - # Fully explicit deterministic step (no RNG): single change-point at 30. - step = vcat(zeros(30), fill(10.0, 30)) - @test F.pelt_change_points(step; penalty_multiplier = 1.0, min_size = 10, jump = 5) == - [30] - - # Edge cases: too-short series -> empty; flat series -> no change-points. - @test F.pelt_change_points(randn(MersenneTwister(0), 15)) == Int[] - @test F.pelt_change_points(fill(2.0, 100)) == Int[] - @test_throws ArgumentError F.pelt_change_points( - randn(MersenneTwister(0), 100); - model = "rbf", - ) -end - -@testset "Optimization — HRP & hedging (parity with Python)" begin - O = RiskLabAI.Optimization - cov = [0.04 0.006 0.0 0.0; 0.006 0.09 0.0 0.0; 0.0 0.0 0.16 0.012; 0.0 0.0 0.012 0.25] - - # Inverse-variance weights. - @test O.inverse_variance_weights(cov) ≈ - [0.53924505692, 0.239664469742, 0.13481126423, 0.086279209107] - - # Cluster variance (inverse-variance weighted). - @test O.cluster_variance(cov, [1, 2]) ≈ 0.03024852071 - @test O.cluster_variance(cov, [3, 4]) ≈ 0.103271861987 - - # Quasi-diagonal ordering from a SciPy-format linkage matrix (-> 1-based). - link = [0.0 1 0.5 2; 2 3 0.6 2; 4 5 0.9 4] - @test O.quasi_diagonal(link) == [1, 2, 3, 4] - - # Recursive-bisection HRP weights. - @test O.recursive_bisection(cov, [1, 2, 3, 4]) ≈ - [0.535468091151, 0.237985818289, 0.138137860097, 0.088408230462] - - # Correlation distance. - @test O.distance_corr([1.0 0.5; 0.5 1.0]) ≈ [0.0 0.5; 0.5 0.0] - - # PCA hedging: sign-free invariant wᵀ C w = risk_target² · Σρ. - w_min = O.pca_weights(cov) - @test length(w_min) == 4 - @test w_min' * cov * w_min ≈ 1.0 - rd = [0.1, 0.2, 0.3, 0.4] - w_custom = O.pca_weights(cov; risk_distribution = rd, risk_target = 2.0) - @test w_custom' * cov * w_custom ≈ 2.0^2 * sum(rd) -end - -@testset "Cluster — ONC, silhouette & generators" begin - C = RiskLabAI.Cluster - - # silhouette_samples matches scikit-learn exactly (precomputed distance). - x = [0.0, 0.1, 0.2, 1.0, 1.1, 1.3] - dist = [abs(x[i] - x[j]) for i = 1:6, j = 1:6] - sil_two = C.silhouette_samples(dist, [1, 1, 1, 2, 2, 2]) - expected_two = [ - 0.867647058824, - 0.903225806452, - 0.839285714286, - 0.777777777778, - 0.85, - 0.791666666667, - ] - @test sil_two ≈ expected_two atol = 1e-9 - - # A singleton cluster yields silhouette 0 for that point. - sil_one = C.silhouette_samples(dist, [1, 1, 1, 1, 1, 2]) - expected_one = - [0.538461538462, 0.5625, 0.545454545455, -0.571428571429, -0.741935483871, 0.0] - @test sil_one ≈ expected_one atol = 1e-9 - @test sil_one[6] == 0.0 - - # covariance_to_correlation delegates to Data.cov_to_corr. - cov2 = [4.0 1.0; 1.0 9.0] - @test C.covariance_to_correlation(cov2) ≈ [1.0 1/6; 1/6 1.0] - - # Two clearly separated blocks built programmatically (formatter-safe). - corr = fill(0.05, 6, 6) - corr[1:3, 1:3] .= 0.9 - corr[4:6, 4:6] .= 0.85 - corr[diagind(corr)] .= 1.0 - - # k-means base returns a valid partition over all items. - _, clusters, sil = C.cluster_k_means_base(corr; max_clusters = 5, random_state = 1) - @test sort(reduce(vcat, collect(values(clusters)))) == collect(1:6) - @test length(sil) == 6 - @test all(!isempty(v) for v in values(clusters)) - - # ONC top-level also yields a valid partition over all items. - _, clusters_top, sil_top = C.cluster_k_means_top(corr; random_state = 1) - @test sort(reduce(vcat, collect(values(clusters_top)))) == collect(1:6) - @test length(sil_top) == 6 - - # Random block correlation: square, symmetric, unit diagonal. - rbc = C.random_block_correlation(8, 2; random_state = 7) - @test size(rbc) == (8, 8) - @test rbc ≈ rbc' - @test all(abs.(diag(rbc) .- 1.0) .< 1e-9) -end - -@testset "Optimization — hrp() & NCO (parity with Python)" begin - O = RiskLabAI.Optimization - cov = [1.0 0.8 0.0 0.0; 0.8 1.0 0.0 0.0; 0.0 0.0 1.0 0.5; 0.0 0.0 0.5 1.0] - - # Markowitz GMV weights (exact). - gmv = O.get_optimal_portfolio_weights(cov) - @test gmv ≈ [0.227272727273, 0.227272727273, 0.272727272727, 0.272727272727] - @test sum(gmv) ≈ 1.0 - - # Markowitz MVO weights (exact); highest-return asset is overweighted. - mu = [0.1, 0.2, 0.05, 0.1] - mvo = O.get_optimal_portfolio_weights(cov; mu = mu) - @test mvo ≈ [-0.625, 1.25, 0.0, 0.375] - @test sum(mvo) ≈ 1.0 - @test mvo[2] > mvo[1] - - # NCO weights: valid allocation summing to one (behavioural; stochastic k-means). - nco = O.get_optimal_portfolio_weights_nco(cov; number_clusters = 2) - @test length(nco) == 4 - @test sum(nco) ≈ 1.0 - - # HRP weights: long-only allocation in original asset order (behavioural order). - corr = RiskLabAI.Cluster.covariance_to_correlation(cov) - w_hrp = O.hrp(cov, corr) - @test length(w_hrp) == 4 - @test sum(w_hrp) ≈ 1.0 - @test all(w_hrp .> 0) - - # Regression: hrp() must accept a correlation with floating-point asymmetry - # (real sample covariances are not bit-exactly symmetric → hclust would throw - # "Distance matrix should be symmetric" without the symmetrisation guard). - g = randn(MersenneTwister(7), 6, 6) - psd = g * g' - sym_corr = RiskLabAI.Cluster.covariance_to_correlation(psd) - asym_corr = copy(sym_corr) - asym_corr[1, 2] += 1e-12 # break exact symmetry, as FP does - w_sample = O.hrp(psd, asym_corr) - @test length(w_sample) == 6 - @test sum(w_sample) ≈ 1.0 -end - -@testset "Validation — cross-validators (parity with Python)" begin - V = RiskLabAI.Validation - # 10 observations; each label's info range spans two steps ahead. - starts = collect(0:9) - ends = starts .+ 2 - - # Standard K-Fold (no shuffle): contiguous test folds, complementary train. - kfold = V.cv_split(V.KFoldCV(5), 10) - @test kfold[1] == ([3, 4, 5, 6, 7, 8, 9, 10], [1, 2]) - @test kfold[3] == ([1, 2, 3, 4, 7, 8, 9, 10], [5, 6]) - @test length(kfold) == 5 - - # Purged K-Fold with embargo = 0.1 (matches pandas reference exactly). - purged = V.cv_split(V.PurgedKFoldCV(5, starts, ends; embargo = 0.1)) - @test purged[1] == ([6, 7, 8, 9, 10], [1, 2]) - @test purged[2] == ([8, 9, 10], [3, 4]) - @test purged[3] == ([1, 2, 10], [5, 6]) - @test purged[5] == ([1, 2, 3, 4, 5, 6], [9, 10]) - - # CPCV: C(6,2) = 15 splits; first split purged against the combination. - cpcv = V.CombinatorialPurgedCV(6, 2, starts, ends) - @test V.get_n_splits(cpcv) == 15 - splits = V.cv_split(cpcv) - @test length(splits) == 15 - @test splits[1] == ([7, 8, 9, 10], [1, 2, 3, 4]) - # Number of backtest paths = n_test_groups · C / n_splits = 2·15/6 = 5. - @test length(V.backtest_paths(cpcv)) == 5 - - # Walk-Forward with gap = 1: growing train window, walking test fold. - walk = V.cv_split(V.WalkForwardCV(5; gap = 1), 10) - @test walk[1] == (Int[], [1, 2]) - @test walk[2] == ([1], [3, 4]) - @test walk[3] == ([1, 2, 3], [5, 6]) - @test walk[5] == ([1, 2, 3, 4, 5, 6, 7], [9, 10]) -end - -@testset "Validation — path-level Adaptive CPCV (parity with Python)" begin - V = RiskLabAI.Validation - # Closed-form 24×5 performance matrix (identical formula in both languages, so - # no RNG and exact parity; the adaptive PBO is fully deterministic). - P = [0.04 * sin(0.5t + j) + 0.01 * cos(0.2 * t * j) for t = 1:24, j = 1:5] - - @test V.estimate_volatility_regimes(P; n_regimes = 2) == - [1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0] - @test V.estimate_volatility_regimes(P; n_regimes = 3) == - [2, 2, 1, 0, 0, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 2, 2, 1, 0, 0] - - pbo, sel = V.adaptive_probability_of_backtest_overfitting(P; n_partitions = 4) - @test pbo ≈ 0.8887779995564427 - @test sel == 3 # Python 0-based 2 -> Julia 1-based 3 - - pbo2, sel2 = V.adaptive_probability_of_backtest_overfitting( - P; - n_partitions = 4, - target_fraction = 0.5, - n_regimes = 2, - ) - @test pbo2 ≈ 0.777888667110224 - @test sel2 == 5 - - @test_throws ArgumentError V.adaptive_probability_of_backtest_overfitting( - P; - n_partitions = 3, - ) -end - -@testset "Validation — path-level Bagged CPCV (behavioural)" begin - V = RiskLabAI.Validation - P = [0.04 * sin(0.5t + j) + 0.01 * cos(0.2 * t * j) for t = 1:24, j = 1:5] - - # Moving-block bootstrap indices: valid 1-based rows, correct length. - idx = V.moving_block_bootstrap_indices(10, 3, MersenneTwister(1)) - @test length(idx) == 10 - @test all(1 .<= idx .<= 10) - - # Bagging is stochastic (Julia RNG): the bagged PBO is in [0, 1] and equals the - # mean of the per-resample PBOs. - bp, pbos = V.bagged_probability_of_backtest_overfitting( - P; - n_partitions = 4, - n_bag = 15, - rng = MersenneTwister(0), - ) - @test 0.0 <= bp <= 1.0 - @test length(pbos) == 15 - @test bp ≈ sum(pbos) / length(pbos) -end - -@testset "Data.SyntheticData (parity with Python)" begin - S = RiskLabAI.Data - - # form_block_matrix: exact block-diagonal correlation. - fbm = S.form_block_matrix(2, 2, 0.5) - @test fbm == [1.0 0.5 0.0 0.0; 0.5 1.0 0.0 0.0; 0.0 0.0 1.0 0.5; 0.0 0.0 0.5 1.0] - - # drift_volatility_burst: exact drift/vol profiles (midpoint clamp + NaN fill). - drifts, vols = S.drift_volatility_burst(5, 1.0, 2.0, 0.5, 1.0, 0.5, 0.5) - @test drifts ≈ [1.414213562373, 2.0, 0.0, 4.0, 2.828427124746] - @test vols ≈ [0.707106781187, 1.0, 1.0, 2.0, 1.414213562373] - - # compute_log_returns: exact Heston–Merton step given fixed increments. - lr = S.compute_log_returns( - 3, - [0.1, 0.1, 0.2], - [1.0, 1.0, 1.0], - [0.04, 0.04, 0.05], - [0.2, 0.2, 0.2], - [0.5, -0.3, 0.1], - [0.2, 0.1, -0.2], - [0.0, 0.3, 0.0], - [0.0, 1.0, 0.0], - 0.01, - 0.1, - [0.5, 0.5, 0.5], - [0.0, 0.0, 0.0], - [0.1, 0.1, 0.1], - [false, false, true], - ) - @test lr ≈ [0.010775, 0.294711297037, 0.003961067977] - - # align_params_length: scalars broadcast, short vectors padded with last value. - aligned, max_len = - S.align_params_length(Dict("mu" => 0.1, "kappa" => [1.0, 2.0], "theta" => 0.04)) - @test max_len == 2 - @test aligned["mu"] == [0.1, 0.1] - @test aligned["kappa"] == [1.0, 2.0] - @test aligned["theta"] == [0.04, 0.04] - - # Stochastic generators: structural checks (shape, symmetry, positivity). - rc = S.random_cov(5, 3; rng = MersenneTwister(1)) - @test size(rc) == (5, 5) - @test rc ≈ rc' - - mu0, cov0 = S.form_true_matrix(2, 3, 0.5; rng = MersenneTwister(2)) - @test length(mu0) == 6 - @test size(cov0) == (6, 6) - - mu1, cov1 = S.simulates_cov_mu(mu0, cov0, 500; rng = MersenneTwister(3)) - @test length(mu1) == 6 - @test size(cov1) == (6, 6) - - regimes = Dict( - "calm" => Dict( - "mu" => 0.05, - "kappa" => 1.0, - "theta" => 0.04, - "xi" => 0.2, - "rho" => -0.5, - "lam" => 0.1, - "m" => 0.0, - "v" => 0.1, - ), - "turbulent" => Dict( - "mu" => -0.1, - "kappa" => 2.0, - "theta" => 0.1, - "xi" => 0.5, - "rho" => -0.7, - "lam" => 1.0, - "m" => -0.02, - "v" => 0.2, - ), - ) - transition = [0.9 0.1; 0.2 0.8] - prices, regime_path = - S.generate_prices_from_regimes(regimes, transition, 1.0, 50; random_state = 1) - @test length(prices) == 50 - @test all(prices .> 0) - @test length(regime_path) == 50 - - all_prices, all_regimes = - S.parallel_generate_prices(3, regimes, transition, 1.0, 20; random_state = 1) - @test size(all_prices) == (20, 3) - @test size(all_regimes) == (20, 3) -end - -@testset "Features — feature importance (orthogonal & weighted-τ)" begin - F = RiskLabAI.Features - x = [1.0 2.0 3.0; 2.0 1.0 0.0; 3.0 3.0 1.0; 0.0 2.0 2.0; 1.0 0.0 4.0; 2.0 2.0 2.0] - - # orthogonal_features: eigenvalues + cumulative variance exact; columns orthogonal. - orth, eigenvalues, _, cumulative = F.orthogonal_features(x) - @test eigenvalues ≈ [9.4191800225, 3.2941004976, 2.2867194799] - @test cumulative ≈ [0.6279453348, 0.8475520347, 1.0] - @test size(orth) == (6, 3) - gram = orth' * orth - @test maximum(abs.(gram - Diagonal(diag(gram)))) < 1e-8 # uncorrelated components - - # variance_threshold trims the retained component count. - orth2, eig2, _, _ = F.orthogonal_features(x; variance_threshold = 0.80) - @test length(eig2) == 2 - @test size(orth2) == (6, 2) - - # weighted Kendall-τ (wrapper compares importances to 1/ranks, flipping signs). - @test F.calculate_weighted_tau([1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0]) ≈ -1.0 - @test F.calculate_weighted_tau([4.0, 3.0, 2.0, 1.0], [1.0, 2.0, 3.0, 4.0]) ≈ 1.0 - @test F.calculate_weighted_tau([0.5, 0.2, 0.8, 0.1], [1.0, 2.0, 3.0, 4.0]) ≈ 0.2 -end - -@testset "Features — tree feature importance (DecisionTree.jl)" begin - F = RiskLabAI.Features - - # Separable dataset: column 1 informative, column 2 pure noise. - rng = MersenneTwister(42) - n = 200 - y = rand(rng, 0:1, n) - x = hcat(Float64.(y) .+ 0.3 .* randn(rng, n), randn(rng, n)) - - # MDI / MDA / SFI must all rank the informative feature above the noise one. - mdi = F.feature_importance_mdi(x, y; n_trees = 40, random_state = 1) - @test length(mdi.mean) == 2 - @test mdi.mean[1] > mdi.mean[2] - - mda = F.feature_importance_mda(x, y; n_splits = 5, n_trees = 40, random_state = 1) - @test length(mda.mean) == 2 - @test mda.mean[1] > mda.mean[2] - - sfi = F.feature_importance_sfi(x, y; n_splits = 5, n_trees = 40) - @test length(sfi.mean) == 2 - @test sfi.mean[1] > sfi.mean[2] - - # get_test_dataset: shapes and informative/redundant/noise column naming. - xd, yd, names = F.get_test_dataset(; - n_features = 12, - n_informative = 4, - n_redundant = 3, - n_samples = 60, - random_state = 0, - ) - @test size(xd) == (60, 12) - @test length(yd) == 60 - @test length(names) == 12 - @test count(startswith("I_"), names) == 4 - @test count(startswith("R_"), names) == 3 -end - -@testset "Features — MDI+ & CPI (behavioural)" begin - F = RiskLabAI.Features - # col 1 informative, col 2 weakly-informative, col 3 pure noise. - rng = MersenneTwister(7) - n = 300 - y = rand(rng, 0:1, n) - x = hcat( - Float64.(y) .+ 0.5 .* randn(rng, n), - 0.2 .* Float64.(y) .+ randn(rng, n), - randn(rng, n), - ) - - # MDI+ (behavioural): the informative feature scores highest, all non-negative. - imp = F.mdi_plus_importance(x, y; n_trees = 40, random_state = 1) - @test length(imp) == 3 - @test all(imp .>= 0.0) - @test argmax(imp) == 1 - @test imp[1] > imp[3] - - # CPI (behavioural): informative feature has the largest importance and the - # smallest p-value (the calibrated significance test MDA lacks). - cpi = - F.conditional_predictive_impact(x, y; n_splits = 4, n_trees = 40, random_state = 1) - @test length(cpi.importance) == 3 - @test length(cpi.p_value) == 3 - @test argmax(cpi.importance) == 1 - @test cpi.p_value[1] < cpi.p_value[3] - @test all(0.0 .<= cpi.p_value .<= 1.0) -end - -@testset "Ensemble & CV scoring (DecisionTree.jl)" begin - E = RiskLabAI.Ensemble - V = RiskLabAI.Validation - - # Theoretical bagging accuracy: exact (binomial survival function). - @test E.bagging_classifier_accuracy(11, 0.6) ≈ 0.75349813248 - @test E.bagging_classifier_accuracy(101, 0.55) ≈ 0.843755399638 - @test E.bagging_classifier_accuracy(3, 0.7) ≈ 0.784 - @test E.bagging_classifier_accuracy(7, 0.51) ≈ 0.5218662521 - @test_throws ArgumentError E.bagging_classifier_accuracy(10, 0.6) - - # Separable dataset for the behavioural pieces. - rng = MersenneTwister(7) - n = 200 - y = rand(rng, 0:1, n) - x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) - train = 1:150 - test = 151:200 - - schemes = E.bagging_evaluate_schemes( - x[train, :], - y[train], - x[test, :], - y[test]; - n_estimators = 40, - max_samples = 60, - random_state = 1, - ) - @test Set(keys(schemes)) == Set(["uniform", "c_i", "variance"]) - @test all(0.0 <= v <= 1.0 for v in values(schemes)) - @test schemes["uniform"] > 0.6 # informative signal is learnable - - trees, classes = E.fit_bagging( - x[train, :], - y[train]; - n_estimators = 40, - max_samples = 60, - random_state = 1, - ) - values_boot, mean_boot, std_boot = E.calculate_bootstrap_accuracy( - trees, - classes, - x[test, :], - y[test]; - n_bootstraps = 50, - ) - @test length(values_boot) == 50 - @test 0.0 <= mean_boot <= 1.0 - - # cross_val_score over a purged K-Fold and a plain K-Fold. - scores = V.cross_val_score(V.KFoldCV(5), x, y; n_trees = 30, random_state = 1) - @test length(scores) == 5 - @test sum(scores) / length(scores) > 0.7 - - starts = collect(1:n) - purged = V.PurgedKFoldCV(5, starts, starts; embargo = 0.0) - pscores = V.cross_val_score(purged, x, y; n_trees = 30, random_state = 1) - @test length(pscores) == 5 -end - -@testset "Hyper-parameter tuning (DecisionTree.jl)" begin - V = RiskLabAI.Validation - rng = MersenneTwister(11) - n = 200 - y = rand(rng, 0:1, n) - x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) - - grid = Dict(:n_trees => [10, 30], :max_depth => [2, 4]) - - # Grid search: every combination scored; best is recovered and a model refit. - gs = V.grid_search_cv(V.KFoldCV(4), x, y, grid; random_state = 1) - @test length(gs.results) == 4 - @test haskey(gs.best_params, :n_trees) - @test gs.best_score > 0.7 - @test gs.best_score == maximum(score for (_, score) in gs.results) - - # Randomised search: n_iter configurations; best score reported. - rs = V.random_search_cv(V.KFoldCV(4), x, y, grid; n_iter = 3, random_state = 1) - @test length(rs.results) == 3 - @test rs.best_score > 0.7 -end - -@testset "Validation — leakage-aware HPO & DSR gate (parity with Python)" begin - V = RiskLabAI.Validation - # deflated_sharpe_gate is deterministic -> exact parity with Python. - r = [0.01 * sin(0.05t) + 0.02 * cos(0.011t) + 0.0005 for t = 0:499] - g = V.deflated_sharpe_gate(r, 100, 0.05) - @test g.observed_sharpe ≈ -0.13536558416850195 - @test g.benchmark_sharpe ≈ 0.12653014466005713 - @test g.deflated_sharpe ≈ 4.703000079086612e-9 atol = 1e-12 - @test g.passes == false - g2 = V.deflated_sharpe_gate(r, 20, 0.2; threshold = 0.3) - @test g2.benchmark_sharpe ≈ 0.3801415902361039 - @test g2.passes == false - - # leakage-aware HPO (behavioural): purged-CV-scored random search returns a - # valid configuration and finite trial scores. - rng = MersenneTwister(11) - n = 200 - y = rand(rng, 0:1, n) - x = hcat(3.0 .* y .+ randn(rng, n), randn(rng, n)) - starts = collect(1:n) - hpo = V.leakage_aware_hpo( - x, - y, - Dict(:n_trees => [10, 30], :max_depth => [2, 4]); - event_starts = starts, - event_ends = starts, - n_trials = 3, - n_splits = 4, - random_state = 1, - ) - @test hpo.n_trials == 3 - @test haskey(hpo.best_params, :n_trees) - @test length(hpo.trial_scores) <= 3 - @test isfinite(hpo.mean_trial_score) -end - -@testset "Pde — equations (parity with Python)" begin - P = RiskLabAI.Pde - x = [100.0 110.0; 90.0 120.0; 100.0 100.0] - y = reshape([1.0, 2.0, 0.5], 3, 1) - z = [0.1 0.2; 0.3 -0.1; 0.0 0.5] - - hjb = P.HJBLQ(2, 1.0, 4) - @test vec(P.pde_driver(hjb, 0.0, x, y, z)) == zeros(3) - @test vec(P.pde_hamiltonian(hjb, 0.0, x, y, z)) ≈ [0.025, 0.05, 0.125] - @test vec(P.pde_terminal(hjb, 1.0, x)) ≈ [9.3102309548, 9.3281678511, 9.2103903707] - - bsb = P.BlackScholesBarenblatt(2, 1.0, 4) - @test vec(P.pde_driver(bsb, 0.0, x, y, z)) ≈ [0.05, 0.05, 0.05] - @test vec(P.pde_hamiltonian(bsb, 0.0, x, y, z)) ≈ [-0.0375, -0.025, -0.0625] - @test vec(P.pde_terminal(bsb, 1.0, x)) ≈ [22100.0, 22500.0, 20000.0] - - dr = P.PricingDefaultRisk(2, 1.0, 4) - @test vec(P.pde_driver(dr, 0.0, x, y, z)) ≈ [0.0866666667, 0.0866666667, 0.0866666667] - @test vec(P.pde_terminal(dr, 1.0, x)) ≈ [100.0, 90.0, 100.0] - - dfr = P.PricingDiffRate(2, 1.0, 4) - @test vec(P.pde_driver(dfr, 0.0, x, y, z)) ≈ [0.06, 0.04, 0.06] - @test vec(P.pde_hamiltonian(dfr, 0.0, x, y, z)) ≈ [0.0, 0.02, 0.0] - @test vec(P.pde_terminal(dfr, 1.0, x)) ≈ [0.0, 0.0, 0.0] - - # Forward-SDE sampling: shapes and the initial condition. - dw, xs = P.pde_sample(bsb, 16; rng = MersenneTwister(1)) - @test size(dw) == (16, 2, 4) - @test size(xs) == (16, 2, 5) - @test xs[:, :, 1] == repeat([1.0 0.5], 16, 1) -end -@testset "Pde — Deep BSDE solver (Lux)" begin - P = RiskLabAI.Pde - eq = P.HJBLQ(1, 0.5, 4) - losses, inits = P.solve_deep_bsde( - eq; - hidden_sizes = [8], - iterations = 12, - batch_size = 64, - init_y = 3.0, - learning_rate = 0.02, - rng = MersenneTwister(1), - ) - @test length(losses) == 12 - @test all(isfinite, losses) - @test all(isfinite, inits) - @test minimum(losses) <= losses[1] # training improves on the initial loss +include(joinpath(@__DIR__, "..", "src", "CausalFactorAnalysis", "CausalFactorAnalysis.jl")) +using .CausalFactorAnalysis + +const EXPECTED_CAUSAL_FACTOR_EXPORTS = Set(( + :BackdoorAdjustmentEvidence, + :BacktestStage, + :CausalDAG, + :CausalAdjustmentSetStage, + :ColliderCoefficients, + :ColliderDiagnostics, + :CausalDiscoveryStage, + :CausalExplanatoryAndPredictivePowerStage, + :CausalFactorProtocolReport, + :CausalPortfolioConstructionStage, + :DSeparationEvidence, + :DifferenceInDifferencesEstimate, + :EventHorizon, + :FoldEvidence, + :FrontdoorAdjustmentEvidence, + :InstrumentEvidence, + :MultipleTestingAdjustmentsStage, + :NodeRoleEvidence, + :PathEvidence, + :PopulationRegressionDiagnostics, + :RegressionDiagnostics, + :SpecificationExperimentResult, + :SpecificationPopulationResult, + :StrategyPerformance, + :TreatmentEffectDecomposition, + :ValidationEvidence, + :VariableSelectionStage, + :average_treatment_effect, + :backdoor_adjusted_average_treatment_effect, + :backdoor_adjusted_expectation, + :causal_role_evidence, + :check_backdoor_adjustment_set, + :check_frontdoor_adjustment_set, + :check_instrument, + :collider_factor_return, + :collider_forecast_return, + :collider_model_diagnostics, + :collider_overcontrolled_coefficients, + :collider_population_diagnostics, + :collider_specification_experiment, + :confounded_mediator_population_diagnostics, + :confounded_mediator_specification_experiment, + :confounder_factor_return, + :confounder_forecast_return, + :confounder_undercontrolled_coefficient, + :d_separation, + :difference_in_differences, + :fork_population_diagnostics, + :fork_specification_experiment, + :frontdoor_adjusted_probability, + :linear_instrumental_variable_effect, + :minimal_backdoor_adjustment_sets, + :minimal_frontdoor_adjustment_sets, + :minimum_variance_factor_weights, + :randomized_mean_difference, + :treatment_effect_decomposition, + :validate_causal_factor_protocol, +)) + +@testset "CausalFactorAnalysis" begin + @test Set(names(CausalFactorAnalysis)) == + union(EXPECTED_CAUSAL_FACTOR_EXPORTS, Set((:CausalFactorAnalysis,))) + include("causal_factor_analysis/test_treatment_effects.jl") + include("causal_factor_analysis/test_specification_diagnostics.jl") + include("causal_factor_analysis/test_factor_mirage.jl") + include("causal_factor_analysis/test_optimizer.jl") + include("causal_factor_analysis/test_graph_identification.jl") + include("causal_factor_analysis/test_protocol.jl") end