From e9d496ec9b21382e155e5feea0175f15c95805b5 Mon Sep 17 00:00:00 2001 From: hamid-arian Date: Sun, 23 Aug 2026 17:07:36 -0400 Subject: [PATCH 1/4] release: prepare RiskLabAI 1.0.0 --- LICENSE | 29 + PACKAGE_FILES.json | 279 +++ PACKAGE_INSPECTION.md | 44 + PUBLIC_API.json | 1052 +++++++++ Project.toml | 33 +- README.md | 58 +- RELEASE_NOTES_NEXT_MAJOR.md | 21 + TEST_INVENTORY.json | 91 + docs/causal_factor_analysis.md | 48 + docs/compatibility.md | 79 + examples/causal_factor_analysis_quickstart.jl | 32 + ext/RiskLabAIDeepBSDEExt.jl | 7 + .../CausalFactorAnalysis.jl | 73 + src/CausalFactorAnalysis/FactorMirage.jl | 268 +++ .../GraphIdentification.jl | 955 ++++++++ src/CausalFactorAnalysis/Optimizer.jl | 132 ++ src/CausalFactorAnalysis/Protocol.jl | 1574 +++++++++++++ .../SpecificationDiagnostics.jl | 208 ++ src/CausalFactorAnalysis/TreatmentEffects.jl | 265 +++ src/Pde/DeepBSDESolver.jl | 7 +- src/Pde/Pde.jl | 23 +- src/RiskLabAI.jl | 6 +- .../test_factor_mirage.jl | 55 + .../test_graph_identification.jl | 577 +++++ test/causal_factor_analysis/test_optimizer.jl | 110 + test/causal_factor_analysis/test_protocol.jl | 276 +++ .../test_specification_diagnostics.jl | 40 + .../test_treatment_effects.jl | 65 + test/preserved_runtests.jl | 1910 ++++++++++++++++ test/runtests.jl | 1988 +---------------- 30 files changed, 8374 insertions(+), 1931 deletions(-) create mode 100644 LICENSE create mode 100644 PACKAGE_FILES.json create mode 100644 PACKAGE_INSPECTION.md create mode 100644 PUBLIC_API.json create mode 100644 RELEASE_NOTES_NEXT_MAJOR.md create mode 100644 TEST_INVENTORY.json create mode 100644 docs/causal_factor_analysis.md create mode 100644 docs/compatibility.md create mode 100644 examples/causal_factor_analysis_quickstart.jl create mode 100644 ext/RiskLabAIDeepBSDEExt.jl create mode 100644 src/CausalFactorAnalysis/CausalFactorAnalysis.jl create mode 100644 src/CausalFactorAnalysis/FactorMirage.jl create mode 100644 src/CausalFactorAnalysis/GraphIdentification.jl create mode 100644 src/CausalFactorAnalysis/Optimizer.jl create mode 100644 src/CausalFactorAnalysis/Protocol.jl create mode 100644 src/CausalFactorAnalysis/SpecificationDiagnostics.jl create mode 100644 src/CausalFactorAnalysis/TreatmentEffects.jl create mode 100644 test/causal_factor_analysis/test_factor_mirage.jl create mode 100644 test/causal_factor_analysis/test_graph_identification.jl create mode 100644 test/causal_factor_analysis/test_optimizer.jl create mode 100644 test/causal_factor_analysis/test_protocol.jl create mode 100644 test/causal_factor_analysis/test_specification_diagnostics.jl create mode 100644 test/causal_factor_analysis/test_treatment_effects.jl create mode 100644 test/preserved_runtests.jl 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..a73a16d --- /dev/null +++ b/PACKAGE_FILES.json @@ -0,0 +1,279 @@ +{ + "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": 88, + "candidate_files": [ + "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": 87, + "release_source_files": [ + "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..1aabe84 --- /dev/null +++ b/PACKAGE_INSPECTION.md @@ -0,0 +1,44 @@ +# 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 declared RiskLabAI 1.0.0 release +members. 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. Repeat the deep solver tests with the optional extension +loaded and confirm that the base package loads without its weak dependencies. + +## 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..34fd04d --- /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": 2093, + "sha256": "B993E1A9BFAF286BA8C6EF7005EF2C110687EB066AAE98C946BAA81AC77FE728" + }, + { + "path": "src/CausalFactorAnalysis/FactorMirage.jl", + "size": 9101, + "sha256": "BF3C981B74C221E2E8C90993C1B410D79FA21F315F67CD62B11419EA186DAA5F" + }, + { + "path": "src/CausalFactorAnalysis/GraphIdentification.jl", + "size": 30414, + "sha256": "FA9FE18C032F4472925DD24B8662B5FFF98CCC8CF4DA47DB572A58E74DA0455B" + }, + { + "path": "src/CausalFactorAnalysis/Optimizer.jl", + "size": 5201, + "sha256": "FECB323B06BBA87BDD9E299CB01FE32EAA9A90F0CE304A884E03E35F12ABA0B2" + }, + { + "path": "src/CausalFactorAnalysis/Protocol.jl", + "size": 56986, + "sha256": "0B12D6E21DB642E33BDA753E180FFF321BC315EB041AC95F46E55B3B7F7AFCF6" + }, + { + "path": "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", + "size": 7041, + "sha256": "304E5149D23695CC8C2D3E3F5F8BB8323FBDA84A57BE6F97E8576201EA1053CC" + }, + { + "path": "src/CausalFactorAnalysis/TreatmentEffects.jl", + "size": 8739, + "sha256": "FF1FFC0BA91A2AA9F768211D6E01865D72D45273254B9F7BB7ADB189866CF39D" + }, + { + "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..3bd9032 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,34 +12,37 @@ 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" [targets] -test = ["Test"] +test = ["Test", "Lux", "Optimisers", "Zygote"] 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..8c63fd5 --- /dev/null +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -0,0 +1,21 @@ +# 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. + +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, and intended package +file 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..46b00c4 --- /dev/null +++ b/TEST_INVENTORY.json @@ -0,0 +1,91 @@ +{ + "schema_version": 1, + "document_type": "RISKLABAI_JULIA_TEST_INVENTORY", + "product": "RiskLabAI", + "language": "Julia", + "version": "1.0.0", + "runner": "Pkg.test / Test", + "file_count": 12, + "verified_assertions_per_required_lane": { + "preserved_non_deep": 508, + "causal_factor_analysis": 138, + "optional_deep_bsde": 4, + "total": 650 + }, + "required_lane_count": 4, + "required_lane_assertion_total": 2600, + "files": [ + { + "path": "test/causal_factor_analysis/test_factor_mirage.jl", + "size": 2839, + "sha256": "7389B9D98273E33CD471546B3DE15D98863D82AC8BBC1DE74D6AF58B56802148", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_graph_identification.jl", + "size": 19676, + "sha256": "309C2A59D6BC9A239CAE4648656AD301B1AC31B025346E0D5ABF3C9B2D87E30D", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_optimizer.jl", + "size": 3447, + "sha256": "6588A76ADA964C07717A86BE46672C531E29B945E8DD227F47435FEDD799EBDC", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_protocol.jl", + "size": 10642, + "sha256": "50193B1253157A2B7002B3B27CFCD3D503FDD79F58D72CB0D84AD87AC630E502", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_specification_diagnostics.jl", + "size": 1994, + "sha256": "297B4F06998A4E264ADA3CB336FC239E0E64CA967695C16A7FCDF1FC2ED624C3", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/causal_factor_analysis/test_treatment_effects.jl", + "size": 2287, + "sha256": "A889AE4B8DD117723D4B9666C0D9B2F8325BD50A44BD79DECAA4F055602E1987", + "group": "CAUSAL_FACTOR_ANALYSIS" + }, + { + "path": "test/fixtures/pelt_x1.txt", + "size": 4800, + "sha256": "60821B281A4E335F1E33755D4DCCC100B1738708FA86743EF0273C754BC9B04A", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/pelt_x2.txt", + "size": 4098, + "sha256": "0CF964248BB19CC7B4EB2851AE42CA158F67B1D564F0CFCDA40F690642BB0799", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/pelt_x3.txt", + "size": 6391, + "sha256": "12DABDD546F793FD08FDDDA85849B837B6D1C11F918917830F40A1A3CFD7802C", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/fixtures/vrsadf_y.txt", + "size": 2345, + "sha256": "86FB01DFECCDCD3CBC73526A0F8EBD2007A4262466F14853463DC207670730DC", + "group": "PRESERVED_FIXTURE" + }, + { + "path": "test/preserved_runtests.jl", + "size": 73730, + "sha256": "0B97BCC98FD2C80F0C200FEB3E31ED9C37366AF4BF67F1F70F9988F6A4418F51", + "group": "PRESERVED_LIBRARY" + }, + { + "path": "test/runtests.jl", + "size": 2711, + "sha256": "B94F8A27571885EEF64F24E2E9DF75279E01FC296AB52506831DB9B35551E9B9", + "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..33c411d --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,79 @@ +# 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. + +TimeSeries is not a required dependency. No preserved source behavior uses it, +and the complete base library passes without it. + +## Matrix evidence + +Four required lanes were exercised: Julia 1.10.12 and 1.12.7, each with the +minimum and current-compatible dependency sets. + +In every lane: + +- the isolated base source loaded without Lux, Optimisers, or Zygote; +- 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 lane and 2,600 across the four lanes. The +causal suite includes independent analytical and exhaustive graph oracles; it +does not use Python-Julia agreement as its sole correctness test. + +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, and +source allowlists are complete. No package tree has been installed or +registered. Temporary 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..cf50216 --- /dev/null +++ b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl @@ -0,0 +1,73 @@ +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..5c7fc6e --- /dev/null +++ b/src/CausalFactorAnalysis/FactorMirage.jl @@ -0,0 +1,268 @@ +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..6c8e88d --- /dev/null +++ b/src/CausalFactorAnalysis/GraphIdentification.jl @@ -0,0 +1,955 @@ +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 in 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 in 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..59eab33 --- /dev/null +++ b/src/CausalFactorAnalysis/Optimizer.jl @@ -0,0 +1,132 @@ +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..ec23f6e --- /dev/null +++ b/src/CausalFactorAnalysis/Protocol.jl @@ -0,0 +1,1574 @@ +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 in 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 in 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..78df598 --- /dev/null +++ b/src/CausalFactorAnalysis/SpecificationDiagnostics.jl @@ -0,0 +1,208 @@ +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..b8b7486 --- /dev/null +++ b/src/CausalFactorAnalysis/TreatmentEffects.jl @@ -0,0 +1,265 @@ +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..77de695 --- /dev/null +++ b/test/causal_factor_analysis/test_factor_mirage.jl @@ -0,0 +1,55 @@ +@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..215b0e4 --- /dev/null +++ b/test/causal_factor_analysis/test_graph_identification.jl @@ -0,0 +1,577 @@ +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 in 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 in 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 in 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 in 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 in 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 in index:-1:2 + ) + to_outcome = all( + (path[position], path[position + 1]) in edge_set for + position in 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..c13a5c6 --- /dev/null +++ b/test/causal_factor_analysis/test_optimizer.jl @@ -0,0 +1,110 @@ +@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..5fa32a0 --- /dev/null +++ b/test/causal_factor_analysis/test_protocol.jl @@ -0,0 +1,276 @@ +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..e19fdc1 --- /dev/null +++ b/test/causal_factor_analysis/test_specification_diagnostics.jl @@ -0,0 +1,40 @@ +@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..be3401d --- /dev/null +++ b/test/causal_factor_analysis/test_treatment_effects.jl @@ -0,0 +1,65 @@ +@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..3ab4cd6 --- /dev/null +++ b/test/preserved_runtests.jl @@ -0,0 +1,1910 @@ +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 +end diff --git a/test/runtests.jl b/test/runtests.jl index 3ab4cd6..1745e8b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,1910 +1,86 @@ +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 From b900967c46751a3a849f25add4c30bc2d8938636 Mon Sep 17 00:00:00 2001 From: hamid-arian Date: Sun, 23 Aug 2026 21:27:00 -0400 Subject: [PATCH 2/4] Fix Julia 1.0.0 release CI and optional Deep-BSDE tests --- .github/workflows/CI.yml | 140 +++-- PACKAGE_FILES.json | 6 +- PACKAGE_INSPECTION.md | 15 +- PUBLIC_API.json | 28 +- Project.toml | 2 +- RELEASE_NOTES_NEXT_MAJOR.md | 15 +- TEST_INVENTORY.json | 42 +- docs/compatibility.md | 34 +- .../CausalFactorAnalysis.jl | 3 +- src/CausalFactorAnalysis/FactorMirage.jl | 101 +--- .../GraphIdentification.jl | 248 ++++----- src/CausalFactorAnalysis/Optimizer.jl | 57 +- src/CausalFactorAnalysis/Protocol.jl | 487 +++++++++--------- .../SpecificationDiagnostics.jl | 43 +- src/CausalFactorAnalysis/TreatmentEffects.jl | 98 +--- .../test_factor_mirage.jl | 3 +- .../test_graph_identification.jl | 292 ++++------- test/causal_factor_analysis/test_optimizer.jl | 5 +- test/causal_factor_analysis/test_protocol.jl | 238 ++++----- .../test_specification_diagnostics.jl | 6 +- .../test_treatment_effects.jl | 29 +- test/preserved_runtests.jl | 54 +- test/runtests.jl | 10 +- 23 files changed, 889 insertions(+), 1067 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2b15e95..13a0ef5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,57 +3,137 @@ 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"), + ]) + 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 = "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/PACKAGE_FILES.json b/PACKAGE_FILES.json index a73a16d..56743b6 100644 --- a/PACKAGE_FILES.json +++ b/PACKAGE_FILES.json @@ -7,8 +7,9 @@ "artifact_types": [ "julia_package_tree" ], - "candidate_file_count": 88, + "candidate_file_count": 89, "candidate_files": [ + ".github/workflows/CI.yml", "LICENSE", "PACKAGE_FILES.json", "PACKAGE_IDENTITY.json", @@ -101,8 +102,9 @@ "local_control_files_excluded_from_artifacts": [ "PACKAGE_IDENTITY.json" ], - "release_source_file_count": 87, + "release_source_file_count": 88, "release_source_files": [ + ".github/workflows/CI.yml", "LICENSE", "PACKAGE_FILES.json", "PACKAGE_INSPECTION.md", diff --git a/PACKAGE_INSPECTION.md b/PACKAGE_INSPECTION.md index 1aabe84..bf3e1af 100644 --- a/PACKAGE_INSPECTION.md +++ b/PACKAGE_INSPECTION.md @@ -13,10 +13,11 @@ 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 declared RiskLabAI 1.0.0 release -members. 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. +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 @@ -32,8 +33,10 @@ 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. Repeat the deep solver tests with the optional extension -loaded and confirm that the base package loads without its weak dependencies. +and current versions. 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 diff --git a/PUBLIC_API.json b/PUBLIC_API.json index 34fd04d..57576f3 100644 --- a/PUBLIC_API.json +++ b/PUBLIC_API.json @@ -732,38 +732,38 @@ }, { "path": "src/CausalFactorAnalysis/CausalFactorAnalysis.jl", - "size": 2093, - "sha256": "B993E1A9BFAF286BA8C6EF7005EF2C110687EB066AAE98C946BAA81AC77FE728" + "size": 2089, + "sha256": "2CEDE65173AE33C337A826AED7D0BD3C7105622A60E17071C4E472985B47F4BA" }, { "path": "src/CausalFactorAnalysis/FactorMirage.jl", - "size": 9101, - "sha256": "BF3C981B74C221E2E8C90993C1B410D79FA21F315F67CD62B11419EA186DAA5F" + "size": 8659, + "sha256": "DE328CEDA83C160CC166F8770F446BCD082B66DDE99511221E30095374F133A2" }, { "path": "src/CausalFactorAnalysis/GraphIdentification.jl", - "size": 30414, - "sha256": "FA9FE18C032F4472925DD24B8662B5FFF98CCC8CF4DA47DB572A58E74DA0455B" + "size": 29966, + "sha256": "D0734576F874404FCE5C6244FE4E9AF1F4FF3C07BBFEE58D43098E76FD3121FB" }, { "path": "src/CausalFactorAnalysis/Optimizer.jl", - "size": 5201, - "sha256": "FECB323B06BBA87BDD9E299CB01FE32EAA9A90F0CE304A884E03E35F12ABA0B2" + "size": 5001, + "sha256": "1896FC5BCD0E43691EAB233E86300872D54BD68ECBFE125A88CB5AD736B7E506" }, { "path": "src/CausalFactorAnalysis/Protocol.jl", - "size": 56986, - "sha256": "0B12D6E21DB642E33BDA753E180FFF321BC315EB041AC95F46E55B3B7F7AFCF6" + "size": 57203, + "sha256": "95DB2ABAA05609F210F77B478457A75800020EAA85806E90018D78C247964E50" }, { "path": "src/CausalFactorAnalysis/SpecificationDiagnostics.jl", - "size": 7041, - "sha256": "304E5149D23695CC8C2D3E3F5F8BB8323FBDA84A57BE6F97E8576201EA1053CC" + "size": 6811, + "sha256": "3FDB30F7D87DA1F94DC5234268E48AF646C069C4B9D5E78E89B033644C4CB93B" }, { "path": "src/CausalFactorAnalysis/TreatmentEffects.jl", - "size": 8739, - "sha256": "FF1FFC0BA91A2AA9F768211D6E01865D72D45273254B9F7BB7ADB189866CF39D" + "size": 8446, + "sha256": "23BD47E0B88E8C73C0BA362CDDC3FE6B84F989CF590F0E4AF7410A765344FD4F" }, { "path": "src/Cluster/Cluster.jl", diff --git a/Project.toml b/Project.toml index 3bd9032..864e8b4 100644 --- a/Project.toml +++ b/Project.toml @@ -45,4 +45,4 @@ julia = "~1.10.12, ~1.12.7" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "Lux", "Optimisers", "Zygote"] +test = ["Test"] diff --git a/RELEASE_NOTES_NEXT_MAJOR.md b/RELEASE_NOTES_NEXT_MAJOR.md index 8c63fd5..4d7ade4 100644 --- a/RELEASE_NOTES_NEXT_MAJOR.md +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -13,9 +13,16 @@ 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. 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, and intended package -file 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. +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 index 46b00c4..bf7a311 100644 --- a/TEST_INVENTORY.json +++ b/TEST_INVENTORY.json @@ -4,51 +4,53 @@ "product": "RiskLabAI", "language": "Julia", "version": "1.0.0", - "runner": "Pkg.test / Test", + "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, - "optional_deep_bsde": 4, + "lane_specific_deep_bsde_or_fallback": 4, "total": 650 }, - "required_lane_count": 4, - "required_lane_assertion_total": 2600, + "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": 2839, - "sha256": "7389B9D98273E33CD471546B3DE15D98863D82AC8BBC1DE74D6AF58B56802148", + "size": 2829, + "sha256": "84B194306913AFE850F6E4B56761491AEA2B13F180BFF24FD83BB20FE10151D7", "group": "CAUSAL_FACTOR_ANALYSIS" }, { "path": "test/causal_factor_analysis/test_graph_identification.jl", - "size": 19676, - "sha256": "309C2A59D6BC9A239CAE4648656AD301B1AC31B025346E0D5ABF3C9B2D87E30D", + "size": 18944, + "sha256": "B15F623E6F474B706F3AA00AB69D693C0F496494DB0E537D824F3B429363D22F", "group": "CAUSAL_FACTOR_ANALYSIS" }, { "path": "test/causal_factor_analysis/test_optimizer.jl", - "size": 3447, - "sha256": "6588A76ADA964C07717A86BE46672C531E29B945E8DD227F47435FEDD799EBDC", + "size": 3424, + "sha256": "C3DD2CB2F226D9C8ABF6C3E645929007BC811419321E10181A5CF8C5AAAC6ECB", "group": "CAUSAL_FACTOR_ANALYSIS" }, { "path": "test/causal_factor_analysis/test_protocol.jl", - "size": 10642, - "sha256": "50193B1253157A2B7002B3B27CFCD3D503FDD79F58D72CB0D84AD87AC630E502", + "size": 11071, + "sha256": "5916A06D7D186DA6A74FA0FD23DC7EDD29BA75BACCBA92CA53E3AB94C201109D", "group": "CAUSAL_FACTOR_ANALYSIS" }, { "path": "test/causal_factor_analysis/test_specification_diagnostics.jl", - "size": 1994, - "sha256": "297B4F06998A4E264ADA3CB336FC239E0E64CA967695C16A7FCDF1FC2ED624C3", + "size": 1974, + "sha256": "0BBB85F99A06450D9940FBFC152C0F239159DA6CC4AB75D6D5A34490E23B717E", "group": "CAUSAL_FACTOR_ANALYSIS" }, { "path": "test/causal_factor_analysis/test_treatment_effects.jl", - "size": 2287, - "sha256": "A889AE4B8DD117723D4B9666C0D9B2F8325BD50A44BD79DECAA4F055602E1987", + "size": 2220, + "sha256": "1D47C23D447AB8C50B8BD013AA6FA3ACA2A577924FFC0D17656BAA9B95CBCAEF", "group": "CAUSAL_FACTOR_ANALYSIS" }, { @@ -77,14 +79,14 @@ }, { "path": "test/preserved_runtests.jl", - "size": 73730, - "sha256": "0B97BCC98FD2C80F0C200FEB3E31ED9C37366AF4BF67F1F70F9988F6A4418F51", + "size": 74538, + "sha256": "3005EE9B73838E7B5FF7E179628F9573ADC08E6C0C146F002B34D8874440C203", "group": "PRESERVED_LIBRARY" }, { "path": "test/runtests.jl", - "size": 2711, - "sha256": "B94F8A27571885EEF64F24E2E9DF75279E01FC296AB52506831DB9B35551E9B9", + "size": 2657, + "sha256": "FAB90A2D9E23A34918915BE579A46B00FC2493346BECC85012B9C78EF31F4DC8", "group": "RUNNER" } ] diff --git a/docs/compatibility.md b/docs/compatibility.md index 33c411d..7daea41 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -44,24 +44,39 @@ capability. The tested minimums are Lux 1.31.4, Optimisers 0.4.7, and Zygote 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. + TimeSeries is not a required dependency. No preserved source behavior uses it, and the complete base library passes without it. ## Matrix evidence -Four required lanes were exercised: Julia 1.10.12 and 1.12.7, each with the -minimum and current-compatible dependency sets. +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; +- 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 lane and 2,600 across the four lanes. The -causal suite includes independent analytical and exhaustive graph oracles; it -does not use Python-Julia agreement as its sole correctness test. +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. 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 @@ -73,7 +88,8 @@ authority. ## Remaining release gates -`Project.toml`, the public surface, package-scoped tests, documentation, and -source allowlists are complete. No package tree has been installed or -registered. Temporary artifact inspection and separate human authorization for +`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/src/CausalFactorAnalysis/CausalFactorAnalysis.jl b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl index cf50216..e0d054f 100644 --- a/src/CausalFactorAnalysis/CausalFactorAnalysis.jl +++ b/src/CausalFactorAnalysis/CausalFactorAnalysis.jl @@ -11,8 +11,7 @@ include("Optimizer.jl") include("GraphIdentification.jl") include("Protocol.jl") -export - BackdoorAdjustmentEvidence, +export BackdoorAdjustmentEvidence, BacktestStage, CausalDAG, CausalAdjustmentSetStage, diff --git a/src/CausalFactorAnalysis/FactorMirage.jl b/src/CausalFactorAnalysis/FactorMirage.jl index 5c7fc6e..2210c70 100644 --- a/src/CausalFactorAnalysis/FactorMirage.jl +++ b/src/CausalFactorAnalysis/FactorMirage.jl @@ -52,8 +52,7 @@ function _coefficient_times_square_root(name, coefficient, radicand) return result end -_confounder_coefficient(beta, gamma, delta) = - beta + gamma * delta / (1 + delta * delta) +_confounder_coefficient(beta, gamma, delta) = beta + gamma * delta / (1 + delta * delta) function _collider_coefficients(beta, gamma, delta) denominator = 1 + gamma * gamma @@ -83,20 +82,13 @@ function confounder_factor_return( 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) + 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, - ) + 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( @@ -106,28 +98,16 @@ function confounder_factor_return( ) end -function confounder_forecast_return( - beta, - gamma, - delta_estimated; - delta_realized = nothing, -) +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) + 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, - ) + 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( @@ -136,9 +116,7 @@ function confounder_forecast_return( ), _finite_output( "undercontrolled forecast return", - (1 + realized * realized) * - estimated_coefficient * - realized_coefficient, + (1 + realized * realized) * estimated_coefficient * realized_coefficient, ), ) end @@ -147,11 +125,7 @@ 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, - ) + 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), @@ -168,8 +142,7 @@ function collider_factor_return(x, collider_proxy, beta, gamma, 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 + (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( @@ -197,34 +170,26 @@ 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_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_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) + 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) + 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 + (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) + 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 @@ -239,29 +204,15 @@ function collider_model_diagnostics(beta, gamma, delta, n_observations) 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, - ), + _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, - ), + _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, ) diff --git a/src/CausalFactorAnalysis/GraphIdentification.jl b/src/CausalFactorAnalysis/GraphIdentification.jl index 6c8e88d..fb4f9d2 100644 --- a/src/CausalFactorAnalysis/GraphIdentification.jl +++ b/src/CausalFactorAnalysis/GraphIdentification.jl @@ -109,16 +109,14 @@ function _node_tuple(value, subject::AbstractString; allow_empty::Bool) 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")) + 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")) + value isa AbstractString && throw(ArgumentError("directed_edges must be a collection")) items = try collect(value) catch @@ -126,9 +124,9 @@ function _edge_tuple(value) 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"), - ) + 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!( @@ -182,29 +180,18 @@ 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, - ) + 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")) + 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), - ) + return CausalDAG(normalized_nodes, normalized_edges, observed, Val(:validated)) end function _endpoints(dag::CausalDAG, treatment, outcome) @@ -230,8 +217,7 @@ 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 isa Integer || throw(ArgumentError("$subject must be positive or nothing")) value >= 1 || throw(ArgumentError("$subject must be positive")) return Int(value) end @@ -262,18 +248,10 @@ function _ancestors(dag::CausalDAG, node::String) 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))) +_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 = (), -) +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 @@ -289,8 +267,8 @@ function _has_directed_path( append!( pending, [ - child for child in children[node] if - !(child in visited) && !(child in blocked_nodes) + child for + child in children[node] if !(child in visited) && !(child in blocked_nodes) ], ) end @@ -320,7 +298,7 @@ function _is_d_separated(dag, left, right, conditioned; edges = dag.directed_edg 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 in first_index + 1:length(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) @@ -340,8 +318,8 @@ function _is_d_separated(dag, left, right, conditioned; edges = dag.directed_edg append!( reachable, [ - neighbor for neighbor in moral[node] if - !(neighbor in blocked) && !(neighbor in visited) + neighbor for + neighbor in moral[node] if !(neighbor in blocked) && !(neighbor in visited) ], ) end @@ -367,19 +345,23 @@ function _simple_paths( 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"), - ) + 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 && + 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,)))) + push!( + stack, + (neighbor, (path..., neighbor), union(visited, (neighbor,))), + ) end end end @@ -393,8 +375,8 @@ function _path_evidence(dag, path, conditioned; edges = dag.directed_edges) conditioned_set = Set(conditioned) colliders = String[] noncolliders = String[] - for index in 2:length(path) - 1 - previous, node, following = path[index - 1], path[index], path[index + 1] + 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 @@ -413,10 +395,7 @@ function _path_evidence(dag, path, conditioned; edges = dag.directed_edges) 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_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, @@ -441,8 +420,7 @@ function _d_separation( max_path_states = _DEFAULT_MAX_PATH_STATES, ) paths = Tuple( - _path_evidence(dag, path, conditioned; edges = edges) for path in - _simple_paths( + _path_evidence(dag, path, conditioned; edges = edges) for path in _simple_paths( dag, left, right; @@ -470,19 +448,13 @@ function d_separation( ) 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, - ) + 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"), - ) + isempty(intersect(Set(right_nodes), Set(conditioned_nodes))) || + throw(ArgumentError("left, right, and conditioned must be pairwise disjoint")) return _d_separation( dag, left_nodes, @@ -502,17 +474,15 @@ function check_backdoor_adjustment_set( 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, - ) + 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))))) + 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, @@ -565,9 +535,9 @@ function _search_minimal_sets(candidates, is_valid, max_candidates) 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"), - ) + 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) @@ -611,13 +581,8 @@ function minimal_backdoor_adjustment_sets( !(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, - ) + is_valid(candidate) = + _is_d_separated(dag, (treatment_node,), (outcome_node,), candidate; edges = edges) return _search_minimal_sets(candidates, is_valid, candidate_limit) end @@ -646,13 +611,14 @@ function _directed_path_tuples( states = 0 while !isempty(stack) states += 1 - max_path_states !== nothing && states > max_path_states && throw( - ArgumentError("directed-path search exceeds max_path_states"), - ) + 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 && + max_paths !== nothing && + length(paths) > max_paths && throw(ArgumentError("directed-path evidence exceeds max_paths")) continue end @@ -685,16 +651,12 @@ function check_frontdoor_adjustment_set( 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, - ) + 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)))))) + endpoints = + Tuple(sort(collect(intersect(Set(mediators), Set((treatment_node, outcome_node)))))) directed_paths = _directed_paths( dag, treatment_node, @@ -704,7 +666,7 @@ function check_frontdoor_adjustment_set( ) unintercepted = Tuple( path for path in directed_paths if - isempty(intersect(Set(path.nodes[2:end-1]), Set(mediators))) + isempty(intersect(Set(path.nodes[2:(end-1)]), Set(mediators))) ) if !isempty(mediators) && isempty(endpoints) treatment_edges = _without_outgoing(dag.directed_edges, (treatment_node,)) @@ -738,9 +700,13 @@ function check_frontdoor_adjustment_set( 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) + !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, @@ -766,25 +732,15 @@ function minimal_frontdoor_adjustment_sets( 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)) + 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 + _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, @@ -797,26 +753,14 @@ function minimal_frontdoor_adjustment_sets( return _search_minimal_sets(candidates, is_valid, candidate_limit) end -function check_instrument( - dag::CausalDAG, - instrument, - treatment, - outcome, - conditioned = (), -) +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, - ) + !(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) @@ -824,12 +768,8 @@ function check_instrument( 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_relevance = + !_is_d_separated(dag, (instrument_node,), (treatment_node,), controls) pearl_exclusion = _is_d_separated( dag, (instrument_node,), @@ -842,22 +782,17 @@ function check_instrument( pearl_exclusion = false end pearl_graphical = - all_observed && conditioning_disjoint && unaffected && - pearl_relevance && pearl_exclusion + 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,), - ) + 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( @@ -868,8 +803,7 @@ function check_instrument( edges = instrument_edges, ) cfi_simple = - instrument_node in observed && cfi_direct && - cfi_full_mediation && cfi_exogeneity + instrument_node in observed && cfi_direct && cfi_full_mediation && cfi_exogeneity return InstrumentEvidence( instrument_node, treatment_node, @@ -898,8 +832,7 @@ function causal_role_evidence( ) 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 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") @@ -918,11 +851,11 @@ function causal_role_evidence( 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] + 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 + 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[] @@ -930,14 +863,15 @@ function causal_role_evidence( 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, + 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, + position -> (path.nodes[position], path.nodes[position+1]) in edge_set, + index:(length(path.nodes)-1), ) - directed_to_treatment && directed_to_outcome && + directed_to_treatment && + directed_to_outcome && push!(common_cause_paths, path.nodes) end return NodeRoleEvidence( diff --git a/src/CausalFactorAnalysis/Optimizer.jl b/src/CausalFactorAnalysis/Optimizer.jl index 59eab33..374670a 100644 --- a/src/CausalFactorAnalysis/Optimizer.jl +++ b/src/CausalFactorAnalysis/Optimizer.jl @@ -4,8 +4,7 @@ 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")) + 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 @@ -13,14 +12,12 @@ function _real_matrix(name::AbstractString, values) catch throw(ArgumentError("$name must be a numeric matrix")) end - all(isfinite, result) || - throw(ArgumentError("$name must contain only finite values")) + 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")) + 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 @@ -28,16 +25,11 @@ function _real_vector(name::AbstractString, values) catch throw(ArgumentError("$name must be a numeric vector")) end - all(isfinite, result) || - throw(ArgumentError("$name must contain only finite values")) + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) return result end -function minimum_variance_factor_weights( - covariance, - factor_exposures, - target_exposures, -) +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) @@ -46,25 +38,18 @@ function minimum_variance_factor_weights( 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", - ), + 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"), - ) + 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", - ), + 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")) + covariance_scale > 0.0 || throw(ArgumentError("covariance must be positive definite")) scaled_covariance = covariance_array / covariance_scale isapprox( scaled_covariance, @@ -72,8 +57,7 @@ function minimum_variance_factor_weights( rtol = _SYMMETRY_RTOL, atol = _SYMMETRY_ATOL, ) || throw(ArgumentError("covariance must be symmetric")) - scaled_covariance = - 0.5 .* scaled_covariance .+ 0.5 .* transpose(scaled_covariance) + scaled_covariance = 0.5 .* scaled_covariance .+ 0.5 .* transpose(scaled_covariance) covariance_cholesky = try cholesky(Symmetric(scaled_covariance); check = true) catch @@ -88,21 +72,16 @@ function minimum_variance_factor_weights( 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", - ), + 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", - ), + 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"), - ) + 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 @@ -124,9 +103,7 @@ function minimum_variance_factor_weights( 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", - ), + 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 index ec23f6e..33cfc54 100644 --- a/src/CausalFactorAnalysis/Protocol.jl +++ b/src/CausalFactorAnalysis/Protocol.jl @@ -14,10 +14,7 @@ const _VARIABLE_SELECTION_METHODS = Set(( "MEAN_DECREASE_IMPURITY", "PERMUTATION_FEATURE_IMPORTANCE", )) -const _RESEARCH_PURPOSES = Set(( - "CAUSAL_ATTRIBUTION", - "RISK_PREMIA_HARVESTING", -)) +const _RESEARCH_PURPOSES = Set(("CAUSAL_ATTRIBUTION", "RISK_PREMIA_HARVESTING")) const _DISCOVERY_METHODS = Set(( "PC", "LINGAM", @@ -27,21 +24,16 @@ const _DISCOVERY_METHODS = Set(( "PEER_REVIEWED_ASSUMPTIONS", "DOMAIN_EXPERTISE", )) -const _ADJUSTMENT_METHODS = Set(( - "BACKDOOR_ADJUSTMENT", - "FRONT_DOOR_ADJUSTMENT", - "INSTRUMENTAL_VARIABLES", -)) +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 _PURGED_VALIDATION_METHODS = + Set(("PURGED_CROSS_VALIDATION", "COMBINATORIAL_PURGED_CROSS_VALIDATION")) const _METRICS_BY_TASK = Dict( "PROBABILITY" => Set(("LOG_LOSS", "BRIER_SCORE")), "RANKING" => Set(( @@ -50,11 +42,7 @@ const _METRICS_BY_TASK = Dict( "MEAN_RECIPROCAL_RANK", "CLASSIFICATION_ACCURACY", )), - "RETURN_SIZE" => Set(( - "MEAN_SQUARED_ERROR", - "R_SQUARED", - "SPEARMAN_CORRELATION", - )), + "RETURN_SIZE" => Set(("MEAN_SQUARED_ERROR", "R_SQUARED", "SPEARMAN_CORRELATION")), ) const _PORTFOLIO_METHODS = Set(( "POSITION_SIZING", @@ -70,18 +58,11 @@ const _BACKTEST_METHODS = Set(( "COMBINATORIAL_PURGED_CROSS_VALIDATION", "MONTE_CARLO", )) -const _MULTIPLE_TESTING_METHODS = Set(( - "HOLM", - "HOCHBERG", - "BENJAMINI_HOCHBERG", - "DEFLATED_SHARPE_RATIO", -)) +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", -)) +const _REFIT_COMPONENTS = + Set(("VARIABLE_SELECTION", "CAUSAL_ESTIMATION", "PORTFOLIO_CONSTRUCTION")) Base.@kwdef struct EventHorizon observation_id::String @@ -270,10 +251,7 @@ function Base.getproperty(report::CausalFactorProtocolReport, name::Symbol) (_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[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)), @@ -339,13 +317,7 @@ function _validate_labels( errors::Vector{String}; allow_empty::Bool = false, ) - valid = _validate_string_tuple( - value, - stage, - subject, - errors; - allow_empty = allow_empty, - ) + 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 @@ -361,21 +333,33 @@ function _validate_event_horizons(evidence, stage, errors) 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.") + _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.") + _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.") + _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.") + _add_protocol_error!( + errors, + "$stage: every event horizon must have positive length.", + ) continue end result[horizon.observation_id] = (start, stop) @@ -388,9 +372,8 @@ _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 in left_index + 1:length(intervals) - _intervals_overlap(intervals[left_index], intervals[right_index]) && - return true + for right_index = (left_index+1):length(intervals) + _intervals_overlap(intervals[left_index], intervals[right_index]) && return true end end return false @@ -433,7 +416,10 @@ function _validate_fold_evidence( 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.") + _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 @@ -450,31 +436,19 @@ function _validate_fold_evidence( "refit components", errors, ) - if components_valid && !issubset(required_refit_components, Set(fold.refit_components)) + 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, - ) + 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, @@ -507,24 +481,24 @@ function _validate_fold_evidence( "$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.", - ) + 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.", - ) + 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 @@ -571,10 +545,8 @@ function _validate_fold_evidence( end if "COMBINATORIAL_PURGED_CROSS_VALIDATION" in validation_methods - length(path_test_ids) >= 2 || _add_protocol_error!( - errors, - "$stage: CPCV requires multiple complete paths.", - ) + 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, @@ -588,7 +560,8 @@ function _validate_fold_evidence( ) end if "PURGED_CROSS_VALIDATION" in validation_methods && - !isempty(known_ids) && nonresampled_test_ids != known_ids + !isempty(known_ids) && + nonresampled_test_ids != known_ids _add_protocol_error!( errors, "$stage: purged cross-validation must predict every observation once.", @@ -641,9 +614,7 @@ 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) - ] + 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 @@ -653,8 +624,8 @@ function _protocol_reachable_avoiding(adjacency, start, blocked) append!( pending, [ - target for target in get(adjacency, node, Set{String}()) if - !(target in blocked) + target for + target in get(adjacency, node, Set{String}()) if !(target in blocked) ], ) end @@ -692,7 +663,7 @@ function _protocol_d_separated(edges, left, right, conditioned) for child in ancestors child_parents = collect(intersect(parents[child], ancestors)) for first_index in eachindex(child_parents) - for second_index in first_index + 1:length(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) @@ -724,16 +695,9 @@ function _validate_discovery(stage, selected_variables, outcome, errors) "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, - ) + _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!( @@ -748,7 +712,10 @@ function _validate_discovery(stage, selected_variables, outcome, errors) 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.") + _add_protocol_error!( + errors, + "$label: every directed edge must use graph nodes.", + ) edges_valid = false end if source == target @@ -759,10 +726,8 @@ function _validate_discovery(stage, selected_variables, outcome, errors) 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.", - ) + 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.", @@ -792,7 +757,10 @@ function _validate_adjustment_sets(value, stage, errors) end frozen = Set(adjustment_set) if frozen in result - _add_protocol_error!(errors, "$stage: admissible adjustment sets must be distinct.") + _add_protocol_error!( + errors, + "$stage: admissible adjustment sets must be distinct.", + ) valid = false end push!(result, frozen) @@ -803,8 +771,10 @@ 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]) + 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 @@ -826,18 +796,12 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err 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.", - ) + 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, @@ -846,11 +810,8 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err errors; allow_empty = true, ) - admissible_sets, _ = _validate_adjustment_sets( - stage.admissible_adjustment_sets, - label, - errors, - ) + 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!( @@ -871,10 +832,16 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err ) 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.") + _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.") + _add_protocol_error!( + errors, + "$label: treatment and outcome cannot be role variables.", + ) end end @@ -887,15 +854,20 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err 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.") + _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.") + 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.", @@ -904,15 +876,15 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err 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,))) + 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)) + node for + (node, degree) in incoming if degree >= 2 && !(node in (treatment, outcome)) ) role_names[:colliders] == graph_colliders || _add_protocol_error!( errors, @@ -921,7 +893,7 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err 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) + outcome in _protocol_reachable(adjacency, node) ) role_names[:mediators] == graph_mediators || _add_protocol_error!( errors, @@ -940,14 +912,11 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err 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.", - ) + 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, @@ -977,27 +946,16 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err "$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, - ) + !_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, - ) + 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.", @@ -1009,14 +967,15 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err errors; allow_empty = true, ) - isempty(stage.open_backdoor_paths) || _add_protocol_error!( - errors, - "$label: no backdoor path may remain open.", - ) + 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.") + _add_protocol_error!( + errors, + "$label: front-door evidence requires the front-door method.", + ) end instrument_premises = ( stage.instrument_relevance_satisfied, @@ -1039,12 +998,12 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err ) 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 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.", @@ -1056,7 +1015,8 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err treatment, mediator, selected, - ) || !_protocol_backdoor_d_separated( + ) || + !_protocol_backdoor_d_separated( directed_edges, mediator, outcome, @@ -1069,7 +1029,8 @@ function _validate_adjustment(stage, graph_nodes, adjacency, directed_edges, err end end elseif stage.method_label == "INSTRUMENTAL_VARIABLES" - if isempty(role_names[:instruments]) || any(value -> value !== true, instrument_premises) + if isempty(role_names[:instruments]) || + any(value -> value !== true, instrument_premises) _add_protocol_error!( errors, "$label: instrumental-variable premises must all hold.", @@ -1081,10 +1042,8 @@ 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.", - ) + _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", @@ -1094,10 +1053,8 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) selection = report.variable_selection label = "Stage 1" - selection.purpose in _RESEARCH_PURPOSES || _add_protocol_error!( - errors, - "$label: research purpose is not source-listed.", - ) + 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, @@ -1112,10 +1069,12 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) "variable-selection method labels", errors, ) - selection_methods = selection_methods_valid ? Set(selection.method_labels) : Set{String}() + 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 || + if selection.overlapping_returns || + selection.strong_time_dependence || "PERMUTATION_FEATURE_IMPORTANCE" in selection_methods _add_protocol_error!( errors, @@ -1135,13 +1094,21 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) "$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.") + 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.") + 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 @@ -1153,13 +1120,7 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) safe_outcome, errors, ) - _validate_adjustment( - adjustment, - graph_nodes, - graph_adjacency, - graph_edges, - errors, - ) + _validate_adjustment(adjustment, graph_nodes, graph_adjacency, graph_edges, errors) stage4 = report.causal_explanatory_and_predictive_power label = "Stage 4" @@ -1176,10 +1137,8 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) union!(allowed_metrics, _METRICS_BY_TASK[task_type]) end end - _nonblank(stage4.estimator_label) || _add_protocol_error!( - errors, - "$label: estimator label must be nonblank.", - ) + _nonblank(stage4.estimator_label) || + _add_protocol_error!(errors, "$label: estimator label must be nonblank.") explanatory_valid = _validate_labels( stage4.explanatory_metric_labels, allowed_metrics, @@ -1198,10 +1157,9 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) ) 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.", - ) + 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}(), @@ -1218,14 +1176,21 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) 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.") + _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.") + _add_protocol_error!( + errors, + "$label: predictive evidence must match reported metrics.", + ) end - if has_explanatory && has_predictive && + if has_explanatory && + has_predictive && stage4.explanatory_evidence_id == stage4.predictive_evidence_id _add_protocol_error!( errors, @@ -1233,15 +1198,19 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) ) end if selection.purpose == "CAUSAL_ATTRIBUTION" && !has_explanatory - _add_protocol_error!(errors, "$label: causal attribution requires explanatory evidence.") + _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.") + _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.", - ) + _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, @@ -1269,9 +1238,10 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) 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) + 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 @@ -1285,7 +1255,10 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) errors, ) if portfolio_labels_valid && Set(portfolio.method_labels) != _PORTFOLIO_METHODS - _add_protocol_error!(errors, "$label: all source portfolio considerations are required.") + _add_protocol_error!( + errors, + "$label: all source portfolio considerations are required.", + ) end causal_valid = _validate_string_tuple( portfolio.causal_exposures, @@ -1301,8 +1274,8 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) allow_empty = true, ) causal_exposures = causal_valid ? Set(portfolio.causal_exposures) : Set{String}() - neutral_exposures = neutral_valid ? - Set(portfolio.controlled_unintended_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 @@ -1330,16 +1303,15 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) 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.", - ) + 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.") + _nonblank(value) || + _add_protocol_error!(errors, "$label: $subject must be nonblank.") end _validate_string_tuple( portfolio.fragility_scenarios, @@ -1372,12 +1344,15 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) 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}() + 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.") + _add_protocol_error!( + errors, + "$label: temporal backtests require validation evidence.", + ) else _validate_fold_evidence( backtest.validation, @@ -1385,9 +1360,10 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) errors; required_refit_components = _REFIT_COMPONENTS, ) - if selection.strong_time_dependence && - (!_finite_protocol_real(backtest.validation.embargo) || - Float64(backtest.validation.embargo) <= 0.0) + 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.", @@ -1406,12 +1382,13 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) ) end if backtest_labels_valid && "MONTE_CARLO" in backtest.method_labels - _nonblank(backtest.monte_carlo_dgp) || _add_protocol_error!( + _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: Monte Carlo requires an explicit DGP.", + "$label: a Monte Carlo DGP requires the Monte Carlo method.", ) - 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 @@ -1476,17 +1453,13 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) "$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.", - ) + 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.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.", @@ -1510,21 +1483,28 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) 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.") + _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.") + _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.") + _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.", - ) + _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!( @@ -1539,10 +1519,8 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) "$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.", - ) + _nonblank(multiple_testing.selection_bias_evidence_id) || + _add_protocol_error!(errors, "$label: selection-bias evidence is required.") elseif any( value -> value !== nothing, ( @@ -1560,9 +1538,8 @@ function validate_causal_factor_protocol(report::CausalFactorProtocolReport) ) end - isempty(errors) || throw( - ArgumentError("Causal factor protocol is invalid. " * join(errors, " ")), - ) + isempty(errors) || + throw(ArgumentError("Causal factor protocol is invalid. " * join(errors, " "))) return report end diff --git a/src/CausalFactorAnalysis/SpecificationDiagnostics.jl b/src/CausalFactorAnalysis/SpecificationDiagnostics.jl index 78df598..bcd168b 100644 --- a/src/CausalFactorAnalysis/SpecificationDiagnostics.jl +++ b/src/CausalFactorAnalysis/SpecificationDiagnostics.jl @@ -69,9 +69,8 @@ function _ols_diagnostics(outcome::Vector{Float64}, regressors) end design = hcat(columns...) n_parameters = size(design, 2) - n_observations > n_parameters || throw( - ArgumentError("the regression requires positive residual degrees of freedom"), - ) + 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")) @@ -81,9 +80,8 @@ function _ols_diagnostics(outcome::Vector{Float64}, regressors) 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"), - ) + 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 @@ -104,8 +102,8 @@ function _ols_diagnostics(outcome::Vector{Float64}, regressors) 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 + adjusted_r_squared = + 1.0 - (1.0 - r_squared) * (n_observations - 1) / residual_degrees_of_freedom return RegressionDiagnostics( Tuple(names), Tuple(Float64.(coefficients)), @@ -123,11 +121,7 @@ function fork_population_diagnostics() "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, - ), + _population_regression(("intercept", "X", "Z"), (0.0, 0.0, 1.0), 0.5), ) end @@ -136,11 +130,7 @@ function collider_population_diagnostics() "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, - ), + _population_regression(("intercept", "X", "Z"), (0.0, -0.5, 0.5), 0.5), ) end @@ -148,16 +138,8 @@ 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, - ), + _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 @@ -189,10 +171,7 @@ function collider_specification_experiment(sample_size = 5_000, seed = 0) ) end -function confounded_mediator_specification_experiment( - sample_size = 5_000, - seed = 0, -) +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) diff --git a/src/CausalFactorAnalysis/TreatmentEffects.jl b/src/CausalFactorAnalysis/TreatmentEffects.jl index b8b7486..93b98ff 100644 --- a/src/CausalFactorAnalysis/TreatmentEffects.jl +++ b/src/CausalFactorAnalysis/TreatmentEffects.jl @@ -41,8 +41,7 @@ function _finite_vector(name::AbstractString, values) catch throw(ArgumentError("$name must contain representable real values")) end - all(isfinite, result) || - throw(ArgumentError("$name must contain only finite values")) + all(isfinite, result) || throw(ArgumentError("$name must contain only finite values")) return result end @@ -83,23 +82,13 @@ function _finite_matrix(name::AbstractString, values) 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")) + 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, - ) +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 @@ -115,14 +104,8 @@ function treatment_effect_decomposition( 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, - ) + effect = _finite_real("average_treatment_effect_on_treated", treated - counterfactual) + selection = _finite_real("sample_selection_bias", counterfactual - control) return TreatmentEffectDecomposition(observed, effect, selection) end @@ -151,26 +134,14 @@ function difference_in_differences( _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, - ) + 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, -) +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, - ) + probabilities = + _probability_vector("adjustment_probabilities", adjustment_probabilities) length(means) == length(probabilities) || throw( ArgumentError( "conditional_outcome_means and adjustment_probabilities must have the same length", @@ -184,18 +155,10 @@ function backdoor_adjusted_average_treatment_effect( 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, - ) + 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", @@ -216,10 +179,8 @@ function frontdoor_adjusted_probability( "mediator_probabilities_given_treatment", mediator_probabilities_given_treatment, ) - treatment_weights = _probability_vector( - "treatment_probabilities", - treatment_probabilities, - ) + treatment_weights = + _probability_vector("treatment_probabilities", treatment_probabilities) conditional = _finite_matrix( "outcome_probabilities_given_mediator_and_treatment", outcome_probabilities_given_mediator_and_treatment, @@ -229,12 +190,12 @@ function frontdoor_adjusted_probability( "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", - ), - ) + 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), @@ -251,14 +212,9 @@ 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, - ) + 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) diff --git a/test/causal_factor_analysis/test_factor_mirage.jl b/test/causal_factor_analysis/test_factor_mirage.jl index 77de695..60ee948 100644 --- a/test/causal_factor_analysis/test_factor_mirage.jl +++ b/test/causal_factor_analysis/test_factor_mirage.jl @@ -27,8 +27,7 @@ @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 + @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 diff --git a/test/causal_factor_analysis/test_graph_identification.jl b/test/causal_factor_analysis/test_graph_identification.jl index 215b0e4..359a6ad 100644 --- a/test/causal_factor_analysis/test_graph_identification.jl +++ b/test/causal_factor_analysis/test_graph_identification.jl @@ -24,16 +24,9 @@ end function oracle_four_node_dags() nodes = ("A", "B", "C", "D") - pairs = ( - ("A", "B"), - ("A", "C"), - ("A", "D"), - ("B", "C"), - ("B", "D"), - ("C", "D"), - ) + pairs = (("A", "B"), ("A", "C"), ("A", "D"), ("B", "C"), ("B", "D"), ("C", "D")) graphs = Tuple[] - for encoding in 0:(3^length(pairs) - 1) + 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) @@ -88,8 +81,8 @@ end function oracle_path_open(nodes, edges, path, conditioned) edge_set = Set(edges) conditioned_set = Set(conditioned) - for index in 2:(length(path) - 1) - previous, node, following = path[index - 1], path[index], path[index + 1] + 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)) @@ -110,10 +103,13 @@ end function oracle_subsets(items) result = Tuple[] - for mask in 0:(2^length(items) - 1) + for mask = 0:(2^length(items)-1) push!( result, - Tuple(items[index] for index in eachindex(items) if !iszero(mask & (1 << (index - 1)))), + Tuple( + items[index] for + index in eachindex(items) if !iszero(mask & (1 << (index - 1))) + ), ) end return result @@ -122,8 +118,7 @@ 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) + 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[ @@ -134,7 +129,8 @@ function oracle_minimal_backdoor_sets(nodes, edges, treatment, outcome) minimal = Tuple[] for candidate in valid candidate_set = Set(candidate) - any(item -> issubset(Set(item), candidate_set), minimal) || push!(minimal, candidate) + any(item -> issubset(Set(item), candidate_set), minimal) || + push!(minimal, candidate) end return Tuple(minimal) end @@ -158,8 +154,8 @@ function oracle_has_directed_path(nodes, edges, left, right; blocked = ()) append!( pending, [ - child for child in children[node] if - !(child in blocked_set) && !(child in visited) + child for + child in children[node] if !(child in blocked_set) && !(child in visited) ], ) end @@ -180,20 +176,10 @@ function oracle_minimal_frontdoor_sets(nodes, edges, treatment, outcome) 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 + 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, @@ -208,12 +194,20 @@ function oracle_minimal_frontdoor_sets(nodes, edges, treatment, outcome) minimal = Tuple[] for candidate in valid candidate_set = Set(candidate) - any(item -> issubset(Set(item), candidate_set), minimal) || push!(minimal, 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) +function oracle_instrument_profile( + nodes, + edges, + instrument, + treatment, + outcome, + conditioned, +) observed = Set(nodes) controls = Set(conditioned) forbidden = Set((instrument, treatment, outcome)) @@ -221,47 +215,22 @@ function oracle_instrument_profile(nodes, edges, instrument, treatment, outcome, 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, - ) + 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, - ) + 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,), - ) + 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_exogeneity = oracle_d_separated(nodes, instrument_edges, instrument, outcome, ()) cfi_simple = cfi_direct && cfi_full_mediation && cfi_exogeneity return ( all_observed, @@ -289,8 +258,8 @@ function oracle_role_profile(nodes, edges, treatment, outcome, role_node) for path in paths colliders = Set{String}() noncolliders = Set{String}() - for index in 2:(length(path) - 1) - previous, node, following = path[index - 1], path[index], path[index + 1] + 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 @@ -299,22 +268,20 @@ function oracle_role_profile(nodes, edges, treatment, outcome, role_node) 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 in 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 && + 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 in index:-1:2 + (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 in index:(length(path) - 1) + (path[position], path[position+1]) in edge_set for + position = index:(length(path)-1) ) to_treatment && to_outcome && push!(common_cause_paths, path) end @@ -354,48 +321,28 @@ end @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"), - ) + 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(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",),) + @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_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")), - ) + 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 CausalDAG(("X", "Y"), (("X", "Y"), ("Y", "X")), ("X", "Y")) @test_throws ArgumentError d_separation(fork, ("X",), ("X",)) end @@ -411,34 +358,32 @@ end 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 + 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)) + push!( + mismatches, + ( + :d_separation, + edges, + treatment, + outcome, + conditioned, + expected, + actual, + ), + ) end end - expected_sets = oracle_minimal_backdoor_sets( - nodes, - edges, - treatment, - outcome, - ) + 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)) + push!( + mismatches, + (:backdoor, edges, treatment, outcome, expected_sets, actual_sets), + ) end end end @@ -456,27 +401,22 @@ end 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, - ) + 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, - )) + 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 @@ -490,13 +430,8 @@ end outcome, conditioned, ) - actual = check_instrument( - dag, - instrument, - treatment, - outcome, - conditioned, - ) + actual = + check_instrument(dag, instrument, treatment, outcome, conditioned) observed = ( actual.all_observed, actual.conditioning_disjoint, @@ -511,16 +446,19 @@ end ) instrument_queries += 1 if expected != observed && length(mismatches) < 10 - push!(mismatches, ( - :instrument, - edges, - instrument, - treatment, - outcome, - conditioned, - expected, - observed, - )) + push!( + mismatches, + ( + :instrument, + edges, + instrument, + treatment, + outcome, + conditioned, + expected, + observed, + ), + ) end end end @@ -541,13 +479,7 @@ end treatment == outcome && continue for role_node in nodes role_node in (treatment, outcome) && continue - expected = oracle_role_profile( - nodes, - edges, - treatment, - outcome, - role_node, - ) + expected = oracle_role_profile(nodes, edges, treatment, outcome, role_node) actual = causal_role_evidence(dag, treatment, outcome, role_node) observed = ( actual.collider_on_paths, @@ -560,14 +492,10 @@ end ) queries += 1 if expected != observed && length(mismatches) < 10 - push!(mismatches, ( - edges, - treatment, - outcome, - role_node, - expected, - observed, - )) + push!( + mismatches, + (edges, treatment, outcome, role_node, expected, observed), + ) end end end diff --git a/test/causal_factor_analysis/test_optimizer.jl b/test/causal_factor_analysis/test_optimizer.jl index c13a5c6..2c29901 100644 --- a/test/causal_factor_analysis/test_optimizer.jl +++ b/test/causal_factor_analysis/test_optimizer.jl @@ -20,10 +20,7 @@ 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], - ) + 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, diff --git a/test/causal_factor_analysis/test_protocol.jl b/test/causal_factor_analysis/test_protocol.jl index 5fa32a0..e3db1dc 100644 --- a/test/causal_factor_analysis/test_protocol.jl +++ b/test/causal_factor_analysis/test_protocol.jl @@ -66,117 +66,128 @@ function protocol_report(; ) 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 + 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, @@ -222,10 +233,7 @@ end 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"), - ), + directed_edges = (report.causal_discovery.directed_edges..., ("Y", "X")), graph_kind = "DAG", ambiguous_edges = (), assumptions = report.causal_discovery.assumptions, diff --git a/test/causal_factor_analysis/test_specification_diagnostics.jl b/test/causal_factor_analysis/test_specification_diagnostics.jl index e19fdc1..56f9ac7 100644 --- a/test/causal_factor_analysis/test_specification_diagnostics.jl +++ b/test/causal_factor_analysis/test_specification_diagnostics.jl @@ -31,10 +31,8 @@ @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 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 index be3401d..103158a 100644 --- a/test/causal_factor_analysis/test_treatment_effects.jl +++ b/test/causal_factor_analysis/test_treatment_effects.jl @@ -14,11 +14,8 @@ @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 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)), @@ -30,13 +27,12 @@ @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 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 average_treatment_effect( + floatmax(Float64), + -floatmax(Float64), + ) @test_throws ArgumentError treatment_effect_decomposition( floatmax(Float64), -floatmax(Float64), @@ -54,12 +50,7 @@ (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), - ) + @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 index 3ab4cd6..5b7406a 100644 --- a/test/preserved_runtests.jl +++ b/test/preserved_runtests.jl @@ -4,6 +4,15 @@ 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 @@ -1891,20 +1900,37 @@ end @test xs[:, :, 1] == repeat([1.0 0.5], 16, 1) end -@testset "Pde — Deep BSDE solver (Lux)" begin +@testset "Pde — optional Deep BSDE extension" 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 + 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 1745e8b..fcf85b1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,15 +3,7 @@ include("preserved_runtests.jl") using Test using LinearAlgebra -include( - joinpath( - @__DIR__, - "..", - "src", - "CausalFactorAnalysis", - "CausalFactorAnalysis.jl", - ), -) +include(joinpath(@__DIR__, "..", "src", "CausalFactorAnalysis", "CausalFactorAnalysis.jl")) using .CausalFactorAnalysis const EXPECTED_CAUSAL_FACTOR_EXPORTS = Set(( From e0e3ff0e7a651c8aeb10ce58e92342bcd30409ed Mon Sep 17 00:00:00 2001 From: hamid-arian Date: Mon, 24 Aug 2026 10:19:44 -0400 Subject: [PATCH 3/4] Fix Julia current-dependency CI environments --- .github/workflows/CI.yml | 17 +++++++++++++++++ PACKAGE_INSPECTION.md | 9 +++++---- RELEASE_NOTES_NEXT_MAJOR.md | 6 ++++-- docs/compatibility.md | 13 +++++++++---- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 13a0ef5..3648a7f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -56,6 +56,16 @@ jobs: 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")) @@ -106,6 +116,13 @@ jobs: ]) 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"), diff --git a/PACKAGE_INSPECTION.md b/PACKAGE_INSPECTION.md index bf3e1af..973ff9b 100644 --- a/PACKAGE_INSPECTION.md +++ b/PACKAGE_INSPECTION.md @@ -33,10 +33,11 @@ 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. 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. +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 diff --git a/RELEASE_NOTES_NEXT_MAJOR.md b/RELEASE_NOTES_NEXT_MAJOR.md index 4d7ade4..6390bbc 100644 --- a/RELEASE_NOTES_NEXT_MAJOR.md +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -16,8 +16,10 @@ 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. The governed workflow also pins JuliaFormatter 2.9.0 for the clean -causal source, causal tests, and test entry point. +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 diff --git a/docs/compatibility.md b/docs/compatibility.md index 7daea41..774b00d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -48,7 +48,11 @@ 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. +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. @@ -74,9 +78,10 @@ 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. 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. +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 From e52e0034d0929aed051724cb4178f57b072d2a58 Mon Sep 17 00:00:00 2001 From: hamid-arian Date: Mon, 24 Aug 2026 13:47:59 -0400 Subject: [PATCH 4/4] Correct Julia test inventory hashes --- TEST_INVENTORY.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/TEST_INVENTORY.json b/TEST_INVENTORY.json index bf7a311..7d3b8a0 100644 --- a/TEST_INVENTORY.json +++ b/TEST_INVENTORY.json @@ -55,32 +55,32 @@ }, { "path": "test/fixtures/pelt_x1.txt", - "size": 4800, - "sha256": "60821B281A4E335F1E33755D4DCCC100B1738708FA86743EF0273C754BC9B04A", + "size": 4560, + "sha256": "D91CCA3A9AC511AD7E57690342491639C2221B7D6971A84D48C06142DCBF7EBF", "group": "PRESERVED_FIXTURE" }, { "path": "test/fixtures/pelt_x2.txt", - "size": 4098, - "sha256": "0CF964248BB19CC7B4EB2851AE42CA158F67B1D564F0CFCDA40F690642BB0799", + "size": 3898, + "sha256": "A6ED3EC032127997915CC490713622C466060B09A196DB6E64B71E75C1F3A9D2", "group": "PRESERVED_FIXTURE" }, { "path": "test/fixtures/pelt_x3.txt", - "size": 6391, - "sha256": "12DABDD546F793FD08FDDDA85849B837B6D1C11F918917830F40A1A3CFD7802C", + "size": 6071, + "sha256": "4ED138F86A26DBEAA424E52656286EFE0ECEF8EC9F5901F4F39B6653348F91CA", "group": "PRESERVED_FIXTURE" }, { "path": "test/fixtures/vrsadf_y.txt", - "size": 2345, - "sha256": "86FB01DFECCDCD3CBC73526A0F8EBD2007A4262466F14853463DC207670730DC", + "size": 2225, + "sha256": "15174F32C7EA4FCF7A158C80C47ABDFB25B1DEFBEDD2DA0B1103B6B2AFF33AD5", "group": "PRESERVED_FIXTURE" }, { "path": "test/preserved_runtests.jl", - "size": 74538, - "sha256": "3005EE9B73838E7B5FF7E179628F9573ADC08E6C0C146F002B34D8874440C203", + "size": 72649, + "sha256": "72BE89B073A52697AC6BD39E9DA67717FEC832B81875E6317C2ABF98C4071896", "group": "PRESERVED_LIBRARY" }, {