diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 061d102..914993e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: working-directory: ${{ runner.temp }} env: RISKLABAI_REPOSITORY_ROOT: ${{ github.workspace }} - run: python -B -c "import os; from pathlib import Path; import RiskLabAI; origin = Path(RiskLabAI.__file__).resolve(); repository = Path(os.environ['RISKLABAI_REPOSITORY_ROOT']).resolve(); assert not origin.is_relative_to(repository), (origin, repository); assert RiskLabAI.__version__ == '3.1.0'; print(origin)" + run: python -B -c "import os; from pathlib import Path; import RiskLabAI; origin = Path(RiskLabAI.__file__).resolve(); repository = Path(os.environ['RISKLABAI_REPOSITORY_ROOT']).resolve(); assert not origin.is_relative_to(repository), (origin, repository); assert RiskLabAI.__version__ == '3.2.0'; print(origin)" - name: Run the installed base package tests working-directory: ${{ runner.temp }} run: python -B -m pytest -q -p no:cacheprovider -c "$GITHUB_WORKSPACE/pyproject.toml" "$GITHUB_WORKSPACE/test" @@ -91,7 +91,7 @@ jobs: working-directory: ${{ runner.temp }} env: RISKLABAI_REPOSITORY_ROOT: ${{ github.workspace }} - run: python -B -c "import os; from pathlib import Path; import RiskLabAI; origin = Path(RiskLabAI.__file__).resolve(); repository = Path(os.environ['RISKLABAI_REPOSITORY_ROOT']).resolve(); assert not origin.is_relative_to(repository), (origin, repository); assert RiskLabAI.__version__ == '3.1.0'; print(origin)" + run: python -B -c "import os; from pathlib import Path; import RiskLabAI; origin = Path(RiskLabAI.__file__).resolve(); repository = Path(os.environ['RISKLABAI_REPOSITORY_ROOT']).resolve(); assert not origin.is_relative_to(repository), (origin, repository); assert RiskLabAI.__version__ == '3.2.0'; print(origin)" - name: Run the installed optional-feature tests working-directory: ${{ runner.temp }} run: python -B -m pytest -q -p no:cacheprovider -c "$GITHUB_WORKSPACE/pyproject.toml" "$GITHUB_WORKSPACE/test" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0935005..c96b70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,38 @@ All notable changes to RiskLabAI.py are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning: [SemVer](https://semver.org/). -## [3.1.0] - Unreleased +## [3.2.0] - Unreleased + +### Added + +- Sequential bootstrap sampling, majorization-based optimization, financial + network clearing, and optimal execution utilities. +- Ambiguity calculations for supplied priors, order-flow features, regime and + drift adaptation, and tail-risk estimation and backtesting. +- Constrained and robust portfolio optimization and decision-focused losses. +- Causal bounds, finite-stratum transport, design-based interference, and + inverse-probability and doubly robust policy evaluation. +- PyTorch implementations of TimeGAN, deep hedging, and finite-grid neural SDEs. +- Analytical examples, independent numerical checks, boundary validation, and + regression tests for the new methods. + +### Fixed + +- Sample-concurrency counting for events that begin before the selected window. + +### Scope and dependencies + +- The neural-SDE implementation uses an explicitly corrected fixed-coordinate + diffusion operator rather than the literal published nearest-frame + construction. Its guarantees are bounded to the implemented finite grid; + general dynamic no-arbitrage and empirical market performance are not claimed. +- Required dependencies and supported Python versions are unchanged. PyTorch + is available through the existing `pde` extra. Selected new methods require + separately installed CVXPY, River, or PySensemakr; these remain optional. +- The additions are Python implementations. Corresponding Julia additions and + book notebooks are deferred. + +## [3.1.0] - Historical preparation notes ### Added diff --git a/INSTALLATION.md b/INSTALLATION.md index 8a3f100..45770ab 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -2,7 +2,7 @@ ## Supported environments -RiskLabAI 3.1.0 supports: +RiskLabAI 3.2.0 supports: - CPython 3.12, 3.13, and 3.14 - NumPy `>=2.2,<3` @@ -73,7 +73,7 @@ The groups provide: | Extra | Capability | |---|---| | `speed` | Numba acceleration | -| `pde` | Deep-BSDE PDE solver using PyTorch | +| `pde` | PyTorch support for Deep-BSDE, neural SDEs, TimeGAN, and deep hedging | | `synth` | synthetic-control utilities using QuantEcon | | `hpo` | hyperparameter tuning using Optuna | | `plot` | Matplotlib, Seaborn, and Plotly helpers | @@ -100,6 +100,18 @@ Notes: - PyWavelets is part of the base installation because its absence changes analytical behavior rather than only disabling acceleration. +## Additional method dependencies + +Selected methods added in 3.2.0 use CVXPY (constrained or robust optimization), +River (ADWIN drift adaptation), or PySensemakr (sensitivity analysis). +These packages are installed separately when needed; they are not base +dependencies or named extras. Local Python 3.13 validation used CVXPY 1.9.3, +River 0.26.1, and PySensemakr 0.0.8. This does not establish compatibility with +every version of those packages or every supported interpreter. + +The 3.2.0 version is a release candidate until published. An unpinned PyPI +installation retrieves the latest published version. + ## Development installation Clone the repository, switch to a supported Python version, and run the diff --git a/PACKAGE_FILES.json b/PACKAGE_FILES.json index 51da517..23b04e9 100644 --- a/PACKAGE_FILES.json +++ b/PACKAGE_FILES.json @@ -3,13 +3,13 @@ "document_type": "RISKLABAI_PYTHON_INTENDED_PACKAGE_FILES", "product": "RiskLabAI", "language": "Python", - "version": "3.1.0", - "version_note": "Final local integration candidate; package artifacts are inspection-only and every version-control and release action remains human-controlled.", + "version": "3.2.0", + "version_note": "Approved 3.2.0 candidate source inventory, including Python book-method additions and the explicitly corrected fixed-coordinate neural-SDE construction. Book work and Julia remain deferred. Evaluation environments and temporary build artifacts are excluded. Publication is not asserted.", "artifact_types": [ "wheel", "source_distribution" ], - "candidate_file_count": 353, + "candidate_file_count": 377, "candidate_files": [ ".github/workflows/ci.yml", ".github/workflows/publish.yml", @@ -152,11 +152,13 @@ "src/RiskLabAI/__init__.py", "src/RiskLabAI/_deprecation.py", "src/RiskLabAI/backtest/__init__.py", + "src/RiskLabAI/backtest/adaptation.py", "src/RiskLabAI/backtest/advanced_bet_sizing.py", "src/RiskLabAI/backtest/backtest_overfitting_simulation.py", "src/RiskLabAI/backtest/backtest_statistics.py", "src/RiskLabAI/backtest/backtest_synthetic_data.py", "src/RiskLabAI/backtest/bet_sizing.py", + "src/RiskLabAI/backtest/deep_hedging.py", "src/RiskLabAI/backtest/multiple_testing.py", "src/RiskLabAI/backtest/ou_trading_rules.py", "src/RiskLabAI/backtest/probabilistic_sharpe_ratio.py", @@ -164,6 +166,7 @@ "src/RiskLabAI/backtest/robust_statistics.py", "src/RiskLabAI/backtest/sharpe_inference.py", "src/RiskLabAI/backtest/strategy_risk.py", + "src/RiskLabAI/backtest/tail_risk.py", "src/RiskLabAI/backtest/test_set_overfitting.py", "src/RiskLabAI/backtest/validation/__init__.py", "src/RiskLabAI/backtest/validation/adaptive_combinatorial_purged.py", @@ -180,10 +183,13 @@ "src/RiskLabAI/backtest/validation/walk_forward.py", "src/RiskLabAI/causal_factor_analysis/__init__.py", "src/RiskLabAI/causal_factor_analysis/allocation_diagnostics.py", + "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "src/RiskLabAI/causal_factor_analysis/graph_identification.py", "src/RiskLabAI/causal_factor_analysis/graph_roles.py", + "src/RiskLabAI/causal_factor_analysis/interference.py", "src/RiskLabAI/causal_factor_analysis/optimizer.py", + "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", "src/RiskLabAI/causal_factor_analysis/protocol.py", "src/RiskLabAI/causal_factor_analysis/search_adjusted_fdr.py", "src/RiskLabAI/causal_factor_analysis/specification_diagnostics.py", @@ -225,6 +231,7 @@ "src/RiskLabAI/data/synthetic_data/drift_burst_hypothesis.py", "src/RiskLabAI/data/synthetic_data/simulation.py", "src/RiskLabAI/data/synthetic_data/synthetic_controlled_environment.py", + "src/RiskLabAI/data/synthetic_data/timegan.py", "src/RiskLabAI/data/weights/__init__.py", "src/RiskLabAI/data/weights/sample_weights.py", "src/RiskLabAI/ensemble/__init__.py", @@ -232,6 +239,7 @@ "src/RiskLabAI/ensemble/empirical_bagging_accuracy.py", "src/RiskLabAI/features/__init__.py", "src/RiskLabAI/features/entropy_features/__init__.py", + "src/RiskLabAI/features/entropy_features/ambiguity.py", "src/RiskLabAI/features/entropy_features/bias_corrected.py", "src/RiskLabAI/features/entropy_features/kontoyiannis.py", "src/RiskLabAI/features/entropy_features/lempel_ziv.py", @@ -255,6 +263,7 @@ "src/RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", "src/RiskLabAI/features/microstructural_features/corwin_schultz.py", "src/RiskLabAI/features/microstructural_features/edge.py", + "src/RiskLabAI/features/microstructural_features/order_flow.py", "src/RiskLabAI/features/structural_breaks/__init__.py", "src/RiskLabAI/features/structural_breaks/pelt_change_points.py", "src/RiskLabAI/features/structural_breaks/structural_breaks.py", @@ -262,15 +271,22 @@ "src/RiskLabAI/hpc/__init__.py", "src/RiskLabAI/hpc/hpc.py", "src/RiskLabAI/optimization/__init__.py", + "src/RiskLabAI/optimization/clearing.py", + "src/RiskLabAI/optimization/constrained.py", + "src/RiskLabAI/optimization/decision_focused.py", + "src/RiskLabAI/optimization/execution.py", "src/RiskLabAI/optimization/hedging.py", "src/RiskLabAI/optimization/hrp.py", "src/RiskLabAI/optimization/hyper_parameter_tuning.py", + "src/RiskLabAI/optimization/majorization.py", "src/RiskLabAI/optimization/nco.py", "src/RiskLabAI/pde/__init__.py", "src/RiskLabAI/pde/equation.py", "src/RiskLabAI/pde/model.py", + "src/RiskLabAI/pde/neural_sde.py", "src/RiskLabAI/pde/solver.py", "src/RiskLabAI/utils/__init__.py", + "src/RiskLabAI/utils/_validation.py", "src/RiskLabAI/utils/constants.py", "src/RiskLabAI/utils/ewma.py", "src/RiskLabAI/utils/momentum_mean_reverting_strategy_sides.py", @@ -352,13 +368,21 @@ "test/features/structural_breaks/test_structural_breaks.py", "test/features/structural_breaks/test_volatility_robust_sadf.py", "test/hpc/test_hpc.py", + "test/optimization/test_clearing.py", + "test/optimization/test_execution.py", "test/optimization/test_hedging.py", "test/optimization/test_hrp.py", "test/optimization/test_hyper_parameter_tuning.py", + "test/optimization/test_majorization.py", "test/optimization/test_nco.py", + "test/pde/test_neural_sde.py", "test/pde/test_pde_solver.py", + "test/test_book_method_validation.py", + "test/test_book_methods.py", "test/test_consolidation.py", "test/test_deprecation.py", + "test/test_existing_book_routes.py", + "test/test_learned_book_methods.py", "test/test_performance.py", "test/utils/test_ewma.py", "test/utils/test_momentum_mean_reverting_strategy_sides.py", @@ -584,7 +608,7 @@ "test/utils/test_momentum_mean_reverting_strategy_sides.py", "test/utils/test_progress.py" ], - "release_source_file_count": 226, + "release_source_file_count": 250, "release_source_files": [ ".github/workflows/ci.yml", "LICENSE", @@ -602,11 +626,13 @@ "src/RiskLabAI/__init__.py", "src/RiskLabAI/_deprecation.py", "src/RiskLabAI/backtest/__init__.py", + "src/RiskLabAI/backtest/adaptation.py", "src/RiskLabAI/backtest/advanced_bet_sizing.py", "src/RiskLabAI/backtest/backtest_overfitting_simulation.py", "src/RiskLabAI/backtest/backtest_statistics.py", "src/RiskLabAI/backtest/backtest_synthetic_data.py", "src/RiskLabAI/backtest/bet_sizing.py", + "src/RiskLabAI/backtest/deep_hedging.py", "src/RiskLabAI/backtest/multiple_testing.py", "src/RiskLabAI/backtest/ou_trading_rules.py", "src/RiskLabAI/backtest/probabilistic_sharpe_ratio.py", @@ -614,6 +640,7 @@ "src/RiskLabAI/backtest/robust_statistics.py", "src/RiskLabAI/backtest/sharpe_inference.py", "src/RiskLabAI/backtest/strategy_risk.py", + "src/RiskLabAI/backtest/tail_risk.py", "src/RiskLabAI/backtest/test_set_overfitting.py", "src/RiskLabAI/backtest/validation/__init__.py", "src/RiskLabAI/backtest/validation/adaptive_combinatorial_purged.py", @@ -630,10 +657,13 @@ "src/RiskLabAI/backtest/validation/walk_forward.py", "src/RiskLabAI/causal_factor_analysis/__init__.py", "src/RiskLabAI/causal_factor_analysis/allocation_diagnostics.py", + "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "src/RiskLabAI/causal_factor_analysis/graph_identification.py", "src/RiskLabAI/causal_factor_analysis/graph_roles.py", + "src/RiskLabAI/causal_factor_analysis/interference.py", "src/RiskLabAI/causal_factor_analysis/optimizer.py", + "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", "src/RiskLabAI/causal_factor_analysis/protocol.py", "src/RiskLabAI/causal_factor_analysis/search_adjusted_fdr.py", "src/RiskLabAI/causal_factor_analysis/specification_diagnostics.py", @@ -675,6 +705,7 @@ "src/RiskLabAI/data/synthetic_data/drift_burst_hypothesis.py", "src/RiskLabAI/data/synthetic_data/simulation.py", "src/RiskLabAI/data/synthetic_data/synthetic_controlled_environment.py", + "src/RiskLabAI/data/synthetic_data/timegan.py", "src/RiskLabAI/data/weights/__init__.py", "src/RiskLabAI/data/weights/sample_weights.py", "src/RiskLabAI/ensemble/__init__.py", @@ -682,6 +713,7 @@ "src/RiskLabAI/ensemble/empirical_bagging_accuracy.py", "src/RiskLabAI/features/__init__.py", "src/RiskLabAI/features/entropy_features/__init__.py", + "src/RiskLabAI/features/entropy_features/ambiguity.py", "src/RiskLabAI/features/entropy_features/bias_corrected.py", "src/RiskLabAI/features/entropy_features/kontoyiannis.py", "src/RiskLabAI/features/entropy_features/lempel_ziv.py", @@ -705,6 +737,7 @@ "src/RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", "src/RiskLabAI/features/microstructural_features/corwin_schultz.py", "src/RiskLabAI/features/microstructural_features/edge.py", + "src/RiskLabAI/features/microstructural_features/order_flow.py", "src/RiskLabAI/features/structural_breaks/__init__.py", "src/RiskLabAI/features/structural_breaks/pelt_change_points.py", "src/RiskLabAI/features/structural_breaks/structural_breaks.py", @@ -712,15 +745,22 @@ "src/RiskLabAI/hpc/__init__.py", "src/RiskLabAI/hpc/hpc.py", "src/RiskLabAI/optimization/__init__.py", + "src/RiskLabAI/optimization/clearing.py", + "src/RiskLabAI/optimization/constrained.py", + "src/RiskLabAI/optimization/decision_focused.py", + "src/RiskLabAI/optimization/execution.py", "src/RiskLabAI/optimization/hedging.py", "src/RiskLabAI/optimization/hrp.py", "src/RiskLabAI/optimization/hyper_parameter_tuning.py", + "src/RiskLabAI/optimization/majorization.py", "src/RiskLabAI/optimization/nco.py", "src/RiskLabAI/pde/__init__.py", "src/RiskLabAI/pde/equation.py", "src/RiskLabAI/pde/model.py", + "src/RiskLabAI/pde/neural_sde.py", "src/RiskLabAI/pde/solver.py", "src/RiskLabAI/utils/__init__.py", + "src/RiskLabAI/utils/_validation.py", "src/RiskLabAI/utils/constants.py", "src/RiskLabAI/utils/ewma.py", "src/RiskLabAI/utils/momentum_mean_reverting_strategy_sides.py", @@ -801,24 +841,32 @@ "test/features/structural_breaks/test_structural_breaks.py", "test/features/structural_breaks/test_volatility_robust_sadf.py", "test/hpc/test_hpc.py", + "test/optimization/test_clearing.py", + "test/optimization/test_execution.py", "test/optimization/test_hedging.py", "test/optimization/test_hrp.py", "test/optimization/test_hyper_parameter_tuning.py", + "test/optimization/test_majorization.py", "test/optimization/test_nco.py", + "test/pde/test_neural_sde.py", "test/pde/test_pde_solver.py", + "test/test_book_method_validation.py", + "test/test_book_methods.py", "test/test_consolidation.py", "test/test_deprecation.py", + "test/test_existing_book_routes.py", + "test/test_learned_book_methods.py", "test/test_performance.py", "test/utils/test_ewma.py", "test/utils/test_momentum_mean_reverting_strategy_sides.py", "test/utils/test_progress.py" ], - "release_file_list_sha256": "4F54899CA32A5EDFC37C774D001A3D902FD5AA952FDF5320F4C623FA5244CA5C", + "release_file_list_sha256": "66B97CE4D1AF7557F7E3A8FDB45311F5A73CAC1DE7AD03126474A81BC5969F22", "release_file_records_excluding_self": [ { "path": ".github/workflows/ci.yml", "size": 4352, - "sha256": "BF972FE1D397A2FCEFE7ED097F749A27D6194D71F8EF83D1662959B05B94ED78" + "sha256": "625E3871A9E67EE0BEE5E632262427E0DD3CDD47F0FF91FE0F1D83116B1C7225" }, { "path": "LICENSE", @@ -832,28 +880,28 @@ }, { "path": "PACKAGE_INSPECTION.md", - "size": 3799, - "sha256": "D2FE6D170C5DC6ECD5118642A05D15342F11B6877D6B53A7456DC3BB1240F044" + "size": 3885, + "sha256": "05DB4C7A0F2B39040005D46C3C7042CACD9FC773E0C8461825403F2061D69591" }, { "path": "PUBLIC_API.json", - "size": 200159, - "sha256": "4DAB507FFCF205F0181ED87D9F76063A6CEA7E4265ACF95E28226AE48D061A5B" + "size": 217570, + "sha256": "DA0EAEC481CF0D656281C7AF4D0F1A4FF71CFFA4FADAC2A08CA4BDF6006124EA" }, { "path": "README.md", - "size": 6615, - "sha256": "09D70FA159B02A994D74775BA695BAECF2B9253EAA3ED0665F4E9AE484032273" + "size": 6938, + "sha256": "46E73886A2DDDF47F35DC60B58DCB0FF7840527BE68F4D0CFB5BAC1864383B27" }, { "path": "RELEASE_NOTES_NEXT_MAJOR.md", - "size": 2403, - "sha256": "F19797FFBB789C6861DE2D2F3C4620CDD3BD36439A981C675EC73F7FCAD8E262" + "size": 3980, + "sha256": "98476503103A59026DFFB8946424D3F79B83EC4FC57D9CC49CEF11D84AFA8292" }, { "path": "TEST_INVENTORY.json", - "size": 22570, - "sha256": "2E2EF942FE59EBE41A75C55532F3E8012657EFDCA6C9931870B77C34A94D58FE" + "size": 24782, + "sha256": "69104E6C787E5DADC9421BFD68479666CEF48DC240058DD85497B2622EC2A404" }, { "path": "docs/causal_factor_analysis.md", @@ -862,23 +910,23 @@ }, { "path": "docs/compatibility.md", - "size": 6667, - "sha256": "12F5CEA996314FA8D96383BFB73B59C8B9231C26359DC76292C6F58B6BDF51FD" + "size": 7691, + "sha256": "AD01766219EC80406D67FBF7FD741538A64362624CF4C4F71C975F239DD1EAB9" }, { "path": "examples/causal_factor_analysis_quickstart.py", "size": 1932, - "sha256": "9C091DB31054D145AFDF6D52AE11DA38C569C59A530FFD7B5D9CEB1B959073D8" + "sha256": "0121461E92353AF4211B8BE8F3D69B28043D90155A84A8D8C4FA4FFB8FBBBDCD" }, { "path": "pyproject.toml", "size": 2878, - "sha256": "7691CCCB4F26525405F7ED9AC90CC76625DD8DA9E06401DEB7C69644BF8720F2" + "sha256": "6A664247D7D424F49D54D31DE2CDA1440570AE1CA78F7E5AC06799F1E3605E42" }, { "path": "src/RiskLabAI/__init__.py", "size": 1095, - "sha256": "1CB2A7E2B804267FFEBE4FF124F01AA095DC19F82492DB862C9A4D97C2A1AF0F" + "sha256": "CDC3F50B0C33EB8AA6E7999E88C67CEA062D71B6D3554DAEF24DA247BFF4C9A8" }, { "path": "src/RiskLabAI/_deprecation.py", @@ -887,8 +935,13 @@ }, { "path": "src/RiskLabAI/backtest/__init__.py", - "size": 6204, - "sha256": "59475AA703033C284FED55C308AB258B79AC6E31A8EE5164B01AF6E9396E4C8C" + "size": 6488, + "sha256": "7B88C6D36058FF9143C0B79CFEE3B804B4F0FBB7D8371DB4501CF31D8628E177" + }, + { + "path": "src/RiskLabAI/backtest/adaptation.py", + "size": 2792, + "sha256": "C60C11C2CF7614DF052AA14171C808E1F18CF9D7F7136F70487C406F58577D29" }, { "path": "src/RiskLabAI/backtest/advanced_bet_sizing.py", @@ -915,6 +968,11 @@ "size": 15577, "sha256": "40704B327DF86A3901B684D6085DDC503947B5641E081BFCBB9B80CBBE1BE889" }, + { + "path": "src/RiskLabAI/backtest/deep_hedging.py", + "size": 8315, + "sha256": "44A5DCF044FCA3CA2FBE765A978E420BDABD521D761EB4C1E03252EED8470499" + }, { "path": "src/RiskLabAI/backtest/multiple_testing.py", "size": 7855, @@ -950,6 +1008,11 @@ "size": 10670, "sha256": "00D0E6574F81E68051A95CD762158E7BA52BB8109D8E47FB723C02C06C476447" }, + { + "path": "src/RiskLabAI/backtest/tail_risk.py", + "size": 5850, + "sha256": "E0551901DCC25E6B7ED9847285628482CDE3907FBDB8685B5B080AA5A841771A" + }, { "path": "src/RiskLabAI/backtest/test_set_overfitting.py", "size": 8865, @@ -1022,14 +1085,19 @@ }, { "path": "src/RiskLabAI/causal_factor_analysis/__init__.py", - "size": 6656, - "sha256": "96B6E483A72D5CBD00B5EDC5762345E7D31F31F6255B91C793C00B9B2BE62CA4" + "size": 6954, + "sha256": "16850DE616B0253426617174652C23FD739C5A1D0CE57C9799C3CBA5298490F2" }, { "path": "src/RiskLabAI/causal_factor_analysis/allocation_diagnostics.py", "size": 11041, "sha256": "089677E3EF24979A421676BD4253EA684CB6819D7D6405FB78A1575F2F47C35C" }, + { + "path": "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", + "size": 3088, + "sha256": "DE2A45CF82F7D9AF78CE480F8B14A1F90A232B261EC6A57F3A7E43680D7E59ED" + }, { "path": "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "size": 18292, @@ -1045,11 +1113,21 @@ "size": 6324, "sha256": "415086CF1403FB0EE955184F224C9B11ACC7047943D70A39AB969A1C0CD793AD" }, + { + "path": "src/RiskLabAI/causal_factor_analysis/interference.py", + "size": 2758, + "sha256": "6583CBD9D9E1908E7758282B78B0EE3F92D6E028E12A7C4A332899D112262F76" + }, { "path": "src/RiskLabAI/causal_factor_analysis/optimizer.py", "size": 8835, "sha256": "C83DFD2630E82E3D31D45E47E3895FD8926CB2FC67A66842C4C25BDEEF2379A5" }, + { + "path": "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", + "size": 5230, + "sha256": "DCC991B45D29D119125750ED4EB4801EA3B80660E76C42F086E60CA5CDAA6DF3" + }, { "path": "src/RiskLabAI/causal_factor_analysis/protocol.py", "size": 64712, @@ -1255,15 +1333,20 @@ "size": 12391, "sha256": "6D5DFCF4C4430A7BAE367A79CB156D3D6DF3D24457C1D60B68A1A4D556CF4AD4" }, + { + "path": "src/RiskLabAI/data/synthetic_data/timegan.py", + "size": 14446, + "sha256": "20F71FF83307217FD2CEAEC9B5C25DC626843CBE0990160D1CCE4B8238189764" + }, { "path": "src/RiskLabAI/data/weights/__init__.py", - "size": 621, - "sha256": "7275D7101AE1F1DC977EF7B6C53819139AC9F46F629742178AA0AA453390A523" + "size": 675, + "sha256": "9B450E6DD143EEA9CD700337C1C65E385B2B47E8EC5AC0A3FBFF2798F3D3B302" }, { "path": "src/RiskLabAI/data/weights/sample_weights.py", - "size": 6991, - "sha256": "4DA73877840EF49BDFF35B087CE1FC52354D14E9136217D16B1948C5577A9BB4" + "size": 11021, + "sha256": "5F47BD2E64EA1215D4A80C0156C246CFA2691E3CC792CED2A62E9CACF48C87AD" }, { "path": "src/RiskLabAI/ensemble/__init__.py", @@ -1287,8 +1370,13 @@ }, { "path": "src/RiskLabAI/features/entropy_features/__init__.py", - "size": 883, - "sha256": "2DB4F80E1037C4562E8034FF3120C8DF41DE54D85C7F8E1CF04628BBA9A52374" + "size": 1027, + "sha256": "316BB9196C89283AE1A378B353F9465D1E820B981D4C71A876FE44B1E58C9CC4" + }, + { + "path": "src/RiskLabAI/features/entropy_features/ambiguity.py", + "size": 2985, + "sha256": "37C9EDB6111606EA78B8A34B206BCF0E1948617CF28F1F2121E1C3E19F538358" }, { "path": "src/RiskLabAI/features/entropy_features/bias_corrected.py", @@ -1387,8 +1475,8 @@ }, { "path": "src/RiskLabAI/features/microstructural_features/__init__.py", - "size": 708, - "sha256": "5A47422A191048EB40286C2BA1169BA3FF04396BFA08F9582439A26521A06353" + "size": 1044, + "sha256": "34FEB260DD09858D9952A3483AD09A8D7BA33179A567B354BC4B51BC30D4C802" }, { "path": "src/RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", @@ -1405,6 +1493,11 @@ "size": 6268, "sha256": "98A0A232CDB6D1ECB2C268B0BBAFCACF23981CDE9D998D8936D7F2A3265C38D7" }, + { + "path": "src/RiskLabAI/features/microstructural_features/order_flow.py", + "size": 7027, + "sha256": "1B00415D5AB22FB7CD6AF4C675BC1D6292456AAED10D12D175F042E4278311A6" + }, { "path": "src/RiskLabAI/features/structural_breaks/__init__.py", "size": 1134, @@ -1437,8 +1530,28 @@ }, { "path": "src/RiskLabAI/optimization/__init__.py", - "size": 1211, - "sha256": "8B178E855B01B7AAA4A6988060A26B794A9E4A867CF5E5F3125D1CBC0AE6900C" + "size": 1865, + "sha256": "E916F6AFEF6F07AC99DB728D77C0B70B52DA80429697A08F89E10117D5826C35" + }, + { + "path": "src/RiskLabAI/optimization/clearing.py", + "size": 3641, + "sha256": "892332557820600EE01D7E4A4710CABC0B3A8A41F59206D47E508256C02291A4" + }, + { + "path": "src/RiskLabAI/optimization/constrained.py", + "size": 5689, + "sha256": "CEFC077B047AF14792632E2E77C0A6B2D22B632749BD94372DD36FBE9E07DD82" + }, + { + "path": "src/RiskLabAI/optimization/decision_focused.py", + "size": 4068, + "sha256": "13FFBA950E6C9F266F3336F6F0F0AD6C1213FA97A0AD35D87484CF4154DD9C39" + }, + { + "path": "src/RiskLabAI/optimization/execution.py", + "size": 4759, + "sha256": "267DF39571C0337A36A11860CAED26ADE731B2ED71221B012694FEEC1EDD0332" }, { "path": "src/RiskLabAI/optimization/hedging.py", @@ -1455,6 +1568,11 @@ "size": 6152, "sha256": "DCAFAFB9A5541BA40FE48FC8DBDD57E804BBD9DC5C22430F7979B0080AD81D29" }, + { + "path": "src/RiskLabAI/optimization/majorization.py", + "size": 8494, + "sha256": "7EBA0DBEB1D5075C7378FB07C4169DEC3187F3A3A7AC32919CF937C1203339B3" + }, { "path": "src/RiskLabAI/optimization/nco.py", "size": 3992, @@ -1462,8 +1580,8 @@ }, { "path": "src/RiskLabAI/pde/__init__.py", - "size": 1833, - "sha256": "C7B95E7E7DF3A7BDD666D940EE776A5D8349ADE4CEE33D8FC453C7C56189C4CA" + "size": 2483, + "sha256": "086B22784DB4A842AFF4AFDE40D74D244DAFDA649524F25DCD61617499D4ED43" }, { "path": "src/RiskLabAI/pde/equation.py", @@ -1475,6 +1593,11 @@ "size": 12264, "sha256": "98F8C874C57C04E28883BB9B4CFD526C0D1622E27C84F723E5883D30CBFF7001" }, + { + "path": "src/RiskLabAI/pde/neural_sde.py", + "size": 27255, + "sha256": "85F05D3596659AB03068BCE1CA1707D66E5680891BBF520C753EF4FCF61D8C59" + }, { "path": "src/RiskLabAI/pde/solver.py", "size": 13859, @@ -1485,6 +1608,11 @@ "size": 3409, "sha256": "DE1F4F4832425C9B8FE275BF48A9621ABA3525BB219F43C9D9E38DBBCEAE0582" }, + { + "path": "src/RiskLabAI/utils/_validation.py", + "size": 1688, + "sha256": "E45F7090314449D3632E53B376B30931B672A17EFD6660C361169011D39D13E2" + }, { "path": "src/RiskLabAI/utils/constants.py", "size": 3003, @@ -1817,8 +1945,8 @@ }, { "path": "test/data/weights/test_sample_weights.py", - "size": 3920, - "sha256": "6A682B9C8273A9480CC40EDEF503F4ADB254FDE03A7C6846A6576B2844177DA5" + "size": 12928, + "sha256": "E7D932C7B8A5421876004FB281FA2F47C93A393B7E91F950D6EB621493C3ABD8" }, { "path": "test/ensemble/test_bagging_classifier_accuracy.py", @@ -1885,6 +2013,16 @@ "size": 2213, "sha256": "2E3414DD74A72FD86FED54059515E2A088F99F4C23492A1992A55A5F311F09A8" }, + { + "path": "test/optimization/test_clearing.py", + "size": 3574, + "sha256": "95AF6C20FB2DD5F402B93FBD8334820410B89E8BAAF806422D9B705FF119973F" + }, + { + "path": "test/optimization/test_execution.py", + "size": 5228, + "sha256": "B0560BA63CEA23270E0B79D02ED68F9AA38CEE008B5FA971C9CDEAE1C6509C0E" + }, { "path": "test/optimization/test_hedging.py", "size": 2026, @@ -1900,16 +2038,36 @@ "size": 3405, "sha256": "C756502F9BA1FFC0EAFCF2F0EE3E73F500CB07D956378FFADE018CC610AEC650" }, + { + "path": "test/optimization/test_majorization.py", + "size": 6535, + "sha256": "052FFDDE6ACF17D0520D428B66677A0BF39158D5A6B2177FB9A53861C417BC9A" + }, { "path": "test/optimization/test_nco.py", "size": 1875, "sha256": "07393543334EED0500954A475403AFDF68BFDB5D9AEC14768E5D21D91E41A2AE" }, + { + "path": "test/pde/test_neural_sde.py", + "size": 17167, + "sha256": "6D212FE87E2A3A309E9FD3120F577E1E9838E093983B1F001B1CBF5844FD3CED" + }, { "path": "test/pde/test_pde_solver.py", "size": 1764, "sha256": "9606F9460D87E1268B55A2EC5FC8AD7BE92B4E9DB64F219E0C444343CDB763DD" }, + { + "path": "test/test_book_method_validation.py", + "size": 4390, + "sha256": "DD6540E190E5FD5A8FF934037CBA6C29F21AB6396110AC0B3C8DA20658BBD896" + }, + { + "path": "test/test_book_methods.py", + "size": 15286, + "sha256": "A32E1A6A3EB17DC7E3B70E0F16030FC13A4D4115ABA91070A940E76526A1DE1D" + }, { "path": "test/test_consolidation.py", "size": 3427, @@ -1920,6 +2078,16 @@ "size": 1746, "sha256": "1318D33E6B9790C48F3E366568733FB14574D0D7ECCF7086D5EF5D8BE8545930" }, + { + "path": "test/test_existing_book_routes.py", + "size": 3988, + "sha256": "CAC8FECF9032735CD18925D1002EAE1FE71D8C3FC9EE2DD45DC033F0BAE31BBA" + }, + { + "path": "test/test_learned_book_methods.py", + "size": 9282, + "sha256": "CC79AD4EDB6B2400F2FC3E1F70A57A853AF3A8E7E96837C9E17108BD8275DC52" + }, { "path": "test/test_performance.py", "size": 6986, @@ -2085,8 +2253,8 @@ }, { "path": "CHANGELOG.md", - "size": 10907, - "sha256": "B607A87ABAA51A2862F08B5C4285B252C8FCFA9FFC09831042366E74C30F2D53" + "size": 12404, + "sha256": "5A310D31B49A6E16DBD4EA660EBF19A272A51F1F62EF19172266E2DD8987FB51" }, { "path": "EXTENDING.md", @@ -2095,8 +2263,8 @@ }, { "path": "INSTALLATION.md", - "size": 3477, - "sha256": "EBE7C2C5A3AAC586D67936682674FFD94CF9C2FC2907BFDBF901D76330D6F5A5" + "size": 4120, + "sha256": "9382B3C3A7F40E5932B014B871ADC68138E54E8FB1941E48A45C36DBAAD0E192" }, { "path": "NAMING_CANON_2.0.0.md", @@ -2115,8 +2283,8 @@ }, { "path": "RiskLabAI/backtest/__init__.py", - "size": 6414, - "sha256": "26D4E3A9972AFF0A26181731F5B1082E8A4B70ACA551D47C3BA372EE7370322C" + "size": 6204, + "sha256": "B80A6D4CA841FF0D6584598DE4953188A33EF261BCFF497AC9D24B65DCA61DFF" }, { "path": "RiskLabAI/backtest/advanced_bet_sizing.py", @@ -2135,8 +2303,8 @@ }, { "path": "RiskLabAI/backtest/backtest_synthetic_data.py", - "size": 3523, - "sha256": "9ACDA6D9D81702F611CBC4EBE11899C31463DA899709D9975931F592CD59AF39" + "size": 3424, + "sha256": "F67B8239A8E5F9EF5BA73F84938614F211C71197CD87AAFCFE10A8EDEBABC627" }, { "path": "RiskLabAI/backtest/bet_sizing.py", @@ -2185,8 +2353,8 @@ }, { "path": "RiskLabAI/backtest/validation/__init__.py", - "size": 1980, - "sha256": "52069C5047BBB0F199D42C35F4D4AAC6E2D3AE6EC8BB58E49338AB6A4F2FC62A" + "size": 1925, + "sha256": "18D99DF58743C28835ACD00F7B307014D919C5FC322D6A639AE126E44AFDFFB5" }, { "path": "RiskLabAI/backtest/validation/adaptive_combinatorial_purged.py", @@ -2215,8 +2383,8 @@ }, { "path": "RiskLabAI/backtest/validation/cross_validator_interface.py", - "size": 8202, - "sha256": "9BB6CFBFF867C8F863CF0CA0B1FF6384463A4385D88C2FD1FEFFA0775B85FD0D" + "size": 7967, + "sha256": "A6FB69106047B94AF0E7ECCDE88FBDCCB5178BE08ED8EE8E03B04564F8E50A7E" }, { "path": "RiskLabAI/backtest/validation/kfold.py", @@ -2240,8 +2408,8 @@ }, { "path": "RiskLabAI/backtest/validation/purged_kfold.py", - "size": 19810, - "sha256": "86802CB8E32ECDD092A522FBAC196C2BA5F05FAAAF4227091C705EDBD2DBFCD8" + "size": 19267, + "sha256": "8E2385391624CFB31E3B9C97B0D8E27B774E1985DF6ED5997F59A1D49ACFF902" }, { "path": "RiskLabAI/backtest/validation/walk_forward.py", @@ -2250,8 +2418,8 @@ }, { "path": "RiskLabAI/cluster/__init__.py", - "size": 669, - "sha256": "CC1993BE2778895842431BD777BFEF3B620341E6DB13B4BEDB5A58891CAF14DC" + "size": 642, + "sha256": "1313FCD1DFC97508FB0ECAE567B46BAF5CE9997748C302ABACD0AA4606D931B3" }, { "path": "RiskLabAI/cluster/clustering.py", @@ -2260,18 +2428,18 @@ }, { "path": "RiskLabAI/controller/__init__.py", - "size": 75, - "sha256": "875DCD9EC172311DF8E9DF665C60102BDC0F56218A5CDDE0760E2A081E369230" + "size": 73, + "sha256": "0DED3F4FAB2B94562CC9337E7D1A170B6ACA95D4F4477C2A6730DF29ABE0A8FF" }, { "path": "RiskLabAI/controller/bars_initializer.py", - "size": 13999, - "sha256": "28284D4877900DE9D3582C2B821FC66173E474E1F62C95F27385190C4A88426F" + "size": 13700, + "sha256": "6C45A2CACEB4BD9AAB6B08EA0EDD240E4144CF823FCE361C5297F2DC9FFF9022" }, { "path": "RiskLabAI/controller/data_structure_controller.py", - "size": 6102, - "sha256": "237843F3AD293C366607FA555C218F4F3CD8ACCD881F784030B3AA6B12BC8E23" + "size": 5914, + "sha256": "4D4CAFBA0B0059A89398A1CD453F57A803E80466C93DC3AC4A0F11BB894DDF8C" }, { "path": "RiskLabAI/core/__init__.py", @@ -2295,8 +2463,8 @@ }, { "path": "RiskLabAI/data/__init__.py", - "size": 664, - "sha256": "F238157F6B54DB5E12E2F910821431BED049AB6E99665B64AD81F95EB7B1488B" + "size": 630, + "sha256": "BF1701C8581DC602A5D37E10BAE6CDB837D489BD8791781B5F50BA4D067876FD" }, { "path": "RiskLabAI/data/denoise/__init__.py", @@ -2305,8 +2473,8 @@ }, { "path": "RiskLabAI/data/denoise/denoising.py", - "size": 11538, - "sha256": "411FFBD20F902E19426506D6C5472570960F9091E67E3D0F8626DE09E63B8B92" + "size": 11161, + "sha256": "AE33547DD525D4905A7322995E491D8D361D434E05F56CDD1155F7AF04CD7371" }, { "path": "RiskLabAI/data/denoise/nercome.py", @@ -2325,8 +2493,8 @@ }, { "path": "RiskLabAI/data/differentiation/differentiation.py", - "size": 12258, - "sha256": "FD6A1B4247CC3F7B6C794866CD1809770460BD2AFC08375F2F96B02AF0D79E02" + "size": 11859, + "sha256": "4E9E45C90AEB0B8949E5F6F774D1AF20AA5973188F5FD23FFB4344E6EF5C5E9F" }, { "path": "RiskLabAI/data/distance/__init__.py", @@ -2340,13 +2508,13 @@ }, { "path": "RiskLabAI/data/distance/distance_metric.py", - "size": 8377, - "sha256": "6399EFDB1E12A82ED3BB6831FF6F985E0EAB18D2420D82670CF1599189D51E73" + "size": 8073, + "sha256": "89073FAA39E9FF195C7028BEA970410562745534AE52AAFD310C5499E4DC914D" }, { "path": "RiskLabAI/data/labeling/__init__.py", - "size": 1193, - "sha256": "E84DB73F915081EF60F6C8441D10BFE5E759037EE7D42945B1AD2FBB77A10CC3" + "size": 1144, + "sha256": "CBDD997697143DFFD4287A26E568C257FDE2634BDBA325955438353D65DC86F7" }, { "path": "RiskLabAI/data/labeling/financial_labels.py", @@ -2370,18 +2538,18 @@ }, { "path": "RiskLabAI/data/structures/abstract_imbalance_bars.py", - "size": 6388, - "sha256": "350535B7AAF4E997E19242E07991EA7AB22BB41C041490D2243F25B2D4D90111" + "size": 6210, + "sha256": "E7A54B451B412FD1F2463E59E8706131A1D7AD8C84D3FE6CC1E2B5A3DC8B1B99" }, { "path": "RiskLabAI/data/structures/abstract_information_driven_bars.py", - "size": 4150, - "sha256": "2035EA576F53B0CF6B7A4A44CEB2DDC64E531BC91E8F6C98ADACF7578A11306A" + "size": 4021, + "sha256": "8601CD7E405A10F66BF6A82192E8EA261F9D71CA2A8C85E48FFA334EBC00D8DF" }, { "path": "RiskLabAI/data/structures/abstract_run_bars.py", - "size": 10204, - "sha256": "51DDD1B7C662E2FB2D749C5C8483782C76EF00584ACC36FD8314787239DAFFED" + "size": 9943, + "sha256": "1B7E12ED9C1B2505F014FE5487CF1AFC938463198C8B40F7360ED1739E035EFD" }, { "path": "RiskLabAI/data/structures/hardened_information_driven_bars.py", @@ -2435,8 +2603,8 @@ }, { "path": "RiskLabAI/data/weights/sample_weights.py", - "size": 7225, - "sha256": "A1AEBA7541663238C20B3B7A76B8706AB62D6860CD171DB6000E2EF92C492724" + "size": 6991, + "sha256": "4DA73877840EF49BDFF35B087CE1FC52354D14E9136217D16B1948C5577A9BB4" }, { "path": "RiskLabAI/ensemble/__init__.py", @@ -2445,8 +2613,8 @@ }, { "path": "RiskLabAI/ensemble/bagging_classifier_accuracy.py", - "size": 1300, - "sha256": "DF1570D491B4452412103BE1ECDC6B0C4CFDA3D9AAF55D9DDD7620B611134F92" + "size": 1255, + "sha256": "9B2150DA7DB358161927137E5E2ED9C3FA0249FF71E86401F29C9F96258B839C" }, { "path": "RiskLabAI/ensemble/empirical_bagging_accuracy.py", @@ -2455,8 +2623,8 @@ }, { "path": "RiskLabAI/features/__init__.py", - "size": 123, - "sha256": "8E6EF92F8D1E0E9BCA37E3436806A37308B8B3CB08C221481F4A1261327C5A18" + "size": 117, + "sha256": "CE30ECC88CBFF140893C4FADFBE38DC90EE954024126EF66F1A5200AE0146C2B" }, { "path": "RiskLabAI/features/entropy_features/__init__.py", @@ -2470,43 +2638,43 @@ }, { "path": "RiskLabAI/features/entropy_features/kontoyiannis.py", - "size": 2902, - "sha256": "08FCF8EEEF1D9C4FFFC4F812A39B77F406050091ED287C9AE1A577E849998A4B" + "size": 2793, + "sha256": "A69F1FEB3DAC762724F5ED9F17BCAD0E9D9EC88B5946348FA80EA4059AD04119" }, { "path": "RiskLabAI/features/entropy_features/lempel_ziv.py", - "size": 1017, - "sha256": "295BFA3C0E3970C75C924C6D19E2D0B384E29A2B50AED7C8C08E1723A975BF4F" + "size": 976, + "sha256": "1F015F187028C1957CC91BC91D944B10202D7D3EF37F3CAAF29D4CFB238C8FF8" }, { "path": "RiskLabAI/features/entropy_features/plug_in.py", - "size": 981, - "sha256": "90DB453E95DD416771EAC11232E33584582C7FF1EA9B79DD441588185BADC483" + "size": 942, + "sha256": "799C9ABB1AD8A864BF15E5F1E3267C5FCB482B83C38A4F0FCF9E4C5F2C81B763" }, { "path": "RiskLabAI/features/entropy_features/pmf.py", - "size": 1862, - "sha256": "649221CC7406A3CF9607DE10CCA44C0F7D661E2B877209626704EBFF93F0BBC9" + "size": 1798, + "sha256": "55A1BA2FAC7BDC384C15E36B7874E01C0DD0B40D5D8FF6E6113972B81FED8D24" }, { "path": "RiskLabAI/features/entropy_features/shannon.py", - "size": 723, - "sha256": "B594B49278CB1904E605CC44083B0A545F71E14B2BDDA6EF0A2BE03FAE2A2DA8" + "size": 689, + "sha256": "FBA55BE6A0B6E529ABD77BE038F5F885945B034A04DA042CFFD99A278735B37E" }, { "path": "RiskLabAI/features/feature_importance/__init__.py", - "size": 2152, - "sha256": "681CBA32A99632E6FAC08636FC4C1E9758DFE385FA9DF0C7A6A7AADD3961CE77" + "size": 2086, + "sha256": "E3BD3D4CDB85D31F9096C459F8468DF540D41D4CF619B73A9CBBFF4DFBB1A75D" }, { "path": "RiskLabAI/features/feature_importance/clustered_feature_importance_mda.py", - "size": 5518, - "sha256": "656BBC12FE44777CE8557AF6A0F3292DA11A69B23C0F812E10501E9AFF4E2BF1" + "size": 5361, + "sha256": "7BF977CB4D65189BB478CAA8378D3094E7D0A8FD672C498AC3BD881FF0B818A6" }, { "path": "RiskLabAI/features/feature_importance/clustered_feature_importance_mdi.py", - "size": 3511, - "sha256": "965BFFF4A3EF79239B54C7DD5A09B7590C3EA1DF8F2B2F10695ADFA095CA45C7" + "size": 3403, + "sha256": "4B2B7A7F98BA4030A8B0B885C2D9077F22445F3D5B07FF470D1E247BF4346516" }, { "path": "RiskLabAI/features/feature_importance/debiased_importance.py", @@ -2525,23 +2693,23 @@ }, { "path": "RiskLabAI/features/feature_importance/feature_importance_mda.py", - "size": 4650, - "sha256": "5E22AE26672B9B3A608CC237791625386C5B500FFBF768AEEC5478154F727CF0" + "size": 4515, + "sha256": "872D141D6D96B4CED9660710ACE3EFBFE4FCCB74065BEEA6FEF5344DF1A61861" }, { "path": "RiskLabAI/features/feature_importance/feature_importance_mdi.py", - "size": 2739, - "sha256": "82F93E0DA408EB9D4984856C3C432704DC8093ADC90FE9DC898D2109E7381A92" + "size": 2651, + "sha256": "C201C6FA3986268AE49D7D2AFE177DBB468AA4E5D32CD9B9CA805DAEAA9EE5AE" }, { "path": "RiskLabAI/features/feature_importance/feature_importance_sfi.py", - "size": 3984, - "sha256": "67CB65A25ACA73457000C23AC4853A1D281CA3C2DB3CE6614C1262AF51B808BF" + "size": 3865, + "sha256": "69ED5D5AEA7F977E72459492D278ED5A1A3B0B5A7099CED6C96D1B5EE26C4833" }, { "path": "RiskLabAI/features/feature_importance/feature_importance_strategy.py", - "size": 894, - "sha256": "E007852540710974A4C56089DB6A5DE46F11B19760690E400BAFCE154156BD1D" + "size": 857, + "sha256": "0B5BF2F9EA40AEF208FAF010308BE4E0AF32B9378B395A8B555CF2CB1D97CCAA" }, { "path": "RiskLabAI/features/feature_importance/generate_synthetic_data.py", @@ -2560,8 +2728,8 @@ }, { "path": "RiskLabAI/features/microstructural_features/__init__.py", - "size": 737, - "sha256": "69C079797912E65103C77DD079B00F420C30479FEFEB7A3C0C3E70AF7BCC855A" + "size": 708, + "sha256": "5A47422A191048EB40286C2BA1169BA3FF04396BFA08F9582439A26521A06353" }, { "path": "RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", @@ -2590,8 +2758,8 @@ }, { "path": "RiskLabAI/features/structural_breaks/structural_breaks.py", - "size": 26144, - "sha256": "36A586A0651EC6D00AFA737E5EAFC2BA5B5FAEACB85B58621C2B3C6F1C98EDA0" + "size": 25436, + "sha256": "F81089327CA345631BBBB943BD431E7F91B2AF30728946602C86B115C1759032" }, { "path": "RiskLabAI/features/structural_breaks/volatility_robust_sadf.py", @@ -2605,13 +2773,13 @@ }, { "path": "RiskLabAI/hpc/hpc.py", - "size": 11685, - "sha256": "B5AEFF775459A0AD8A5AD0E10B0806E7FC08137F1CCD0CD85966FFF60D03D1C5" + "size": 11306, + "sha256": "CC6377F6E22F2BB25D87723DE0D6C36DEB555F4D08C19BDD4B55C64D5FABEED1" }, { "path": "RiskLabAI/optimization/__init__.py", - "size": 1231, - "sha256": "B72FE375FA3A5970B3CD8C14738D59D8AB69FEB0E0BC69E75255A27AD701DD46" + "size": 1183, + "sha256": "715140F90C84ABB1E7C7A4B99820F0D588F01D040592778A69AFDDC9C53F85A6" }, { "path": "RiskLabAI/optimization/hedging.py", @@ -2620,18 +2788,18 @@ }, { "path": "RiskLabAI/optimization/hrp.py", - "size": 6323, - "sha256": "5A5F6F3D3145C9F7870CD1FEE15BD88D11A7C7E707D4A6EDCF83F9F43F92AED1" + "size": 6108, + "sha256": "5DD1818F65CDCE5C07870D96353627600C325ADE1C4297AAB933AB5954538047" }, { "path": "RiskLabAI/optimization/hyper_parameter_tuning.py", - "size": 6335, - "sha256": "C832378FA5F978B4379984FBB14DD57431B0C5DB8A02FBB7824580CA81EE9B14" + "size": 6138, + "sha256": "D8C4BE57768BDD84D1DF1A5B1A4821E10468E919A3E72CE8BADED3E0E71B9461" }, { "path": "RiskLabAI/optimization/nco.py", - "size": 4122, - "sha256": "96D9A482B49D9E784B6790B46E28EFE706E3C63A262521D721BD75C46A59085A" + "size": 3992, + "sha256": "4FB1EE548E62CEF2BDD71E289F8F3DAD403F92AEE95820DA2BF1DA9409CAAA53" }, { "path": "RiskLabAI/pde/__init__.py", @@ -2660,13 +2828,13 @@ }, { "path": "RiskLabAI/utils/constants.py", - "size": 3062, - "sha256": "31CE9261BA913AAE7E4C187E7AAFEB2FABC9028B159EC463AB532C2546C65AA9" + "size": 2975, + "sha256": "7B6E25F3CA6DE207E0E48EDF656A4F1249F74D328E40F58E123A274E12D75A5C" }, { "path": "RiskLabAI/utils/ewma.py", - "size": 1754, - "sha256": "7F64DE6409360FC02D93283E31D7E26B067E53BC74172DD6897A36201A804507" + "size": 1696, + "sha256": "D4E094A2E192B92725195B490B3CA8EF3A3449A654ECC74510852BA709CA1A0C" }, { "path": "RiskLabAI/utils/momentum_mean_reverting_strategy_sides.py", @@ -2709,19 +2877,21 @@ "sha256": "1838861E627D0AFB96673CE7365D6DFEA2841DC0535C8D025DC41AF765005AFC" } ], - "repository_tree_file_count": 353, - "repository_tree_file_list_sha256": "FBF55A5B6C5A30E2C52C065431063E59A0280CF8F30B34BC1BC20679BDFBB289", + "repository_tree_file_count": 377, + "repository_tree_file_list_sha256": "1C0853FF5E18E088FF9BBD35CE4A3116E20AD0B07C6B0F17439BA70F9BEB7147", "repository_tree_policy": "The repository tree is the exact disjoint union of active release-source files and already-public repository-only files. Repository-only files are preserved but are not wheel payload members or active package load roots.", - "runtime_source_file_count": 128, + "runtime_source_file_count": 144, "runtime_source_files": [ "src/RiskLabAI/__init__.py", "src/RiskLabAI/_deprecation.py", "src/RiskLabAI/backtest/__init__.py", + "src/RiskLabAI/backtest/adaptation.py", "src/RiskLabAI/backtest/advanced_bet_sizing.py", "src/RiskLabAI/backtest/backtest_overfitting_simulation.py", "src/RiskLabAI/backtest/backtest_statistics.py", "src/RiskLabAI/backtest/backtest_synthetic_data.py", "src/RiskLabAI/backtest/bet_sizing.py", + "src/RiskLabAI/backtest/deep_hedging.py", "src/RiskLabAI/backtest/multiple_testing.py", "src/RiskLabAI/backtest/ou_trading_rules.py", "src/RiskLabAI/backtest/probabilistic_sharpe_ratio.py", @@ -2729,6 +2899,7 @@ "src/RiskLabAI/backtest/robust_statistics.py", "src/RiskLabAI/backtest/sharpe_inference.py", "src/RiskLabAI/backtest/strategy_risk.py", + "src/RiskLabAI/backtest/tail_risk.py", "src/RiskLabAI/backtest/test_set_overfitting.py", "src/RiskLabAI/backtest/validation/__init__.py", "src/RiskLabAI/backtest/validation/adaptive_combinatorial_purged.py", @@ -2745,10 +2916,13 @@ "src/RiskLabAI/backtest/validation/walk_forward.py", "src/RiskLabAI/causal_factor_analysis/__init__.py", "src/RiskLabAI/causal_factor_analysis/allocation_diagnostics.py", + "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "src/RiskLabAI/causal_factor_analysis/graph_identification.py", "src/RiskLabAI/causal_factor_analysis/graph_roles.py", + "src/RiskLabAI/causal_factor_analysis/interference.py", "src/RiskLabAI/causal_factor_analysis/optimizer.py", + "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", "src/RiskLabAI/causal_factor_analysis/protocol.py", "src/RiskLabAI/causal_factor_analysis/search_adjusted_fdr.py", "src/RiskLabAI/causal_factor_analysis/specification_diagnostics.py", @@ -2790,6 +2964,7 @@ "src/RiskLabAI/data/synthetic_data/drift_burst_hypothesis.py", "src/RiskLabAI/data/synthetic_data/simulation.py", "src/RiskLabAI/data/synthetic_data/synthetic_controlled_environment.py", + "src/RiskLabAI/data/synthetic_data/timegan.py", "src/RiskLabAI/data/weights/__init__.py", "src/RiskLabAI/data/weights/sample_weights.py", "src/RiskLabAI/ensemble/__init__.py", @@ -2797,6 +2972,7 @@ "src/RiskLabAI/ensemble/empirical_bagging_accuracy.py", "src/RiskLabAI/features/__init__.py", "src/RiskLabAI/features/entropy_features/__init__.py", + "src/RiskLabAI/features/entropy_features/ambiguity.py", "src/RiskLabAI/features/entropy_features/bias_corrected.py", "src/RiskLabAI/features/entropy_features/kontoyiannis.py", "src/RiskLabAI/features/entropy_features/lempel_ziv.py", @@ -2820,6 +2996,7 @@ "src/RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", "src/RiskLabAI/features/microstructural_features/corwin_schultz.py", "src/RiskLabAI/features/microstructural_features/edge.py", + "src/RiskLabAI/features/microstructural_features/order_flow.py", "src/RiskLabAI/features/structural_breaks/__init__.py", "src/RiskLabAI/features/structural_breaks/pelt_change_points.py", "src/RiskLabAI/features/structural_breaks/structural_breaks.py", @@ -2827,15 +3004,22 @@ "src/RiskLabAI/hpc/__init__.py", "src/RiskLabAI/hpc/hpc.py", "src/RiskLabAI/optimization/__init__.py", + "src/RiskLabAI/optimization/clearing.py", + "src/RiskLabAI/optimization/constrained.py", + "src/RiskLabAI/optimization/decision_focused.py", + "src/RiskLabAI/optimization/execution.py", "src/RiskLabAI/optimization/hedging.py", "src/RiskLabAI/optimization/hrp.py", "src/RiskLabAI/optimization/hyper_parameter_tuning.py", + "src/RiskLabAI/optimization/majorization.py", "src/RiskLabAI/optimization/nco.py", "src/RiskLabAI/pde/__init__.py", "src/RiskLabAI/pde/equation.py", "src/RiskLabAI/pde/model.py", + "src/RiskLabAI/pde/neural_sde.py", "src/RiskLabAI/pde/solver.py", "src/RiskLabAI/utils/__init__.py", + "src/RiskLabAI/utils/_validation.py", "src/RiskLabAI/utils/constants.py", "src/RiskLabAI/utils/ewma.py", "src/RiskLabAI/utils/momentum_mean_reverting_strategy_sides.py", @@ -2843,7 +3027,7 @@ "src/RiskLabAI/utils/publication_plots.py", "src/RiskLabAI/utils/update_figure_layout.py" ], - "test_file_count": 85, + "test_file_count": 93, "test_files": [ "test/backtest/test_advanced_bet_sizing.py", "test/backtest/test_backtest_overfitting_simulation.py", @@ -2919,13 +3103,21 @@ "test/features/structural_breaks/test_structural_breaks.py", "test/features/structural_breaks/test_volatility_robust_sadf.py", "test/hpc/test_hpc.py", + "test/optimization/test_clearing.py", + "test/optimization/test_execution.py", "test/optimization/test_hedging.py", "test/optimization/test_hrp.py", "test/optimization/test_hyper_parameter_tuning.py", + "test/optimization/test_majorization.py", "test/optimization/test_nco.py", + "test/pde/test_neural_sde.py", "test/pde/test_pde_solver.py", + "test/test_book_method_validation.py", + "test/test_book_methods.py", "test/test_consolidation.py", "test/test_deprecation.py", + "test/test_existing_book_routes.py", + "test/test_learned_book_methods.py", "test/test_performance.py", "test/utils/test_ewma.py", "test/utils/test_momentum_mean_reverting_strategy_sides.py", @@ -2935,11 +3127,13 @@ "src/RiskLabAI/__init__.py", "src/RiskLabAI/_deprecation.py", "src/RiskLabAI/backtest/__init__.py", + "src/RiskLabAI/backtest/adaptation.py", "src/RiskLabAI/backtest/advanced_bet_sizing.py", "src/RiskLabAI/backtest/backtest_overfitting_simulation.py", "src/RiskLabAI/backtest/backtest_statistics.py", "src/RiskLabAI/backtest/backtest_synthetic_data.py", "src/RiskLabAI/backtest/bet_sizing.py", + "src/RiskLabAI/backtest/deep_hedging.py", "src/RiskLabAI/backtest/multiple_testing.py", "src/RiskLabAI/backtest/ou_trading_rules.py", "src/RiskLabAI/backtest/probabilistic_sharpe_ratio.py", @@ -2947,6 +3141,7 @@ "src/RiskLabAI/backtest/robust_statistics.py", "src/RiskLabAI/backtest/sharpe_inference.py", "src/RiskLabAI/backtest/strategy_risk.py", + "src/RiskLabAI/backtest/tail_risk.py", "src/RiskLabAI/backtest/test_set_overfitting.py", "src/RiskLabAI/backtest/validation/__init__.py", "src/RiskLabAI/backtest/validation/adaptive_combinatorial_purged.py", @@ -2963,10 +3158,13 @@ "src/RiskLabAI/backtest/validation/walk_forward.py", "src/RiskLabAI/causal_factor_analysis/__init__.py", "src/RiskLabAI/causal_factor_analysis/allocation_diagnostics.py", + "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "src/RiskLabAI/causal_factor_analysis/graph_identification.py", "src/RiskLabAI/causal_factor_analysis/graph_roles.py", + "src/RiskLabAI/causal_factor_analysis/interference.py", "src/RiskLabAI/causal_factor_analysis/optimizer.py", + "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", "src/RiskLabAI/causal_factor_analysis/protocol.py", "src/RiskLabAI/causal_factor_analysis/search_adjusted_fdr.py", "src/RiskLabAI/causal_factor_analysis/specification_diagnostics.py", @@ -3008,6 +3206,7 @@ "src/RiskLabAI/data/synthetic_data/drift_burst_hypothesis.py", "src/RiskLabAI/data/synthetic_data/simulation.py", "src/RiskLabAI/data/synthetic_data/synthetic_controlled_environment.py", + "src/RiskLabAI/data/synthetic_data/timegan.py", "src/RiskLabAI/data/weights/__init__.py", "src/RiskLabAI/data/weights/sample_weights.py", "src/RiskLabAI/ensemble/__init__.py", @@ -3015,6 +3214,7 @@ "src/RiskLabAI/ensemble/empirical_bagging_accuracy.py", "src/RiskLabAI/features/__init__.py", "src/RiskLabAI/features/entropy_features/__init__.py", + "src/RiskLabAI/features/entropy_features/ambiguity.py", "src/RiskLabAI/features/entropy_features/bias_corrected.py", "src/RiskLabAI/features/entropy_features/kontoyiannis.py", "src/RiskLabAI/features/entropy_features/lempel_ziv.py", @@ -3038,6 +3238,7 @@ "src/RiskLabAI/features/microstructural_features/bekker_parkinson_volatility_estimator.py", "src/RiskLabAI/features/microstructural_features/corwin_schultz.py", "src/RiskLabAI/features/microstructural_features/edge.py", + "src/RiskLabAI/features/microstructural_features/order_flow.py", "src/RiskLabAI/features/structural_breaks/__init__.py", "src/RiskLabAI/features/structural_breaks/pelt_change_points.py", "src/RiskLabAI/features/structural_breaks/structural_breaks.py", @@ -3045,15 +3246,22 @@ "src/RiskLabAI/hpc/__init__.py", "src/RiskLabAI/hpc/hpc.py", "src/RiskLabAI/optimization/__init__.py", + "src/RiskLabAI/optimization/clearing.py", + "src/RiskLabAI/optimization/constrained.py", + "src/RiskLabAI/optimization/decision_focused.py", + "src/RiskLabAI/optimization/execution.py", "src/RiskLabAI/optimization/hedging.py", "src/RiskLabAI/optimization/hrp.py", "src/RiskLabAI/optimization/hyper_parameter_tuning.py", + "src/RiskLabAI/optimization/majorization.py", "src/RiskLabAI/optimization/nco.py", "src/RiskLabAI/pde/__init__.py", "src/RiskLabAI/pde/equation.py", "src/RiskLabAI/pde/model.py", + "src/RiskLabAI/pde/neural_sde.py", "src/RiskLabAI/pde/solver.py", "src/RiskLabAI/utils/__init__.py", + "src/RiskLabAI/utils/_validation.py", "src/RiskLabAI/utils/constants.py", "src/RiskLabAI/utils/ewma.py", "src/RiskLabAI/utils/momentum_mean_reverting_strategy_sides.py", diff --git a/PACKAGE_INSPECTION.md b/PACKAGE_INSPECTION.md index 49faeb8..77ddaba 100644 --- a/PACKAGE_INSPECTION.md +++ b/PACKAGE_INSPECTION.md @@ -1,7 +1,7 @@ # Future Python package inspection specification This specification governs temporary local artifacts built from the exact -RiskLabAI 3.1.0 additive integration candidate. It does not authorize using +RiskLabAI 3.2.0 additive integration candidate. It does not authorize using version control, uploading, publishing, tagging, or releasing anything. ## Inputs @@ -50,8 +50,9 @@ the reviewed source bytes. In fresh, isolated environments for every supported Python and valid NumPy lane, verify the exact module origins, the approved version binding, the root exports, all module and package exports in `PUBLIC_API.json`, and the complete -87-name causal API. Require the released 57-name causal prefix to remain exact -and the 30 additions to match the parity contract. For every module that +92-name causal API. Require the released 87 causal names to retain their relative +order, including the original 57 names and 30 parity additions. The five new +Python causal exports do not carry a Julia parity claim. For every module that declares `__all__`, require every listed name to be bound on that installed module and resolvable by an explicit import; an advertised but unbound name rejects the artifact. Run the exact package-scoped test inventory and the valid diff --git a/PUBLIC_API.json b/PUBLIC_API.json index 0c6ab2b..7f1869b 100644 --- a/PUBLIC_API.json +++ b/PUBLIC_API.json @@ -3,7 +3,7 @@ "document_type": "RISKLABAI_PYTHON_PUBLIC_API_INVENTORY", "product": "RiskLabAI", "language": "Python", - "version": "3.1.0", + "version": "3.2.0", "baseline_contract": { "released_version": "3.0.0", "released_causal_export_count": 57, @@ -32,13 +32,13 @@ "pde", "utils" ], - "runtime_module_count": 128, + "runtime_module_count": 144, "runtime_modules": [ { "path": "src/RiskLabAI/__init__.py", "module": "RiskLabAI", "module_kind": "package", - "sha256": "1CB2A7E2B804267FFEBE4FF124F01AA095DC19F82492DB862C9A4D97C2A1AF0F", + "sha256": "CDC3F50B0C33EB8AA6E7999E88C67CEA062D71B6D3554DAEF24DA247BFF4C9A8", "size": 1095, "defined_public_names": [], "definitions": [] @@ -76,11 +76,28 @@ "path": "src/RiskLabAI/backtest/__init__.py", "module": "RiskLabAI.backtest", "module_kind": "package", - "sha256": "59475AA703033C284FED55C308AB258B79AC6E31A8EE5164B01AF6E9396E4C8C", - "size": 6204, + "sha256": "7B88C6D36058FF9143C0B79CFEE3B804B4F0FBB7D8371DB4501CF31D8628E177", + "size": 6488, "defined_public_names": [], "definitions": [] }, + { + "path": "src/RiskLabAI/backtest/adaptation.py", + "size": 2792, + "sha256": "C60C11C2CF7614DF052AA14171C808E1F18CF9D7F7136F70487C406F58577D29", + "module": "RiskLabAI.backtest.adaptation", + "module_kind": "module", + "defined_public_names": [ + "prequential_adaptation" + ], + "definitions": [ + { + "name": "prequential_adaptation", + "kind": "function", + "signature": "(features, targets, estimator, *, loss_function, window_size, min_samples, delta)" + } + ] + }, { "path": "src/RiskLabAI/backtest/advanced_bet_sizing.py", "module": "RiskLabAI.backtest.advanced_bet_sizing", @@ -394,6 +411,52 @@ } ] }, + { + "path": "src/RiskLabAI/backtest/deep_hedging.py", + "size": 8315, + "sha256": "44A5DCF044FCA3CA2FBE765A978E420BDABD521D761EB4C1E03252EED8470499", + "module": "RiskLabAI.backtest.deep_hedging", + "module_kind": "module", + "defined_public_names": [ + "DeepHedge", + "black_scholes_call_delta", + "entropic_risk", + "gbm_hedging_paths", + "hedging_pnl", + "train_deep_hedge" + ], + "definitions": [ + { + "name": "gbm_hedging_paths", + "kind": "function", + "signature": "(*, spot, maturity, volatility, drift, steps, paths, seed)" + }, + { + "name": "hedging_pnl", + "kind": "function", + "signature": "(prices, positions, *, strike, cost_rate, premium=0.0)" + }, + { + "name": "entropic_risk", + "kind": "function", + "signature": "(pnl, risk_aversion)" + }, + { + "name": "black_scholes_call_delta", + "kind": "function", + "signature": "(prices, *, strike, maturity, volatility)" + }, + { + "name": "DeepHedge", + "kind": "class" + }, + { + "name": "train_deep_hedge", + "kind": "function", + "signature": "(model, training_prices, *, strike, maturity, cost_rate, risk_aversion, iterations, learning_rate=0.001)" + } + ] + }, { "path": "src/RiskLabAI/backtest/multiple_testing.py", "module": "RiskLabAI.backtest.multiple_testing", @@ -645,6 +708,35 @@ } ] }, + { + "path": "src/RiskLabAI/backtest/tail_risk.py", + "size": 5850, + "sha256": "E0551901DCC25E6B7ED9847285628482CDE3907FBDB8685B5B080AA5A841771A", + "module": "RiskLabAI.backtest.tail_risk", + "module_kind": "module", + "defined_public_names": [ + "fit_pot_tail_risk", + "generalized_pareto_tail_risk", + "quantile_exception_tests" + ], + "definitions": [ + { + "name": "generalized_pareto_tail_risk", + "kind": "function", + "signature": "(threshold, exceedance_probability, shape, scale, probability)" + }, + { + "name": "fit_pot_tail_risk", + "kind": "function", + "signature": "(losses, *, threshold, probability)" + }, + { + "name": "quantile_exception_tests", + "kind": "function", + "signature": "(exceptions, *, exception_probability)" + } + ] + }, { "path": "src/RiskLabAI/backtest/test_set_overfitting.py", "module": "RiskLabAI.backtest.test_set_overfitting", @@ -933,8 +1025,8 @@ "path": "src/RiskLabAI/causal_factor_analysis/__init__.py", "module": "RiskLabAI.causal_factor_analysis", "module_kind": "package", - "sha256": "96B6E483A72D5CBD00B5EDC5762345E7D31F31F6255B91C793C00B9B2BE62CA4", - "size": 6656, + "sha256": "16850DE616B0253426617174652C23FD739C5A1D0CE57C9799C3CBA5298490F2", + "size": 6954, "defined_public_names": [], "definitions": [] }, @@ -961,6 +1053,29 @@ } ] }, + { + "path": "src/RiskLabAI/causal_factor_analysis/bounds_transport.py", + "size": 3088, + "sha256": "DE2A45CF82F7D9AF78CE480F8B14A1F90A232B261EC6A57F3A7E43680D7E59ED", + "module": "RiskLabAI.causal_factor_analysis.bounds_transport", + "module_kind": "module", + "defined_public_names": [ + "manski_ate_bounds", + "transported_ate" + ], + "definitions": [ + { + "name": "manski_ate_bounds", + "kind": "function", + "signature": "(outcomes, treatment, *, lower, upper)" + }, + { + "name": "transported_ate", + "kind": "function", + "signature": "(treated_means, control_means, target_probabilities, source_treated_counts, source_control_counts)" + } + ] + }, { "path": "src/RiskLabAI/causal_factor_analysis/factor_mirage.py", "module": "RiskLabAI.causal_factor_analysis.factor_mirage", @@ -1180,6 +1295,23 @@ } ] }, + { + "path": "src/RiskLabAI/causal_factor_analysis/interference.py", + "size": 2758, + "sha256": "6583CBD9D9E1908E7758282B78B0EE3F92D6E028E12A7C4A332899D112262F76", + "module": "RiskLabAI.causal_factor_analysis.interference", + "module_kind": "module", + "defined_public_names": [ + "exposure_mean_contrast" + ], + "definitions": [ + { + "name": "exposure_mean_contrast", + "kind": "function", + "signature": "(outcomes, exposures, exposure_probabilities, *, first, second)" + } + ] + }, { "path": "src/RiskLabAI/causal_factor_analysis/optimizer.py", "module": "RiskLabAI.causal_factor_analysis.optimizer", @@ -1197,6 +1329,29 @@ } ] }, + { + "path": "src/RiskLabAI/causal_factor_analysis/policy_evaluation.py", + "size": 5230, + "sha256": "DCC991B45D29D119125750ED4EB4801EA3B80660E76C42F086E60CA5CDAA6DF3", + "module": "RiskLabAI.causal_factor_analysis.policy_evaluation", + "module_kind": "module", + "defined_public_names": [ + "evaluate_policy", + "select_policy" + ], + "definitions": [ + { + "name": "evaluate_policy", + "kind": "function", + "signature": "(actions, rewards, behavior_probabilities, policy_probabilities, outcome_predictions)" + }, + { + "name": "select_policy", + "kind": "function", + "signature": "(actions, rewards, behavior_probabilities, candidate_probabilities, outcome_predictions, *, training_indices, evaluation_indices)" + } + ] + }, { "path": "src/RiskLabAI/causal_factor_analysis/protocol.py", "module": "RiskLabAI.causal_factor_analysis.protocol", @@ -2438,12 +2593,46 @@ } ] }, + { + "path": "src/RiskLabAI/data/synthetic_data/timegan.py", + "size": 14446, + "sha256": "20F71FF83307217FD2CEAEC9B5C25DC626843CBE0990160D1CCE4B8238189764", + "module": "RiskLabAI.data.synthetic_data.timegan", + "module_kind": "module", + "defined_public_names": [ + "TimeGAN", + "chronological_timegan_data", + "fit_timegan", + "timegan_quality_report" + ], + "definitions": [ + { + "name": "chronological_timegan_data", + "kind": "function", + "signature": "(series, *, training_end, window_length)" + }, + { + "name": "TimeGAN", + "kind": "class" + }, + { + "name": "fit_timegan", + "kind": "function", + "signature": "(model, training_windows, *, embedding_steps, supervised_steps, joint_steps, batch_size, learning_rate=0.001, embedding_weight=1.0, generator_weight=10.0, seed=0)" + }, + { + "name": "timegan_quality_report", + "kind": "function", + "signature": "(real_training, real_validation, synthetic_training, synthetic_validation)" + } + ] + }, { "path": "src/RiskLabAI/data/weights/__init__.py", "module": "RiskLabAI.data.weights", "module_kind": "package", - "sha256": "7275D7101AE1F1DC977EF7B6C53819139AC9F46F629742178AA0AA453390A523", - "size": 621, + "sha256": "9B450E6DD143EEA9CD700337C1C65E385B2B47E8EC5AC0A3FBFF2798F3D3B302", + "size": 675, "defined_public_names": [], "definitions": [] }, @@ -2451,13 +2640,14 @@ "path": "src/RiskLabAI/data/weights/sample_weights.py", "module": "RiskLabAI.data.weights.sample_weights", "module_kind": "module", - "sha256": "4DA73877840EF49BDFF35B087CE1FC52354D14E9136217D16B1948C5577A9BB4", - "size": 6991, + "sha256": "5F47BD2E64EA1215D4A80C0156C246CFA2691E3CC792CED2A62E9CACF48C87AD", + "size": 11021, "defined_public_names": [ "calculate_average_uniqueness", "calculate_time_decay", "expand_label_for_meta_labeling", - "sample_weight_absolute_return_meta_labeling" + "sample_weight_absolute_return_meta_labeling", + "sequential_bootstrap" ], "definitions": [ { @@ -2470,6 +2660,11 @@ "kind": "function", "signature": "(index_matrix: pd.DataFrame)" }, + { + "name": "sequential_bootstrap", + "kind": "function", + "signature": "(indicator_matrix: pd.DataFrame | np.ndarray, sample_length: int | None=None, random_state: int | np.random.Generator | None=None)" + }, { "name": "sample_weight_absolute_return_meta_labeling", "kind": "function", @@ -2550,11 +2745,34 @@ "path": "src/RiskLabAI/features/entropy_features/__init__.py", "module": "RiskLabAI.features.entropy_features", "module_kind": "package", - "sha256": "2DB4F80E1037C4562E8034FF3120C8DF41DE54D85C7F8E1CF04628BBA9A52374", - "size": 883, + "sha256": "316BB9196C89283AE1A378B353F9465D1E820B981D4C71A876FE44B1E58C9CC4", + "size": 1027, "defined_public_names": [], "definitions": [] }, + { + "path": "src/RiskLabAI/features/entropy_features/ambiguity.py", + "size": 2985, + "sha256": "37C9EDB6111606EA78B8A34B206BCF0E1948617CF28F1F2121E1C3E19F538358", + "module": "RiskLabAI.features.entropy_features.ambiguity", + "module_kind": "module", + "defined_public_names": [ + "normal_prior_bin_probabilities", + "probability_ambiguity" + ], + "definitions": [ + { + "name": "probability_ambiguity", + "kind": "function", + "signature": "(probabilities, *, prior_weights=None, bin_width=None)" + }, + { + "name": "normal_prior_bin_probabilities", + "kind": "function", + "signature": "(means, standard_deviations, *, edges=None)" + } + ] + }, { "path": "src/RiskLabAI/features/entropy_features/bias_corrected.py", "module": "RiskLabAI.features.entropy_features.bias_corrected", @@ -2904,8 +3122,8 @@ "path": "src/RiskLabAI/features/microstructural_features/__init__.py", "module": "RiskLabAI.features.microstructural_features", "module_kind": "package", - "sha256": "5A47422A191048EB40286C2BA1169BA3FF04396BFA08F9582439A26521A06353", - "size": 708, + "sha256": "34FEB260DD09858D9952A3483AD09A8D7BA33179A567B354BC4B51BC30D4C802", + "size": 1044, "defined_public_names": [], "definitions": [] }, @@ -2984,6 +3202,53 @@ } ] }, + { + "path": "src/RiskLabAI/features/microstructural_features/order_flow.py", + "size": 7027, + "sha256": "1B00415D5AB22FB7CD6AF4C675BC1D6292456AAED10D12D175F042E4278311A6", + "module": "RiskLabAI.features.microstructural_features.order_flow", + "module_kind": "module", + "defined_public_names": [ + "fit_hawkes", + "hawkes_integrated_intensity", + "hawkes_intensity", + "hawkes_log_likelihood", + "simulate_hawkes", + "volume_synchronized_pin" + ], + "definitions": [ + { + "name": "hawkes_intensity", + "kind": "function", + "signature": "(times, events, mu, alpha, beta)" + }, + { + "name": "hawkes_integrated_intensity", + "kind": "function", + "signature": "(events, horizon, mu, alpha, beta)" + }, + { + "name": "hawkes_log_likelihood", + "kind": "function", + "signature": "(events, horizon, mu, alpha, beta)" + }, + { + "name": "simulate_hawkes", + "kind": "function", + "signature": "(horizon, mu, alpha, beta, *, random_state=None, max_events=100000)" + }, + { + "name": "fit_hawkes", + "kind": "function", + "signature": "(events, horizon, *, initial=None)" + }, + { + "name": "volume_synchronized_pin", + "kind": "function", + "signature": "(buy_volume, sell_volume, *, bucket_volume, window)" + } + ] + }, { "path": "src/RiskLabAI/features/structural_breaks/__init__.py", "module": "RiskLabAI.features.structural_breaks", @@ -3176,11 +3441,91 @@ "path": "src/RiskLabAI/optimization/__init__.py", "module": "RiskLabAI.optimization", "module_kind": "package", - "sha256": "8B178E855B01B7AAA4A6988060A26B794A9E4A867CF5E5F3125D1CBC0AE6900C", - "size": 1211, + "sha256": "E916F6AFEF6F07AC99DB728D77C0B70B52DA80429697A08F89E10117D5826C35", + "size": 1865, "defined_public_names": [], "definitions": [] }, + { + "path": "src/RiskLabAI/optimization/clearing.py", + "size": 3641, + "sha256": "892332557820600EE01D7E4A4710CABC0B3A8A41F59206D47E508256C02291A4", + "module": "RiskLabAI.optimization.clearing", + "module_kind": "module", + "defined_public_names": [ + "eisenberg_noe_clearing" + ], + "definitions": [ + { + "name": "eisenberg_noe_clearing", + "kind": "function", + "signature": "(external_assets: np.ndarray, nominal_liabilities: np.ndarray, relative_liabilities: np.ndarray, *, atol: float=1e-08)" + } + ] + }, + { + "path": "src/RiskLabAI/optimization/constrained.py", + "size": 5689, + "sha256": "CEFC077B047AF14792632E2E77C0A6B2D22B632749BD94372DD36FBE9E07DD82", + "module": "RiskLabAI.optimization.constrained", + "module_kind": "module", + "defined_public_names": [ + "constrained_minimum_variance", + "robust_mean_variance" + ], + "definitions": [ + { + "name": "constrained_minimum_variance", + "kind": "function", + "signature": "(covariance, *, expected_returns=None, return_floor=None, caps=None)" + }, + { + "name": "robust_mean_variance", + "kind": "function", + "signature": "(expected_returns, covariance, uncertainty_loading, *, radius, risk_aversion, caps=None, return_floor=None)" + } + ] + }, + { + "path": "src/RiskLabAI/optimization/decision_focused.py", + "size": 4068, + "sha256": "13FFBA950E6C9F266F3336F6F0F0AD6C1213FA97A0AD35D87484CF4154DD9C39", + "module": "RiskLabAI.optimization.decision_focused", + "module_kind": "module", + "defined_public_names": [ + "spo_plus_loss", + "spo_plus_torch" + ], + "definitions": [ + { + "name": "spo_plus_loss", + "kind": "function", + "signature": "(predicted_cost, true_cost, vertices)" + }, + { + "name": "spo_plus_torch", + "kind": "function", + "signature": "(predicted_cost, true_cost, vertices)" + } + ] + }, + { + "path": "src/RiskLabAI/optimization/execution.py", + "size": 4759, + "sha256": "267DF39571C0337A36A11860CAED26ADE731B2ED71221B012694FEEC1EDD0332", + "module": "RiskLabAI.optimization.execution", + "module_kind": "module", + "defined_public_names": [ + "almgren_chriss_execution" + ], + "definitions": [ + { + "name": "almgren_chriss_execution", + "kind": "function", + "signature": "(inventory: float, horizon: float, intervals: int, *, volatility: float, temporary_impact: float, permanent_impact: float=0.0, risk_aversion: float=0.0)" + } + ] + }, { "path": "src/RiskLabAI/optimization/hedging.py", "module": "RiskLabAI.optimization.hedging", @@ -3274,6 +3619,41 @@ } ] }, + { + "path": "src/RiskLabAI/optimization/majorization.py", + "size": 8494, + "sha256": "7EBA0DBEB1D5075C7378FB07C4169DEC3187F3A3A7AC32919CF937C1203339B3", + "module": "RiskLabAI.optimization.majorization", + "module_kind": "module", + "defined_public_names": [ + "birkhoff_von_neumann_decomposition", + "is_doubly_stochastic", + "is_majorized", + "majorization_matrix" + ], + "definitions": [ + { + "name": "is_doubly_stochastic", + "kind": "function", + "signature": "(matrix: np.ndarray, *, atol: float=1e-10)" + }, + { + "name": "is_majorized", + "kind": "function", + "signature": "(target: np.ndarray, source: np.ndarray, *, atol: float=1e-10)" + }, + { + "name": "majorization_matrix", + "kind": "function", + "signature": "(target: np.ndarray, source: np.ndarray, *, atol: float=1e-10)" + }, + { + "name": "birkhoff_von_neumann_decomposition", + "kind": "function", + "signature": "(matrix: np.ndarray, *, atol: float=1e-10)" + } + ] + }, { "path": "src/RiskLabAI/optimization/nco.py", "module": "RiskLabAI.optimization.nco", @@ -3301,8 +3681,8 @@ "path": "src/RiskLabAI/pde/__init__.py", "module": "RiskLabAI.pde", "module_kind": "package", - "sha256": "C7B95E7E7DF3A7BDD666D940EE776A5D8349ADE4CEE33D8FC453C7C56189C4CA", - "size": 1833, + "sha256": "086B22784DB4A842AFF4AFDE40D74D244DAFDA649524F25DCD61617499D4ED43", + "size": 2483, "defined_public_names": [], "definitions": [] }, @@ -3430,6 +3810,75 @@ } ] }, + { + "path": "src/RiskLabAI/pde/neural_sde.py", + "size": 27255, + "sha256": "85F05D3596659AB03068BCE1CA1707D66E5680891BBF520C753EF4FCF61D8C59", + "module": "RiskLabAI.pde.neural_sde", + "module_kind": "module", + "defined_public_names": [ + "IntervalNeuralSDE", + "PolytopeNeuralSDE", + "affine_price_state_interval", + "fit_interval_neural_sde", + "fit_polytope_neural_sde", + "hjm_drift_residual", + "normalized_call_grid_constraints", + "normalized_call_grid_witness", + "simulate_interval_neural_sde", + "simulate_polytope_neural_sde" + ], + "definitions": [ + { + "name": "normalized_call_grid_constraints", + "kind": "function", + "signature": "(strikes, maturities)" + }, + { + "name": "normalized_call_grid_witness", + "kind": "function", + "signature": "(strikes, maturities, prices)" + }, + { + "name": "affine_price_state_interval", + "kind": "function", + "signature": "(offset, loading, matrix, bounds)" + }, + { + "name": "IntervalNeuralSDE", + "kind": "class" + }, + { + "name": "fit_interval_neural_sde", + "kind": "function", + "signature": "(model, states, next_states, *, dt, iterations, learning_rate=0.001)" + }, + { + "name": "simulate_interval_neural_sde", + "kind": "function", + "signature": "(model, initial, brownian_increments, *, dt)" + }, + { + "name": "PolytopeNeuralSDE", + "kind": "class" + }, + { + "name": "hjm_drift_residual", + "kind": "function", + "signature": "(price_basis, drift, diffusion, required_price_drift)" + }, + { + "name": "fit_polytope_neural_sde", + "kind": "function", + "signature": "(model, states, next_states, *, dt, iterations, learning_rate=0.001)" + }, + { + "name": "simulate_polytope_neural_sde", + "kind": "function", + "signature": "(model, initial, brownian_increments, *, dt)" + } + ] + }, { "path": "src/RiskLabAI/pde/solver.py", "module": "RiskLabAI.pde.solver", @@ -3468,6 +3917,41 @@ "defined_public_names": [], "definitions": [] }, + { + "path": "src/RiskLabAI/utils/_validation.py", + "size": 1688, + "sha256": "E45F7090314449D3632E53B376B30931B672A17EFD6660C361169011D39D13E2", + "module": "RiskLabAI.utils._validation", + "module_kind": "module", + "defined_public_names": [ + "positive_integer", + "probability_rows", + "real_array", + "real_scalar" + ], + "definitions": [ + { + "name": "real_array", + "kind": "function", + "signature": "(value, name, ndim=None, *, empty=False)" + }, + { + "name": "real_scalar", + "kind": "function", + "signature": "(value, name, *, minimum=None, strict=False)" + }, + { + "name": "positive_integer", + "kind": "function", + "signature": "(value, name)" + }, + { + "name": "probability_rows", + "kind": "function", + "signature": "(value, name, ndim=2)" + } + ] + }, { "path": "src/RiskLabAI/utils/constants.py", "module": "RiskLabAI.utils.constants", @@ -3632,6 +4116,10 @@ "module": "RiskLabAI.backtest", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "prequential_adaptation", + "generalized_pareto_tail_risk", + "fit_pot_tail_risk", + "quantile_exception_tests", "validation", "PlattCalibrator", "distributionally_robust_kelly_fraction", @@ -3737,6 +4225,11 @@ "module": "RiskLabAI.causal_factor_analysis", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "manski_ate_bounds", + "transported_ate", + "exposure_mean_contrast", + "evaluate_policy", + "select_policy", "BackdoorAdjustmentEvidence", "BacktestStage", "CausalDAG", @@ -3968,7 +4461,8 @@ "expand_label_for_meta_labeling", "calculate_average_uniqueness", "sample_weight_absolute_return_meta_labeling", - "calculate_time_decay" + "calculate_time_decay", + "sequential_bootstrap" ] }, { @@ -3995,6 +4489,8 @@ "module": "RiskLabAI.features.entropy_features", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "probability_ambiguity", + "normal_prior_bin_probabilities", "shannon_entropy", "lempel_ziv_entropy", "probability_mass_function", @@ -4034,6 +4530,12 @@ "module": "RiskLabAI.features.microstructural_features", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "hawkes_intensity", + "hawkes_integrated_intensity", + "hawkes_log_likelihood", + "simulate_hawkes", + "fit_hawkes", + "volume_synchronized_pin", "beta_estimates", "gamma_estimates", "alpha_estimates", @@ -4080,6 +4582,16 @@ "module": "RiskLabAI.optimization", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "constrained_minimum_variance", + "robust_mean_variance", + "spo_plus_loss", + "spo_plus_torch", + "eisenberg_noe_clearing", + "almgren_chriss_execution", + "birkhoff_von_neumann_decomposition", + "is_doubly_stochastic", + "is_majorized", + "majorization_matrix", "cluster_variance", "quasi_diagonal", "recursive_bisection", @@ -4096,6 +4608,16 @@ "module": "RiskLabAI.pde", "export_rule": "EXPLICIT___ALL__", "ordered_exports": [ + "normalized_call_grid_constraints", + "normalized_call_grid_witness", + "affine_price_state_interval", + "IntervalNeuralSDE", + "PolytopeNeuralSDE", + "fit_interval_neural_sde", + "simulate_interval_neural_sde", + "fit_polytope_neural_sde", + "simulate_polytope_neural_sde", + "hjm_drift_residual", "Equation", "PricingDefaultRisk", "HJBLQ", @@ -5244,5 +5766,12 @@ "implementation_module": "RiskLabAI.causal_factor_analysis.search_adjusted_fdr" } ] + }, + "current_contract": { + "version": "3.2.0", + "total_causal_export_count": 92, + "preserved_3_1_0_causal_export_count": 87, + "new_python_causal_export_count": 5, + "new_julia_parity_asserted": false } } diff --git a/README.md b/README.md index 44b2634..95865ac 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,18 @@ implementations of methods associated with Marcos López de Prado's *Advances in Financial Machine Learning*, *Machine Learning for Asset Managers*, and *Causal Factor Investing*. -RiskLabAI 3.1.0 preserves the clean 57-name causal-factor-analysis namespace -released in 3.0.0 and adds 30 paper-derived names, producing an 87-name causal +Version 3.1.0 preserved the clean 57-name causal-factor-analysis namespace +released in 3.0.0 and added 30 paper-derived names, producing an 87-name causal API. The companion [RiskLabAI.jl](https://github.com/RiskLabAI/RiskLabAI.jl) package independently implements the same concepts. This parity statement applies to the causal API, not to every Python module. +The 3.2.0 candidate adds Python methods for sampling, optimization, market +features, causal evaluation, synthetic data, and neural financial models. +See [the release notes](RELEASE_NOTES_NEXT_MAJOR.md) for scope and limitations. +Julia implementations of these new additions are deferred. + ## What is included - **Causal factor analysis** - constrained minimum-variance allocation, @@ -42,7 +47,7 @@ not to every Python module. ## Compatibility -RiskLabAI 3.1.0 supports CPython 3.12, +RiskLabAI 3.2.0 supports CPython 3.12, 3.13, and 3.14 with NumPy `>=2.2,<3`. The complete tested policy and feature-specific limitations are in [`docs/compatibility.md`](https://github.com/RiskLabAI/RiskLabAI.py/blob/main/docs/compatibility.md). @@ -60,7 +65,7 @@ Optional dependency groups can be installed individually or together: | Extra | Enables | |---|---| | `speed` | Numba acceleration | -| `pde` | Deep-BSDE PDE solver using PyTorch | +| `pde` | PyTorch support for Deep-BSDE, neural SDEs, TimeGAN, and deep hedging | | `synth` | synthetic-control utilities using QuantEcon | | `hpo` | hyperparameter tuning using Optuna | | `plot` | Matplotlib, Seaborn, and Plotly helpers | diff --git a/RELEASE_NOTES_NEXT_MAJOR.md b/RELEASE_NOTES_NEXT_MAJOR.md index c83a61e..576d0d2 100644 --- a/RELEASE_NOTES_NEXT_MAJOR.md +++ b/RELEASE_NOTES_NEXT_MAJOR.md @@ -1,4 +1,39 @@ -# RiskLabAI Python 3.1.0 additive causal release notes +# RiskLabAI Python 3.2.0 release notes + +Version **3.2.0** is prepared locally and remains unreleased. + +## [3.2.0] - Unreleased + +### Added + +- Sequential bootstrap sampling, majorization-based optimization, financial + network clearing, and optimal execution utilities. +- Ambiguity calculations for supplied priors, order-flow features, regime and + drift adaptation, and tail-risk estimation and backtesting. +- Constrained and robust portfolio optimization and decision-focused losses. +- Causal bounds, finite-stratum transport, design-based interference, and + inverse-probability and doubly robust policy evaluation. +- PyTorch implementations of TimeGAN, deep hedging, and finite-grid neural SDEs. +- Analytical examples, independent numerical checks, boundary validation, and + regression tests for the new methods. + +### Fixed + +- Sample-concurrency counting for events that begin before the selected window. + +### Scope and dependencies + +- The neural-SDE implementation uses an explicitly corrected fixed-coordinate + diffusion operator rather than the literal published nearest-frame + construction. Its guarantees are bounded to the implemented finite grid; + general dynamic no-arbitrage and empirical market performance are not claimed. +- Required dependencies and supported Python versions are unchanged. PyTorch + is available through the existing `pde` extra. Selected new methods require + separately installed CVXPY, River, or PySensemakr; these remain optional. +- The additions are Python implementations. Corresponding Julia additions and + book notebooks are deferred. + +## Historical 3.1.0 additive causal release notes Version: **3.1.0**. The version is frozen for local integration and artifact inspection. Version-control, publication, upload, and release actions remain diff --git a/TEST_INVENTORY.json b/TEST_INVENTORY.json index 6b0bec3..09d40e3 100644 --- a/TEST_INVENTORY.json +++ b/TEST_INVENTORY.json @@ -3,15 +3,16 @@ "document_type": "RISKLABAI_PYTHON_TEST_INVENTORY", "product": "RiskLabAI", "language": "Python", - "version": "3.1.0", - "version_note": "Final local integration candidate; every version-control and release action remains human-controlled.", + "version": "3.2.0", + "version_note": "The file inventory describes the 3.2.0 candidate. Collected-case counts and matrices below remain historical 3.1.0 evidence. The 3.2.0 candidate passed 1178 tests with one platform skip locally on Python 3.13. Fresh supported-interpreter CI must rerun after the version commit. No release certification is asserted.", "runner": "pytest / six base endpoints / three optional-feature interpreter lines", - "file_count": 85, + "file_count": 93, "file_groups": { - "CAUSAL_FACTOR_ANALYSIS": 13, - "CAUSAL_PARITY_FIXTURE": 1, "PRESERVED_LIBRARY": 70, - "TECHNICAL_INDICATOR_ORACLES": 1 + "TECHNICAL_INDICATOR_ORACLES": 1, + "CAUSAL_PARITY_FIXTURE": 1, + "CAUSAL_FACTOR_ANALYSIS": 13, + "LOCAL_BOOK_REQUIREMENTS": 8 }, "collected_cases": { "causal_factor_analysis": 507, @@ -463,10 +464,10 @@ }, { "path": "test/data/weights/test_sample_weights.py", - "size": 3920, - "sha256": "6A682B9C8273A9480CC40EDEF503F4ADB254FDE03A7C6846A6576B2844177DA5", + "size": 12928, + "sha256": "E7D932C7B8A5421876004FB281FA2F47C93A393B7E91F950D6EB621493C3ABD8", "group": "PRESERVED_LIBRARY", - "declared_test_functions": 4 + "declared_test_functions": 18 }, { "path": "test/ensemble/test_bagging_classifier_accuracy.py", @@ -559,6 +560,20 @@ "group": "PRESERVED_LIBRARY", "declared_test_functions": 5 }, + { + "path": "test/optimization/test_clearing.py", + "size": 3574, + "sha256": "95AF6C20FB2DD5F402B93FBD8334820410B89E8BAAF806422D9B705FF119973F", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 6 + }, + { + "path": "test/optimization/test_execution.py", + "size": 5228, + "sha256": "B0560BA63CEA23270E0B79D02ED68F9AA38CEE008B5FA971C9CDEAE1C6509C0E", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 9 + }, { "path": "test/optimization/test_hedging.py", "size": 2026, @@ -580,6 +595,13 @@ "group": "PRESERVED_LIBRARY", "declared_test_functions": 4 }, + { + "path": "test/optimization/test_majorization.py", + "size": 6535, + "sha256": "052FFDDE6ACF17D0520D428B66677A0BF39158D5A6B2177FB9A53861C417BC9A", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 11 + }, { "path": "test/optimization/test_nco.py", "size": 1875, @@ -587,6 +609,13 @@ "group": "PRESERVED_LIBRARY", "declared_test_functions": 3 }, + { + "path": "test/pde/test_neural_sde.py", + "size": 17167, + "sha256": "6D212FE87E2A3A309E9FD3120F577E1E9838E093983B1F001B1CBF5844FD3CED", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 14 + }, { "path": "test/pde/test_pde_solver.py", "size": 1764, @@ -594,6 +623,20 @@ "group": "PRESERVED_LIBRARY", "declared_test_functions": 2 }, + { + "path": "test/test_book_method_validation.py", + "size": 4390, + "sha256": "DD6540E190E5FD5A8FF934037CBA6C29F21AB6396110AC0B3C8DA20658BBD896", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 5 + }, + { + "path": "test/test_book_methods.py", + "size": 15286, + "sha256": "A32E1A6A3EB17DC7E3B70E0F16030FC13A4D4115ABA91070A940E76526A1DE1D", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 19 + }, { "path": "test/test_consolidation.py", "size": 3427, @@ -608,6 +651,20 @@ "group": "PRESERVED_LIBRARY", "declared_test_functions": 4 }, + { + "path": "test/test_existing_book_routes.py", + "size": 3988, + "sha256": "CAC8FECF9032735CD18925D1002EAE1FE71D8C3FC9EE2DD45DC033F0BAE31BBA", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 4 + }, + { + "path": "test/test_learned_book_methods.py", + "size": 9282, + "sha256": "CC79AD4EDB6B2400F2FC3E1F70A57A853AF3A8E7E96837C9E17108BD8275DC52", + "group": "LOCAL_BOOK_REQUIREMENTS", + "declared_test_functions": 10 + }, { "path": "test/test_performance.py", "size": 6986, diff --git a/docs/compatibility.md b/docs/compatibility.md index f8615e6..7680afb 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,10 +1,12 @@ # Python runtime and dependency support -Status: **3.1.0 local integration candidate; publication and release blocked**. +Status: **3.2.0 candidate; not yet released**. -RiskLabAI 3.1.0 retains the support policy released with RiskLabAI 3.0.0 while -adding 30 causal names. Package metadata is frozen for local integration and -artifact inspection. No version shown in this document authorizes a +RiskLabAI 3.2.0 retains the Python and dependency support policy of 3.1.0. +The new Python methods preserve the existing optional dependency boundaries. +CVXPY, River, and PySensemakr are additional optional suppliers installed +separately for selected methods; see `INSTALLATION.md`. Package metadata is +prepared for local artifact inspection. No version shown in this document authorizes a version-control action, upload, publication, or release. ## Supported interpreter and NumPy policy @@ -14,6 +16,9 @@ base numerical policy is `numpy>=2.2,<3`. A matrix lane uses only versions for which compatible distributions actually exist; it never forces an older NumPy source release onto a newer interpreter. +The following exact versions describe the historical 3.1.0 validation matrix. +They are not a claim that these versions were rerun for 3.2.0. + | Lane | CPython | NumPy | Required-dependency position | |---|---:|---:|---| | 3.12 minimum | 3.12.13 | 2.2.0 | approved floors | @@ -95,7 +100,7 @@ emit `DeprecationWarning`. Their removal target is 4.0.0. Package-scoped tests bind both continued availability and the stated target; no preserved alias is deleted in this release. -## Matrix evidence +## Historical 3.1.0 matrix evidence The causal tree collects exactly 507 tests: the released 57-name suite, the 30 additive-name suite, independent analytical and graph oracles, boundary and @@ -126,6 +131,19 @@ The causal public design is exactly 87 names: the released 57-name contract as an unchanged prefix plus 30 documented additions. The candidate did not import, modify, or reintroduce the excluded legacy causal implementation. +## 3.2.0 validation scope + +The existing Python and NumPy floors remain unchanged for this additive +candidate. The implementation commit passed the six base and three optional +GitHub CI lanes and the static checks. The version update requires a new CI +run before merging. Historical pass counts above are not 3.2.0 results. +The 3.2.0 candidate passed 1,178 tests locally on Python 3.13, with one +Windows platform skip and 73 existing warnings. +Supplemental CVXPY, River, and PySensemakr checks were run locally on Python +3.13; the existing optional CI groups do not install those three suppliers. +The causal namespace now contains 92 names, retaining the previous 87 names. +The five new causal exports do not imply new Julia parity. + ## Remaining release gates The public surface, analytical matrix, exact source and test inventories, and diff --git a/examples/causal_factor_analysis_quickstart.py b/examples/causal_factor_analysis_quickstart.py index 32d48cb..9d13718 100644 --- a/examples/causal_factor_analysis_quickstart.py +++ b/examples/causal_factor_analysis_quickstart.py @@ -13,7 +13,7 @@ minimum_variance_factor_weights, ) -assert RiskLabAI.__version__ == "3.1.0" +assert RiskLabAI.__version__ == "3.2.0" covariance = np.diag([1.0, 2.0, 4.0]) diff --git a/pyproject.toml b/pyproject.toml index 8e2d302..0054b84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "RiskLabAI" -version = "3.1.0" +version = "3.2.0" description = "Financial machine-learning and causal-factor analysis tools for research and model validation." readme = "README.md" requires-python = ">=3.12,<3.15" diff --git a/src/RiskLabAI/__init__.py b/src/RiskLabAI/__init__.py index edb201f..7b3a08e 100644 --- a/src/RiskLabAI/__init__.py +++ b/src/RiskLabAI/__init__.py @@ -4,7 +4,7 @@ from . import causal_factor_analysis -__version__ = "3.1.0" +__version__ = "3.2.0" _LEGACY_SUBMODULES = frozenset( { diff --git a/src/RiskLabAI/backtest/__init__.py b/src/RiskLabAI/backtest/__init__.py index b8e1f27..feef8f5 100644 --- a/src/RiskLabAI/backtest/__init__.py +++ b/src/RiskLabAI/backtest/__init__.py @@ -9,6 +9,12 @@ """ from . import validation +from .adaptation import prequential_adaptation +from .tail_risk import ( + generalized_pareto_tail_risk, + fit_pot_tail_risk, + quantile_exception_tests, +) from .advanced_bet_sizing import ( PlattCalibrator, distributionally_robust_kelly_fraction, @@ -116,6 +122,10 @@ # Define what `from RiskLabAI.backtest import *` will import __all__ = [ + "prequential_adaptation", + "generalized_pareto_tail_risk", + "fit_pot_tail_risk", + "quantile_exception_tests", "validation", # from advanced_bet_sizing (independent validation includes) "PlattCalibrator", diff --git a/src/RiskLabAI/backtest/adaptation.py b/src/RiskLabAI/backtest/adaptation.py new file mode 100644 index 0000000..7c2d103 --- /dev/null +++ b/src/RiskLabAI/backtest/adaptation.py @@ -0,0 +1,66 @@ +"""Prequential prediction with an optional ADWIN-triggered rolling refit.""" + +import numpy as np +from sklearn.base import clone + +from RiskLabAI.utils._validation import positive_integer, real_array, real_scalar + +__all__ = ["prequential_adaptation"] + + +def prequential_adaptation( + features, targets, estimator, *, loss_function, window_size, min_samples, delta +): + """Predict each row before observing its target, then update ADWIN. + + loss_function(target, prediction) must return a value in [0, 1]. The + initial fit uses the first min_samples observed rows. A drift alarm + triggers a fit using at most window_size most recent observed rows, + including the current row, so that refit can affect only later forecasts. + Warm-up predictions/losses are NaN. The supplied estimator is cloned; + callers must set its own random_state for deterministic behavior. + Requires optional River; returns predictions, losses and refit indices. + """ + from river.drift import ADWIN + + x = real_array(features, "features", 2) + y = real_array(targets, "targets", 1) + window_size = positive_integer(window_size, "window_size") + min_samples = positive_integer(min_samples, "min_samples") + delta = real_scalar(delta, "delta") + if ( + len(x) != len(y) + or min_samples > window_size + or min_samples >= len(y) + or not 0 < delta < 1 + or not callable(loss_function) + ): + raise ValueError( + "Require matching rows, min_samples <= window_size < infinity, enough data, and delta in (0,1)." + ) + detector = ADWIN(delta=delta) + predictions = np.full(len(y), np.nan) + losses = np.full(len(y), np.nan) + alarms, refits = [], [min_samples - 1] + model = clone(estimator).fit(x[:min_samples], y[:min_samples]) + for index in range(min_samples, len(y)): + raw_prediction = np.asarray(model.predict(x[index : index + 1])) + if raw_prediction.shape != (1,): + raise ValueError("Estimator must return one scalar prediction per row.") + prediction = real_scalar(raw_prediction[0], "prediction") + loss = real_scalar(loss_function(y[index], prediction), "loss", minimum=0) + if loss > 1: + raise ValueError("ADWIN losses must be in [0,1].") + predictions[index], losses[index] = prediction, loss + detector.update(loss) + if detector.drift_detected: + alarms.append(index) + begin = max(0, index + 1 - window_size) + model = clone(estimator).fit(x[begin : index + 1], y[begin : index + 1]) + refits.append(index) + return { + "predictions": predictions, + "losses": losses, + "alarm_indices": np.array(alarms, dtype=int), + "refit_indices": np.array(refits, dtype=int), + } diff --git a/src/RiskLabAI/backtest/deep_hedging.py b/src/RiskLabAI/backtest/deep_hedging.py new file mode 100644 index 0000000..422b870 --- /dev/null +++ b/src/RiskLabAI/backtest/deep_hedging.py @@ -0,0 +1,212 @@ +"""Single-asset entropic deep hedging with explicit proportional trading costs. + +Independent implementation of the mathematical deep-hedging benchmark. +Requires optional PyTorch. All amounts use zero interest and dividend rates; +the supplied GBM drift is the simulation measure's drift, not a pricing claim. +""" + +import math + +import numpy as np +import torch +from torch import nn + +from RiskLabAI.utils._validation import positive_integer, real_scalar + +__all__ = [ + "gbm_hedging_paths", + "hedging_pnl", + "entropic_risk", + "black_scholes_call_delta", + "DeepHedge", + "train_deep_hedge", +] + + +def _tensor(value, name, dimensions): + if ( + not isinstance(value, torch.Tensor) + or not value.is_floating_point() + or value.ndim != dimensions + or value.numel() == 0 + or not torch.isfinite(value).all() + ): + raise ValueError( + f"{name} must be a nonempty finite floating tensor with {dimensions} dimensions." + ) + return value + + +def gbm_hedging_paths(*, spot, maturity, volatility, drift, steps, paths, seed): + """Simulate exact GBM observations with a local CPU float64 generator.""" + spot = real_scalar(spot, "spot", minimum=0, strict=True) + maturity = real_scalar(maturity, "maturity", minimum=0, strict=True) + volatility = real_scalar(volatility, "volatility", minimum=0) + drift = real_scalar(drift, "drift") + steps, paths = positive_integer(steps, "steps"), positive_integer(paths, "paths") + generator = torch.Generator(device="cpu").manual_seed(seed) + dt = maturity / steps + increments = (drift - volatility**2 / 2) * dt + volatility * math.sqrt( + dt + ) * torch.randn(paths, steps, generator=generator, dtype=torch.float64) + result = spot * torch.exp( + torch.cat( + [torch.zeros(paths, 1, dtype=torch.float64), increments.cumsum(dim=1)], + dim=1, + ) + ) + if not torch.isfinite(result).all() or torch.any(result <= 0): + raise ValueError("GBM paths exceeded the supported numerical range.") + return result + + +def hedging_pnl(prices, positions, *, strike, cost_rate, premium=0.0): + """Return terminal cash flow of a short call and self-financing hedge. + + Prices are (paths, steps+1); positions are (paths, steps). Initial buying + and terminal liquidation both pay cost_rate times absolute traded value. + The caller is responsible for positions using only contemporaneous data. + """ + prices, positions = _tensor(prices, "prices", 2), _tensor(positions, "positions", 2) + strike = real_scalar(strike, "strike", minimum=0, strict=True) + cost = real_scalar(cost_rate, "cost_rate", minimum=0) + premium = real_scalar(premium, "premium") + if ( + prices.shape != (positions.shape[0], positions.shape[1] + 1) + or torch.any(prices <= 0) + or prices.dtype != positions.dtype + or prices.device != positions.device + ): + raise ValueError( + "Positive prices and matching positions must share dtype/device." + ) + zero = torch.zeros_like(positions[:, :1]) + trades = torch.diff(torch.cat([zero, positions, zero], dim=1), dim=1) + gains = (positions * torch.diff(prices, dim=1)).sum(dim=1) + costs = cost * (prices * trades.abs()).sum(dim=1) + result = premium + gains - costs - torch.relu(prices[:, -1] - strike) + if not torch.isfinite(result).all(): + raise ValueError("Hedging cash flow exceeded the supported numerical range.") + return result + + +def entropic_risk(pnl, risk_aversion): + """Return log(mean(exp(-risk_aversion*pnl)))/risk_aversion stably.""" + pnl = _tensor(pnl, "pnl", 1) + aversion = real_scalar(risk_aversion, "risk_aversion", minimum=0, strict=True) + anchor = pnl.min().detach() + result = ( + -anchor + + (torch.logsumexp(-aversion * (pnl - anchor), dim=0) - math.log(len(pnl))) + / aversion + ) + if not torch.isfinite(result): + raise ValueError("Entropic risk exceeded the supported numerical range.") + return result + + +def black_scholes_call_delta(prices, *, strike, maturity, volatility): + """Return zero-rate call deltas at all preterminal equally spaced times.""" + prices = _tensor(prices, "prices", 2) + strike = real_scalar(strike, "strike", minimum=0, strict=True) + maturity = real_scalar(maturity, "maturity", minimum=0, strict=True) + volatility = real_scalar(volatility, "volatility", minimum=0, strict=True) + if prices.shape[1] < 2 or torch.any(prices <= 0): + raise ValueError("At least two positive price observations are required.") + steps = prices.shape[1] - 1 + remaining = ( + maturity + * torch.arange(steps, 0, -1, dtype=prices.dtype, device=prices.device) + / steps + ) + d1 = (torch.log(prices[:, :-1] / strike) + volatility**2 * remaining / 2) / ( + volatility * torch.sqrt(remaining) + ) + return torch.special.ndtr(d1) + + +class DeepHedge(nn.Module): + """Shared feedforward hedge using time, current log-price and past holding. + + Positions are unconstrained real values. Initialize with a local CPU seed; + module initialization preserves the caller's random-number state. + """ + + def __init__(self, hidden_size=16, *, seed=0): + super().__init__() + hidden_size = positive_integer(hidden_size, "hidden_size") + with torch.random.fork_rng(devices=[]): + torch.manual_seed(seed) + self.network = nn.Sequential( + nn.Linear(3, hidden_size), + nn.Tanh(), + nn.Linear(hidden_size, hidden_size), + nn.Tanh(), + nn.Linear(hidden_size, 1), + ).double() + + def forward(self, prices, *, strike, maturity): + """Compute adapted positions; future prices never enter earlier states.""" + prices = _tensor(prices, "prices", 2) + strike = real_scalar(strike, "strike", minimum=0, strict=True) + maturity = real_scalar(maturity, "maturity", minimum=0, strict=True) + if prices.shape[1] < 2 or torch.any(prices <= 0): + raise ValueError("At least two positive price observations are required.") + parameter = next(self.parameters()) + if prices.dtype != parameter.dtype or prices.device != parameter.device: + raise ValueError("Prices must match the model dtype and device.") + steps = prices.shape[1] - 1 + previous = torch.zeros_like(prices[:, 0]) + positions = [] + for index in range(steps): + features = torch.stack( + [ + torch.full_like(previous, maturity * (steps - index) / steps), + torch.log(prices[:, index] / strike), + previous, + ], + dim=1, + ) + previous = self.network(features).squeeze(-1) + positions.append(previous) + return torch.stack(positions, dim=1) + + +def train_deep_hedge( + model, + training_prices, + *, + strike, + maturity, + cost_rate, + risk_aversion, + iterations, + learning_rate=0.001, +): + """Fit only supplied training paths and return per-iteration entropic loss. + + Evaluate on independent paths afterwards. No evaluation outcomes select + weights or stopping time here; no optimality or superiority is asserted. + """ + iterations = positive_integer(iterations, "iterations") + learning_rate = real_scalar(learning_rate, "learning_rate", minimum=0, strict=True) + optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) + history = [] + for _ in range(iterations): + optimizer.zero_grad() + positions = model(training_prices, strike=strike, maturity=maturity) + risk = entropic_risk( + hedging_pnl(training_prices, positions, strike=strike, cost_rate=cost_rate), + risk_aversion, + ) + risk.backward() + if any( + p.grad is not None and not torch.isfinite(p.grad).all() + for p in model.parameters() + ): + raise RuntimeError("Nonfinite hedging gradients.") + optimizer.step() + if any(not torch.isfinite(p).all() for p in model.parameters()): + raise RuntimeError("Nonfinite fitted hedge parameters.") + history.append(risk.item()) + return np.array(history) diff --git a/src/RiskLabAI/backtest/tail_risk.py b/src/RiskLabAI/backtest/tail_risk.py new file mode 100644 index 0000000..cf2d672 --- /dev/null +++ b/src/RiskLabAI/backtest/tail_risk.py @@ -0,0 +1,147 @@ +"""Peaks-over-threshold risk and likelihood tests of quantile exceptions.""" + +import numpy as np +from scipy.special import xlogy +from scipy.stats import chi2, genpareto + +from RiskLabAI.utils._validation import real_array, real_scalar + +__all__ = [ + "generalized_pareto_tail_risk", + "fit_pot_tail_risk", + "quantile_exception_tests", +] + + +def generalized_pareto_tail_risk( + threshold, exceedance_probability, shape, scale, probability +): + """Return a loss quantile and upper-tail expectation from a POT model. + + probability is the unconditional quantile level and must exceed + 1-exceedance_probability. Expected shortfall is infinite for shape >= 1. + The threshold and scale use the same loss units. No time-series filter + or threshold-selection rule is implied. + """ + threshold = real_scalar(threshold, "threshold") + rate = real_scalar( + exceedance_probability, "exceedance_probability", minimum=0, strict=True + ) + shape = real_scalar(shape, "shape") + scale = real_scalar(scale, "scale", minimum=0, strict=True) + probability = real_scalar(probability, "probability") + if rate > 1 or not 1 - rate < probability < 1: + raise ValueError( + "Require 0 < exceedance_probability <= 1 and 1-rate < probability < 1." + ) + excess = float(genpareto.isf((1 - probability) / rate, shape, scale=scale)) + quantile = threshold + excess + shortfall = ( + np.inf if shape >= 1 else quantile + (scale + shape * excess) / (1 - shape) + ) + if not np.isfinite(quantile) or (shape < 1 and not np.isfinite(shortfall)): + raise ValueError("Tail risk is outside the supported floating-point range.") + return {"value_at_risk": float(quantile), "expected_shortfall": float(shortfall)} + + +def fit_pot_tail_risk(losses, *, threshold, probability): + """Fit a zero-location generalized Pareto to strictly positive excesses. + + The caller must choose the threshold without using held-out outcomes. + Return estimated parameters, exceedance count and risk measures. + SciPy's numerical MLE does not guarantee a globally optimal fit. + """ + losses = real_array(losses, "losses", 1) + threshold = real_scalar(threshold, "threshold") + excess = losses[losses > threshold] - threshold + if len(excess) < 2 or np.ptp(excess) == 0: + raise ValueError("At least two distinct positive excesses are required.") + shape, location, scale = genpareto.fit(excess, floc=0) + if ( + location != 0 + or not np.isfinite(genpareto.logpdf(excess, shape, scale=scale)).all() + ): + raise RuntimeError( + "The fitted Pareto model fails its support/likelihood check." + ) + result = generalized_pareto_tail_risk( + threshold, len(excess) / len(losses), shape, scale, probability + ) + return { + **result, + "shape": float(shape), + "scale": float(scale), + "threshold": threshold, + "exceedance_count": len(excess), + "sample_count": len(losses), + "exceedance_probability": len(excess) / len(losses), + } + + +def _bernoulli_log_likelihood(zeros, ones, probability): + return float(xlogy(ones, probability) + xlogy(zeros, 1 - probability)) + + +def quantile_exception_tests(exceptions, *, exception_probability): + """Return Kupiec coverage and Christoffersen independence/coverage tests. + + exceptions is a nonempty binary vector, with one indicating a loss above + its forecast quantile. P-values use asymptotic chi-square reference laws. + Coverage uses all observations; transition likelihoods condition on the + first observation. If either previous-state transition row is absent, + independence and conditional coverage are unidentified and return None. + These are tests of quantile forecasts, not expected-shortfall forecasts. + """ + raw = np.asarray(exceptions) + if ( + raw.ndim != 1 + or raw.size == 0 + or raw.dtype.kind not in "biuf" + or not np.all((raw == 0) | (raw == 1)) + ): + raise ValueError("exceptions must be a nonempty binary vector.") + hits = raw.astype(int) + probability = real_scalar(exception_probability, "exception_probability") + if not 0 < probability < 1: + raise ValueError("exception_probability must lie in (0, 1).") + ones, total = int(hits.sum()), len(hits) + lr_coverage = max( + 0.0, + 2 + * ( + _bernoulli_log_likelihood(total - ones, ones, ones / total) + - _bernoulli_log_likelihood(total - ones, ones, probability) + ), + ) + transitions = np.zeros((2, 2), dtype=int) + np.add.at(transitions, (hits[:-1], hits[1:]), 1) + rows = transitions.sum(axis=1) + lr_independence = None + if np.all(rows > 0): + conditional = sum( + _bernoulli_log_likelihood( + transitions[i, 0], transitions[i, 1], transitions[i, 1] / rows[i] + ) + for i in range(2) + ) + transition_ones = transitions[:, 1].sum() + pooled = _bernoulli_log_likelihood( + transitions[:, 0].sum(), transition_ones, transition_ones / (total - 1) + ) + lr_independence = max(0.0, 2 * (conditional - pooled)) + combined = None if lr_independence is None else lr_coverage + lr_independence + return { + "coverage_statistic": lr_coverage, + "coverage_pvalue": float(chi2.sf(lr_coverage, 1)), + "independence_statistic": lr_independence, + "independence_pvalue": ( + None if lr_independence is None else float(chi2.sf(lr_independence, 1)) + ), + "conditional_coverage_statistic": combined, + "conditional_coverage_pvalue": ( + None if combined is None else float(chi2.sf(combined, 2)) + ), + "transitions": transitions, + "exception_count": ones, + "sample_count": total, + } diff --git a/src/RiskLabAI/causal_factor_analysis/__init__.py b/src/RiskLabAI/causal_factor_analysis/__init__.py index 28a336b..099f096 100644 --- a/src/RiskLabAI/causal_factor_analysis/__init__.py +++ b/src/RiskLabAI/causal_factor_analysis/__init__.py @@ -6,6 +6,9 @@ namespaces. """ +from .bounds_transport import manski_ate_bounds, transported_ate +from .interference import exposure_mean_contrast +from .policy_evaluation import evaluate_policy, select_policy from .allocation_diagnostics import ( AllocationMisspecificationDiagnostics, allocation_misspecification_diagnostics, @@ -114,6 +117,11 @@ ) __all__ = [ + "manski_ate_bounds", + "transported_ate", + "exposure_mean_contrast", + "evaluate_policy", + "select_policy", "BackdoorAdjustmentEvidence", "BacktestStage", "CausalDAG", diff --git a/src/RiskLabAI/causal_factor_analysis/bounds_transport.py b/src/RiskLabAI/causal_factor_analysis/bounds_transport.py new file mode 100644 index 0000000..a869535 --- /dev/null +++ b/src/RiskLabAI/causal_factor_analysis/bounds_transport.py @@ -0,0 +1,75 @@ +"""Bounded-outcome identification and explicitly supported standardization.""" + +import numpy as np + +from RiskLabAI.utils._validation import probability_rows, real_array, real_scalar +from .treatment_effects import backdoor_adjusted_average_treatment_effect + +__all__ = ["manski_ate_bounds", "transported_ate"] + + +def manski_ate_bounds(outcomes, treatment, *, lower, upper): + """Return worst-case finite-population ATE bounds under consistency. + + Treatment is binary and every potential outcome must lie in the supplied + [lower, upper]. No treatment-exchangeability assumption is made. Bounds + concern this empirical population; no sampling confidence interval is + supplied. All-treated and all-control populations are allowed. + """ + y = real_array(outcomes, "outcomes", 1) + a = np.asarray(treatment) + lower, upper = real_scalar(lower, "lower"), real_scalar(upper, "upper") + if ( + a.shape != y.shape + or a.dtype.kind not in "biuf" + or not np.all((a == 0) | (a == 1)) + or lower > upper + or np.any(y < lower) + or np.any(y > upper) + ): + raise ValueError( + "Require binary matching treatment and outcomes within ordered bounds." + ) + lower_effect = np.where(a == 1, y - upper, lower - y) + upper_effect = np.where(a == 1, y - lower, upper - y) + return { + "lower": float(lower_effect.mean()), + "upper": float(upper_effect.mean()), + "treated_fraction": float(a.mean()), + "sample_count": len(y), + } + + +def transported_ate( + treated_means, + control_means, + target_probabilities, + source_treated_counts, + source_control_counts, +): + """Standardize supported stratum effects to the target population. + + Identification requires within-source treatment exchangeability, + consistency, shared treatment definitions, and transportability of + conditional potential-outcome means. These scientific assumptions cannot + be verified numerically here. Every target-positive stratum needs source + observations of both treatments. Counts may be nonnegative real weights. + Zero-target-mass strata contribute nothing, but supplied means stay finite. + """ + treated = real_array(treated_means, "treated_means", 1) + control = real_array(control_means, "control_means", 1) + probabilities = probability_rows(target_probabilities, "target_probabilities", 1) + count1 = real_array(source_treated_counts, "source_treated_counts", 1) + count0 = real_array(source_control_counts, "source_control_counts", 1) + if ( + any( + array.shape != treated.shape + for array in (control, probabilities, count1, count0) + ) + or np.any(count1 < 0) + or np.any(count0 < 0) + ): + raise ValueError("Stratum arrays must match with nonnegative source counts.") + if np.any((probabilities > 0) & ((count1 == 0) | (count0 == 0))): + raise ValueError("Target-positive strata lack source treatment support.") + return backdoor_adjusted_average_treatment_effect(treated, control, probabilities) diff --git a/src/RiskLabAI/causal_factor_analysis/interference.py b/src/RiskLabAI/causal_factor_analysis/interference.py new file mode 100644 index 0000000..c3283d3 --- /dev/null +++ b/src/RiskLabAI/causal_factor_analysis/interference.py @@ -0,0 +1,65 @@ +"""Design-based exposure contrasts under a supplied interference mapping.""" + +import numpy as np + +from RiskLabAI.utils._validation import real_array + +__all__ = ["exposure_mean_contrast"] + + +def exposure_mean_contrast( + outcomes, exposures, exposure_probabilities, *, first, second +): + """Return the Horvitz-Thompson mean contrast first minus second. + + Each row of exposure_probabilities lists the unit's known probabilities + under the randomized assignment design. exposures contains integer column + indices for realized exposures. All units need positive probability for + both contrasted exposures. The mapping must make potential outcomes + well-defined; graph edges alone do not establish this assumption. + No variance or confidence interval is returned without joint exposure + probabilities. Probabilities need not sum to one if other exposure + categories are omitted, but each row's sum cannot exceed one. + """ + y = real_array(outcomes, "outcomes", 1) + probabilities = real_array(exposure_probabilities, "exposure_probabilities", 2) + labels = np.asarray(exposures) + if ( + probabilities.shape[0] != len(y) + or labels.shape != y.shape + or labels.dtype.kind not in "iu" + or np.any(labels < 0) + or np.any(labels >= probabilities.shape[1]) + ): + raise ValueError("Exposures must be valid integer columns matching outcomes.") + for value in (first, second): + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or not 0 <= value < probabilities.shape[1] + ): + raise ValueError("Contrast indices must name probability columns.") + if ( + first == second + or np.any(probabilities < 0) + or np.any(probabilities.sum(axis=1) > 1 + 1e-12) + ): + raise ValueError("Require distinct contrasts and valid probability mass.") + if np.any(probabilities[:, [first, second]] <= 0) or np.any( + probabilities[np.arange(len(y)), labels] <= 0 + ): + raise ValueError("The requested exposure contrast lacks positivity.") + first_scores = (labels == first) * y / probabilities[:, first] + second_scores = (labels == second) * y / probabilities[:, second] + if ( + not np.isfinite(first_scores).all() + or not np.isfinite(second_scores).all() + or not np.isfinite(first_scores - second_scores).all() + ): + raise ValueError("Exposure scores exceed floating-point range.") + return { + "first_mean": float(first_scores.mean()), + "second_mean": float(second_scores.mean()), + "contrast": float((first_scores - second_scores).mean()), + "unit_contributions": first_scores - second_scores, + } diff --git a/src/RiskLabAI/causal_factor_analysis/policy_evaluation.py b/src/RiskLabAI/causal_factor_analysis/policy_evaluation.py new file mode 100644 index 0000000..9e1ea0d --- /dev/null +++ b/src/RiskLabAI/causal_factor_analysis/policy_evaluation.py @@ -0,0 +1,140 @@ +"""One-step discrete-action policy evaluation with explicit overlap checks.""" + +import numpy as np + +from RiskLabAI.utils._validation import probability_rows, real_array + +__all__ = ["evaluate_policy", "select_policy"] + + +def evaluate_policy( + actions, rewards, behavior_probabilities, policy_probabilities, outcome_predictions +): + """Return inverse-propensity and doubly robust policy-value estimates. + + Probability matrices and outcome_predictions have shape (rows, actions). + Outcome predictions must be out of fold, or from an independent sample. + The caller must justify consistency and conditional exchangeability. + Every policy-supported action needs behavior support. No clipping, + self-normalization or sequential reinforcement-learning interpretation + is used. Diagnostics include weights and their effective sample size. + """ + rewards = real_array(rewards, "rewards", 1) + behavior = probability_rows(behavior_probabilities, "behavior_probabilities") + policy = probability_rows(policy_probabilities, "policy_probabilities") + predictions = real_array(outcome_predictions, "outcome_predictions", 2) + actions = np.asarray(actions) + if ( + behavior.shape != policy.shape + or predictions.shape != policy.shape + or len(behavior) != len(rewards) + or actions.shape != rewards.shape + or actions.dtype.kind not in "iu" + or np.any(actions < 0) + or np.any(actions >= behavior.shape[1]) + ): + raise ValueError("Actions, outcomes and probability shapes are incompatible.") + row = np.arange(len(actions)) + observed = behavior[row, actions] + if np.any((policy > 0) & (behavior == 0)) or np.any(observed == 0): + raise ValueError("Policy evaluation requires behavior-policy overlap.") + weights = policy[row, actions] / observed + ipw = weights * rewards + dr = (policy * predictions).sum(axis=1) + weights * ( + rewards - predictions[row, actions] + ) + if ( + not np.all(np.isfinite(ipw)) + or not np.all(np.isfinite(dr)) + or not np.all(np.isfinite(weights)) + ): + raise ValueError("Policy scores exceed floating-point range.") + normalized = weights / weights.max() if weights.max() else weights + effective = ( + 0.0 + if not normalized.any() + else float(normalized.sum() ** 2 / (normalized @ normalized)) + ) + return { + "ipw_value": float(ipw.mean()), + "dr_value": float(dr.mean()), + "ipw_scores": ipw, + "dr_scores": dr, + "importance_weights": weights, + "effective_sample_size": effective, + "max_weight": float(weights.max()), + "mean_weight": float(weights.mean()), + } + + +def select_policy( + actions, + rewards, + behavior_probabilities, + candidate_probabilities, + outcome_predictions, + *, + training_indices, + evaluation_indices, +): + """Select the highest training DR value, then evaluate on later held-out rows. + + Candidates have shape (policies, rows, actions), fixed before evaluation. + The first candidate wins ties. All training indices must precede all + evaluation indices, with no duplicates or overlap. Nuisance predictions + must independently obey the same information boundary; this function + cannot verify their training history. Evaluation outcomes never select + the policy. No value estimate for a reused selection sample is reported + as held-out performance. + """ + rewards = real_array(rewards, "rewards", 1) + candidates = real_array(candidate_probabilities, "candidate_probabilities", 3) + actions = np.asarray(actions) + behavior = np.asarray(behavior_probabilities) + outcomes = np.asarray(outcome_predictions) + if ( + candidates.shape[1] != len(rewards) + or actions.shape != rewards.shape + or behavior.shape != candidates.shape[1:] + or outcomes.shape != behavior.shape + ): + raise ValueError("Candidate and observation arrays must match.") + indices = [] + for raw in (training_indices, evaluation_indices): + index = np.asarray(raw) + if ( + index.ndim != 1 + or index.dtype.kind not in "iu" + or index.size == 0 + or np.any(index < 0) + or np.any(index >= len(rewards)) + or len(np.unique(index)) != len(index) + ): + raise ValueError("Split indices must be nonempty, distinct and valid.") + indices.append(index) + train, evaluation = indices + if train.max() >= evaluation.min(): + raise ValueError("Training must precede held-out evaluation.") + values = [ + evaluate_policy( + actions[train], + rewards[train], + behavior[train], + candidate[train], + outcomes[train], + )["dr_value"] + for candidate in candidates + ] + selected = int(np.argmax(values)) + result = evaluate_policy( + actions[evaluation], + rewards[evaluation], + behavior[evaluation], + candidates[selected, evaluation], + outcomes[evaluation], + ) + return { + "selected_policy": selected, + "training_values": np.array(values), + "evaluation": result, + } diff --git a/src/RiskLabAI/data/synthetic_data/timegan.py b/src/RiskLabAI/data/synthetic_data/timegan.py new file mode 100644 index 0000000..461518f --- /dev/null +++ b/src/RiskLabAI/data/synthetic_data/timegan.py @@ -0,0 +1,350 @@ +"""Fixed-length temporal TimeGAN using the paper's reconstruction and GAN losses. + +Implements the temporal-only setting of equations 7--11, with an autoregressive +generator used both freely and with teacher forcing. It does not reproduce the +reference repository's separate supervisor or moment-matching extensions. +Requires optional PyTorch. Synthetic windows are not an unlimited market path. +""" + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +from RiskLabAI.utils._validation import positive_integer, real_array, real_scalar + +__all__ = [ + "chronological_timegan_data", + "TimeGAN", + "fit_timegan", + "timegan_quality_report", +] + + +def chronological_timegan_data(series, *, training_end, window_length): + """Split before windowing and scale using only the training segment. + + Return overlapping windows inside each disjoint segment and the feature + minimum/scale needed for inversion. Validation values are never clipped. + Constant training features have scale one. No window crosses the split. + """ + series = real_array(series, "series", 2) + training_end = positive_integer(training_end, "training_end") + length = positive_integer(window_length, "window_length") + if length < 2 or training_end < length or len(series) - training_end < length: + raise ValueError( + "Both chronological segments need a complete window of length >= 2." + ) + minimum = series[:training_end].min(axis=0) + scale = np.ptp(series[:training_end], axis=0) + scale[scale == 0] = 1 + scaled = (series - minimum) / scale + + def windows(part): + return np.stack([part[i : i + length] for i in range(len(part) - length + 1)]) + + return { + "training": windows(scaled[:training_end]), + "validation": windows(scaled[training_end:]), + "minimum": minimum, + "scale": scale, + "training_end": training_end, + } + + +class _SequenceMap(nn.Module): + def __init__(self, inputs, hidden, outputs, *, sigmoid=True, bidirectional=False): + super().__init__() + self.recurrent = nn.GRU( + inputs, hidden, batch_first=True, bidirectional=bidirectional + ) + self.output = nn.Linear(hidden * (2 if bidirectional else 1), outputs) + self.sigmoid = sigmoid + + def forward(self, inputs): + output = self.output(self.recurrent(inputs)[0]) + return torch.sigmoid(output) if self.sigmoid else output + + +class TimeGAN(nn.Module): + """Temporal-only GRU TimeGAN with an explicitly autoregressive generator. + + The embedding and recovery use causal GRUs; the discriminator uses a + bidirectional GRU. Independent Gaussian innovations enter every step. + Supplied training data must be scaled to [0,1]. CPU float64 is the default. + """ + + def __init__(self, features, hidden_size=12, *, seed=0): + super().__init__() + self.features = positive_integer(features, "features") + self.hidden_size = positive_integer(hidden_size, "hidden_size") + with torch.random.fork_rng(devices=[]): + torch.manual_seed(seed) + self.embedding = _SequenceMap(features, hidden_size, hidden_size) + self.recovery = _SequenceMap(hidden_size, hidden_size, features) + self.generator_cell = nn.GRUCell(2 * hidden_size, hidden_size) + self.generator_output = nn.Linear(hidden_size, hidden_size) + self.discriminator = _SequenceMap( + hidden_size, hidden_size, 1, sigmoid=False, bidirectional=True + ) + self.double() + + def _check(self, value, name, features): + parameter = next(self.parameters()) + if ( + not isinstance(value, torch.Tensor) + or value.ndim != 3 + or value.shape[0] == 0 + or value.shape[1] < 2 + or value.shape[2] != features + or not value.is_floating_point() + or value.dtype != parameter.dtype + or value.device != parameter.device + or not torch.isfinite(value).all() + ): + raise ValueError( + f"{name} must be finite (windows, time>=2, {features}) and match the model dtype/device." + ) + + def latent_sequence(self, noise, teacher=None): + """Generate latents freely, or condition step t on observed latent t-1.""" + self._check(noise, "noise", self.hidden_size) + if teacher is not None: + self._check(teacher, "teacher", self.hidden_size) + if teacher.shape != noise.shape: + raise ValueError("Teacher and noise shapes must match.") + state = torch.zeros_like(noise[:, 0]) + previous = torch.zeros_like(state) + outputs = [] + for index in range(noise.shape[1]): + if teacher is not None and index > 0: + previous = teacher[:, index - 1] + state = self.generator_cell( + torch.cat([previous, noise[:, index]], dim=1), state + ) + previous = torch.sigmoid(self.generator_output(state)) + outputs.append(previous) + return torch.stack(outputs, dim=1) + + def losses(self, observations, noise): + """Return equations 7--9, averaged over windows and summed over time. + + Discriminator loss is -L_U. Generator adversarial loss is the minimax + fake-data term log(1-D(G(z))); the real-data term has zero G gradient. + Supervision includes the initial latent with zero initial history. + """ + self._check(observations, "observations", self.features) + if torch.any((observations < 0) | (observations > 1)): + raise ValueError("Training observations must be scaled to [0,1].") + if observations.shape[:2] != noise.shape[:2]: + raise ValueError("Observations and noise need matching windows/times.") + latent = self.embedding(observations) + reconstructed = self.recovery(latent) + synthetic = self.latent_sequence(noise) + teacher = self.latent_sequence(noise, latent) + real_logits = self.discriminator(latent) + fake_logits = self.discriminator(synthetic) + reconstruction = (observations - reconstructed).square().sum(dim=(1, 2)).mean() + supervised = (latent - teacher).square().sum(dim=(1, 2)).mean() + real_log = F.logsigmoid(real_logits).sum(dim=(1, 2)).mean() + fake_log = F.logsigmoid(-fake_logits).sum(dim=(1, 2)).mean() + return { + "reconstruction": reconstruction, + "supervised": supervised, + "discriminator": -real_log - fake_log, + "generator_adversarial": fake_log, + } + + def sample(self, windows, length, *, seed): + """Return detached scaled windows from independent local seeded noise.""" + windows, length = positive_integer(windows, "windows"), positive_integer( + length, "length" + ) + parameter = next(self.parameters()) + generator = torch.Generator(device=parameter.device).manual_seed(seed) + noise = torch.randn( + windows, + length, + self.hidden_size, + generator=generator, + dtype=parameter.dtype, + device=parameter.device, + ) + with torch.no_grad(): + return self.recovery(self.latent_sequence(noise)) + + +def fit_timegan( + model, + training_windows, + *, + embedding_steps, + supervised_steps, + joint_steps, + batch_size, + learning_rate=0.001, + embedding_weight=1.0, + generator_weight=10.0, + seed=0, +): + """Train reconstruction, teacher-forced generation, then all objectives. + + Step counts are fixed in advance; only training windows enter optimization. + Joint training separately updates E/R on L_R+lambda*L_S, G on L_U+eta*L_S, + and D on -L_U. Returns losses, which do not establish generation quality. + """ + counts = [ + positive_integer(value, name) + for value, name in zip( + [embedding_steps, supervised_steps, joint_steps], + ["embedding_steps", "supervised_steps", "joint_steps"], + ) + ] + batch_size = positive_integer(batch_size, "batch_size") + learning_rate = real_scalar(learning_rate, "learning_rate", minimum=0, strict=True) + embedding_weight = real_scalar(embedding_weight, "embedding_weight", minimum=0) + generator_weight = real_scalar(generator_weight, "generator_weight", minimum=0) + parameter = next(model.parameters()) + data = torch.as_tensor( + real_array(training_windows, "training_windows", 3), + dtype=parameter.dtype, + device=parameter.device, + ) + model._check(data, "training_windows", model.features) + if torch.any((data < 0) | (data > 1)): + raise ValueError("Training observations must be scaled to [0,1].") + generator = torch.Generator(device=parameter.device).manual_seed(seed) + groups = [ + list(model.embedding.parameters()) + list(model.recovery.parameters()), + list(model.generator_cell.parameters()) + + list(model.generator_output.parameters()), + list(model.discriminator.parameters()), + ] + optimizers = [torch.optim.Adam(group, lr=learning_rate) for group in groups] + history = [] + for phase, count in enumerate(counts): + for _ in range(count): + index = torch.randint( + len(data), (batch_size,), generator=generator, device=data.device + ) + observed = data[index] + noise = torch.randn( + batch_size, + data.shape[1], + model.hidden_size, + generator=generator, + dtype=data.dtype, + device=data.device, + ) + updates = [0] if phase == 0 else [1] if phase == 1 else [0, 1, 2] + for group in updates: + model.zero_grad(set_to_none=True) + losses = model.losses(observed, noise) + if group == 0: + objective = losses["reconstruction"] + ( + embedding_weight * losses["supervised"] if phase == 2 else 0 + ) + elif group == 1: + objective = generator_weight * losses["supervised"] + ( + losses["generator_adversarial"] if phase == 2 else 0 + ) + else: + objective = losses["discriminator"] + objective.backward() + if not torch.isfinite(objective) or any( + p.grad is not None and not torch.isfinite(p.grad).all() + for p in groups[group] + ): + raise RuntimeError( + "TimeGAN objective or gradients became nonfinite." + ) + optimizers[group].step() + if any(not torch.isfinite(p).all() for p in groups[group]): + raise RuntimeError("TimeGAN parameters became nonfinite.") + history.append( + { + "phase": ("embedding", "supervised", "joint")[phase], + **{key: value.detach().item() for key, value in losses.items()}, + } + ) + return history + + +def timegan_quality_report( + real_training, real_validation, synthetic_training, synthetic_validation +): + """Evaluate held-out temporal, ridge-predictive and logistic-discrimination scores. + + All arrays are windows in the same units. Real validation must be from a + disjoint chronological segment; synthetic validation uses fresh noise. + Report descriptive lag-one correlation gaps, train-synthetic/test-real + next-step MSE, real-trained MSE, and held-out balanced real/fake accuracy. + These light diagnostics are not the paper's learned GRU evaluation or a + certification of realistic financial dynamics. No uncertainty is asserted + for overlapping windows, and no metric selects model parameters here. + """ + from sklearn.linear_model import LogisticRegression, Ridge + from sklearn.metrics import balanced_accuracy_score + from sklearn.pipeline import make_pipeline + from sklearn.preprocessing import StandardScaler + + arrays = [ + real_array(value, "windows", 3) + for value in ( + real_training, + real_validation, + synthetic_training, + synthetic_validation, + ) + ] + if ( + any( + array.shape[1:] != arrays[0].shape[1:] or len(array) < 2 for array in arrays + ) + or arrays[0].shape[1] < 2 + ): + raise ValueError( + "All window shapes must agree and need at least two samples/times." + ) + rt, rv, st, sv = arrays + + def lag(array): + left, right = array[:, :-1].reshape(-1, array.shape[-1]), array[:, 1:].reshape( + -1, array.shape[-1] + ) + left, right = left - left.mean(axis=0), right - right.mean(axis=0) + denominator = np.sqrt((left**2).sum(axis=0) * (right**2).sum(axis=0)) + return np.divide( + (left * right).sum(axis=0), + denominator, + out=np.full(array.shape[-1], np.nan), + where=denominator > 0, + ) + + def predictive(train): + model = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit( + train[:, :-1].reshape(len(train), -1), train[:, -1] + ) + prediction = np.asarray(model.predict(rv[:, :-1].reshape(len(rv), -1))).reshape( + rv[:, -1].shape + ) + return float(np.mean((prediction - rv[:, -1]) ** 2)) + + train_x = np.concatenate([rt.reshape(len(rt), -1), st.reshape(len(st), -1)]) + train_y = np.r_[np.ones(len(rt)), np.zeros(len(st))] + classifier = make_pipeline( + StandardScaler(), + LogisticRegression(class_weight="balanced", max_iter=1000, random_state=0), + ).fit(train_x, train_y) + test_x = np.concatenate([rv.reshape(len(rv), -1), sv.reshape(len(sv), -1)]) + test_y = np.r_[np.ones(len(rv)), np.zeros(len(sv))] + return { + "real_lag_one_correlation": lag(rv), + "synthetic_lag_one_correlation": lag(sv), + "lag_one_absolute_gap": np.abs(lag(rv) - lag(sv)), + "synthetic_trained_prediction_mse": predictive(st), + "real_trained_prediction_mse": predictive(rt), + "balanced_discriminator_accuracy": float( + balanced_accuracy_score(test_y, classifier.predict(test_x)) + ), + } diff --git a/src/RiskLabAI/data/weights/__init__.py b/src/RiskLabAI/data/weights/__init__.py index 5788242..554f017 100644 --- a/src/RiskLabAI/data/weights/__init__.py +++ b/src/RiskLabAI/data/weights/__init__.py @@ -14,6 +14,7 @@ calculate_time_decay, expand_label_for_meta_labeling, sample_weight_absolute_return_meta_labeling, + sequential_bootstrap, ) __all__ = [ @@ -21,4 +22,5 @@ "calculate_average_uniqueness", "sample_weight_absolute_return_meta_labeling", "calculate_time_decay", + "sequential_bootstrap", ] diff --git a/src/RiskLabAI/data/weights/sample_weights.py b/src/RiskLabAI/data/weights/sample_weights.py index d85988d..a827c48 100644 --- a/src/RiskLabAI/data/weights/sample_weights.py +++ b/src/RiskLabAI/data/weights/sample_weights.py @@ -33,18 +33,20 @@ def expand_label_for_meta_labeling( timestamp : pd.Series Series where index is event start time, value is event end time. molecule : pd.Index - The subset of event start times to process. + The subset of event start times to process. All overlapping events + in `timestamp` contribute to concurrency, including events outside + this subset. Returns ------- pd.Series - A Series indexed by `close_index` where each value is the - count of active events at that timestamp. + A Series on `close_index` from the first start in `molecule` through + its latest event end, containing the count of all active events. """ # Filter events that are relevant to this molecule ts = timestamp.fillna(close_index[-1]) - ts = ts[ts.index.isin(molecule)] - ts = ts[ts > molecule[0]] + molecule_end = ts.loc[molecule].max() + ts = ts[(ts >= molecule[0]) & (ts.index <= molecule_end)] if ts.empty: # Return an empty series; align in the caller will handle it @@ -52,7 +54,7 @@ def expand_label_for_meta_labeling( # Find min/max index locations iloc_min = close_index.searchsorted(ts.index[0]) - iloc_max = close_index.searchsorted(ts.max()) + iloc_max = close_index.searchsorted(molecule_end) # Create a count series over the relevant time span count = pd.Series(0, index=close_index[iloc_min : iloc_max + 1]) @@ -63,7 +65,7 @@ def expand_label_for_meta_labeling( t_out = min(t_out, count.index[-1]) count.loc[t_in:t_out] += 1 - return count.loc[molecule[0] : ts.max()] + return count.loc[molecule[0] : molecule_end] def calculate_average_uniqueness( @@ -111,6 +113,90 @@ def calculate_average_uniqueness( return average_uniqueness +def sequential_bootstrap( + indicator_matrix: pd.DataFrame | np.ndarray, + sample_length: int | None = None, + random_state: int | np.random.Generator | None = None, +) -> np.ndarray: + """Draw event positions with replacement using average added uniqueness. + + Parameters + ---------- + indicator_matrix : pd.DataFrame or np.ndarray + Binary matrix with observation intervals in rows and events in columns. + Each event must contain at least one active interval. Row ordering and + DataFrame labels do not affect the sampling probabilities. + sample_length : int or None, default=None + Number of draws. Defaults to the number of event columns. Must be a + nonnegative integer. A matrix with no columns permits only zero draws. + random_state : int, np.random.Generator or None, default=None + Nonnegative seed or generator. A supplied generator advances in place. + None creates a new generator. The legacy global random state is unused. + + Returns + ------- + np.ndarray + Integer column positions in draw order, including repeated selections. + For DataFrame column labels, use ``indicator_matrix.columns.take`` on + the returned positions. A zero-length draw consumes no random numbers. + + Raises + ------ + ValueError + If the matrix is not two-dimensional, real numeric or boolean, finite, + binary, or has an event with no active intervals; if the requested + length is invalid; or if random_state is unsupported. + + Notes + ----- + For candidate j, average 1 / (c_t + 1) over its active intervals, where c_t + counts previous selections, including repetitions. Normalize these scores + to obtain the next draw probabilities. The first draw is uniform. This is + the Chapter 4 sampling method described in the module reference, not a + guarantee of improved model performance for every sample. + + Examples + -------- + >>> sequential_bootstrap(np.ones((2, 1)), sample_length=3, random_state=0) + array([0, 0, 0]) + """ + values = np.asarray(indicator_matrix) + if values.ndim != 2 or values.dtype.kind not in "biuf": + raise ValueError("indicator_matrix must be a two-dimensional binary matrix") + if not np.all(np.isfinite(values)) or not np.all((values == 0) | (values == 1)): + raise ValueError("indicator_matrix must contain only finite zeros and ones") + matrix = values.astype(float, copy=False) + event_count = matrix.shape[1] + durations = matrix.sum(axis=0) + if np.any(durations == 0): + raise ValueError("each event must contain at least one active interval") + if sample_length is None: + sample_length = event_count + if ( + isinstance(sample_length, (bool, np.bool_)) + or not isinstance(sample_length, (int, np.integer)) + or sample_length < 0 + ): + raise ValueError("sample_length must be a nonnegative integer") + if event_count == 0 and sample_length > 0: + raise ValueError("cannot draw from an indicator matrix with no events") + if random_state is not None and not isinstance(random_state, np.random.Generator): + if ( + isinstance(random_state, (bool, np.bool_)) + or not isinstance(random_state, (int, np.integer)) + or random_state < 0 + ): + raise ValueError("random_state must be a nonnegative seed or Generator") + generator = np.random.default_rng(random_state) + selected = np.empty(sample_length, dtype=np.intp) + concurrency = np.zeros(matrix.shape[0], dtype=float) + for draw in range(sample_length): + scores = (matrix.T @ (1.0 / (concurrency + 1.0))) / durations + selected[draw] = generator.choice(event_count, p=scores / scores.sum()) + concurrency += matrix[:, selected[draw]] + return selected + + def sample_weight_absolute_return_meta_labeling( timestamp: pd.Series, price: pd.Series, molecule: pd.Index ) -> pd.Series: diff --git a/src/RiskLabAI/features/entropy_features/__init__.py b/src/RiskLabAI/features/entropy_features/__init__.py index 96576ca..3cc2127 100644 --- a/src/RiskLabAI/features/entropy_features/__init__.py +++ b/src/RiskLabAI/features/entropy_features/__init__.py @@ -6,6 +6,7 @@ (Miller-Madow, Grassberger, NSB) for undersampled symbol counts. """ +from .ambiguity import probability_ambiguity, normal_prior_bin_probabilities from .bias_corrected import ( grassberger_entropy, miller_madow_entropy, @@ -18,6 +19,8 @@ from .shannon import shannon_entropy __all__ = [ + "probability_ambiguity", + "normal_prior_bin_probabilities", "shannon_entropy", "lempel_ziv_entropy", "probability_mass_function", diff --git a/src/RiskLabAI/features/entropy_features/ambiguity.py b/src/RiskLabAI/features/entropy_features/ambiguity.py new file mode 100644 index 0000000..5555587 --- /dev/null +++ b/src/RiskLabAI/features/entropy_features/ambiguity.py @@ -0,0 +1,75 @@ +"""Probability uncertainty over supplied priors, separate from return variance.""" + +import numpy as np +from scipy.special import ndtr + +from RiskLabAI.utils._validation import probability_rows, real_array, real_scalar + +__all__ = ["probability_ambiguity", "normal_prior_bin_probabilities"] + + +def probability_ambiguity(probabilities, *, prior_weights=None, bin_width=None): + """Return sum of prior-mean bin probability times its population variance. + + Rows are priors, columns are mutually exclusive exhaustive outcome bins. + Explicit prior weights must sum to one; the default weights are uniform. + Variance uses the finite prior distribution, with no sample correction. + If supplied, bin_width must be in (0, 1); scaled is unscaled/[w*(1-w)]. + This implements the discrete Brenner-Izhakian supplied-prior measure, + not their raw intraday probability-estimation pipeline. + """ + probabilities = probability_rows(probabilities, "probabilities") + weights = ( + np.full(len(probabilities), 1 / len(probabilities)) + if prior_weights is None + else probability_rows(prior_weights, "prior_weights", 1) + ) + if weights.shape != (len(probabilities),): + raise ValueError("There must be one weight per prior.") + mean = weights @ probabilities + variance = weights @ ((probabilities - mean) ** 2) + value = float(mean @ variance) + width = ( + None + if bin_width is None + else real_scalar(bin_width, "bin_width", minimum=0, strict=True) + ) + if width is not None and width >= 1: + raise ValueError("bin_width must be less than one.") + return { + "unscaled": value, + "scaled": None if width is None else value / (width * (1 - width)), + "mean_probabilities": mean, + "probability_variances": variance, + "prior_weights": weights, + "bin_width": width, + } + + +def normal_prior_bin_probabilities(means, standard_deviations, *, edges=None): + """Convert supplied normal priors to exhaustive bin probabilities. + + Default finite edges are -0.06 through 0.06 in steps of 0.002. + Two tail bins are included. Means and standard deviations must use + the same return units as the edges. Standard deviations are positive. + """ + means = real_array(means, "means", 1) + scales = real_array(standard_deviations, "standard_deviations", 1) + edges = ( + np.linspace(-0.06, 0.06, 61) if edges is None else real_array(edges, "edges", 1) + ) + if ( + scales.shape != means.shape + or np.any(scales <= 0) + or np.any(np.diff(edges) <= 0) + ): + raise ValueError( + "Scales must match means and be positive; edges must increase." + ) + z = (edges[None, :] - means[:, None]) / scales[:, None] + interior = np.where( + z[:, :-1] >= 0, + ndtr(-z[:, :-1]) - ndtr(-z[:, 1:]), + ndtr(z[:, 1:]) - ndtr(z[:, :-1]), + ) + return np.column_stack((ndtr(z[:, 0]), interior, ndtr(-z[:, -1]))) diff --git a/src/RiskLabAI/features/microstructural_features/__init__.py b/src/RiskLabAI/features/microstructural_features/__init__.py index ed25a06..853c18d 100644 --- a/src/RiskLabAI/features/microstructural_features/__init__.py +++ b/src/RiskLabAI/features/microstructural_features/__init__.py @@ -17,8 +17,22 @@ gamma_estimates, ) from .edge import edge_estimator +from .order_flow import ( + hawkes_intensity, + hawkes_integrated_intensity, + hawkes_log_likelihood, + simulate_hawkes, + fit_hawkes, + volume_synchronized_pin, +) __all__ = [ + "hawkes_intensity", + "hawkes_integrated_intensity", + "hawkes_log_likelihood", + "simulate_hawkes", + "fit_hawkes", + "volume_synchronized_pin", "beta_estimates", "gamma_estimates", "alpha_estimates", diff --git a/src/RiskLabAI/features/microstructural_features/order_flow.py b/src/RiskLabAI/features/microstructural_features/order_flow.py new file mode 100644 index 0000000..31dd9ab --- /dev/null +++ b/src/RiskLabAI/features/microstructural_features/order_flow.py @@ -0,0 +1,180 @@ +"""Exponential Hawkes events and VPIN from supplied completed volume buckets.""" + +import numpy as np +from scipy.optimize import minimize + +from RiskLabAI.utils._validation import positive_integer, real_array, real_scalar + +__all__ = [ + "hawkes_intensity", + "hawkes_integrated_intensity", + "hawkes_log_likelihood", + "simulate_hawkes", + "fit_hawkes", + "volume_synchronized_pin", +] + + +def _parameters(mu, alpha, beta): + mu = real_scalar(mu, "mu", minimum=0, strict=True) + alpha = real_scalar(alpha, "alpha", minimum=0) + beta = real_scalar(beta, "beta", minimum=0, strict=True) + if alpha >= beta: + raise ValueError("Stationarity requires alpha < beta.") + return mu, alpha, beta + + +def _events(events, horizon): + events = real_array(events, "events", 1, empty=True) + if np.any(events < 0) or np.any(events > horizon) or np.any(np.diff(events) <= 0): + raise ValueError("Events must be strictly increasing in [0, horizon].") + return events + + +def hawkes_intensity(times, events, mu, alpha, beta): + """Return predictable intensity using only events strictly before each time. + + The model has empty prehistory at zero and kernel alpha*exp(-beta*t). + All query times and event times must be finite and nonnegative. + """ + mu, alpha, beta = _parameters(mu, alpha, beta) + times = real_array(times, "times", 1, empty=True) + events = _events(events, np.inf) + if np.any(times < 0): + raise ValueError("times must be nonnegative.") + return np.array( + [mu + alpha * np.exp(-beta * (t - events[events < t])).sum() for t in times] + ) + + +def hawkes_integrated_intensity(events, horizon, mu, alpha, beta): + """Integrate empty-history intensity over [0, horizon].""" + mu, alpha, beta = _parameters(mu, alpha, beta) + horizon = real_scalar(horizon, "horizon", minimum=0, strict=True) + events = _events(events, horizon) + value = mu * horizon + alpha / beta * (-np.expm1(-beta * (horizon - events))).sum() + if not np.isfinite(value): + raise ValueError("Integrated intensity is outside floating-point range.") + return float(value) + + +def hawkes_log_likelihood(events, horizon, mu, alpha, beta): + """Compute the exact event log likelihood with an O(n) exponential recurrence.""" + mu, alpha, beta = _parameters(mu, alpha, beta) + horizon = real_scalar(horizon, "horizon", minimum=0, strict=True) + events = _events(events, horizon) + excitation = 0.0 + total = 0.0 + previous = 0.0 + for index, event in enumerate(events): + excitation = np.exp(-beta * (event - previous)) * ( + excitation + (1.0 if index else 0.0) + ) + total += np.log(mu + alpha * excitation) + previous = event + return float(total - hawkes_integrated_intensity(events, horizon, mu, alpha, beta)) + + +def simulate_hawkes(horizon, mu, alpha, beta, *, random_state=None, max_events=100000): + """Simulate empty-history events by adaptive thinning with a local RNG. + + Raise RuntimeError at max_events instead of returning a truncated path. + """ + mu, alpha, beta = _parameters(mu, alpha, beta) + horizon = real_scalar(horizon, "horizon", minimum=0, strict=True) + max_events = positive_integer(max_events, "max_events") + rng = np.random.default_rng(random_state) + events = [] + time, excitation = 0.0, 0.0 + while True: + bound = mu + excitation + wait = rng.exponential(1 / bound) + next_time = time + wait + if next_time > horizon: + break + if next_time <= time: + raise RuntimeError("Time increments are below floating-point resolution.") + excitation *= np.exp(-beta * wait) + time = next_time + if rng.random() * bound <= mu + excitation: + if len(events) >= max_events: + raise RuntimeError("max_events reached before simulation completed.") + events.append(time) + excitation += alpha + return np.asarray(events) + + +def fit_hawkes(events, horizon, *, initial=None): + """Fit mu, alpha, beta by constrained likelihood with three fixed starts. + + Empty histories have no positive-baseline MLE and are rejected. Return + optimizer diagnostics; a successful numerical fit is not an assertion + of parameter identification or a globally optimal likelihood. + """ + horizon = real_scalar(horizon, "horizon", minimum=0, strict=True) + events = _events(events, horizon) + if len(events) < 2: + raise ValueError("At least two events are required to fit three parameters.") + scaled = events / horizon + n = len(events) + starts = [(n * (1 - ratio), ratio * n, n) for ratio in (0.1, 0.5, 0.8)] + if initial is not None: + initial = real_array(initial, "initial", 1) + if initial.shape != (3,): + raise ValueError("initial must contain mu, alpha and beta.") + starts = [tuple(x * horizon for x in _parameters(*initial))] + + def objective(x): + baseline, branching, decay = x + return -hawkes_log_likelihood(scaled, 1.0, baseline, branching * decay, decay) + + results = [ + minimize( + objective, + [mu, alpha / beta, beta], + method="L-BFGS-B", + bounds=[(1e-9, None), (0, 1 - 1e-8), (1e-9, None)], + ) + for mu, alpha, beta in starts + ] + valid = [r for r in results if r.success and np.isfinite(r.fun)] + if not valid: + raise RuntimeError("Hawkes likelihood optimization failed.") + best = min(valid, key=lambda r: r.fun) + mu, branching, beta = best.x + parameters = (mu / horizon, branching * beta / horizon, beta / horizon) + return { + "mu": float(parameters[0]), + "alpha": float(parameters[1]), + "beta": float(parameters[2]), + "log_likelihood": hawkes_log_likelihood(events, horizon, *parameters), + "branching_ratio": float(branching), + "converged_starts": len(valid), + "message": str(best.message), + } + + +def volume_synchronized_pin(buy_volume, sell_volume, *, bucket_volume, window): + """Compute rolling VPIN for equal-volume completed, preclassified buckets. + + Return n-window+1 values, aligned to the final bucket of each window. + Partial buckets and implicit trade classification are not accepted. + """ + buy = real_array(buy_volume, "buy_volume", 1) + sell = real_array(sell_volume, "sell_volume", 1) + volume = real_scalar(bucket_volume, "bucket_volume", minimum=0, strict=True) + window = positive_integer(window, "window") + if ( + sell.shape != buy.shape + or np.any(buy < 0) + or np.any(sell < 0) + or not np.allclose((buy + sell) / volume, 1, atol=1e-12, rtol=1e-12) + ): + raise ValueError( + "Buy and sell volumes must be nonnegative completed equal-volume buckets." + ) + if window > len(buy): + raise ValueError("window cannot exceed the number of completed buckets.") + return np.convolve( + np.abs(buy - sell) / volume, np.full(window, 1 / window), mode="valid" + ) diff --git a/src/RiskLabAI/optimization/__init__.py b/src/RiskLabAI/optimization/__init__.py index eab6496..a18fa34 100644 --- a/src/RiskLabAI/optimization/__init__.py +++ b/src/RiskLabAI/optimization/__init__.py @@ -8,6 +8,10 @@ - Custom Hyper-Parameter Tuning """ +from .clearing import eisenberg_noe_clearing +from .constrained import constrained_minimum_variance, robust_mean_variance +from .decision_focused import spo_plus_loss, spo_plus_torch +from .execution import almgren_chriss_execution from .hedging import ( pca_weights, ) @@ -22,6 +26,12 @@ SampleWeightedPipeline, clf_hyper_fit, ) +from .majorization import ( + birkhoff_von_neumann_decomposition, + is_doubly_stochastic, + is_majorized, + majorization_matrix, +) from .nco import ( # cluster_kmeans_base is imported into nco.py, not defined there. # It should be imported from RiskLabAI.cluster.clustering directly @@ -31,6 +41,16 @@ ) __all__ = [ + "constrained_minimum_variance", + "robust_mean_variance", + "spo_plus_loss", + "spo_plus_torch", + "eisenberg_noe_clearing", + "almgren_chriss_execution", + "birkhoff_von_neumann_decomposition", + "is_doubly_stochastic", + "is_majorized", + "majorization_matrix", # hrp.py "cluster_variance", "quasi_diagonal", diff --git a/src/RiskLabAI/optimization/clearing.py b/src/RiskLabAI/optimization/clearing.py new file mode 100644 index 0000000..206e16c --- /dev/null +++ b/src/RiskLabAI/optimization/clearing.py @@ -0,0 +1,82 @@ +"""Proportional clearing for a network with an outside creditor.""" + +import numpy as np +from scipy.optimize import linprog + + +def eisenberg_noe_clearing( + external_assets: np.ndarray, + nominal_liabilities: np.ndarray, + relative_liabilities: np.ndarray, + *, + atol: float = 1e-8, +) -> np.ndarray: + """Return the greatest Eisenberg--Noe clearing payment vector. + + ``relative_liabilities[i, j]`` is the fraction of debtor i's total + liability owed to creditor j. Nonnegative rows sum to at most one; + missing mass is owed outside the network. All arrays must be finite, + with nonnegative assets and liabilities and at least one firm. + + Payments solve ``p = minimum(nominal_liabilities, + external_assets + relative_liabilities.T @ p)``. Maximizing total + feasible payments with a linear program selects the greatest solution, + including when the fixed point is not unique. The model has proportional + payments, no bankruptcy costs and no fire sales. + + ``atol`` is the absolute verification tolerance after dividing monetary + quantities by the largest nominal liability. It must lie in (0, 1). + Invalid inputs raise ValueError; failed or unchecked optimization raises + RuntimeError. Inputs are not modified. + """ + arrays = [] + for value in (external_assets, nominal_liabilities, relative_liabilities): + raw = np.asarray(value) + if raw.dtype.kind not in "iuf": + raise ValueError("Inputs must contain real numeric values.") + array = np.asarray(raw, dtype=float) + if not np.all(np.isfinite(array)) or np.any(array < 0): + raise ValueError("Inputs must be finite and nonnegative.") + arrays.append(array) + assets, liabilities, relative = arrays + if assets.ndim != 1 or assets.size == 0 or liabilities.shape != assets.shape: + raise ValueError("Assets and liabilities must be equal nonempty vectors.") + if relative.shape != (assets.size, assets.size): + raise ValueError( + "Relative liabilities must be a square matrix matching assets." + ) + if np.any(relative.sum(axis=1) > 1): + raise ValueError("Relative-liability rows must sum to at most one.") + tolerance = np.asarray(atol) + if tolerance.ndim != 0 or tolerance.dtype.kind not in "iuf": + raise ValueError("atol must be finite and lie in (0, 1).") + atol = float(tolerance) + if not np.isfinite(atol) or not 0 < atol < 1: + raise ValueError("atol must be finite and lie in (0, 1).") + scale = float(liabilities.max()) + if scale == 0: + return np.zeros_like(liabilities) + upper = liabilities / scale + resources = np.minimum(assets, liabilities) / scale + result = linprog( + -np.ones(assets.size), + A_ub=np.eye(assets.size) - relative.T, + b_ub=resources, + bounds=np.column_stack((np.zeros(assets.size), upper)), + method="highs", + ) + if not result.success or result.x is None: + raise RuntimeError(f"Clearing optimization failed: {result.message}") + payments = np.asarray(result.x, dtype=float) + if ( + payments.shape != liabilities.shape + or not np.all(np.isfinite(payments)) + or np.any(payments < -atol) + or np.any(payments > upper + atol) + ): + raise RuntimeError("Clearing optimization returned invalid payment bounds.") + payments = np.clip(payments, 0, upper) + fixed_point = np.minimum(upper, resources + relative.T @ payments) + if np.max(np.abs(payments - fixed_point)) > atol: + raise RuntimeError("Clearing optimization failed the fixed-point check.") + return payments * scale diff --git a/src/RiskLabAI/optimization/constrained.py b/src/RiskLabAI/optimization/constrained.py new file mode 100644 index 0000000..c9a8354 --- /dev/null +++ b/src/RiskLabAI/optimization/constrained.py @@ -0,0 +1,156 @@ +"""Checked convex portfolio problems using optional CVXPY solvers.""" + +import numpy as np + +from RiskLabAI.utils._validation import real_array, real_scalar + +__all__ = ["constrained_minimum_variance", "robust_mean_variance"] + + +def _inputs(covariance, expected_returns, caps, return_floor): + covariance = real_array(covariance, "covariance", 2) + n = len(covariance) + magnitude = max(float(np.max(np.abs(covariance))), np.finfo(float).tiny) + if covariance.shape != (n, n) or not np.allclose( + covariance / magnitude, covariance.T / magnitude, atol=1e-12, rtol=1e-12 + ): + raise ValueError("covariance must be symmetric and square.") + if np.linalg.eigvalsh(covariance / magnitude).min() < -1e-12 * np.linalg.norm( + covariance / magnitude, 2 + ): + raise ValueError("covariance must be positive semidefinite.") + mean = ( + np.zeros(n) + if expected_returns is None + else real_array(expected_returns, "expected_returns", 1) + ) + cap = np.ones(n) if caps is None else real_array(caps, "caps", 1) + if mean.shape != (n,) or cap.shape != (n,) or np.any(cap < 0) or cap.sum() < 1: + raise ValueError( + "Means/caps must match covariance and caps must allow full investment." + ) + floor = None if return_floor is None else real_scalar(return_floor, "return_floor") + if floor is not None and expected_returns is None: + raise ValueError("A return floor requires expected_returns.") + return covariance, mean, cap, floor + + +def _solve( + covariance, + mean, + cap, + floor, + *, + loading=None, + radius=0.0, + risk_aversion=1.0, + minimum_variance=False, +): + try: + import cvxpy as cp + except ImportError as error: + raise ImportError("This method requires the optional cvxpy package.") from error + w = cp.Variable(len(mean)) + constraints = [cp.sum(w) == 1, w >= 0, w <= cap] + if floor is not None: + constraints.append(mean @ w >= floor) + variance = cp.quad_form(w, cp.psd_wrap(covariance)) + if minimum_variance: + objective = variance + scale = max(np.max(np.abs(covariance)), np.finfo(float).tiny) + else: + penalty = 0 if radius == 0 else radius * cp.norm(loading.T @ w, 2) + objective = risk_aversion * variance / 2 - mean @ w + penalty + scale = max( + np.max(np.abs(mean)), + risk_aversion * np.max(np.abs(covariance)), + radius * np.linalg.norm(loading), + np.finfo(float).tiny, + ) + problem = cp.Problem(cp.Minimize(objective / scale), constraints) + try: + problem.solve( + solver="CLARABEL", tol_gap_abs=1e-10, tol_feas=1e-10, tol_gap_rel=1e-10 + ) + except cp.error.SolverError as error: + raise RuntimeError("Portfolio solver failed.") from error + if problem.status in (cp.INFEASIBLE, cp.INFEASIBLE_INACCURATE): + raise ValueError("Portfolio constraints are infeasible.") + if problem.status != cp.OPTIMAL or w.value is None: + raise RuntimeError(f"Portfolio optimization is not verified: {problem.status}") + weights = np.asarray(w.value) + if ( + not np.all(np.isfinite(weights)) + or abs(weights.sum() - 1) > 1e-7 + or np.any(weights < -1e-7) + or np.any(weights > cap + 1e-7) + or (floor is not None and mean @ weights < floor - 1e-7 * max(1, abs(floor))) + ): + raise RuntimeError("Portfolio solver returned an infeasible result.") + value = float(weights @ covariance @ weights) + expected = float(mean @ weights) + worst_mean = ( + expected + if loading is None + else expected - radius * float(np.linalg.norm(loading.T @ weights)) + ) + return { + "weights": weights, + "variance": value, + "expected_return": expected, + "worst_case_expected_return": worst_mean, + "objective": ( + value if minimum_variance else worst_mean - risk_aversion * value / 2 + ), + "solver_status": problem.status, + } + + +def constrained_minimum_variance( + covariance, *, expected_returns=None, return_floor=None, caps=None +): + """Minimize variance over fully invested nonnegative capped weights. + + An optional return floor uses the supplied expected returns. Covariance + must be PSD within numerical eigensolver tolerance; it is not repaired. + Requires optional CVXPY with Clarabel. Returns weights and diagnostics. + """ + covariance, mean, cap, floor = _inputs( + covariance, expected_returns, caps, return_floor + ) + return _solve(covariance, mean, cap, floor, minimum_variance=True) + + +def robust_mean_variance( + expected_returns, + covariance, + uncertainty_loading, + *, + radius, + risk_aversion, + caps=None, + return_floor=None, +): + """Maximize the worst mean minus half risk_aversion times variance. + + Means range over mu + L*u, ||u||_2 <= radius. Covariance is fixed. + The optional return floor constrains nominal, not worst-case, return. + All uncertainty inputs are supplied, not inferred or calibrated here. + """ + covariance, mean, cap, floor = _inputs( + covariance, expected_returns, caps, return_floor + ) + loading = real_array(uncertainty_loading, "uncertainty_loading", 2) + if loading.shape[0] != len(mean): + raise ValueError("uncertainty_loading must have one row per asset.") + radius = real_scalar(radius, "radius", minimum=0) + risk_aversion = real_scalar(risk_aversion, "risk_aversion", minimum=0) + return _solve( + covariance, + mean, + cap, + floor, + loading=loading, + radius=radius, + risk_aversion=risk_aversion, + ) diff --git a/src/RiskLabAI/optimization/decision_focused.py b/src/RiskLabAI/optimization/decision_focused.py new file mode 100644 index 0000000..373e15a --- /dev/null +++ b/src/RiskLabAI/optimization/decision_focused.py @@ -0,0 +1,99 @@ +"""SPO+ loss over the convex hull of explicitly supplied feasible vertices.""" + +import numpy as np + +from RiskLabAI.utils._validation import real_array + +__all__ = ["spo_plus_loss", "spo_plus_torch"] + + +def spo_plus_loss(predicted_cost, true_cost, vertices): + """Return SPO+ loss and a subgradient for linear cost minimization. + + The fixed feasible region is the convex hull of rows of vertices. + Complete enumeration is an exact optimization oracle for this region. + Ties choose the first supplied vertex. This does not represent a general + nonlinear optimization oracle or enumerate vertices automatically. + """ + predicted = real_array(predicted_cost, "predicted_cost", 1) + truth = real_array(true_cost, "true_cost", 1) + vertices = real_array(vertices, "vertices", 2) + if predicted.shape != truth.shape or vertices.shape[1] != len(truth): + raise ValueError("Costs and vertices must have the same decision dimension.") + true_objectives = vertices @ truth + transformed_objectives = vertices @ (2 * predicted - truth) + if ( + not np.isfinite(true_objectives).all() + or not np.isfinite(transformed_objectives).all() + ): + raise ValueError("Oracle objectives exceed floating-point range.") + true_index = int(np.argmin(true_objectives)) + transformed_index = int(np.argmin(transformed_objectives)) + true_decision, transformed = vertices[true_index], vertices[transformed_index] + loss = float( + (truth - 2 * predicted) @ transformed + (2 * predicted - truth) @ true_decision + ) + if ( + not np.isfinite(loss) + or not np.isfinite(2 * (true_decision - transformed)).all() + ): + raise ValueError("Loss/subgradient exceeds floating-point range.") + return { + "loss": loss, + "subgradient": 2 * (true_decision - transformed), + "true_vertex": true_index, + "transformed_vertex": transformed_index, + } + + +def spo_plus_torch(predicted_cost, true_cost, vertices): + """Return differentiable SPO+ losses for one vector or a batch of costs. + + All inputs are finite floating tensors on the same device, with matching + dtype. true_cost and vertices are constants for differentiation. The + gradient with respect to predicted_cost is a valid selected subgradient, + using first-index tie breaking. No batch reduction is performed. + """ + import torch + + if not all( + isinstance(x, torch.Tensor) + and x.is_floating_point() + and torch.isfinite(x).all() + for x in (predicted_cost, true_cost, vertices) + ): + raise ValueError("All inputs must be finite floating tensors.") + if ( + predicted_cost.ndim not in (1, 2) + or predicted_cost.shape != true_cost.shape + or vertices.ndim != 2 + or vertices.shape[0] == 0 + or vertices.shape[1] != predicted_cost.shape[-1] + or predicted_cost.numel() == 0 + ): + raise ValueError("Costs and vertex shapes are incompatible.") + if any( + x.device != predicted_cost.device or x.dtype != predicted_cost.dtype + for x in (true_cost, vertices) + ): + raise ValueError("All tensors must use the same device and dtype.") + truth, choices = true_cost.detach(), vertices.detach() + true_objectives = truth @ choices.T + transformed_objectives = (2 * predicted_cost.detach() - truth) @ choices.T + if ( + not torch.isfinite(true_objectives).all() + or not torch.isfinite(transformed_objectives).all() + ): + raise ValueError("Oracle objectives exceed floating-point range.") + true_decision = choices[torch.argmin(true_objectives, dim=-1)] + transformed = choices[torch.argmin(transformed_objectives, dim=-1)] + loss = ( + (truth - 2 * predicted_cost) * transformed + + (2 * predicted_cost - truth) * true_decision + ).sum(dim=-1) + if ( + not torch.isfinite(loss).all() + or not torch.isfinite(2 * (true_decision - transformed)).all() + ): + raise ValueError("Loss/subgradient exceeds floating-point range.") + return loss diff --git a/src/RiskLabAI/optimization/execution.py b/src/RiskLabAI/optimization/execution.py new file mode 100644 index 0000000..c910ee1 --- /dev/null +++ b/src/RiskLabAI/optimization/execution.py @@ -0,0 +1,115 @@ +"""Discrete single-asset execution with constant linear market impact.""" + +import numpy as np + + +def almgren_chriss_execution( + inventory: float, + horizon: float, + intervals: int, + *, + volatility: float, + temporary_impact: float, + permanent_impact: float = 0.0, + risk_aversion: float = 0.0, +) -> dict[str, np.ndarray | float]: + """Minimize expected execution cost plus risk_aversion times variance. + + This is the discrete Almgren--Chriss single-asset sell model with zero + drift and spread, constant volatility, and linear temporary/permanent + impact. ``inventory`` is a nonnegative quantity, ``horizon`` is positive, + and ``intervals`` is a positive integer. All other inputs are finite and + nonnegative, with strictly positive temporary impact. + + With tau = horizon / intervals, the effective temporary coefficient + eta = temporary_impact - permanent_impact * tau / 2 must be positive. + This is the strict-convexity condition of the discrete model. + Volatility is price per square-root time; temporary impact is price + times time per quantity; permanent impact is price per quantity. + Risk aversion has units of inverse monetary cost. + + Return ``times`` and ``inventory`` arrays of length intervals + 1, + ``trades`` of length intervals, and scalar ``expected_cost`` and + ``cost_variance``. For post-trade inventory x[k] and trades n[k], cost + is permanent_impact * inventory**2 / 2 + eta / tau * sum(n[k]**2), + and variance is volatility**2 * tau * sum(x[k]**2), k = 1,...,N. + Thus one-interval liquidation has zero modeled variance. Zero risk + aversion or volatility gives constant-size trades. Numerical underflow + may round very small remaining inventories to zero. + + Invalid inputs or nonfinite derived costs raise ValueError. + """ + values = {} + for name, value in ( + ("inventory", inventory), + ("horizon", horizon), + ("volatility", volatility), + ("temporary_impact", temporary_impact), + ("permanent_impact", permanent_impact), + ("risk_aversion", risk_aversion), + ): + raw = np.asarray(value) + if raw.ndim != 0 or raw.dtype.kind not in "iuf": + raise ValueError(f"{name} must be a real numeric scalar.") + number = float(raw) + if not np.isfinite(number) or number < 0: + raise ValueError(f"{name} must be finite and nonnegative.") + values[name] = number + if ( + isinstance(intervals, (bool, np.bool_)) + or not isinstance(intervals, (int, np.integer)) + or intervals <= 0 + ): + raise ValueError("intervals must be a positive integer.") + quantity = values["inventory"] + duration = values["horizon"] + sigma = values["volatility"] + eta = values["temporary_impact"] + gamma = values["permanent_impact"] + risk = values["risk_aversion"] + if duration == 0 or eta == 0: + raise ValueError("horizon and temporary_impact must be positive.") + tau = duration / intervals + effective_eta = eta - 0.5 * gamma * tau + if tau == 0 or not np.isfinite(effective_eta) or effective_eta <= 0: + raise ValueError( + "The time step and effective temporary impact must be positive." + ) + index = np.arange(intervals + 1, dtype=float) + if quantity == 0 or risk == 0 or sigma == 0: + remaining = quantity * (1 - index / intervals) + else: + log_z = ( + np.log(tau) + + np.log(sigma) + + 0.5 * (np.log(risk) - np.log(effective_eta)) + - np.log(2.0) + ) + step = ( + 2 * np.arcsinh(np.exp(log_z)) if log_z < 350 else 2 * (log_z + np.log(2.0)) + ) + if step == 0: + remaining = quantity * (1 - index / intervals) + else: + remaining = ( + quantity + * np.exp(-step * index) + * (-np.expm1(-2 * step * (intervals - index))) + / (-np.expm1(-2 * step * intervals)) + ) + remaining[0], remaining[-1] = quantity, 0.0 + trades = -np.diff(remaining) + with np.errstate(over="ignore", invalid="ignore", divide="ignore"): + expected_cost = 0.5 * gamma * np.float64( + quantity + ) ** 2 + effective_eta / tau * np.dot(trades, trades) + variance = np.float64(sigma) ** 2 * tau * np.dot(remaining[1:], remaining[1:]) + if not np.isfinite(expected_cost) or not np.isfinite(variance): + raise ValueError("Execution costs exceed the supported floating-point range.") + return { + "times": np.linspace(0, duration, intervals + 1), + "inventory": remaining, + "trades": trades, + "expected_cost": float(expected_cost), + "cost_variance": float(variance), + } diff --git a/src/RiskLabAI/optimization/majorization.py b/src/RiskLabAI/optimization/majorization.py new file mode 100644 index 0000000..c724664 --- /dev/null +++ b/src/RiskLabAI/optimization/majorization.py @@ -0,0 +1,214 @@ +"""Finite vector majorization and checked doubly stochastic representations.""" + +import numpy as np +from scipy.optimize import linear_sum_assignment, linprog +from scipy.sparse import coo_matrix + + +def _absolute_tolerance(atol: float) -> float: + if ( + isinstance(atol, (bool, np.bool_)) + or not isinstance(atol, (int, float, np.integer, np.floating)) + or not np.isfinite(atol) + or not 0 < atol < 1 + ): + raise ValueError( + "atol must be a finite real number strictly between zero and one" + ) + return float(atol) + + +def _real_array(values: np.ndarray, name: str) -> np.ndarray: + array = np.asarray(values) + if array.dtype.kind not in "biuf" or not np.all(np.isfinite(array)): + raise ValueError(f"{name} must contain finite real numbers") + return array.astype(float, copy=True) + + +def _vectors(target: np.ndarray, source: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + left = _real_array(target, "target") + right = _real_array(source, "source") + if left.ndim != 1 or right.shape != left.shape or left.size == 0: + raise ValueError("target and source must be nonempty vectors of equal length") + return left, right + + +def is_doubly_stochastic(matrix: np.ndarray, *, atol: float = 1e-10) -> bool: + """Check nonnegativity and unit row and column sums within absolute tolerance. + + Return False for a non-square, empty, nonfinite or non-real matrix. + Entries as small as ``-atol`` are accepted as numerical roundoff. + ``atol`` must be finite and strictly between zero and one. No matrix + normalization or input mutation is performed. + """ + tolerance = _absolute_tolerance(atol) + try: + values = _real_array(matrix, "matrix") + except (ValueError, TypeError): + return False + if values.ndim != 2 or values.shape[0] == 0 or values.shape[0] != values.shape[1]: + return False + return bool( + np.all(values >= -tolerance) + and np.all(np.abs(values.sum(axis=0) - 1.0) <= tolerance) + and np.all(np.abs(values.sum(axis=1) - 1.0) <= tolerance) + ) + + +def is_majorized( + target: np.ndarray, source: np.ndarray, *, atol: float = 1e-10 +) -> bool: + """Return whether ``target`` is majorized by ``source``. + + Sort both finite real vectors in descending order. Every proper partial + sum of target must be no greater than the corresponding source sum, and + their totals must agree. All comparisons use the absolute tolerance + ``atol``; negative entries are allowed. Unequal dimensions and nonfinite + inputs raise ValueError. This is ordinary equal-total vector majorization. + + Examples + -------- + >>> is_majorized([0.5, 0.5], [1.0, 0.0]) + True + >>> is_majorized([1.0, 0.0], [0.5, 0.5]) + False + """ + tolerance = _absolute_tolerance(atol) + left, right = _vectors(target, source) + left_sums = np.cumsum(np.sort(left)[::-1], dtype=np.longdouble) + right_sums = np.cumsum(np.sort(right)[::-1], dtype=np.longdouble) + if not np.all(np.isfinite(left_sums)) or not np.all(np.isfinite(right_sums)): + raise ValueError("partial sums exceed the supported numerical range") + return bool( + abs(left_sums[-1] - right_sums[-1]) <= tolerance + and np.all(left_sums[:-1] <= right_sums[:-1] + tolerance) + ) + + +def majorization_matrix( + target: np.ndarray, source: np.ndarray, *, atol: float = 1e-10 +) -> np.ndarray: + """Construct a doubly stochastic witness ``D @ source == target``. + + Solve the finite linear feasibility problem with SciPy's HiGHS backend. + Validate row sums, column sums, nonnegativity and the mapping residual + before returning the matrix. All checks use absolute tolerance ``atol``. + The witness need not be unique and its entries may vary across solvers. + Raise ValueError when the inputs fail the majorization check and + RuntimeError when a numerically verified witness cannot be obtained. + + Examples + -------- + >>> source = np.array([1.0, 0.0]) + >>> target = np.array([0.5, 0.5]) + >>> np.allclose(majorization_matrix(target, source) @ source, target) + True + """ + tolerance = _absolute_tolerance(atol) + left, right = _vectors(target, source) + if not is_majorized(left, right, atol=tolerance): + raise ValueError("target is not majorized by source within atol") + size = left.size + if np.all(np.abs(left - right) <= tolerance): + return np.eye(size) + scale = max(1.0, float(np.max(np.abs(right))), float(np.max(np.abs(left)))) + columns = np.arange(size * size) + row_indices = np.repeat(np.arange(size), size) + column_indices = np.tile(np.arange(size), size) + constraints = coo_matrix( + ( + np.concatenate( + [ + np.ones(size * size), + np.ones(size * size), + np.tile(right / scale, size), + ] + ), + ( + np.concatenate( + [row_indices, size + column_indices, 2 * size + row_indices] + ), + np.tile(columns, 3), + ), + ), + shape=(3 * size, size * size), + ).tocsr() + result = linprog( + np.zeros(size * size), + A_eq=constraints, + b_eq=np.concatenate([np.ones(2 * size), left / scale]), + bounds=(0.0, 1.0), + method="highs", + options={ + "primal_feasibility_tolerance": 1e-10, + "dual_feasibility_tolerance": 1e-10, + }, + ) + if not result.success or result.x is None: + raise RuntimeError(f"No verified majorization witness: {result.message}") + matrix = result.x.reshape(size, size) + if not is_doubly_stochastic(matrix, atol=tolerance) or not np.all( + np.abs(matrix @ right - left) <= tolerance + ): + raise RuntimeError( + "Majorization witness exceeds the requested residual tolerance" + ) + return matrix + + +def birkhoff_von_neumann_decomposition( + matrix: np.ndarray, *, atol: float = 1e-10 +) -> tuple[np.ndarray, np.ndarray]: + """Represent a doubly stochastic matrix as weighted permutation matrices. + + Return ``(weights, permutations)``. Component k selects column + ``permutations[k, i]`` in row i, so its matrix is + ``np.eye(n)[permutations[k]]``. The weighted sum reconstructs the input + within absolute tolerance ``atol``. Weights are positive and sum to one + within that tolerance. The decomposition is generally nonunique. + + SciPy finds a matching on the positive residual support at each step. + Inputs are neither normalized nor mutated. Tiny negative entries within + tolerance are treated as roundoff, and the final result is checked + against the original input. Raise ValueError for an invalid input and + RuntimeError if a checked decomposition cannot be obtained numerically. + + Examples + -------- + >>> matrix = np.array([[0.25, 0.75], [0.75, 0.25]]) + >>> weights, permutations = birkhoff_von_neumann_decomposition(matrix) + >>> np.allclose(sum(w * np.eye(2)[p] for w, p in zip(weights, permutations)), matrix) + True + """ + tolerance = _absolute_tolerance(atol) + if not is_doubly_stochastic(matrix, atol=tolerance): + raise ValueError("matrix must be doubly stochastic within atol") + original = _real_array(matrix, "matrix") + residual = np.maximum(original, 0.0) + reconstruction = np.zeros_like(original) + weights = [] + permutations = [] + mass = 0.0 + for _ in range(original.size): + if abs(mass - 1.0) <= tolerance and np.max(np.abs(residual)) <= tolerance: + break + try: + rows, columns = linear_sum_assignment(np.where(residual > 0.0, 0.0, np.inf)) + except ValueError as error: + raise RuntimeError( + "Residual support has no complete positive matching" + ) from error + weight = float(np.min(residual[rows, columns])) + if weight <= 0.0: + raise RuntimeError("Decomposition did not make positive progress") + weights.append(weight) + permutations.append(columns) + residual[rows, columns] -= weight + reconstruction[rows, columns] += weight + mass += weight + if ( + abs(mass - 1.0) > tolerance + or np.max(np.abs(reconstruction - original)) > tolerance + ): + raise RuntimeError("Decomposition exceeds the requested residual tolerance") + return np.asarray(weights), np.asarray(permutations, dtype=np.intp) diff --git a/src/RiskLabAI/pde/__init__.py b/src/RiskLabAI/pde/__init__.py index 2f5feb6..a7cfcd9 100644 --- a/src/RiskLabAI/pde/__init__.py +++ b/src/RiskLabAI/pde/__init__.py @@ -46,8 +46,30 @@ FBSNNSolver, initialize_weights, ) +from .neural_sde import ( + normalized_call_grid_constraints, + normalized_call_grid_witness, + affine_price_state_interval, + IntervalNeuralSDE, + PolytopeNeuralSDE, + fit_interval_neural_sde, + simulate_interval_neural_sde, + fit_polytope_neural_sde, + simulate_polytope_neural_sde, + hjm_drift_residual, +) __all__ = [ + "normalized_call_grid_constraints", + "normalized_call_grid_witness", + "affine_price_state_interval", + "IntervalNeuralSDE", + "PolytopeNeuralSDE", + "fit_interval_neural_sde", + "simulate_interval_neural_sde", + "fit_polytope_neural_sde", + "simulate_polytope_neural_sde", + "hjm_drift_residual", # Equations "Equation", "PricingDefaultRisk", diff --git a/src/RiskLabAI/pde/neural_sde.py b/src/RiskLabAI/pde/neural_sde.py new file mode 100644 index 0000000..1d42386 --- /dev/null +++ b/src/RiskLabAI/pde/neural_sde.py @@ -0,0 +1,616 @@ +"""Finite-grid benchmarks for constrained neural option dynamics. + +Uses the affine price-state representation and hard boundary restrictions of +Cohen, Reisinger and Wang, with a corrected diffusion transformation in fixed +state coordinates and linear boundary vanishing. This transformation differs +from the paper's literal one-sided, square-root shrinking construction. +These factor models are not a general +surface calibration engine. Static grid inequalities do not certify dynamic +HJM consistency, a risk-neutral pricing measure, or off-grid interpolation. +Requires optional PyTorch. No reference implementation source is incorporated. +""" + +import math + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +from RiskLabAI.utils._validation import positive_integer, real_array, real_scalar + +__all__ = [ + "normalized_call_grid_constraints", + "normalized_call_grid_witness", + "affine_price_state_interval", + "IntervalNeuralSDE", + "fit_interval_neural_sde", + "simulate_interval_neural_sde", + "PolytopeNeuralSDE", + "fit_polytope_neural_sde", + "simulate_polytope_neural_sde", + "hjm_drift_residual", +] + + +def normalized_call_grid_constraints(strikes, maturities): + """Build weak linear call-price inequalities A*c >= b on a rectangular grid. + + Strikes are positive normalized strikes K/forward; prices are calls divided + by discounted forward. Flatten prices in maturity-major order. Conditions + impose intrinsic/upper bounds, vertical-spread slopes in [-1,0], strike + convexity (including the known strike-zero call price 1), and monotonicity + in maturity. These weak finite-grid checks alone do not establish an + attainable full probability distribution in degenerate tail-equality + cases, or arbitrage freedom outside the supplied grid. + """ + strikes = real_array(strikes, "strikes", 1) + maturities = real_array(maturities, "maturities", 1) + if ( + len(strikes) < 2 + or np.any(strikes <= 0) + or np.any(maturities <= 0) + or np.any(np.diff(strikes) <= 0) + or np.any(np.diff(maturities) <= 0) + ): + raise ValueError( + "Require increasing positive strikes (at least two) and maturities." + ) + n, m = len(strikes), len(maturities) + rows, bounds = [], [] + identity = np.eye(n * m) + for t in range(m): + for k in range(n): + e = identity[t * n + k] + rows.extend([e, -e]) + bounds.extend([max(1 - strikes[k], 0), -1]) + slopes, constants = [], [] + slopes.append(identity[t * n] / strikes[0]) + constants.append(-1 / strikes[0]) + for k in range(n - 1): + slopes.append( + (identity[t * n + k + 1] - identity[t * n + k]) + / (strikes[k + 1] - strikes[k]) + ) + constants.append(0) + for slope, constant in zip(slopes, constants): + rows.extend([slope, -slope]) + bounds.extend([-1 - constant, constant]) + for k in range(len(slopes) - 1): + rows.append(slopes[k + 1] - slopes[k]) + bounds.append(constants[k] - constants[k + 1]) + if t: + for k in range(n): + rows.append(identity[t * n + k] - identity[(t - 1) * n + k]) + bounds.append(0) + return np.array(rows), np.array(bounds) + + +def normalized_call_grid_witness(strikes, maturities, prices): + """Construct finite probability marginals reproducing a normalized call grid. + + Prices have shape (maturities, strikes). Piecewise linear call curves join + (0,1), the supplied quotes, and a common zero-price tail strike. Slope + differences give nonnegative masses with mean one. Calendar ordering at + their common knots implies ordering of every interpolated call value. + Reject a positive flat tail, which weak grid inequalities alone admit. + Tiny negative masses from floating-point arithmetic are rounded to zero; + all mass, mean and repricing residuals are then checked at 1e-10 tolerance. + This is a static finite-grid witness, not a calibrated dynamic measure, + a unique interpolation rule, or evidence about unquoted market prices. + """ + matrix, bounds = normalized_call_grid_constraints(strikes, maturities) + strikes = real_array(strikes, "strikes", 1) + maturities = real_array(maturities, "maturities", 1) + prices = real_array(prices, "prices", 2) + if prices.shape != (len(maturities), len(strikes)): + raise ValueError("Prices must have shape (maturities, strikes).") + if np.min(matrix @ prices.ravel() - bounds) < -1e-12: + raise ValueError("Prices violate normalized call-grid inequalities.") + final_slope = (prices[:, -1] - prices[:, -2]) / (strikes[-1] - strikes[-2]) + positive_tail = prices[:, -1] > 0 + if np.any(positive_tail & (final_slope >= 0)): + raise ValueError("A positive flat tail has no attainable call-price witness.") + tail_length = np.zeros(len(maturities)) + tail_length[positive_tail] = prices[positive_tail, -1] / -final_slope[positive_tail] + tail = strikes[-1] + max(1.0, 2 * float(tail_length.max())) + if not np.isfinite(tail) or tail <= strikes[-1]: + raise ValueError("Tail support is not numerically representable.") + support = np.r_[0, strikes, tail] + values = np.column_stack( + [np.ones(len(maturities)), prices, np.zeros(len(maturities))] + ) + slopes = np.diff(values, axis=1) / np.diff(support) + probabilities = np.column_stack( + [1 + slopes[:, 0], np.diff(slopes, axis=1), -slopes[:, -1]] + ) + if np.min(probabilities) < -1e-12: + raise ValueError("Call slopes do not yield nonnegative probability masses.") + probabilities = np.maximum(probabilities, 0) + probabilities /= probabilities.sum(axis=1, keepdims=True) + reconstructed = probabilities @ np.maximum(support[:, None] - strikes, 0) + mean_error = np.max(np.abs(probabilities @ support - 1)) + price_error = np.max(np.abs(reconstructed - prices)) + if ( + not np.isfinite([mean_error, price_error]).all() + or max(mean_error, price_error) > 1e-10 + ): + raise ValueError("Probability witness fails mean or repricing validation.") + return { + "support": support, + "probabilities": probabilities, + "maximum_mean_error": float(mean_error), + "maximum_price_error": float(price_error), + } + + +def affine_price_state_interval(offset, loading, matrix, bounds): + """Solve A*(offset+loading*x) >= b exactly for a bounded scalar interval. + + The caller supplies the price basis and complete desired constraints. + Reject empty, singleton and unbounded state spaces; never repair prices. + Bounds are linear-algebra results, not an inferred pricing model. + """ + offset, loading = real_array(offset, "offset", 1), real_array(loading, "loading", 1) + matrix, bounds = real_array(matrix, "matrix", 2), real_array(bounds, "bounds", 1) + if offset.shape != loading.shape or matrix.shape != (len(bounds), len(offset)): + raise ValueError("Price basis and inequality shapes must agree.") + slopes = matrix @ loading + right = bounds - matrix @ offset + if np.any((slopes == 0) & (right > 0)): + raise ValueError("Price constraints are infeasible for this basis.") + lower = max(right[slopes > 0] / slopes[slopes > 0], default=-np.inf) + upper = min(right[slopes < 0] / slopes[slopes < 0], default=np.inf) + if not np.isfinite([lower, upper]).all() or not lower < upper: + raise ValueError( + "The affine price state must have a bounded nonempty interior." + ) + return float(lower), float(upper) + + +class IntervalNeuralSDE(nn.Module): + """Hard-constrained scalar-factor neural SDE on a supplied interval. + + Drift corrections follow the paper's inward correction, with numerator + (-normal*raw_drift-distance_tolerance)_+. Diffusion amplitude shrinks by + distance/(diffusion_scale+distance) at the nearest boundary. This is the + scalar version of the corrected fixed-coordinate polytope transformation, + with Lipschitz linear vanishing instead of the paper's square-root scale. + At either boundary diffusion is exactly zero and drift points inward. + General multi-factor polytope geometry and HJM calibration are excluded. + """ + + def __init__( + self, lower, upper, *, drift_bound, diffusion_scale, hidden_size=12, seed=0 + ): + super().__init__() + self.lower = real_scalar(lower, "lower") + self.upper = real_scalar(upper, "upper") + self.drift_bound = real_scalar( + drift_bound, "drift_bound", minimum=0, strict=True + ) + self.diffusion_scale = real_scalar( + diffusion_scale, "diffusion_scale", minimum=0, strict=True + ) + hidden_size = positive_integer(hidden_size, "hidden_size") + if self.lower >= self.upper or not np.isfinite(self.upper - self.lower): + raise ValueError("Interval width must be finite and positive.") + with torch.random.fork_rng(devices=[]): + torch.manual_seed(seed) + self.network = nn.Sequential( + nn.Linear(1, hidden_size), nn.Tanh(), nn.Linear(hidden_size, 2) + ).double() + + def forward(self, state): + """Return drift and diffusion for a finite vector of admissible states.""" + parameter = next(self.parameters()) + if ( + not isinstance(state, torch.Tensor) + or state.ndim != 1 + or state.numel() == 0 + or state.dtype != parameter.dtype + or state.device != parameter.device + or not torch.isfinite(state).all() + or torch.any((state < self.lower) | (state > self.upper)) + ): + raise ValueError( + "States must be a finite admissible vector matching model dtype/device." + ) + width = self.upper - self.lower + center = self.lower + width / 2 + raw = self.network(((state - center) / width).unsqueeze(-1)) + raw_drift = self.drift_bound * torch.tanh(raw[:, 0]) + raw_diffusion = F.softplus(raw[:, 1]) + rho = width / 4 + left, right = state - self.lower, self.upper - state + left_correction = F.relu(-raw_drift - self.drift_bound * left / rho) + right_correction = F.relu(raw_drift - self.drift_bound * right / rho) + drift = raw_drift + left_correction - right_correction + distance = torch.minimum(left, right) + shrinking = distance / (self.diffusion_scale + distance) + diffusion = raw_diffusion * shrinking + return drift, diffusion + + def negative_log_likelihood(self, states, next_states, dt): + """Return mean Gaussian Euler transition NLL for strictly interior starts. + + This approximate likelihood is not the exact constrained transition + density. Boundary starts have singular diffusion and are rejected; + adding variance jitter would violate the hard boundary contract. + """ + dt = real_scalar(dt, "dt", minimum=0, strict=True) + drift, diffusion = self(states) + if ( + not isinstance(next_states, torch.Tensor) + or next_states.shape != states.shape + or next_states.dtype != states.dtype + or next_states.device != states.device + or not torch.isfinite(next_states).all() + or torch.any((next_states < self.lower) | (next_states > self.upper)) + or torch.any(diffusion <= 0) + ): + raise ValueError( + "Likelihood requires interior starts and matching admissible next states." + ) + variance = diffusion.square() * dt + loss = ( + 0.5 + * ( + math.log(2 * math.pi) + + torch.log(variance) + + (next_states - states - drift * dt).square() / variance + ).mean() + ) + if not torch.isfinite(loss): + raise ValueError("Transition likelihood is nonfinite.") + return loss + + +def fit_interval_neural_sde( + model, states, next_states, *, dt, iterations, learning_rate=0.001 +): + """Fit Euler likelihood on supplied training transitions only.""" + iterations = positive_integer(iterations, "iterations") + learning_rate = real_scalar(learning_rate, "learning_rate", minimum=0, strict=True) + optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) + history = [] + for _ in range(iterations): + optimizer.zero_grad() + loss = model.negative_log_likelihood(states, next_states, dt) + loss.backward() + if any( + p.grad is not None and not torch.isfinite(p.grad).all() + for p in model.parameters() + ): + raise RuntimeError("Nonfinite neural-SDE gradients.") + optimizer.step() + if any(not torch.isfinite(p).all() for p in model.parameters()): + raise RuntimeError("Nonfinite neural-SDE parameters.") + history.append(loss.item()) + return np.array(history) + + +def simulate_interval_neural_sde(model, initial, brownian_increments, *, dt): + """Apply ordinary Euler steps to caller-supplied Brownian increments. + + Continuous-time boundary conditions do not make Euler steps invariant. + Raise on any exit, reporting the step; never clip, reflect, discard paths + or claim a price from the failed run. Use coupled increments at finer + steps for sensitivity checks. The simulation is under the fitted measure. + """ + dt = real_scalar(dt, "dt", minimum=0, strict=True) + model(initial) + if ( + not isinstance(brownian_increments, torch.Tensor) + or brownian_increments.ndim != 2 + or brownian_increments.shape[0] != len(initial) + or brownian_increments.shape[1] == 0 + or brownian_increments.dtype != initial.dtype + or brownian_increments.device != initial.device + or not torch.isfinite(brownian_increments).all() + ): + raise ValueError( + "Brownian increments must match initial states and model dtype/device." + ) + states = [initial.detach().clone()] + with torch.no_grad(): + for index in range(brownian_increments.shape[1]): + drift, diffusion = model(states[-1]) + proposal = ( + states[-1] + drift * dt + diffusion * brownian_increments[:, index] + ) + if not torch.isfinite(proposal).all() or torch.any( + (proposal < model.lower) | (proposal > model.upper) + ): + raise RuntimeError( + f"Euler step {index + 1} exits the admissible price state; reduce the step and reassess." + ) + states.append(proposal) + return torch.stack(states, dim=1) + + +class PolytopeNeuralSDE(nn.Module): + """Neural SDE with corrected continuous polytope diffusion shrinking. + + Supply V,b for V*x>=b and a strictly interior point. Price-state constraints + are obtained from A*(offset+basis*x)>=price_bounds. The caller supplies the + grid, price basis and constraints; this class does not identify factors. + Normal rows are normalized internally without changing the feasible set. + For an orthonormal column frame Q from nearest independent normals, use + sigma = Q diag(g(distances)) Q.T L, where g(d)=d/(diffusion_scale+d) and L + is the neural base diffusion. Both coordinate transforms are required: + equal-distance frame permutations then leave the shrinking matrix intact. + This corrects the paper's literal one-sided transformation; linear rather + than square-root vanishing makes coefficients Lipschitz at a face. + Drift corrections point toward + the supplied interior point and vanish beyond a fixed interior distance. + Negative distances within a relative 1e-12 roundoff tolerance are evaluated + as zero; positive distances are never snapped to zero. Normal geometry must + be numerically well conditioned. HJM consistency, an attainable price grid + and the fitted measure require separate checks. Boundary invariance does + not certify dynamic arbitrage freedom or invariant discrete Euler steps. + """ + + def __init__( + self, + normals, + bounds, + interior, + *, + drift_bound, + diffusion_scale, + hidden_size=16, + seed=0, + ): + super().__init__() + from scipy.optimize import linprog + + normals = real_array(normals, "normals", 2) + bounds, interior = real_array(bounds, "bounds", 1), real_array( + interior, "interior", 1 + ) + self.dimension = len(interior) + if normals.shape != (len(bounds), self.dimension): + raise ValueError("Normals, bounds and interior dimensions must agree.") + norms = np.linalg.norm(normals, axis=1) + if np.any(norms == 0) or np.linalg.matrix_rank(normals) < self.dimension: + raise ValueError("Normals must be nonzero and span the state dimension.") + normals, bounds = normals / norms[:, None], bounds / norms + slack = normals @ interior - bounds + if np.any(slack <= 0): + raise ValueError( + "The supplied interior must satisfy every inequality strictly." + ) + for direction in np.r_[np.eye(self.dimension), -np.eye(self.dimension)]: + result = linprog( + direction, + A_ub=-normals, + b_ub=-bounds, + bounds=[(None, None)] * self.dimension, + method="highs", + ) + if not result.success: + raise ValueError( + "The supplied polytope must be bounded and numerically feasible." + ) + self.drift_bound = real_scalar( + drift_bound, "drift_bound", minimum=0, strict=True + ) + self.diffusion_scale = real_scalar( + diffusion_scale, "diffusion_scale", minimum=0, strict=True + ) + self.rho = float(slack.min() / 2) + self.register_buffer("normals", torch.tensor(normals, dtype=torch.float64)) + self.register_buffer("bounds", torch.tensor(bounds, dtype=torch.float64)) + self.register_buffer("interior", torch.tensor(interior, dtype=torch.float64)) + hidden_size = positive_integer(hidden_size, "hidden_size") + outputs = self.dimension + self.dimension * (self.dimension + 1) // 2 + with torch.random.fork_rng(devices=[]): + torch.manual_seed(seed) + self.network = nn.Sequential( + nn.Linear(self.dimension, hidden_size), + nn.Tanh(), + nn.Linear(hidden_size, outputs), + ).double() + + def _distances(self, states): + if ( + not isinstance(states, torch.Tensor) + or states.ndim != 2 + or states.shape[1] != self.dimension + or states.shape[0] == 0 + or states.dtype != self.normals.dtype + or states.device != self.normals.device + or not torch.isfinite(states).all() + ): + raise ValueError( + "States must be finite rows matching the polytope dtype/device." + ) + distances = states @ self.normals.T - self.bounds + tolerance = 1e-12 * max( + 1.0, self.bounds.abs().max().item(), states.abs().max().item() + ) + if torch.any(distances < -tolerance): + raise ValueError("State lies outside the supplied polytope.") + return distances.clamp_min(0) + + def forward(self, states): + """Return drift (rows,d) and diffusion (rows,d,d), with hard boundaries.""" + distances = self._distances(states) + raw = self.network((states - self.interior) / self.rho) + drift = self.drift_bound * torch.tanh(raw[:, : self.dimension]) + triangular = torch.tril_indices( + self.dimension, self.dimension, device=states.device + ) + entries = raw[:, self.dimension :] + entries = torch.where( + triangular[0] == triangular[1], F.softplus(entries), entries + ) + base_diffusion = torch.zeros( + len(states), + self.dimension, + self.dimension, + dtype=states.dtype, + device=states.device, + ) + base_diffusion[:, triangular[0], triangular[1]] = entries + transformed = [] + for index, distance in enumerate(distances): + directions, scales = [], [] + for face in torch.argsort(distance.detach(), stable=True).tolist(): + normal = self.normals[face] + residual = normal + for _ in range(2): + for direction in directions: + residual = residual - torch.dot(residual, direction) * direction + norm = torch.linalg.vector_norm(residual) + if norm.item() > 1e-10: + directions.append(residual / norm) + scales.append( + distance[face] / (self.diffusion_scale + distance[face]) + ) + if len(directions) == self.dimension: + break + if len(directions) != self.dimension: + raise RuntimeError("Boundary normals are numerically rank deficient.") + basis = torch.stack(directions, dim=1) + shrinking = (basis * torch.stack(scales)) @ basis.T + transformed.append(shrinking @ base_diffusion[index]) + corrections = self.interior - states + normal_drift = drift @ self.normals.T + inward_product = corrections @ self.normals.T + active = distances < self.rho + safe_denominator = torch.where( + active, inward_product, torch.ones_like(inward_product) + ) + tolerance = self.drift_bound * math.sqrt(self.dimension) * distances / self.rho + weights = torch.where( + active, + F.relu(-normal_drift - tolerance) / safe_denominator, + torch.zeros_like(distances), + ) + drift = drift + weights.sum(dim=1, keepdim=True) * corrections + return drift, torch.stack(transformed) + + def negative_log_likelihood(self, states, next_states, dt): + """Mean approximate Euler Gaussian NLL; starts must be strictly interior.""" + dt = real_scalar(dt, "dt", minimum=0, strict=True) + if torch.any(self._distances(states) <= 0): + raise ValueError("Likelihood starts must be strictly interior.") + self._distances(next_states) + if next_states.shape != states.shape: + raise ValueError("Transition shapes must match.") + drift, diffusion = self(states) + covariance = dt * diffusion @ diffusion.transpose(-1, -2) + factor, info = torch.linalg.cholesky_ex(covariance) + if torch.any(info != 0): + raise RuntimeError( + "Transition covariance is not numerically positive definite." + ) + residual = (next_states - states - dt * drift).unsqueeze(-1) + standardized = torch.linalg.solve_triangular(factor, residual, upper=False) + log_determinant = 2 * torch.log(torch.diagonal(factor, dim1=-2, dim2=-1)).sum( + dim=1 + ) + loss = ( + 0.5 + * ( + self.dimension * math.log(2 * math.pi) + + log_determinant + + standardized.square().sum(dim=(1, 2)) + ).mean() + ) + if not torch.isfinite(loss): + raise ValueError("Transition likelihood is nonfinite.") + return loss + + +def hjm_drift_residual(price_basis, drift, diffusion, required_price_drift): + """Report compatibility of the supplied finite-grid HJM linear equation. + + Solve (G*sigma)*market_price_of_risk = G*mu - required_price_drift by + least squares. G has (prices,factors); the required drift is independently + supplied from the paper's maturity/moneyness derivatives and stock model. + A nonzero residual signals incompatibility. Even zero residual alone does + not establish an equivalent martingale measure or verify those derivatives. + """ + basis, diffusion = real_array(price_basis, "price_basis", 2), real_array( + diffusion, "diffusion", 2 + ) + drift = real_array(drift, "drift", 1) + required = real_array(required_price_drift, "required_price_drift", 1) + if basis.shape != (len(required), len(drift)) or diffusion.shape != ( + len(drift), + len(drift), + ): + raise ValueError("Price and state drift/diffusion dimensions must agree.") + operator = basis @ diffusion + rhs = basis @ drift - required + risk_price, _, rank, singular = np.linalg.lstsq(operator, rhs, rcond=None) + residual = rhs - operator @ risk_price + return { + "market_price_of_risk": risk_price, + "residual": residual, + "squared_residual": float(residual @ residual), + "rank": int(rank), + "singular_values": singular, + } + + +def fit_polytope_neural_sde( + model, states, next_states, *, dt, iterations, learning_rate=0.001 +): + """Fit supplied interior training transitions by approximate Euler likelihood. + + Training uses no validation observations. Finite optimization does not + certify parameter recovery, HJM consistency or global optimality. + """ + return fit_interval_neural_sde( + model, + states, + next_states, + dt=dt, + iterations=iterations, + learning_rate=learning_rate, + ) + + +def simulate_polytope_neural_sde(model, initial, brownian_increments, *, dt): + """Euler simulation with explicit exit failure and no projection or clipping. + + initial is (paths,factors); increments are (paths,steps,factors), with + Brownian variance dt supplied by the caller. A failed path invalidates the + entire run. Compare coupled finer increments to assess discretization. + """ + dt = real_scalar(dt, "dt", minimum=0, strict=True) + model._distances(initial) + if ( + not isinstance(brownian_increments, torch.Tensor) + or brownian_increments.ndim != 3 + or brownian_increments.shape[0] != len(initial) + or brownian_increments.shape[1] == 0 + or brownian_increments.shape[2] != model.dimension + or brownian_increments.dtype != initial.dtype + or brownian_increments.device != initial.device + or not torch.isfinite(brownian_increments).all() + ): + raise ValueError( + "Brownian increments must match state dimensions and dtype/device." + ) + states = [initial.detach().clone()] + with torch.no_grad(): + for index in range(brownian_increments.shape[1]): + drift, diffusion = model(states[-1]) + proposal = ( + states[-1] + + drift * dt + + (diffusion @ brownian_increments[:, index, :, None]).squeeze(-1) + ) + try: + model._distances(proposal) + except ValueError as error: + raise RuntimeError( + f"Euler step {index + 1} exits the polytope; reduce the step and reassess." + ) from error + states.append(proposal) + return torch.stack(states, dim=1) diff --git a/src/RiskLabAI/utils/_validation.py b/src/RiskLabAI/utils/_validation.py new file mode 100644 index 0000000..f000fee --- /dev/null +++ b/src/RiskLabAI/utils/_validation.py @@ -0,0 +1,43 @@ +"""Small input checks shared by numerical methods.""" + +import numpy as np + + +def real_array(value, name, ndim=None, *, empty=False): + """Return an independent finite float array of the requested dimension.""" + raw = np.asarray(value) + if raw.dtype.kind not in "iuf" or (ndim is not None and raw.ndim != ndim): + raise ValueError(f"{name} must be a real numeric array of dimension {ndim}.") + result = np.array(raw, dtype=float, copy=True) + if (not empty and result.size == 0) or not np.all(np.isfinite(result)): + raise ValueError(f"{name} must contain finite values and cannot be empty.") + return result + + +def real_scalar(value, name, *, minimum=None, strict=False): + """Validate a finite scalar with an optional lower bound.""" + result = float(real_array(value, name, 0)) + if minimum is not None and (result <= minimum if strict else result < minimum): + raise ValueError(f"{name} is below its allowed bound.") + return result + + +def positive_integer(value, name): + """Reject booleans and nonpositive or nonintegral counts.""" + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or value < 1 + ): + raise ValueError(f"{name} must be a positive integer.") + return int(value) + + +def probability_rows(value, name, ndim=2): + """Validate explicit probability vectors without clipping or renormalizing.""" + result = real_array(value, name, ndim) + if np.any((result < 0) | (result > 1)) or not np.allclose( + result.sum(axis=-1), 1, atol=1e-12, rtol=1e-12 + ): + raise ValueError(f"{name} must have nonnegative rows summing to one.") + return result diff --git a/test/data/weights/test_sample_weights.py b/test/data/weights/test_sample_weights.py index 325b35e..859ca0e 100644 --- a/test/data/weights/test_sample_weights.py +++ b/test/data/weights/test_sample_weights.py @@ -11,6 +11,7 @@ calculate_time_decay, expand_label_for_meta_labeling, sample_weight_absolute_return_meta_labeling, + sequential_bootstrap, ) @@ -125,3 +126,233 @@ def test_calculate_time_decay(): # new_weights = 0.5 + 0.05 * [1, ..., 10] = [0.55, 0.6, ..., 1.0] expected_05 = 0.5 + 0.05 * np.arange(1, 11) assert np.allclose(decayed_05, expected_05) + + +@pytest.mark.parametrize( + "start_positions,end_positions,molecule_positions", + [ + ([0, 1], [2, 3], [1]), + ([0, 1], [2, 3], [0]), + ([0, 1, 3], [6, 4, 5], [1]), + ([0, 1, 4], [1, 3, 5], [1]), + ([0, 1, 3], [4, 2, None], [1]), + ([0, 1, 3], [4, None, 5], [1]), + ([0, 1, 3, 5], [2, 4, 6, 6], [0, 2]), + ], +) +def test_concurrency_subset_counts_all_overlapping_events( + start_positions, end_positions, molecule_positions +): + """Subset concurrency equals the full event indicator sum on its interval.""" + close_index = pd.date_range("2024-01-01", periods=7) + event_starts = close_index[start_positions] + event_ends = pd.Series( + [close_index[end] if end is not None else pd.NaT for end in end_positions], + index=event_starts, + ) + molecule = event_starts[molecule_positions] + resolved_ends = event_ends.fillna(close_index[-1]) + requested_index = close_index[ + (close_index >= molecule[0]) + & (close_index <= resolved_ends.loc[molecule].max()) + ] + expected = pd.Series( + [ + sum(start <= date <= end for start, end in resolved_ends.items()) + for date in requested_index + ], + index=requested_index, + dtype=int, + ) + + actual = expand_label_for_meta_labeling(close_index, event_ends, molecule) + + pd.testing.assert_series_equal(actual, expected) + + +class _RecordingGenerator(np.random.Generator): + """Record conditional probabilities while supplying predetermined draws.""" + + def __init__(self, draws): + super().__init__(np.random.PCG64(0)) + self.draws = iter(draws) + self.probabilities = [] + + def choice(self, a, p): + self.probabilities.append(np.array(p, copy=True)) + result = next(self.draws) + assert 0 <= result < a + return result + + +def test_sequential_bootstrap_exact_conditional_probabilities(): + """The next-draw probabilities match exact mean-uniqueness fractions.""" + matrix = np.array([[1, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1]]) + generator = _RecordingGenerator([1, 2, 0, 2]) + + actual = sequential_bootstrap(matrix, sample_length=4, random_state=generator) + + np.testing.assert_array_equal(actual, [1, 2, 0, 2]) + np.testing.assert_allclose( + generator.probabilities, + [ + [1 / 3, 1 / 3, 1 / 3], + [5 / 14, 3 / 14, 6 / 14], + [5 / 11, 3 / 11, 3 / 11], + [16 / 49, 15 / 49, 18 / 49], + ], + rtol=1e-14, + atol=1e-14, + ) + + +@pytest.mark.parametrize("seed", [0, 7, 42]) +def test_sequential_bootstrap_matches_rebuilt_indicator_reference(seed): + """Repeated draws agree with rebuilding the full selected-event matrix.""" + matrix = np.array([[1, 0, 0], [1, 1, 0], [0, 1, 1], [0, 0, 1]], dtype=float) + generator = np.random.default_rng(seed) + expected = [] + for _ in range(20): + scores = [] + for candidate in range(matrix.shape[1]): + reduced = matrix[:, expected + [candidate]] + active = reduced[:, -1] > 0 + scores.append(np.mean(1.0 / reduced[active].sum(axis=1))) + probabilities = np.asarray(scores) / sum(scores) + expected.append(generator.choice(matrix.shape[1], p=probabilities)) + + actual = sequential_bootstrap(matrix, sample_length=20, random_state=seed) + + np.testing.assert_array_equal(actual, expected) + + +def test_sequential_bootstrap_dataframe_and_generator_contract(): + """Labels do not replace positional output, and generator state is consumed.""" + matrix = pd.DataFrame([[1, 0], [1, 1], [0, 1]], columns=["event B", "event A"]) + original = matrix.copy(deep=True) + generator = np.random.default_rng(12) + reference_generator = np.random.default_rng(12) + for _ in range(2): + actual = sequential_bootstrap(matrix, random_state=generator) + expected = sequential_bootstrap( + matrix.to_numpy(), random_state=reference_generator + ) + np.testing.assert_array_equal(actual, expected) + assert actual.shape == (2,) + assert np.issubdtype(actual.dtype, np.integer) + pd.testing.assert_frame_equal(matrix, original) + assert generator.bit_generator.state == reference_generator.bit_generator.state + + +def test_sequential_bootstrap_leaves_legacy_global_rng_unchanged(): + """Both seeded and unseeded calls avoid the legacy global RNG.""" + before = np.random.get_state() + sequential_bootstrap(np.ones((2, 2)), random_state=3) + sequential_bootstrap(np.ones((2, 2))) + after = np.random.get_state() + assert before[0] == after[0] + np.testing.assert_array_equal(before[1], after[1]) + assert before[2:] == after[2:] + + +@pytest.mark.parametrize("shape", [(0, 0), (3, 0), (2, 2)]) +def test_sequential_bootstrap_zero_length_does_not_consume_rng(shape): + """Zero draws return integer positions without advancing a supplied RNG.""" + generator = np.random.default_rng(5) + before = generator.bit_generator.state + actual = sequential_bootstrap( + np.ones(shape), sample_length=0, random_state=generator + ) + assert actual.shape == (0,) + assert np.issubdtype(actual.dtype, np.integer) + assert generator.bit_generator.state == before + + +def test_sequential_bootstrap_single_and_identical_events(): + """A single event repeats; identical events remain equally probable.""" + np.testing.assert_array_equal( + sequential_bootstrap(np.ones((2, 1)), 5, 0), np.zeros(5, dtype=int) + ) + generator = _RecordingGenerator([0, 0, 1, 0]) + sequential_bootstrap(np.ones((3, 2)), 4, generator) + np.testing.assert_allclose(generator.probabilities, np.full((4, 2), 0.5)) + + +@pytest.mark.parametrize( + "matrix", + [ + [1, 0], + np.ones((1, 1, 1)), + [[1, np.nan]], + [[1, np.inf]], + [[1, -1]], + [[1, 0.5]], + [[1, 2]], + [[1, 0]], + np.empty((0, 1)), + [["1"]], + [[1 + 1j]], + ], +) +def test_sequential_bootstrap_rejects_invalid_indicator_matrix(matrix): + """Only finite binary matrices with supported events define the algorithm.""" + with pytest.raises(ValueError): + sequential_bootstrap(matrix, random_state=0) + + +@pytest.mark.parametrize("length", [-1, 1.5, True, np.bool_(False), "2"]) +def test_sequential_bootstrap_rejects_invalid_lengths(length): + """Draw counts must be nonnegative integers, not boolean flags.""" + with pytest.raises(ValueError, match="sample_length"): + sequential_bootstrap(np.ones((1, 1)), sample_length=length, random_state=0) + + +@pytest.mark.parametrize("state", [-1, 1.5, True, "2", np.random.RandomState(0)]) +def test_sequential_bootstrap_rejects_unsupported_random_state(state): + """Unsupported RNG objects are rejected rather than silently reseeded.""" + with pytest.raises(ValueError, match="random_state"): + sequential_bootstrap(np.ones((1, 1)), random_state=state) + + +def test_sequential_bootstrap_empty_population_and_numpy_integer_arguments(): + """Empty populations cannot produce positive draws; NumPy integers work.""" + assert sequential_bootstrap(np.empty((0, 0))).size == 0 + with pytest.raises(ValueError, match="no events"): + sequential_bootstrap(np.empty((2, 0)), sample_length=1) + np.testing.assert_array_equal( + sequential_bootstrap(np.ones((1, 1)), np.int64(2), np.int64(4)), [0, 0] + ) + + +def test_sequential_bootstrap_public_export(): + """The sampling function is available through the sample-weights package.""" + from RiskLabAI.data import weights + + assert weights.sequential_bootstrap is sequential_bootstrap + assert "sequential_bootstrap" in weights.__all__ + + +def test_concurrency_chunks_match_full_event_counts(): + """Processing separate event groups leaves their shared counts unchanged.""" + close_index = pd.date_range("2024-01-01", periods=9) + event_ends = pd.Series(close_index[[5, 4, 7, 8]], index=close_index[[0, 1, 3, 6]]) + full = expand_label_for_meta_labeling(close_index, event_ends, event_ends.index) + + for molecule in np.array_split(event_ends.index, 2): + actual = expand_label_for_meta_labeling(close_index, event_ends, molecule) + expected = full.loc[molecule[0] : event_ends.loc[molecule].max()] + pd.testing.assert_series_equal(actual, expected) + + +def test_subset_return_weights_use_full_event_concurrency(): + """Return attribution uses all overlaps and retains subset normalization.""" + close_index = pd.date_range("2024-01-01", periods=7) + event_ends = pd.Series(close_index[[4, 3, 6]], index=close_index[[0, 1, 3]]) + molecule = event_ends.index[1:] + prices = pd.Series(np.exp(np.arange(7, dtype=float)), index=close_index) + expected_raw = pd.Series([4.0 / 3.0, 17.0 / 6.0], index=molecule) + expected = expected_raw * len(molecule) / expected_raw.sum() + + actual = sample_weight_absolute_return_meta_labeling(event_ends, prices, molecule) + + pd.testing.assert_series_equal(actual, expected) diff --git a/test/optimization/test_clearing.py b/test/optimization/test_clearing.py new file mode 100644 index 0000000..12ed742 --- /dev/null +++ b/test/optimization/test_clearing.py @@ -0,0 +1,101 @@ +"""Check proportional clearing against hand calculations and independent iteration.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from RiskLabAI.optimization import eisenberg_noe_clearing +from RiskLabAI.optimization import clearing + + +@pytest.mark.parametrize( + "assets,liabilities,relative,expected", + [ + ([2, 8], [5, 5], [[0, 0], [0, 0]], [2, 5]), + ([3, 0, 0], [5, 4, 2], [[0, 1, 0], [0, 0, 1], [0, 0, 0]], [3, 3, 2]), + ([0, 0], [2, 3], [[0, 1], [1, 0]], [2, 2]), + ([1, 0], [10, 10], [[0, 0.5], [0.5, 0]], [4 / 3, 2 / 3]), + ([0, 0], [2, 3], [[0, 1], [1, 0]], [2, 2]), + ([100, 100], [2, 3], [[0, 0.5], [0.5, 0]], [2, 3]), + ([1, 2], [0, 0], [[0, 0], [0, 0]], [0, 0]), + ([1, 0], [2, 0], [[0, 1], [0, 0]], [1, 0]), + ([0], [7], [[1]], [7]), + ], +) +def test_hand_calculated_networks(assets, liabilities, relative, expected): + np.testing.assert_allclose( + eisenberg_noe_clearing(assets, liabilities, relative), expected + ) + + +def test_random_networks_match_contractive_iteration(): + rng = np.random.default_rng(971) + for size in range(1, 9): + for _ in range(10): + relative = rng.uniform(size=(size, size)) + relative *= 0.8 / relative.sum(axis=1, keepdims=True) + liabilities = rng.uniform(0, 5, size) + assets = rng.uniform(0, 1, size) + expected = liabilities.copy() + for _ in range(250): + expected = np.minimum(liabilities, assets + relative.T @ expected) + actual = eisenberg_noe_clearing(assets, liabilities, relative) + np.testing.assert_allclose(actual, expected, atol=1e-9) + + +@pytest.mark.parametrize("scale", [1e-12, 1, 1e12]) +def test_monetary_units_and_input_preservation(scale): + assets = np.array([1, 0.0]) * scale + liabilities = np.array([10, 10.0]) * scale + relative = np.array([[0, 0.5], [0.5, 0]]) + before = [x.copy() for x in (assets, liabilities, relative)] + actual = eisenberg_noe_clearing(assets, liabilities, relative) + np.testing.assert_allclose(actual / scale, [4 / 3, 2 / 3]) + for array, original in zip((assets, liabilities, relative), before): + np.testing.assert_array_equal(array, original) + + +@pytest.mark.parametrize( + "assets,liabilities,relative", + [ + ([], [], []), + ([[1]], [1], [[0]]), + ([1], [1, 2], [[0]]), + ([1], [1], [0]), + ([-1], [1], [[0]]), + ([1], [-1], [[0]]), + ([1], [1], [[-0.1]]), + ([1], [1], [[1.01]]), + ([np.nan], [1], [[0]]), + ([1], [np.inf], [[0]]), + ([1], [1], [[np.nan]]), + ([1j], [1], [[0]]), + (["1"], [1], [[0]]), + ], +) +def test_reject_invalid_network(assets, liabilities, relative): + with pytest.raises(ValueError): + eisenberg_noe_clearing(assets, liabilities, relative) + + +@pytest.mark.parametrize("atol", [0, -1, 1, np.nan, np.inf, True, "0.1", [0.1]]) +def test_reject_invalid_tolerance(atol): + with pytest.raises(ValueError): + eisenberg_noe_clearing([1], [2], [[0]], atol=atol) + + +@pytest.mark.parametrize( + "success,payments", + [(False, None), (True, [np.nan]), (True, [-1]), (True, [2]), (True, [0])], +) +def test_reject_failed_or_unchecked_solver(monkeypatch, success, payments): + monkeypatch.setattr( + clearing, + "linprog", + lambda *args, **kwargs: SimpleNamespace( + success=success, x=payments, message="test" + ), + ) + with pytest.raises(RuntimeError): + eisenberg_noe_clearing([1], [2], [[0]]) diff --git a/test/optimization/test_execution.py b/test/optimization/test_execution.py new file mode 100644 index 0000000..c594f07 --- /dev/null +++ b/test/optimization/test_execution.py @@ -0,0 +1,155 @@ +"""Validate the discrete execution benchmark against quadratic optimization.""" + +import numpy as np +import pytest + +from RiskLabAI.optimization import almgren_chriss_execution + + +@pytest.mark.parametrize("risk,volatility", [(0, 2), (1, 0), (0, 0), (1e-30, 2)]) +def test_constant_rate_limit(risk, volatility): + result = almgren_chriss_execution( + 100, + 2, + 8, + volatility=volatility, + temporary_impact=0.4, + permanent_impact=0.1, + risk_aversion=risk, + ) + np.testing.assert_allclose(result["inventory"], np.linspace(100, 0, 9)) + np.testing.assert_allclose(result["trades"], np.full(8, 12.5)) + assert result["expected_cost"] == pytest.approx(0.05 * 100**2 + 0.3875 * 100**2 / 2) + assert result["cost_variance"] == pytest.approx( + volatility**2 * 0.25 * sum((100 - k * 12.5) ** 2 for k in range(1, 9)) + ) + + +@pytest.mark.parametrize("intervals", [1, 2, 5, 20]) +@pytest.mark.parametrize("risk", [0, 1e-4, 0.3, 1000]) +def test_schedule_matches_independent_quadratic_system(intervals, risk): + quantity, horizon, sigma, eta, gamma = 80, 3, 1.7, 0.8, 0.1 + result = almgren_chriss_execution( + quantity, + horizon, + intervals, + volatility=sigma, + temporary_impact=eta, + permanent_impact=gamma, + risk_aversion=risk, + ) + tau = horizon / intervals + difference = np.eye(intervals, intervals + 1) - np.eye( + intervals, intervals + 1, k=1 + ) + boundary = np.zeros(intervals + 1) + boundary[0] = quantity + interior = difference[:, 1:-1] + coefficient = eta / tau - gamma / 2 + hessian = coefficient * interior.T @ interior + risk * sigma**2 * tau * np.eye( + intervals - 1 + ) + linear = coefficient * interior.T @ (difference @ boundary) + expected = boundary.copy() + if intervals > 1: + expected[1:-1] = np.linalg.solve(hessian, -linear) + np.testing.assert_allclose(result["inventory"], expected, atol=1e-10) + inventory = result["inventory"] + trades = result["trades"] + assert inventory[0] == quantity and inventory[-1] == 0 + assert np.all(trades >= 0) + assert trades.sum() == pytest.approx(quantity) + direct_cost = gamma * np.dot(inventory[1:], trades) + eta / tau * np.dot( + trades, trades + ) + assert result["expected_cost"] == pytest.approx(direct_cost) + assert result["cost_variance"] == pytest.approx( + sigma**2 * tau * np.dot(inventory[1:], inventory[1:]) + ) + np.testing.assert_allclose(result["times"], np.linspace(0, horizon, intervals + 1)) + + +def test_increased_risk_aversion_trades_earlier(): + paths = [ + almgren_chriss_execution( + 50, 1, 10, volatility=2, temporary_impact=0.1, risk_aversion=r + ) + for r in [0, 0.1, 10] + ] + for slower, faster in zip(paths, paths[1:]): + assert np.all(faster["inventory"] <= slower["inventory"] + 1e-12) + assert faster["cost_variance"] < slower["cost_variance"] + assert faster["expected_cost"] > slower["expected_cost"] + + +def test_extreme_risk_aversion_is_finite(): + result = almgren_chriss_execution( + 50, 1, 10, volatility=2, temporary_impact=0.1, risk_aversion=1e308 + ) + np.testing.assert_allclose(result["inventory"], [50] + [0] * 10, atol=1e-300) + assert np.isfinite(result["expected_cost"]) + + +def test_zero_inventory_and_single_interval(): + zero = almgren_chriss_execution(0, 1, 4, volatility=2, temporary_impact=1) + assert zero["expected_cost"] == zero["cost_variance"] == 0 + np.testing.assert_array_equal(zero["inventory"], np.zeros(5)) + single = almgren_chriss_execution( + 20, + 2, + 1, + volatility=2, + temporary_impact=1, + permanent_impact=0.1, + risk_aversion=5, + ) + assert single["expected_cost"] == pytest.approx(200) + assert single["cost_variance"] == 0 + + +@pytest.mark.parametrize( + "name", + [ + "inventory", + "horizon", + "volatility", + "temporary_impact", + "permanent_impact", + "risk_aversion", + ], +) +@pytest.mark.parametrize("bad", [-1, np.nan, np.inf, True, "1", [1], 1j]) +def test_invalid_scalar_parameters(name, bad): + arguments = dict( + inventory=10, + horizon=1, + intervals=5, + volatility=1, + temporary_impact=1, + permanent_impact=0, + risk_aversion=1, + ) + arguments[name] = bad + with pytest.raises(ValueError): + almgren_chriss_execution(**arguments) + + +@pytest.mark.parametrize("intervals", [0, -1, 2.5, True, "2"]) +def test_invalid_interval_count(intervals): + with pytest.raises(ValueError): + almgren_chriss_execution(10, 1, intervals, volatility=1, temporary_impact=1) + + +@pytest.mark.parametrize( + "horizon,eta,gamma", [(0, 1, 0), (1, 0, 0), (2, 0.1, 1), (1, 0.1, 1)] +) +def test_invalid_discrete_convexity(horizon, eta, gamma): + with pytest.raises(ValueError): + almgren_chriss_execution( + 10, horizon, 5, volatility=1, temporary_impact=eta, permanent_impact=gamma + ) + + +def test_cost_overflow_rejected(): + with pytest.raises(ValueError, match="floating-point"): + almgren_chriss_execution(1e300, 1, 5, volatility=1, temporary_impact=1) diff --git a/test/optimization/test_majorization.py b/test/optimization/test_majorization.py new file mode 100644 index 0000000..213d036 --- /dev/null +++ b/test/optimization/test_majorization.py @@ -0,0 +1,194 @@ +"""Mathematical oracles for majorization and permutation-mixture reconstruction.""" + +from itertools import permutations +from types import SimpleNamespace + +import numpy as np +import pytest + +from RiskLabAI.optimization import ( + birkhoff_von_neumann_decomposition, + is_doubly_stochastic, + is_majorized, + majorization_matrix, +) +from RiskLabAI.optimization import majorization as module + + +@pytest.mark.parametrize( + "target,source,expected", + [ + ([0.5, 0.5], [1.0, 0.0], True), + ([1.0, 0.0], [0.5, 0.5], False), + ([2, 2, 2], [4, 1, 1], True), + ([3, 3, 0], [4, 1, 1], False), + ([4, 1, 1], [3, 3, 0], False), + ([-1, 0, 1], [-2, 0, 2], True), + ([2], [2], True), + ([1], [2], False), + ([0, 0], [1, 0], False), + ], +) +def test_majorization_partial_sum_cases(target, source, expected): + assert is_majorized(target, source) is expected + for ordering in permutations(range(len(target))): + assert is_majorized(np.asarray(target)[list(ordering)], source) is expected + + +@pytest.mark.parametrize( + "target,source", + [ + ([], []), + ([1], [1, 2]), + ([[1]], [[1]]), + ([np.nan], [1]), + ([1], [np.inf]), + ([1j], [1]), + (["1"], [1]), + ], +) +def test_vector_inputs_rejected(target, source): + with pytest.raises(ValueError): + is_majorized(target, source) + with pytest.raises(ValueError): + majorization_matrix(target, source) + + +@pytest.mark.parametrize("atol", [0, -1, 1, np.inf, np.nan, True, "1e-10"]) +def test_invalid_tolerance_rejected(atol): + for function, args in [ + (is_majorized, ([1], [1])), + (majorization_matrix, ([1], [1])), + (is_doubly_stochastic, ([[1]],)), + (birkhoff_von_neumann_decomposition, ([[1]],)), + ]: + with pytest.raises(ValueError, match="atol"): + function(*args, atol=atol) + + +@pytest.mark.parametrize( + "matrix", + [ + [], + [1], + [[1, 0]], + [[np.nan]], + [[np.inf]], + [[1j]], + [["1"]], + [[0.4, 0.6], [0.4, 0.6]], + [[1.1, -0.1], [-0.1, 1.1]], + ], +) +def test_invalid_stochastic_matrix_rejected(matrix): + assert not is_doubly_stochastic(matrix) + with pytest.raises(ValueError): + birkhoff_von_neumann_decomposition(matrix) + + +@pytest.mark.parametrize( + "target,source", + [ + ([0.5, 0.5], [1, 0]), + ([2, 2, 2], [4, 1, 1]), + ([-1, 0, 1], [-2, 0, 2]), + ([2, -2, 0], [-2, 0, 2]), + ([3], [3]), + ([1, 1, 1], [1, 1, 1]), + ], +) +def test_witness_direction_and_constraints(target, source): + target = np.asarray(target, dtype=float) + source = np.asarray(source, dtype=float) + old_target, old_source = target.copy(), source.copy() + matrix = majorization_matrix(target, source) + assert np.min(matrix) >= -1e-10 + np.testing.assert_allclose(matrix.sum(axis=0), 1, atol=1e-10, rtol=0) + np.testing.assert_allclose(matrix.sum(axis=1), 1, atol=1e-10, rtol=0) + np.testing.assert_allclose(matrix @ source, target, atol=1e-10, rtol=0) + np.testing.assert_array_equal(target, old_target) + np.testing.assert_array_equal(source, old_source) + + +def test_non_majorized_pair_has_no_witness(): + with pytest.raises(ValueError, match="not majorized"): + majorization_matrix([3, 3, 0], [4, 1, 1]) + + +@pytest.mark.parametrize("size", [1, 2, 3, 5, 10]) +def test_mixture_reconstruction_and_witness(size): + generator = np.random.default_rng(304 + size) + terms = [np.eye(size)[generator.permutation(size)] for _ in range(20)] + coefficients = generator.dirichlet(np.ones(len(terms))) + matrix = sum(weight * term for weight, term in zip(coefficients, terms)) + before = matrix.copy() + weights, orders = birkhoff_von_neumann_decomposition(matrix) + assert len(weights) == len(orders) + assert np.all(weights > 0) + assert np.isclose(weights.sum(), 1, atol=1e-10, rtol=0) + for order in orders: + np.testing.assert_array_equal(np.sort(order), np.arange(size)) + restored = sum( + weight * np.eye(size)[order] for weight, order in zip(weights, orders) + ) + np.testing.assert_allclose(restored, matrix, atol=1e-10, rtol=0) + np.testing.assert_array_equal(matrix, before) + source = generator.normal(size=size) + target = matrix @ source + assert is_majorized(target, source) + witness = majorization_matrix(target, source) + np.testing.assert_allclose(witness @ source, target, atol=1e-10, rtol=0) + + +def test_single_permutation_and_tiny_mixture_component(): + matrix = np.eye(3)[[2, 0, 1]] + weights, orders = birkhoff_von_neumann_decomposition(matrix) + np.testing.assert_array_equal(weights, [1.0]) + np.testing.assert_array_equal(orders, [[2, 0, 1]]) + tiny = 5e-12 + matrix = (1 - tiny) * np.eye(3) + tiny * np.eye(3)[[1, 2, 0]] + weights, orders = birkhoff_von_neumann_decomposition(matrix, atol=1e-13) + restored = sum(w * np.eye(3)[p] for w, p in zip(weights, orders)) + np.testing.assert_allclose(restored, matrix, atol=1e-13, rtol=0) + assert np.isclose(weights.sum(), 1, atol=1e-13, rtol=0) + + +def test_tolerance_does_not_normalize_inputs(): + matrix = np.eye(2) * (1 + 5e-11) + before = matrix.copy() + assert is_doubly_stochastic(matrix) + assert not is_doubly_stochastic(matrix, atol=1e-12) + weights, orders = birkhoff_von_neumann_decomposition(matrix) + restored = sum(w * np.eye(2)[p] for w, p in zip(weights, orders)) + np.testing.assert_array_equal(restored, matrix) + np.testing.assert_array_equal(matrix, before) + + +def test_solver_failure_and_unchecked_result_are_not_returned(monkeypatch): + monkeypatch.setattr( + module, + "linprog", + lambda *args, **kwargs: SimpleNamespace( + success=False, x=None, message="test failure" + ), + ) + with pytest.raises(RuntimeError, match="test failure"): + majorization_matrix([0.5, 0.5], [1, 0]) + monkeypatch.setattr( + module, + "linprog", + lambda *args, **kwargs: SimpleNamespace( + success=True, x=np.eye(2).ravel(), message="unchecked" + ), + ) + with pytest.raises(RuntimeError, match="residual"): + majorization_matrix([0.5, 0.5], [1, 0]) + + +def test_matching_failure_is_reported(monkeypatch): + def fail(*args, **kwargs): + raise ValueError("test failure") + + monkeypatch.setattr(module, "linear_sum_assignment", fail) + with pytest.raises(RuntimeError, match="matching"): + birkhoff_von_neumann_decomposition(np.eye(2)) diff --git a/test/pde/test_neural_sde.py b/test/pde/test_neural_sde.py new file mode 100644 index 0000000..1fde759 --- /dev/null +++ b/test/pde/test_neural_sde.py @@ -0,0 +1,406 @@ +"""Boundary, likelihood and coupled-step checks for the scalar option factor.""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from scipy.special import ndtr + +torch = pytest.importorskip("torch") +method = pytest.importorskip("RiskLabAI.pde.neural_sde") + + +def _grid(): + strikes, maturities = np.array([0.8, 1, 1.2]), np.array([0.5, 1]) + + def prices(volatility): + scale = volatility * np.sqrt(maturities[:, None]) + d1 = -np.log(strikes)[None, :] / scale + scale / 2 + return (ndtr(d1) - strikes * ndtr(d1 - scale)).ravel() + + offset, loading = prices(0.2), prices(0.3) - prices(0.1) + matrix, bounds = method.normalized_call_grid_constraints(strikes, maturities) + interval = method.affine_price_state_interval(offset, loading, matrix, bounds) + return offset, loading, matrix, bounds, interval + + +def test_grid_constraints_black_scholes_and_endpoint_states(): + offset, loading, matrix, bounds, (lower, upper) = _grid() + assert np.all(matrix @ offset >= bounds - 1e-12) + assert lower < 0 < upper + states = np.linspace(lower, upper, 51) + prices = offset + states[:, None] * loading + assert np.min(prices @ matrix.T - bounds) >= -1e-12 + for state in (lower - 0.01, upper + 0.01): + assert np.any(matrix @ (offset + state * loading) < bounds - 1e-8) + + +@pytest.mark.parametrize("seed", range(5)) +def test_hard_boundary_conditions(seed): + model = method.IntervalNeuralSDE( + -2, 3, drift_bound=0.4, diffusion_scale=0.2, seed=seed + ) + mu, sigma = model(torch.tensor([-2, 3], dtype=torch.float64)) + assert mu[0] >= 0 and mu[1] <= 0 + assert_allclose(sigma.detach(), 0, atol=0) + for raw in (-100, 100): + with torch.no_grad(): + for parameter in model.parameters(): + parameter.zero_() + model.network[-1].bias[0] = raw + mu, sigma = model(torch.tensor([-2, 3], dtype=torch.float64)) + assert mu[0] >= 0 and mu[1] <= 0 + assert_allclose(sigma.detach(), 0, atol=0) + + +def test_neural_likelihood_against_normal_density_and_gradients(): + model = method.IntervalNeuralSDE( + -1, 1, drift_bound=0.4, diffusion_scale=0.2, seed=43 + ) + states = torch.tensor([-0.3, 0.1, 0.7], dtype=torch.float64) + next_states = states + torch.tensor([0.01, -0.02, 0.03], dtype=torch.float64) + mu, sigma = model(states) + distribution = torch.distributions.Normal(states + mu * 0.01, sigma * np.sqrt(0.01)) + expected = -distribution.log_prob(next_states).mean().item() + loss = model.negative_log_likelihood(states, next_states, 0.01) + assert loss.item() == pytest.approx(expected) + loss.backward() + assert all( + p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters() + ) + history = method.fit_interval_neural_sde( + model, states, next_states, dt=0.01, iterations=15, learning_rate=0.01 + ) + assert history[-1] < history[0] + with pytest.raises(ValueError): + model.negative_log_likelihood(torch.tensor([-1.0]), torch.tensor([-0.9]), 0.01) + + +def test_simulation_grid_and_coupled_step_benchmark(): + offset, loading, matrix, bounds, (lower, upper) = _grid() + model = method.IntervalNeuralSDE( + lower, upper, drift_bound=0.1, diffusion_scale=0.2, seed=91 + ) + with torch.no_grad(): + for parameter in model.parameters(): + parameter.zero_() + model.network[-1].bias[1] = np.log(np.expm1(0.03)) + generator = torch.Generator().manual_seed(92) + fine_dw = torch.randn( + 2048, 128, generator=generator, dtype=torch.float64 + ) / np.sqrt(128) + coarse_dw = fine_dw.reshape(2048, 32, 4).sum(dim=2) + initial = torch.zeros(2048, dtype=torch.float64) + fine = method.simulate_interval_neural_sde(model, initial, fine_dw, dt=1 / 128) + coarse = method.simulate_interval_neural_sde(model, initial, coarse_dw, dt=1 / 32) + for paths in (fine, coarse): + prices = offset + paths.numpy()[:, :, None] * loading + assert np.min(prices.reshape(-1, len(offset)) @ matrix.T - bounds) > -1e-12 + final_prices = prices[:, -1] + standard_error = final_prices.std(axis=0, ddof=1) / np.sqrt(len(paths)) + assert np.all(np.abs(final_prices.mean(axis=0) - offset) <= 5 * standard_error) + assert torch.sqrt(torch.mean((fine[:, -1] - coarse[:, -1]) ** 2)).item() < 0.001 + with pytest.raises(RuntimeError, match="Euler step"): + method.simulate_interval_neural_sde( + model, initial[:1], torch.full((1, 1), 1000.0, dtype=torch.float64), dt=1 + ) + + +@pytest.mark.parametrize( + "call", + [ + lambda: method.normalized_call_grid_constraints([1, 0.5], [1]), + lambda: method.normalized_call_grid_constraints([0.5, 1], [0]), + lambda: method.affine_price_state_interval([1], [0], [[1]], [2]), + lambda: method.affine_price_state_interval([1], [1], [[1]], [0]), + lambda: method.IntervalNeuralSDE(1, 1, drift_bound=1, diffusion_scale=1), + ], +) +def test_invalid_grid_or_state_contract(call): + with pytest.raises(ValueError): + call() + + +@pytest.mark.parametrize("angle", [0, 0.3, 1.1]) +def test_rotated_polytope_faces_and_vertices(angle): + rotation = np.array( + [[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]] + ) + normals = np.array([[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1]]) @ rotation + bounds = np.array([-1, -1, -1, -1, -2]) + model = method.PolytopeNeuralSDE( + normals, bounds, [0, 0], drift_bound=0.4, diffusion_scale=0.2, seed=82 + ) + points = np.array([[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1]]) @ rotation + drift, diffusion = model(torch.tensor(points, dtype=torch.float64)) + for i, point in enumerate(points): + active = np.isclose(normals @ point, bounds, atol=1e-12) + assert np.min(normals[active] @ drift[i].detach().numpy()) >= -1e-12 + assert_allclose(normals[active] @ diffusion[i].detach().numpy(), 0, atol=1e-12) + + +def test_polytope_likelihood_training_and_exit_handling(): + model = method.PolytopeNeuralSDE( + [[1, 0], [0, 1], [-1, -1]], + [0, 0, -1], + [0.3, 0.3], + drift_bound=0.1, + diffusion_scale=0.2, + seed=11, + ) + states = torch.tensor([[0.2, 0.3], [0.4, 0.2], [0.1, 0.5]], dtype=torch.float64) + following = states + torch.tensor( + [[0.01, -0.02], [-0.01, 0.01], [0.01, 0.01]], dtype=torch.float64 + ) + drift, diffusion = model(states) + distribution = torch.distributions.MultivariateNormal( + states + 0.01 * drift, + covariance_matrix=0.01 * diffusion @ diffusion.transpose(-1, -2), + ) + expected = -distribution.log_prob(following).mean().item() + assert model.negative_log_likelihood( + states, following, 0.01 + ).item() == pytest.approx(expected) + losses = method.fit_polytope_neural_sde( + model, states, following, dt=0.01, iterations=5 + ) + assert np.isfinite(losses).all() + increments = torch.zeros(3, 5, 2, dtype=torch.float64) + paths = method.simulate_polytope_neural_sde(model, states, increments, dt=0.001) + assert paths.shape == (3, 6, 2) + with pytest.raises(RuntimeError, match="Euler step"): + method.simulate_polytope_neural_sde(model, states, increments + 100, dt=0.001) + + +def test_hjm_residual_detects_incompatible_price_drift(): + basis = np.array([[1, 0], [0, 1], [1, 1]]) + drift, diffusion = np.array([0.2, -0.3]), np.diag([0.1, 0.2]) + risk_price = np.array([0.4, 0.7]) + required = basis @ (drift - diffusion @ risk_price) + result = method.hjm_drift_residual(basis, drift, diffusion, required) + assert_allclose(result["market_price_of_risk"], risk_price) + assert result["squared_residual"] < 1e-25 + required[-1] += 1 + assert ( + method.hjm_drift_residual(basis, drift, diffusion, required)["squared_residual"] + > 0.1 + ) + + +def _constant_diffusion(model, matrix): + with torch.no_grad(): + for parameter in model.parameters(): + parameter.zero_() + rows, columns = np.tril_indices(model.dimension) + values = matrix[rows, columns].copy() + diagonal = rows == columns + values[diagonal] = np.log(np.expm1(values[diagonal])) + model.network[-1].bias[model.dimension :] = torch.tensor(values) + + +def _projector_integral(normals, distances, scale): + """Independent SVD evaluation of the subspace-projector integral.""" + dimension = normals.shape[1] + result, previous = np.zeros((dimension, dimension)), 0.0 + identity = np.eye(dimension) + for level in np.unique(distances): + earlier = normals[distances < level] + projector = np.zeros_like(identity) + if len(earlier): + _, singular, vectors = np.linalg.svd(earlier, full_matrices=False) + basis = vectors[singular > 1e-10] + projector = basis.T @ basis + value = level / (scale + level) + result += (value - previous) * (identity - projector) + previous = value + return result + + +def test_anisotropic_covariance_is_continuous_at_nearest_face_tie(): + model = method.PolytopeNeuralSDE( + [[1, 0], [-1, 0], [0, 1], [0, -1]], + [-1] * 4, + [0, 0], + drift_bound=0.1, + diffusion_scale=0.2, + ) + _constant_diffusion(model, np.diag([0.1, 0.2])) + for epsilon in (1e-3, 1e-6, 1e-9): + states = torch.tensor( + [[0.2 + epsilon, 0.2], [0.2, 0.2 + epsilon]], dtype=torch.float64 + ) + _, diffusion = model(states) + covariance = (diffusion @ diffusion.transpose(-1, -2)).detach().numpy() + g = (0.8 - epsilon) / (1 - epsilon) + expected = [ + np.diag([0.01 * g**2, 0.04 * 0.8**2]), + np.diag([0.01 * 0.8**2, 0.04 * g**2]), + ] + assert_allclose(covariance, expected, rtol=1e-12, atol=1e-15) + assert np.linalg.norm(covariance[0] - covariance[1]) < 0.02 * epsilon + + +@pytest.mark.parametrize("dimension", [2, 3, 4]) +def test_oblique_shrinking_matches_projector_integral_and_row_permutations(dimension): + rng = np.random.default_rng(761 + dimension) + normals = np.r_[ + np.eye(dimension), -np.eye(dimension), rng.normal(size=(5, dimension)) + ] + normals /= np.linalg.norm(normals, axis=1, keepdims=True) + normals = np.r_[normals, normals[[0]]] + bounds = np.full(len(normals), -1.0) + model = method.PolytopeNeuralSDE( + normals, bounds, np.zeros(dimension), drift_bound=0.1, diffusion_scale=0.3 + ) + base = np.tril(rng.normal(size=(dimension, dimension)) * 0.05) + np.fill_diagonal(base, np.arange(1, dimension + 1) * 0.1) + _constant_diffusion(model, base) + points = np.r_[np.zeros((1, dimension)), rng.normal(size=(12, dimension)) * 0.05] + states = torch.tensor(points, dtype=torch.float64) + _, observed = model(states) + expected = np.array( + [ + _projector_integral(normals, normals @ point - bounds, 0.3) @ base + for point in points + ] + ) + assert_allclose(observed.detach(), expected, atol=1e-13) + permutation = rng.permutation(len(normals)) + reordered = method.PolytopeNeuralSDE( + normals[permutation], + bounds[permutation], + np.zeros(dimension), + drift_bound=0.1, + diffusion_scale=0.3, + ) + reordered.network.load_state_dict(model.network.state_dict()) + mu1, sigma1 = model(states) + mu2, sigma2 = reordered(states) + assert_allclose(mu1.detach(), mu2.detach(), atol=1e-14) + assert_allclose(sigma1.detach(), sigma2.detach(), atol=1e-13) + for epsilon in (1e-4, 1e-7, 1e-10): + direction = rng.normal(size=dimension) + direction /= np.linalg.norm(direction) + _, paired = model( + torch.tensor( + np.array([epsilon * direction, -epsilon * direction]), + dtype=torch.float64, + ) + ) + assert torch.linalg.matrix_norm(paired[0] - paired[1]) < 20 * epsilon + + +def test_nonsimple_vertex_and_linear_boundary_approach(): + normals = np.array( + [[1, 1, 1], [1, -1, 1], [-1, 1, 1], [-1, -1, 1], [0, 0, -1]], dtype=float + ) + bounds = np.array([0, 0, 0, 0, -1], dtype=float) + model = method.PolytopeNeuralSDE( + normals, bounds, [0, 0, 0.5], drift_bound=0.1, diffusion_scale=0.2 + ) + states = torch.tensor( + [[0, 0, 0], [0.3, 0, 0.3], [0, 0, 1]], dtype=torch.float64, requires_grad=True + ) + drift, diffusion = model(states) + for index, state in enumerate(states.detach().numpy()): + active = np.isclose(normals @ state - bounds, 0) + assert_allclose( + normals[active] @ diffusion[index].detach().numpy(), 0, atol=1e-13 + ) + assert np.min(normals[active] @ drift[index].detach().numpy()) >= -1e-12 + diffusion.square().sum().backward() + assert torch.isfinite(states.grad).all() + for epsilon in (1e-4, 1e-7, 1e-10, 1e-13): + _, sigma = model(torch.tensor([[0, 0, epsilon]], dtype=torch.float64)) + ratio = torch.linalg.matrix_norm(sigma[0]).item() / epsilon + assert 0 < ratio < 100 + + +def test_scalar_linear_boundary_and_polytope_agreement(): + scalar = method.IntervalNeuralSDE(-1, 1, drift_bound=0.1, diffusion_scale=0.2) + polytope = method.PolytopeNeuralSDE( + [[1], [-1]], [-1, -1], [0], drift_bound=0.1, diffusion_scale=0.2 + ) + _constant_diffusion(polytope, np.array([[0.2]])) + with torch.no_grad(): + for parameter in scalar.parameters(): + parameter.zero_() + scalar.network[-1].bias[1] = np.log(np.expm1(0.2)) + states = torch.tensor( + [-1, -1 + 1e-8, -0.2, 0.0, 0.4, 1], dtype=torch.float64, requires_grad=True + ) + mu, sigma = scalar(states) + other_mu, other_sigma = polytope(states[:, None]) + assert_allclose(mu.detach(), other_mu[:, 0].detach()) + assert_allclose(sigma.detach(), other_sigma[:, 0, 0].detach()) + sigma.sum().backward() + assert torch.isfinite(states.grad).all() + assert abs(states.grad[1].item()) <= 1.01 + + +def test_call_grid_witness_reprices_and_has_ordered_marginals(): + offset, _, _, _, _ = _grid() + strikes, maturities = np.array([0.8, 1, 1.2]), np.array([0.5, 1]) + result = method.normalized_call_grid_witness( + strikes, maturities, offset.reshape(2, 3) + ) + support, probabilities = result["support"], result["probabilities"] + assert np.all(probabilities >= 0) + assert_allclose(probabilities.sum(axis=1), 1) + assert_allclose(probabilities @ support, 1) + calls = probabilities @ np.maximum(support[:, None] - strikes, 0) + assert_allclose(calls, offset.reshape(2, 3), atol=1e-12) + dense_strikes = np.linspace(0, support[-1] * 1.1, 301) + dense_calls = probabilities @ np.maximum(support[:, None] - dense_strikes, 0) + assert np.min(np.diff(dense_calls, axis=0)) >= -1e-12 + deterministic = method.normalized_call_grid_witness([0.5, 1, 2], [1], [[0.5, 0, 0]]) + assert_allclose(deterministic["probabilities"] @ deterministic["support"], 1) + with pytest.raises(ValueError, match="positive flat tail"): + method.normalized_call_grid_witness([1, 2], [1], [[0.1, 0.1]]) + with pytest.raises(ValueError, match="inequalities"): + method.normalized_call_grid_witness([1, 2], [1], [[0.1, 0.2]]) + with pytest.raises(ValueError, match="shape"): + method.normalized_call_grid_witness([1, 2], [1], [[0.1, 0.05], [0.1, 0.05]]) + + +def test_multifactor_grid_martingale_levels_and_coupled_steps(): + """Check a bounded finite-grid martingale, not a dynamic payoff-pricing claim.""" + offset, loading, matrix, bounds, _ = _grid() + basis = np.column_stack([loading, loading * np.repeat([-1, 1], 3)]) + normals, state_bounds = matrix @ basis, bounds - matrix @ offset + nonzero = np.linalg.norm(normals, axis=1) > 1e-12 + model = method.PolytopeNeuralSDE( + normals[nonzero], + state_bounds[nonzero], + [0, 0], + drift_bound=0.1, + diffusion_scale=0.2, + ) + _constant_diffusion(model, np.diag([0.02, 0.015])) + increments = ( + torch.randn( + 128, + 64, + 2, + generator=torch.Generator().manual_seed(892), + dtype=torch.float64, + ) + / 8 + ) + initial = torch.zeros(128, 2, dtype=torch.float64) + fine = method.simulate_polytope_neural_sde(model, initial, increments, dt=1 / 64) + coarse = method.simulate_polytope_neural_sde( + model, initial, increments.reshape(128, 16, 4, 2).sum(dim=2), dt=1 / 16 + ) + assert torch.sqrt(torch.mean((fine[:, -1] - coarse[:, -1]) ** 2)) < 0.001 + for paths in (fine, coarse): + prices = offset + paths.numpy() @ basis.T + assert np.min(prices.reshape(-1, len(offset)) @ matrix.T - bounds) > 0 + terminal = prices[:, -1] + errors = terminal.mean(axis=0) - offset + standard_errors = terminal.std(axis=0, ddof=1) / np.sqrt(len(terminal)) + assert np.all(np.abs(errors) < 5 * standard_errors) + for prices in offset + fine[:, -1].numpy() @ basis.T: + witness = method.normalized_call_grid_witness( + [0.8, 1, 1.2], [0.5, 1], prices.reshape(2, 3) + ) + assert witness["maximum_price_error"] < 1e-12 diff --git a/test/test_book_method_validation.py b/test/test_book_method_validation.py new file mode 100644 index 0000000..95d2e6e --- /dev/null +++ b/test/test_book_method_validation.py @@ -0,0 +1,100 @@ +"""Invalid inputs and degenerate cases must fail explicitly.""" + +import numpy as np +import pytest + +from RiskLabAI.features.entropy_features.ambiguity import ( + probability_ambiguity, + normal_prior_bin_probabilities, +) +from RiskLabAI.features.microstructural_features.order_flow import ( + hawkes_log_likelihood, + fit_hawkes, + volume_synchronized_pin, + simulate_hawkes, +) +from RiskLabAI.backtest.tail_risk import ( + generalized_pareto_tail_risk, + fit_pot_tail_risk, + quantile_exception_tests, +) +from RiskLabAI.optimization.decision_focused import spo_plus_loss +from RiskLabAI.causal_factor_analysis.bounds_transport import ( + manski_ate_bounds, + transported_ate, +) +from RiskLabAI.causal_factor_analysis.interference import exposure_mean_contrast +from RiskLabAI.causal_factor_analysis.policy_evaluation import evaluate_policy + + +@pytest.mark.parametrize( + "call", + [ + lambda: probability_ambiguity([[0.2, 0.2]]), + lambda: probability_ambiguity([[-1, 2]]), + lambda: probability_ambiguity([[0.5, 0.5]], prior_weights=[0.5, 0.5]), + lambda: probability_ambiguity([[0.5, 0.5]], bin_width=1), + lambda: probability_ambiguity([[True, False]]), + lambda: normal_prior_bin_probabilities([0], [0]), + lambda: normal_prior_bin_probabilities([0], [1], edges=[1, 0]), + lambda: normal_prior_bin_probabilities([0, 1], [1]), + lambda: hawkes_log_likelihood([0.5], 1, 1, 1, 1), + lambda: hawkes_log_likelihood([0.5], 1, 0, 0, 1), + lambda: hawkes_log_likelihood([1.5], 1, 1, 0, 1), + lambda: hawkes_log_likelihood([0.7, 0.5], 1, 1, 0, 1), + lambda: hawkes_log_likelihood([np.nan], 1, 1, 0, 1), + lambda: fit_hawkes([], 1), + lambda: volume_synchronized_pin([1], [0], bucket_volume=1, window=2), + lambda: volume_synchronized_pin([1], [0], bucket_volume=1, window=True), + lambda: volume_synchronized_pin([-1], [2], bucket_volume=1, window=1), + lambda: generalized_pareto_tail_risk(0, 1.1, 0, 1, 0.9), + lambda: generalized_pareto_tail_risk(0, 0.1, 0, -1, 0.99), + lambda: fit_pot_tail_risk([1, 1], threshold=1, probability=0.99), + lambda: quantile_exception_tests([], exception_probability=0.1), + lambda: quantile_exception_tests([0, 0.5], exception_probability=0.1), + lambda: quantile_exception_tests([0, 1], exception_probability=1), + lambda: spo_plus_loss([1], [1, 2], [[1, 2]]), + lambda: spo_plus_loss([1], [2], []), + lambda: manski_ate_bounds([2], [1], lower=0, upper=1), + lambda: manski_ate_bounds([0.5], [2], lower=0, upper=1), + lambda: transported_ate([1], [0], [0.5], [1], [1]), + lambda: transported_ate([1], [0], [1], [-1], [1]), + lambda: exposure_mean_contrast([1], [0], [[1, 0]], first=0, second=1), + lambda: exposure_mean_contrast([1], [0], [[0.5, 0.5]], first=0, second=0), + lambda: exposure_mean_contrast([1], [0.0], [[0.5, 0.5]], first=0, second=1), + lambda: exposure_mean_contrast([1], [0], [[0.6, 0.6]], first=0, second=1), + lambda: evaluate_policy([0], [1], [[0.5, 0.5]], [[0.4, 0.4]], [[1, 2]]), + lambda: evaluate_policy([2], [1], [[0.5, 0.5]], [[0.5, 0.5]], [[1, 2]]), + lambda: evaluate_policy([True], [1], [[0.5, 0.5]], [[0.5, 0.5]], [[1, 2]]), + ], +) +def test_invalid_contract_inputs(call): + with pytest.raises(ValueError): + call() + + +def test_hawkes_cap_does_not_silently_truncate(): + with pytest.raises(RuntimeError, match="max_events"): + simulate_hawkes(100, 100, 0, 1, random_state=1, max_events=2) + + +def test_spo_first_vertex_tie_and_identical_costs(): + vertices = [[0, 0], [1, 0], [0, 1]] + result = spo_plus_loss([0, 0], [0, 0], vertices) + assert result["true_vertex"] == result["transformed_vertex"] == 0 + assert result["loss"] == 0 + + +def test_manski_all_treated_and_constant_outcomes(): + assert manski_ate_bounds([1, 1], [1, 1], lower=0, upper=1)["lower"] == 0 + result = manski_ate_bounds([2, 2], [0, 1], lower=2, upper=2) + assert result["lower"] == result["upper"] == 0 + + +def test_unobserved_policy_actions_have_explicit_zero_weight(): + result = evaluate_policy( + [0, 0], [1, 2], [[0.5, 0.5]] * 2, [[0, 1]] * 2, [[1, 3]] * 2 + ) + assert result["ipw_value"] == 0 + assert result["effective_sample_size"] == 0 + assert result["dr_value"] == 3 diff --git a/test/test_book_methods.py b/test/test_book_methods.py new file mode 100644 index 0000000..573d8ee --- /dev/null +++ b/test/test_book_methods.py @@ -0,0 +1,427 @@ +"""Independent mathematical checks for the bounded book-method contracts.""" + +import itertools + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from scipy.integrate import quad + +from RiskLabAI.features.entropy_features.ambiguity import ( + probability_ambiguity, + normal_prior_bin_probabilities, +) +from RiskLabAI.features.microstructural_features.order_flow import ( + hawkes_intensity, + hawkes_integrated_intensity, + hawkes_log_likelihood, + simulate_hawkes, + fit_hawkes, + volume_synchronized_pin, +) +from RiskLabAI.backtest.tail_risk import ( + generalized_pareto_tail_risk, + fit_pot_tail_risk, + quantile_exception_tests, +) +from RiskLabAI.optimization.constrained import ( + constrained_minimum_variance, + robust_mean_variance, +) +from RiskLabAI.optimization.decision_focused import spo_plus_loss, spo_plus_torch +from RiskLabAI.causal_factor_analysis.bounds_transport import ( + manski_ate_bounds, + transported_ate, +) +from RiskLabAI.causal_factor_analysis.interference import exposure_mean_contrast +from RiskLabAI.causal_factor_analysis.policy_evaluation import ( + evaluate_policy, + select_policy, +) + + +def test_probability_ambiguity_enumeration(): + p = np.array([[0.2, 0.8], [0.6, 0.4], [1, 0]]) + w = np.array([0.2, 0.3, 0.5]) + expected = sum( + sum(w[i] * p[i, k] for i in range(3)) + * sum( + w[i] * w[j] * (p[i, k] - p[j, k]) ** 2 / 2 + for i in range(3) + for j in range(3) + ) + for k in range(2) + ) + result = probability_ambiguity(p, prior_weights=w, bin_width=0.002) + assert result["unscaled"] == pytest.approx(expected) + assert result["scaled"] == pytest.approx(expected / (0.002 * 0.998)) + assert probability_ambiguity(p[::-1], prior_weights=w[::-1])[ + "unscaled" + ] == pytest.approx(expected) + assert probability_ambiguity([[0.5, 0.5]] * 3)["unscaled"] == 0 + assert probability_ambiguity([[0, 1], [1, 0]])["unscaled"] == 0.25 + + +def test_normal_bins_and_units(): + p = normal_prior_bin_probabilities([0, 0.01, -100], [0.01, 0.02, 0.1]) + assert p.shape == (3, 62) + assert_allclose(p.sum(axis=1), 1) + assert_allclose(p[0], p[0, ::-1], atol=1e-15) + assert np.all(p >= 0) + assert_allclose( + normal_prior_bin_probabilities([1], [2], edges=[-3, 0, 4]), + normal_prior_bin_probabilities([100], [200], edges=[-300, 0, 400]), + ) + + +@pytest.mark.parametrize("mu,alpha,beta", [(0.8, 0, 1), (0.3, 0.7, 2), (3, 0.01, 0.02)]) +def test_hawkes_integral_and_direct_likelihood(mu, alpha, beta): + events = np.array([0.2, 0.7, 1.4, 2.0]) + horizon = 3.0 + integral = quad( + lambda t: mu + + sum(alpha * np.exp(-beta * (t - event)) for event in events if event < t), + 0, + horizon, + points=events, + )[0] + assert hawkes_integrated_intensity( + events, horizon, mu, alpha, beta + ) == pytest.approx(integral) + direct = ( + sum( + np.log( + mu + + sum( + alpha * np.exp(-beta * (event - past)) + for past in events + if past < event + ) + ) + for event in events + ) + - integral + ) + assert hawkes_log_likelihood(events, horizon, mu, alpha, beta) == pytest.approx( + direct + ) + assert_allclose(hawkes_intensity([0, 0.2], events, mu, alpha, beta), mu) + + +def test_hawkes_simulation_and_fit(): + events = simulate_hawkes(100, 1.0, 0.4, 1.2, random_state=93) + assert_allclose(events, simulate_hawkes(100, 1.0, 0.4, 1.2, random_state=93)) + assert np.all(np.diff(events) > 0) and np.all((events >= 0) & (events <= 100)) + fit = fit_hawkes(events, 100) + assert 0 <= fit["alpha"] < fit["beta"] and fit["mu"] > 0 + poisson = len(events) * np.log(len(events) / 100) - len(events) + assert fit["log_likelihood"] >= poisson - 1e-5 + assert hawkes_log_likelihood([], 3, 2, 0, 1) == -6 + with pytest.raises(ValueError): + hawkes_log_likelihood([1, 1], 3, 2, 0, 1) + + +def test_vpin_completed_buckets(): + assert_allclose( + volume_synchronized_pin( + [5, 10, 0, 5], [5, 0, 10, 5], bucket_volume=10, window=2 + ), + [0.5, 1, 0.5], + ) + with pytest.raises(ValueError): + volume_synchronized_pin([5, 8], [5, 0], bucket_volume=10, window=2) + + +@pytest.mark.parametrize("shape", [-0.4, 0, 0.3, 0.9]) +def test_pareto_tail_integral(shape): + from scipy.stats import genpareto + + result = generalized_pareto_tail_risk(2, 0.1, shape, 0.7, 0.98) + q = result["value_at_risk"] - 2 + upper = np.inf if shape >= 0 else -0.7 / shape + excess_mean = quad( + lambda x: genpareto.sf(x, shape, scale=0.7) / genpareto.sf(q, shape, scale=0.7), + q, + upper, + )[0] + assert result["expected_shortfall"] == pytest.approx( + result["value_at_risk"] + excess_mean, rel=1e-6 + ) + assert genpareto.cdf(q, shape, scale=0.7) == pytest.approx(0.8) + + +def test_pareto_fit_and_domains(): + from scipy.stats import genpareto + + losses = np.r_[ + np.zeros(100), 2 + genpareto.ppf(np.linspace(0.001, 0.999, 500), 0, scale=0.7) + ] + result = fit_pot_tail_risk(losses, threshold=2, probability=0.98) + assert abs(result["shape"]) < 0.03 + assert result["scale"] == pytest.approx(0.7, rel=0.03) + assert np.isinf( + generalized_pareto_tail_risk(0, 0.1, 1, 1, 0.99)["expected_shortfall"] + ) + with pytest.raises(ValueError): + generalized_pareto_tail_risk(0, 0.1, 0, 1, 0.5) + + +def test_exception_likelihood_degenerate_and_alternating(): + result = quantile_exception_tests([0, 1, 0, 1, 0], exception_probability=0.4) + assert result["coverage_statistic"] == pytest.approx(0) + assert_allclose(result["transitions"], [[0, 2], [2, 0]]) + assert result["independence_statistic"] == pytest.approx(8 * np.log(2)) + for value in (0, 1): + result = quantile_exception_tests([value] * 4, exception_probability=0.2) + assert result["independence_statistic"] is None + assert result["coverage_statistic"] == pytest.approx( + -8 * np.log(0.2 if value else 0.8) + ) + + +@pytest.mark.parametrize("scale", [1e-8, 1, 1e8]) +def test_constrained_portfolio_analytic(scale): + pytest.importorskip("cvxpy") + result = constrained_minimum_variance(np.diag([1, 2, 4]) * scale) + assert_allclose(result["weights"], [4 / 7, 2 / 7, 1 / 7], atol=1e-5) + constrained = constrained_minimum_variance( + np.eye(2) * scale, expected_returns=[0, 1], return_floor=0.8 + ) + assert_allclose(constrained["weights"], [0.2, 0.8], atol=1e-5) + with pytest.raises(ValueError): + constrained_minimum_variance( + np.eye(2), expected_returns=[0, 1], return_floor=0.8, caps=[1, 0.5] + ) + with pytest.raises(ValueError): + constrained_minimum_variance([[1, 2], [2, 1]]) + with pytest.raises(ValueError): + constrained_minimum_variance(np.diag([-1, 2]) * scale * 1e-15) + with pytest.raises(ValueError): + constrained_minimum_variance(np.array([[1, 1], [0, 1]]) * scale * 1e-15) + + +def test_robust_portfolio_grid_oracle(): + pytest.importorskip("cvxpy") + mean, covariance, loading = ( + np.array([0.1, 0.2]), + np.diag([0.2, 0.3]), + np.diag([0.03, 0.08]), + ) + for radius in (0, 1.2): + result = robust_mean_variance( + mean, covariance, loading, radius=radius, risk_aversion=2 + ) + grid = np.column_stack((np.linspace(0, 1, 10001), np.linspace(1, 0, 10001))) + objective = ( + grid @ mean + - radius * np.linalg.norm(grid @ loading, axis=1) + - np.einsum("ni,ij,nj->n", grid, covariance, grid) + ) + assert result["objective"] >= objective.max() - 1e-8 + w = result["weights"] + direction = -loading.T @ w / np.linalg.norm(loading.T @ w) + assert w @ (mean + radius * loading @ direction) == pytest.approx( + result["worst_case_expected_return"] + ) + + +def test_spo_subgradient_and_torch(): + torch = pytest.importorskip("torch") + vertices = np.array([[0, 0], [1, 0], [0, 1], [0.2, 0.8]]) + actual, predicted = np.array([2, -1]), np.array([-0.4, 0.8]) + result = spo_plus_loss(predicted, actual, vertices) + for shifted in np.random.default_rng(54).normal(size=(30, 2)): + assert ( + spo_plus_loss(shifted, actual, vertices)["loss"] + >= result["loss"] + result["subgradient"] @ (shifted - predicted) - 1e-12 + ) + prediction = torch.tensor(predicted, requires_grad=True) + loss = spo_plus_torch( + prediction, torch.tensor(actual, dtype=torch.float64), torch.tensor(vertices) + ) + loss.backward() + assert loss.item() == pytest.approx(result["loss"]) + assert_allclose(prediction.grad, result["subgradient"]) + assert spo_plus_loss(actual, actual, vertices)["loss"] == pytest.approx(0) + + +def test_manski_extreme_counterfactual_enumeration(): + observed, treatment = np.array([0.2, 0.8, 0.5]), np.array([1, 0, 1]) + effects = [] + for missing in itertools.product([0, 1], repeat=3): + y1 = np.where(treatment, observed, missing) + y0 = np.where(treatment, missing, observed) + effects.append(np.mean(y1 - y0)) + bounds = manski_ate_bounds(observed, treatment, lower=0, upper=1) + assert bounds["lower"] == min(effects) + assert bounds["upper"] == max(effects) + + +def test_transport_standardization_support(): + assert ( + transported_ate([2, 4, 8], [1, 2, 9], [0.25, 0.75, 0], [2, 4, 0], [3, 7, 0]) + == 1.75 + ) + with pytest.raises(ValueError): + transported_ate([2, 4], [1, 2], [0.25, 0.75], [2, 0], [3, 7]) + + +def test_exposure_contrast_exact_randomized_design(): + assignments = list(itertools.product([0, 1], repeat=3)) + exposure = np.array( + [[int(a[(i + 1) % 3] or a[(i + 2) % 3]) for i in range(3)] for a in assignments] + ) + probabilities = np.array([[0.25, 0.75]] * 3) + potential = np.array([[1, 2], [3, 7], [2, 1]]) + estimates = [] + for realized in exposure: + y = potential[np.arange(3), realized] + estimates.append( + exposure_mean_contrast(y, realized, probabilities, first=1, second=0)[ + "contrast" + ] + ) + assert np.mean(estimates) == pytest.approx( + np.mean(potential[:, 1] - potential[:, 0]) + ) + + +def test_policy_double_robustness_exact_population(): + actions = np.array([0, 0, 0, 1]) + rewards = np.array([2, 2, 2, 6]) + true_behavior = np.array([[0.75, 0.25]] * 4) + policy = np.array([[0.4, 0.6]] * 4) + correct_q = np.array([[2, 6]] * 4) + for behavior, q in ( + (true_behavior, np.zeros((4, 2))), + (np.ones((4, 2)) / 2, correct_q), + ): + result = evaluate_policy(actions, rewards, behavior, policy, q) + assert result["dr_value"] == pytest.approx(4.4) + with pytest.raises(ValueError): + evaluate_policy([0], [1], [[1, 0]], [[0, 1]], [[1, 1]]) + + +def test_policy_selection_does_not_use_evaluation_outcomes(): + actions = np.array([0, 1, 0, 1]) + behavior = np.ones((4, 2)) / 2 + policies = np.stack([np.tile([1, 0], (4, 1)), np.tile([0, 1], (4, 1))]) + for rewards in ([1, 3, 100, -100], [1, 3, -100, 100]): + result = select_policy( + actions, + rewards, + behavior, + policies, + np.zeros((4, 2)), + training_indices=[0, 1], + evaluation_indices=[2, 3], + ) + assert result["selected_policy"] == 1 + with pytest.raises(ValueError): + select_policy( + actions, + rewards, + behavior, + policies, + np.zeros((4, 2)), + training_indices=[0, 2], + evaluation_indices=[1, 3], + ) + + +def test_sensitivity_supplier_against_omitted_variable_ols(): + sensemakr = pytest.importorskip("sensemakr") + import statsmodels.api as sm + + rng = np.random.default_rng(65) + x, z, noise, noise_y = rng.normal(size=(4, 500)) + d = 0.8 * z + 0.3 * x + noise + y = 2 * d + 1.4 * z + x + noise_y + reduced = sm.OLS(y, sm.add_constant(np.column_stack([d, x]))).fit() + full = sm.OLS(y, sm.add_constant(np.column_stack([d, x, z]))).fit() + d_reduced = sm.OLS(d, sm.add_constant(x)).fit() + d_full = sm.OLS(d, sm.add_constant(np.column_stack([x, z]))).fit() + r2d = 1 - d_full.ssr / d_reduced.ssr + r2y = 1 - full.ssr / reduced.ssr + adjusted = sensemakr.adjusted_estimate( + float(r2d), + float(r2y), + estimate=float(reduced.params[1]), + se=float(reduced.bse[1]), + dof=int(reduced.df_resid), + ) + adjusted_se = sensemakr.adjusted_se( + float(r2d), float(r2y), se=float(reduced.bse[1]), dof=int(reduced.df_resid) + ) + assert float(np.asarray(adjusted).item()) == pytest.approx(full.params[1]) + assert float(np.asarray(adjusted_se).item()) == pytest.approx(full.bse[1]) + assert ( + float( + np.asarray( + sensemakr.adjusted_estimate(0, 0, estimate=2, se=0.1, dof=30) + ).item() + ) + == 2 + ) + + +def test_markov_supplier_filter_has_no_future_information(): + from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression + + data = np.random.default_rng(4).normal(size=50) + parameters = np.array([0.9, 0.8, -1, 1, 0.5, 2]) + full = ( + MarkovRegression(data, k_regimes=2, trend="c", switching_variance=True) + .filter(parameters) + .filtered_marginal_probabilities + ) + prefix = ( + MarkovRegression(data[:25], k_regimes=2, trend="c", switching_variance=True) + .filter(parameters) + .filtered_marginal_probabilities + ) + assert_allclose(full[:25], prefix) + assert_allclose(full.sum(axis=1), 1) + swapped = np.array( + [ + 1 - parameters[1], + 1 - parameters[0], + parameters[3], + parameters[2], + parameters[5], + parameters[4], + ] + ) + reversed_states = ( + MarkovRegression(data, k_regimes=2, trend="c", switching_variance=True) + .filter(swapped) + .filtered_marginal_probabilities + ) + assert_allclose(full, reversed_states[:, ::-1], atol=1e-12) + + +def test_adaptation_prediction_precedes_current_outcome(): + pytest.importorskip("river") + from sklearn.dummy import DummyRegressor + from RiskLabAI.backtest.adaptation import prequential_adaptation + + x = np.arange(250)[:, None] + y = np.r_[np.zeros(100), np.ones(150)] + arguments = dict( + estimator=DummyRegressor(), + loss_function=lambda actual, prediction: abs(actual - prediction), + window_size=30, + min_samples=20, + delta=0.1, + ) + full = prequential_adaptation(x, y, **arguments) + assert len(full["alarm_indices"]) > 0 + for endpoint in (101, 160): + prefix = prequential_adaptation(x[:endpoint], y[:endpoint], **arguments) + assert_allclose( + full["predictions"][:endpoint], prefix["predictions"], equal_nan=True + ) + changed = y.copy() + changed[100] = 0 + altered = prequential_adaptation(x, changed, **arguments) + assert full["predictions"][100] == altered["predictions"][100] diff --git a/test/test_existing_book_routes.py b/test/test_existing_book_routes.py new file mode 100644 index 0000000..fce0e3a --- /dev/null +++ b/test/test_existing_book_routes.py @@ -0,0 +1,103 @@ +"""Direct mathematical checks for previously indirectly tested book routes.""" + +import numpy as np +import pandas as pd +import pytest +from numpy.testing import assert_allclose +from scipy.cluster.hierarchy import leaves_list + +from RiskLabAI.data.synthetic_data.simulation import form_true_matrix, simulates_cov_mu +from RiskLabAI.optimization.hrp import ( + cluster_variance, + quasi_diagonal, + recursive_bisection, +) + + +def test_covariance_simulation_preserves_block_spectrum(): + state = np.random.get_state() + try: + np.random.seed(740) + mean, covariance = form_true_matrix(2, 3, 0.4) + assert mean.shape == (6, 1) + standard_deviation = np.sqrt(np.diag(covariance)) + correlation = covariance / np.outer(standard_deviation, standard_deviation) + assert_allclose(np.linalg.eigvalsh(correlation), [0.6] * 4 + [1.8] * 2) + simulated_mean, simulated_covariance = simulates_cov_mu( + np.zeros((2, 1)), np.diag([1, 4]), 50000 + ) + assert_allclose(simulated_mean, 0, atol=0.03) + assert_allclose(simulated_covariance, np.diag([1, 4]), atol=0.06) + finally: + np.random.set_state(state) + + +def test_hrp_helpers_against_diagonal_allocation_and_scipy_tree(): + covariance = pd.DataFrame( + np.diag([1, 2, 4, 8]), index=list("abcd"), columns=list("abcd") + ) + assert cluster_variance(covariance, ["a", "c"]) == pytest.approx(0.8) + allocation = recursive_bisection(covariance, list("abcd")) + assert_allclose(allocation, np.array([8, 4, 2, 1]) / 15) + linkage = np.array([[0, 2, 0.1, 2], [1, 3, 0.2, 2], [4, 5, 0.8, 4]]) + assert quasi_diagonal(linkage) == leaves_list(linkage).tolist() + + +def test_barenblatt_exact_solution_satisfies_public_driver(): + torch = pytest.importorskip("torch") + equation = pytest.importorskip("RiskLabAI.pde.equation") + pde = equation.BlackScholesBarenblatt( + {"dim": 2, "total_time": 1, "num_time_interval": 8} + ) + x = torch.tensor([[0.7, 1.3], [1.1, 0.9]], dtype=torch.float64, requires_grad=True) + time = torch.tensor([[0.2], [0.5]], dtype=torch.float64, requires_grad=True) + value = torch.exp((pde.sigma**2 + pde.rate) * (1 - time)) * (x**2).sum( + dim=1, keepdim=True + ) + dx, dt = torch.autograd.grad(value.sum(), (x, time), create_graph=True) + dxx = torch.stack( + [ + torch.autograd.grad(dx[:, j].sum(), x, retain_graph=True)[0][:, j] + for j in range(2) + ], + dim=1, + ) + z = pde.sigma_matrix(x) * dx + ito_drift = dt + 0.5 * (pde.sigma_matrix(x).square() * dxx).sum(dim=1, keepdim=True) + bsde_drift = pde.r_u(time, x, value, z) * value + pde.h_z(time, x, value, z) + assert_allclose(ito_drift.detach(), bsde_drift.detach(), atol=1e-8) + assert_allclose( + pde.terminal(time, x).detach(), (x**2).sum(dim=1, keepdim=True).detach() + ) + + +def test_default_risk_full_recovery_linear_pricing_limit(): + torch = pytest.importorskip("torch") + equation = pytest.importorskip("RiskLabAI.pde.equation") + solver_module = pytest.importorskip("RiskLabAI.pde.solver") + pde = equation.PricingDefaultRisk( + {"dim": 1, "total_time": 1, "num_time_interval": 4} + ) + pde.delta = 1.0 + + class ExactGradient(torch.nn.Module): + def forward(self, time, values): + return pde.sigma * values + + solver = solver_module.FBSDESolver( + pde, [1, 4, 1], 0.001, "DTNN", torch.device("cpu") + ) + solver.solver = ExactGradient() + dw = torch.tensor( + [[[0.1, -0.1, 0.2, -0.2]], [[-0.2, 0.1, -0.1, 0.2]]], dtype=torch.float32 + ) + prices = torch.zeros(2, 1, 5) + prices[:, :, 0] = 100 + for index in range(4): + prices[:, :, index + 1] = prices[:, :, index] * ( + 1 + pde.rate * pde.delta_t + pde.sigma * dw[:, :, index] + ) + loss, _, _, _ = solver.compute_loss( + prices, dw, torch.ones(2, 1), torch.tensor([100.0]), torch.tensor([[20.0]]) + ) + assert loss.item() < 1e-8 diff --git a/test/test_learned_book_methods.py b/test/test_learned_book_methods.py new file mode 100644 index 0000000..24c217f --- /dev/null +++ b/test/test_learned_book_methods.py @@ -0,0 +1,238 @@ +"""Cash-flow, information-boundary and objective checks for learned methods.""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +torch = pytest.importorskip("torch") + +hedging = pytest.importorskip("RiskLabAI.backtest.deep_hedging") +generation = pytest.importorskip("RiskLabAI.data.synthetic_data.timegan") +gbm_hedging_paths = hedging.gbm_hedging_paths +hedging_pnl = hedging.hedging_pnl +entropic_risk = hedging.entropic_risk +black_scholes_call_delta = hedging.black_scholes_call_delta +DeepHedge = hedging.DeepHedge +train_deep_hedge = hedging.train_deep_hedge +chronological_timegan_data = generation.chronological_timegan_data +TimeGAN = generation.TimeGAN +fit_timegan = generation.fit_timegan +timegan_quality_report = generation.timegan_quality_report + + +def test_cash_flow_from_cash_account_and_terminal_sale(): + prices = torch.tensor([[100, 110, 105], [100, 90, 95]], dtype=torch.float64) + positions = torch.tensor( + [[0.4, 0.8], [-0.2, 0.1]], dtype=torch.float64, requires_grad=True + ) + result = hedging_pnl(prices, positions, strike=100, cost_rate=0.01, premium=3) + expected = [] + for row in range(2): + cash, holding = 3, 0 + for index in range(2): + desired = positions.detach().numpy()[row, index] + trade = desired - holding + cash -= ( + trade * prices[row, index].item() + + abs(trade) * prices[row, index].item() * 0.01 + ) + holding = desired + cash += ( + holding * prices[row, -1].item() + - abs(holding) * prices[row, -1].item() * 0.01 + ) + cash -= max(prices[row, -1].item() - 100, 0) + expected.append(cash) + assert_allclose(result.detach(), expected, atol=1e-13) + result.sum().backward() + assert torch.isfinite(positions.grad).all() + zero = hedging_pnl(prices, torch.zeros_like(positions), strike=100, cost_rate=0.5) + assert_allclose(zero, [-5, 0]) + + +def test_entropic_risk_translation_and_independent_exponential(): + pnl = torch.tensor([-2, 1, 3], dtype=torch.float64) + expected = np.log(np.exp(-0.7 * pnl.numpy()).mean()) / 0.7 + assert entropic_risk(pnl, 0.7).item() == pytest.approx(expected) + assert entropic_risk(pnl + 50000, 0.7).item() == pytest.approx(expected - 50000) + assert entropic_risk(pnl, 1e-6).item() == pytest.approx( + -pnl.mean().item(), abs=1e-5 + ) + + +def test_gbm_exact_deterministic_and_local_seed(): + before = torch.random.get_rng_state().clone() + arguments = dict( + spot=100, maturity=1, volatility=0, drift=0.1, steps=4, paths=3, seed=24 + ) + paths = gbm_hedging_paths(**arguments) + assert_allclose(paths, np.tile(100 * np.exp(np.arange(5) * 0.025), (3, 1))) + assert torch.equal(before, torch.random.get_rng_state()) + assert torch.equal(paths, gbm_hedging_paths(**arguments)) + + +def test_hedge_causal_features_and_seed(): + before = torch.random.get_rng_state().clone() + model = DeepHedge(5, seed=64) + assert torch.equal(before, torch.random.get_rng_state()) + prices = gbm_hedging_paths( + spot=1, maturity=1, volatility=0.2, drift=0, steps=8, paths=5, seed=13 + ) + changed = prices.clone() + changed[:, 4:] *= 2 + positions = model(prices, strike=1, maturity=1) + altered = model(changed, strike=1, maturity=1) + assert_allclose(positions[:, :4].detach(), altered[:, :4].detach(), atol=0) + twin = DeepHedge(5, seed=64) + assert_allclose( + positions.detach(), twin(prices, strike=1, maturity=1).detach(), atol=0 + ) + + +def test_delta_hedging_refinement_against_call_price(): + from scipy.special import ndtr + + fine = gbm_hedging_paths( + spot=1, maturity=1, volatility=0.2, drift=0, steps=128, paths=2048, seed=821 + ) + option_price = ndtr(0.1) - ndtr(-0.1) + errors = [] + for stride in (16, 1): + prices = fine[:, ::stride] + positions = black_scholes_call_delta( + prices, strike=1, maturity=1, volatility=0.2 + ) + pnl = hedging_pnl( + prices, positions, strike=1, cost_rate=0, premium=option_price + ) + errors.append(pnl.square().mean().item()) + assert abs(pnl.mean().item()) < 0.004 + assert errors[1] < errors[0] / 4 + + +def test_deep_hedge_training_and_independent_evaluation(): + train = gbm_hedging_paths( + spot=1, maturity=1, volatility=0.2, drift=0, steps=8, paths=128, seed=61 + ) + evaluation = gbm_hedging_paths( + spot=1, maturity=1, volatility=0.2, drift=0, steps=8, paths=256, seed=62 + ) + model = DeepHedge(8, seed=63) + history = train_deep_hedge( + model, + train, + strike=1, + maturity=1, + cost_rate=0.002, + risk_aversion=3, + iterations=20, + learning_rate=0.01, + ) + assert np.isfinite(history).all() + assert history[-1] < history[0] + for positions in ( + model(evaluation, strike=1, maturity=1), + black_scholes_call_delta(evaluation, strike=1, maturity=1, volatility=0.2), + ): + risk = entropic_risk( + hedging_pnl(evaluation, positions, strike=1, cost_rate=0.002), 3 + ) + assert torch.isfinite(risk) + + +def test_timegan_scaling_and_nonoverlapping_split(): + series = np.column_stack([np.arange(20), np.ones(20)]) + result = chronological_timegan_data(series, training_end=12, window_length=4) + assert_allclose(result["minimum"], [0, 1]) + assert_allclose(result["scale"], [11, 1]) + assert_allclose( + result["training"][-1] * result["scale"] + result["minimum"], series[8:12] + ) + assert_allclose( + result["validation"][0] * result["scale"] + result["minimum"], series[12:16] + ) + changed = series.copy() + changed[12:] *= 100 + altered = chronological_timegan_data(changed, training_end=12, window_length=4) + assert_allclose(result["training"], altered["training"]) + assert_allclose(result["scale"], altered["scale"]) + + +def test_timegan_objectives_independent_arrays_and_gradients(): + model = TimeGAN(2, 4, seed=1) + rng = np.random.default_rng(42) + observed = torch.tensor(rng.uniform(size=(3, 5, 2)), dtype=torch.float64) + noise = torch.tensor(rng.normal(size=(3, 5, 4)), dtype=torch.float64) + losses = model.losses(observed, noise) + with torch.no_grad(): + h = model.embedding(observed) + reconstructed = model.recovery(h).numpy() + synthetic = model.latent_sequence(noise) + teacher = model.latent_sequence(noise, h).numpy() + real_logits = model.discriminator(h).numpy() + fake_logits = model.discriminator(synthetic).numpy() + reconstruction = ( + sum(np.sum((a - b) ** 2) for a, b in zip(observed.numpy(), reconstructed)) / 3 + ) + supervised = sum(np.sum((a - b) ** 2) for a, b in zip(h.numpy(), teacher)) / 3 + discriminator = ( + np.logaddexp(0, -real_logits).sum() / 3 + np.logaddexp(0, fake_logits).sum() / 3 + ) + adversarial = -np.logaddexp(0, fake_logits).sum() / 3 + for key, value in zip( + ["reconstruction", "supervised", "discriminator", "generator_adversarial"], + [reconstruction, supervised, discriminator, adversarial], + ): + assert losses[key].item() == pytest.approx(value) + sum(losses.values()).backward() + assert all( + p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters() + ) + changed = noise.clone() + changed[:, 3:] += 100 + assert_allclose( + model.latent_sequence(noise)[:, :3].detach(), + model.latent_sequence(changed)[:, :3].detach(), + atol=0, + ) + + +def test_timegan_three_phase_determinism_and_quality_report(): + before = torch.random.get_rng_state().clone() + model = TimeGAN(1, 4, seed=8) + assert torch.equal(before, torch.random.get_rng_state()) + series = np.sin(np.arange(100) / 4)[:, None] + data = chronological_timegan_data(series, training_end=60, window_length=6) + settings = dict( + embedding_steps=2, supervised_steps=2, joint_steps=2, batch_size=5, seed=12 + ) + history = fit_timegan(model, data["training"], **settings) + twin = TimeGAN(1, 4, seed=8) + assert fit_timegan(twin, data["training"], **settings) == history + assert [entry["phase"] for entry in history] == ["embedding"] * 2 + [ + "supervised" + ] * 2 + ["joint"] * 2 + assert_allclose(model.sample(20, 6, seed=10), twin.sample(20, 6, seed=10), atol=0) + quality = timegan_quality_report( + data["training"], + data["validation"], + model.sample(40, 6, seed=20).numpy(), + model.sample(40, 6, seed=21).numpy(), + ) + assert 0 <= quality["balanced_discriminator_accuracy"] <= 1 + assert quality["synthetic_trained_prediction_mse"] >= 0 + assert np.isfinite(quality["lag_one_absolute_gap"]).all() + + +def test_quality_detects_destroyed_temporal_information(): + rng = np.random.default_rng(891) + sequences = np.repeat(rng.normal(size=(100, 1, 1)), 5, axis=1) + independent = rng.normal(size=(100, 5, 1)) + result = timegan_quality_report( + sequences[:60], sequences[60:], independent[:60], independent[60:] + ) + assert result["lag_one_absolute_gap"][0] > 0.7 + assert ( + result["synthetic_trained_prediction_mse"] + > result["real_trained_prediction_mse"] + 0.1 + )