diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..11d6b09 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Frozen evidence is hashed as bytes; checkouts must not rewrite line endings. +docs/experiments/real-sp500/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88a97ca..b56818a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - name: Docs integrity (mkdocs nav resolves) run: python scripts/check_docs.py + - name: Independently verify the saved real-market comparison + run: python scripts/verify_comparison.py docs/experiments/real-sp500 + - name: Unit & regression tests (fast) run: pytest -m "not slow" diff --git a/.gitignore b/.gitignore index d83c57b..1054920 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ data/processed/* .pytest_tmp/ # MkDocs build output site/ +outputs/ +research/real_runs/ +research/comparison/ diff --git a/README.md b/README.md index 85a2459..78eb30e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # AlphaForge +[Real-market comparison](docs/research-comparison.md): frozen data, shared +walk-forward coverage, equal-weight and momentum baselines, three model seeds, +one explicit size-feature ablation, and 22 base/doubled-cost cases. Source +history and availability limitations keep this experiment exploratory. + **Python toolkit for factor research, portfolio construction, and backtesting** [![CI](https://github.com/dev-belly/alphaforge/actions/workflows/ci.yml/badge.svg)](https://github.com/dev-belly/alphaforge/actions/workflows/ci.yml) diff --git a/configs/real_sp500.yaml b/configs/real_sp500.yaml index d546ecc..dd44ed8 100644 --- a/configs/real_sp500.yaml +++ b/configs/real_sp500.yaml @@ -23,7 +23,7 @@ data: provider: local universe: SP500 start_date: "2000-01-01" - end_date: "2019-12-31" + end_date: "2019-11-14" cache_dir: data/processed raw_dir: data/raw storage_backend: parquet @@ -88,7 +88,7 @@ risk: backtest: start_date: "2005-01-01" - end_date: "2019-12-31" + end_date: "2019-11-14" rebalance: monthly initial_capital: 10_000_000 execution_lag_days: 1 @@ -103,7 +103,7 @@ cost: min_commission: 0.0 reporting: - output_dir: research/reports + output_dir: research/real_runs/report figure_dpi: 130 figure_format: png diff --git a/docs/experiments/real-sp500/daily_returns.csv.gz b/docs/experiments/real-sp500/daily_returns.csv.gz new file mode 100644 index 0000000..b945bf1 Binary files /dev/null and b/docs/experiments/real-sp500/daily_returns.csv.gz differ diff --git a/docs/experiments/real-sp500/folds.csv b/docs/experiments/real-sp500/folds.csv new file mode 100644 index 0000000..b6d0a5f --- /dev/null +++ b/docs/experiments/real-sp500/folds.csv @@ -0,0 +1,55 @@ +method,seed,fold,rank_ic_mean,rank_ic_std,n_days,icir +ridge,11,0,0.04412592633746016,0.0966486106764495,252,0.4565603791779325 +ridge,11,1,0.018713655037396176,0.08398618101527376,252,0.22281826380452877 +ridge,11,2,-0.02324649092185162,0.0944463490283868,252,-0.24613435205276862 +ridge,11,3,0.02219967924672747,0.08837599819123425,251,0.2511957963822964 +ridge,11,4,-0.005444657308135957,0.12453645599247631,251,-0.04371938533777522 +ridge,11,5,0.009387714645803524,0.10163328992091236,200,0.09236850104044383 +ridge,29,0,0.04412592633746016,0.0966486106764495,252,0.4565603791779325 +ridge,29,1,0.018713655037396176,0.08398618101527376,252,0.22281826380452877 +ridge,29,2,-0.02324649092185162,0.0944463490283868,252,-0.24613435205276862 +ridge,29,3,0.02219967924672747,0.08837599819123425,251,0.2511957963822964 +ridge,29,4,-0.005444657308135957,0.12453645599247631,251,-0.04371938533777522 +ridge,29,5,0.009387714645803524,0.10163328992091236,200,0.09236850104044383 +ridge,42,0,0.04412592633746016,0.0966486106764495,252,0.4565603791779325 +ridge,42,1,0.018713655037396176,0.08398618101527376,252,0.22281826380452877 +ridge,42,2,-0.02324649092185162,0.0944463490283868,252,-0.24613435205276862 +ridge,42,3,0.02219967924672747,0.08837599819123425,251,0.2511957963822964 +ridge,42,4,-0.005444657308135957,0.12453645599247631,251,-0.04371938533777522 +ridge,42,5,0.009387714645803524,0.10163328992091236,200,0.09236850104044383 +lightgbm,11,0,0.022477486143247915,0.08734886863632098,252,0.25733002034443564 +lightgbm,11,1,0.002234562428456477,0.05797573874961744,252,0.038543060884605324 +lightgbm,11,2,-0.0011553035855261433,0.10126092459062845,252,-0.011409174765061003 +lightgbm,11,3,0.03166639744953009,0.06502624933653303,251,0.48697868587876997 +lightgbm,11,4,0.031668053472560784,0.08124079588245281,251,0.3898048157773004 +lightgbm,11,5,0.03433905815716499,0.055633472983191,200,0.6172373629728299 +lightgbm,29,0,0.025787883052372746,0.08480912689437926,252,0.3040696679319527 +lightgbm,29,1,-0.0030296977978654153,0.057293289941090675,252,-0.05288049963583118 +lightgbm,29,2,0.00907244205085725,0.09838261707558023,252,0.09221590480651223 +lightgbm,29,3,0.032402329646731974,0.06749892297971691,251,0.48004217276872274 +lightgbm,29,4,0.017454562276111394,0.08673166092103583,251,0.20124787293077168 +lightgbm,29,5,0.039869933952373565,0.06175223753608078,200,0.6456435514434314 +lightgbm,42,0,0.020528439407260922,0.09214009289193455,252,0.22279594867933838 +lightgbm,42,1,-0.0035977333884247465,0.0583879427725417,252,-0.06161774533554323 +lightgbm,42,2,0.005875082979020472,0.1014245680706104,252,0.05792563962343245 +lightgbm,42,3,0.03195550150870484,0.06497403962210874,251,0.4918195281463049 +lightgbm,42,4,0.017572815275974017,0.08240420495439846,251,0.21325143887619097 +lightgbm,42,5,0.04023812238863307,0.05787186037686153,200,0.6952968528504602 +ridge_without_size_feature,11,0,0.04359069019775172,0.09661460929879445,252,0.4511811465587083 +ridge_without_size_feature,11,1,0.016504867360824224,0.08288305728509346,252,0.19913439370428038 +ridge_without_size_feature,11,2,-0.02476588151153135,0.09478300955559105,252,-0.2612903053791086 +ridge_without_size_feature,11,3,0.02027092225650099,0.09210790690979509,251,0.22007798175625798 +ridge_without_size_feature,11,4,-0.007838736870762348,0.12484721777420321,251,-0.0627866364226023 +ridge_without_size_feature,11,5,0.0029383498668230815,0.10368188633286714,200,0.02834005023201073 +ridge_without_size_feature,29,0,0.04359069019775172,0.09661460929879445,252,0.4511811465587083 +ridge_without_size_feature,29,1,0.016504867360824224,0.08288305728509346,252,0.19913439370428038 +ridge_without_size_feature,29,2,-0.02476588151153135,0.09478300955559105,252,-0.2612903053791086 +ridge_without_size_feature,29,3,0.02027092225650099,0.09210790690979509,251,0.22007798175625798 +ridge_without_size_feature,29,4,-0.007838736870762348,0.12484721777420321,251,-0.0627866364226023 +ridge_without_size_feature,29,5,0.0029383498668230815,0.10368188633286714,200,0.02834005023201073 +ridge_without_size_feature,42,0,0.04359069019775172,0.09661460929879445,252,0.4511811465587083 +ridge_without_size_feature,42,1,0.016504867360824224,0.08288305728509346,252,0.19913439370428038 +ridge_without_size_feature,42,2,-0.02476588151153135,0.09478300955559105,252,-0.2612903053791086 +ridge_without_size_feature,42,3,0.02027092225650099,0.09210790690979509,251,0.22007798175625798 +ridge_without_size_feature,42,4,-0.007838736870762348,0.12484721777420321,251,-0.0627866364226023 +ridge_without_size_feature,42,5,0.0029383498668230815,0.10368188633286714,200,0.02834005023201073 diff --git a/docs/experiments/real-sp500/index.html b/docs/experiments/real-sp500/index.html new file mode 100644 index 0000000..7f5d07e --- /dev/null +++ b/docs/experiments/real-sp500/index.html @@ -0,0 +1,10 @@ + +AlphaForge · Frozen comparison + +

EXPLORATORY_REAL_MARKET

One dataset.
Declared comparisons.

+

840,736 samples · 24 usable features · 551,698 shared forecasts. +Models, the fixed momentum baseline and equal-weight baseline share dates, outcomes, execution lag and cost rules.

+ +

All methods and cost scenarios

MethodCostsRunsNet CAGRNet Sharpe mean ± seed stdMax drawdown
equal_weight1×112.75%1.141 ± 0.000-18.70%
equal_weight2×112.70%1.137 ± 0.000-18.71%
lightgbm1×318.23%1.337 ± 0.022-21.46%
lightgbm2×317.51%1.290 ± 0.023-21.64%
ridge1×312.85%1.005 ± 0.000-22.06%
ridge2×312.20%0.960 ± 0.000-22.20%
ridge_without_size_feature1×312.24%0.955 ± 0.000-21.68%
ridge_without_size_feature2×311.58%0.909 ± 0.000-21.84%
single_momentum1×111.38%0.871 ± 0.000-23.40%
single_momentum2×110.95%0.842 ± 0.000-23.47%
+

Source identity, exact protocol and all runs · Metrics CSV · Fold results · Daily returns (gzip CSV) · Hashes

+

This is a protocol comparison. Its real-market variant remains exploratory until the declared source limitations are resolved. No test result selects a model or seed for deployment.

\ No newline at end of file diff --git a/docs/experiments/real-sp500/manifest.json b/docs/experiments/real-sp500/manifest.json new file mode 100644 index 0000000..a25c776 --- /dev/null +++ b/docs/experiments/real-sp500/manifest.json @@ -0,0 +1,10 @@ +{ + "dataset_sha256": "8906ace6a84d22bdc78a05fedcd75498d964dd56555da00492892f5eb7a9a7cd", + "files_sha256": { + "daily_returns.csv.gz": "87eb8aeee44cd396844433d06cc0b3d937041c9710d55e1418acebc2d6b09ede", + "folds.csv": "8ae472f7117df46258db54cfff3a5bb349df99e04fdeceb1e3ce37a7c8ac88df", + "index.html": "aa9003c9a31717baafba4838c7955ed62ec63ca76e424a518af4cb6e8b44ac2e", + "metrics.csv": "41b0d117631aa0613d0d054b817a959c3792079ec775ea3c73a409265d89d106", + "summary.json": "8f51affb4f487f9cb25995e89de91d831b5e2e4933ea69495478d4883066a2fb" + } +} diff --git a/docs/experiments/real-sp500/metrics.csv b/docs/experiments/real-sp500/metrics.csv new file mode 100644 index 0000000..76030fe --- /dev/null +++ b/docs/experiments/real-sp500/metrics.csv @@ -0,0 +1,23 @@ +method,seed,cost_multiplier,features,rank_ic_mean,icir,total_return,cagr,ann_vol,sharpe,sortino,calmar,max_drawdown,information_ratio,beta,alpha_ann,avg_turnover,hit_rate,gross_total_return,gross_cagr,gross_ann_vol,gross_sharpe,gross_sortino,gross_calmar,gross_max_drawdown,cost_drag_cagr,n_rebalances,backtest_start,backtest_end +ridge,11.0,1.0,24,0.01122386777239804,0.11077674858956181,0.7928565220112447,0.12849801912232928,0.12859325367043897,1.004732725413232,1.3953939459613223,0.5825484512335781,-0.22057911037309896,0.4350860416950542,0.7246827621496951,0.06400516464836423,0.8341489292928463,0.4396055875102712,0.8440497746578677,0.13509608403907292,0.1286665274721246,1.0495623603489088,1.4609738122587594,0.6161997717360378,-0.2192407239270192,0.00659806491674364,45,2015-01-02,2019-10-31 +ridge,11.0,2.0,24,0.01122386777239804,0.11077674858956181,0.7437088759892885,0.12202151659455684,0.12862917360798956,0.9597243979273145,1.3299354866024526,0.5495692428916782,-0.22203119656499348,0.3711556418881977,0.7247745833450341,0.05824360984515073,0.8343953476036271,0.4371405094494659,0.8447281806657327,0.1351825402700706,0.12874788458471878,1.0495726145464614,1.4609915906058084,0.6162672866990959,-0.2193569952319666,0.01316102367551375,45,2015-01-02,2019-10-31 +ridge,29.0,1.0,24,0.01122386777239804,0.11077674858956181,0.7928565220112447,0.12849801912232928,0.12859325367043897,1.004732725413232,1.3953939459613223,0.5825484512335781,-0.22057911037309896,0.4350860416950542,0.7246827621496951,0.06400516464836423,0.8341489292928463,0.4396055875102712,0.8440497746578677,0.13509608403907292,0.1286665274721246,1.0495623603489088,1.4609738122587594,0.6161997717360378,-0.2192407239270192,0.00659806491674364,45,2015-01-02,2019-10-31 +ridge,29.0,2.0,24,0.01122386777239804,0.11077674858956181,0.7437088759892885,0.12202151659455684,0.12862917360798956,0.9597243979273145,1.3299354866024526,0.5495692428916782,-0.22203119656499348,0.3711556418881977,0.7247745833450341,0.05824360984515073,0.8343953476036271,0.4371405094494659,0.8447281806657327,0.1351825402700706,0.12874788458471878,1.0495726145464614,1.4609915906058084,0.6162672866990959,-0.2193569952319666,0.01316102367551375,45,2015-01-02,2019-10-31 +ridge,42.0,1.0,24,0.01122386777239804,0.11077674858956181,0.7928565220112447,0.12849801912232928,0.12859325367043897,1.004732725413232,1.3953939459613223,0.5825484512335781,-0.22057911037309896,0.4350860416950542,0.7246827621496951,0.06400516464836423,0.8341489292928463,0.4396055875102712,0.8440497746578677,0.13509608403907292,0.1286665274721246,1.0495623603489088,1.4609738122587594,0.6161997717360378,-0.2192407239270192,0.00659806491674364,45,2015-01-02,2019-10-31 +ridge,42.0,2.0,24,0.01122386777239804,0.11077674858956181,0.7437088759892885,0.12202151659455684,0.12862917360798956,0.9597243979273145,1.3299354866024526,0.5495692428916782,-0.22203119656499348,0.3711556418881977,0.7247745833450341,0.05824360984515073,0.8343953476036271,0.4371405094494659,0.8447281806657327,0.1351825402700706,0.12874788458471878,1.0495726145464614,1.4609915906058084,0.6162672866990959,-0.2193569952319666,0.01316102367551375,45,2015-01-02,2019-10-31 +lightgbm,11.0,1.0,24,0.019865709064018168,0.2528670523803887,1.2061191895168832,0.1780238094323885,0.13161726095889753,1.3110999966631693,1.8645039106154508,0.8499798921189075,-0.20944473049661738,0.8855211491992206,0.7305365202511559,0.106840066620979,0.905309825287292,0.44207066557107644,1.2742887970761,0.1854706122472891,0.13169056955101677,1.358322684194003,1.9369339036334832,0.8925617586785181,-0.20779583086988573,0.007446802814900622,45,2015-01-02,2019-10-31 +lightgbm,11.0,2.0,24,0.019865709064018168,0.2528670523803887,1.14116877772552,0.170756948083435,0.13166540404347488,1.2636460472613429,1.7922257778076478,0.8072623739450959,-0.2115259593345653,0.8189642237955739,0.7307339841091748,0.10063737859783992,0.9055999993970426,0.44124897288414133,1.275567368543514,0.18560858183741358,0.1317802430972225,1.3583718901820283,1.9370216228059136,0.8927100592571888,-0.20791586239305493,0.014851633753978577,45,2015-01-02,2019-10-31 +lightgbm,29.0,1.0,24,0.019772361592348513,0.2494739043742614,1.234808182847567,0.18117970621526136,0.1313035357613569,1.3343176106682244,1.8985565735470926,0.8622818965002308,-0.21011656043182725,0.9207503385828961,0.7318323998086893,0.10936071144743903,0.8788130473087776,0.4437140509449466,1.301813260372016,0.18842726393567455,0.13138128367171562,1.3801939471434774,1.968737115959602,0.9024006071952294,-0.20880666794022817,0.007247557720413189,45,2015-01-02,2019-10-31 +lightgbm,29.0,2.0,24,0.019772361592348513,0.2494739043742614,1.1709810188293543,0.17411383171132244,0.13134246033129526,1.288252456940464,1.8285782130139536,0.8215806720491827,-0.21192542331485054,0.855730315688115,0.7319865758443684,0.10334846798556419,0.8790710605867381,0.4428923582580115,1.30314145864519,0.1885692272277406,0.1314676026985098,1.3802834078505006,1.9688842743805863,0.9026170266152419,-0.20891388226395813,0.014455395516418168,45,2015-01-02,2019-10-31 +lightgbm,42.0,1.0,24,0.018191187721655802,0.22741309824507214,1.2945242413056,0.1876470252107607,0.13242946647501735,1.3653464541110114,1.9486046720917776,0.836945201923181,-0.22420467287413148,0.9723686877997519,0.7355107801222112,0.11464126527462654,0.8911360298345471,0.45110928512736237,1.3642999579983695,0.19503687241956813,0.13250144294048496,1.411520399944314,2.0195858510874203,0.8753469794749738,-0.22281092754389775,0.007389847208807421,45,2015-01-02,2019-10-31 +lightgbm,42.0,2.0,24,0.018191187721655802,0.22741309824507214,1.2281049460575093,0.1804452137826429,0.13247609776905764,1.318971941521655,1.877762416020701,0.7994316119533469,-0.2257168857030053,0.9070455285986505,0.7357135082544666,0.10854318007830074,0.8914036720295224,0.45028759244042726,1.3657045311710778,0.19518384313119563,0.13258935087666976,1.4116007502259589,2.019722205568994,0.8755294089372784,-0.2229323665644889,0.014738629348552745,45,2015-01-02,2019-10-31 +ridge_without_size_feature,11.0,1.0,23,0.008857962167016904,0.08666738333965751,0.7467380769870151,0.12242485200147213,0.12983863647340832,0.9547716733118214,1.3180534756663251,0.5646363280403609,-0.21682071436381445,0.37614814804730384,0.7322683240452622,0.05808712525889852,0.8544216117998843,0.44124897288414133,0.7977676110958178,0.12913741804725953,0.12990886553601438,1.0002429794192376,1.3840379133855978,0.599701613700191,-0.21533611899170113,0.006712566045787405,45,2015-01-02,2019-10-31 +ridge_without_size_feature,11.0,2.0,23,0.008857962167016904,0.08666738333965751,0.6977429983617107,0.11583195079079611,0.12988151280826885,0.9091243208432253,1.2522011615567512,0.5302814184925408,-0.21843486637732423,0.31085084060917056,0.732340821002718,0.052192792925675455,0.8546842578021412,0.4396055875102712,0.798407857285804,0.1292206727743226,0.12999325797781444,1.0002457299332892,1.3840498654914128,0.5997185615706021,-0.21546885665153792,0.013388721983526475,45,2015-01-02,2019-10-31 +ridge_without_size_feature,29.0,1.0,23,0.008857962167016904,0.08666738333965751,0.7467380769870151,0.12242485200147213,0.12983863647340832,0.9547716733118214,1.3180534756663251,0.5646363280403609,-0.21682071436381445,0.37614814804730384,0.7322683240452622,0.05808712525889852,0.8544216117998843,0.44124897288414133,0.7977676110958178,0.12913741804725953,0.12990886553601438,1.0002429794192376,1.3840379133855978,0.599701613700191,-0.21533611899170113,0.006712566045787405,45,2015-01-02,2019-10-31 +ridge_without_size_feature,29.0,2.0,23,0.008857962167016904,0.08666738333965751,0.6977429983617107,0.11583195079079611,0.12988151280826885,0.9091243208432253,1.2522011615567512,0.5302814184925408,-0.21843486637732423,0.31085084060917056,0.732340821002718,0.052192792925675455,0.8546842578021412,0.4396055875102712,0.798407857285804,0.1292206727743226,0.12999325797781444,1.0002457299332892,1.3840498654914128,0.5997185615706021,-0.21546885665153792,0.013388721983526475,45,2015-01-02,2019-10-31 +ridge_without_size_feature,42.0,1.0,23,0.008857962167016904,0.08666738333965751,0.7467380769870151,0.12242485200147213,0.12983863647340832,0.9547716733118214,1.3180534756663251,0.5646363280403609,-0.21682071436381445,0.37614814804730384,0.7322683240452622,0.05808712525889852,0.8544216117998843,0.44124897288414133,0.7977676110958178,0.12913741804725953,0.12990886553601438,1.0002429794192376,1.3840379133855978,0.599701613700191,-0.21533611899170113,0.006712566045787405,45,2015-01-02,2019-10-31 +ridge_without_size_feature,42.0,2.0,23,0.008857962167016904,0.08666738333965751,0.6977429983617107,0.11583195079079611,0.12988151280826885,0.9091243208432253,1.2522011615567512,0.5302814184925408,-0.21843486637732423,0.31085084060917056,0.732340821002718,0.052192792925675455,0.8546842578021412,0.4396055875102712,0.798407857285804,0.1292206727743226,0.12999325797781444,1.0002457299332892,1.3840498654914128,0.5997185615706021,-0.21546885665153792,0.013388721983526475,45,2015-01-02,2019-10-31 +equal_weight,,1.0,24,,,0.7849849012643737,0.12747027098593877,0.11054939563518908,1.1409073225701978,1.5937506090921907,0.6816769511600029,-0.18699513129939904,0.445336613265992,0.6504673183190346,0.06760678334561901,0.07063301370457535,0.44207066557107644,0.7890038989028143,0.12799545431690418,0.11055005618379983,1.1451156455557616,1.5998571065208835,0.6848207094879702,-0.18690359760382302,0.0005251833309654064,45,2015-01-02,2019-10-31 +equal_weight,,2.0,24,,,0.7810494703280884,0.1269550983762857,0.11055882575792245,1.1366837192823027,1.5876249630772774,0.6785680424417304,-0.18709265753137427,0.43969281742268385,0.6505026079364971,0.06714741076202517,0.0706409243163589,0.44207066557107644,0.7890806541400734,0.12800547521524308,0.11055911049509211,1.1451113068922971,1.5998646202039242,0.6848524033819003,-0.18690958020024973,0.0010503768389573676,45,2015-01-02,2019-10-31 +single_momentum,,1.0,24,0.0016129404992156642,0.01029025630928,0.6824996421436609,0.11375000726678941,0.13408918719385457,0.870788198164501,1.206036863113215,0.4860084735184234,-0.2340494321905633,0.2800742962310092,0.7359513182474047,0.05055281113531381,0.5618111476483862,0.4486442070665571,0.7142228393418086,0.11806616546279569,0.13409941518308496,0.8995860058491596,1.2477145842423036,0.505667546234715,-0.23348574837743907,0.004316158196006281,45,2015-01-02,2019-10-31 +single_momentum,,2.0,24,0.0016129404992156642,0.01029025630928,0.6517367152896028,0.10950242495160722,0.13414790490937456,0.8419717273276843,1.164462361719082,0.4665625701886216,-0.234700406651433,0.24002286182939522,0.7359976610719443,0.046734103368734986,0.5619319358802547,0.4486442070665571,0.7146228026723886,0.11812017745504755,0.13415632280535472,0.8996217122413772,1.2477730567980523,0.5057089509783171,-0.23357343631458105,0.008617752503440324,45,2015-01-02,2019-10-31 diff --git a/docs/experiments/real-sp500/summary.json b/docs/experiments/real-sp500/summary.json new file mode 100644 index 0000000..6ee7ca5 --- /dev/null +++ b/docs/experiments/real-sp500/summary.json @@ -0,0 +1,966 @@ +{ + "aggregates": [ + { + "cagr_mean": 0.12747027098593877, + "cagr_std": 0.0, + "cost_multiplier": 1.0, + "max_drawdown_mean": -0.18699513129939904, + "max_drawdown_std": 0.0, + "method": "equal_weight", + "rank_ic_mean_mean": null, + "rank_ic_mean_std": null, + "runs": 1, + "sharpe_mean": 1.1409073225701978, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.1269550983762857, + "cagr_std": 0.0, + "cost_multiplier": 2.0, + "max_drawdown_mean": -0.18709265753137427, + "max_drawdown_std": 0.0, + "method": "equal_weight", + "rank_ic_mean_mean": null, + "rank_ic_mean_std": null, + "runs": 1, + "sharpe_mean": 1.1366837192823027, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.18228351361947018, + "cagr_std": 0.004005443260403496, + "cost_multiplier": 1.0, + "max_drawdown_mean": -0.2145886546008587, + "max_drawdown_std": 0.006805081162356945, + "method": "lightgbm", + "rank_ic_mean_mean": 0.01927641945934083, + "rank_ic_mean_std": 0.0007683204110161005, + "runs": 3, + "sharpe_mean": 1.336921353814135, + "sharpe_std": 0.02222242328801463 + }, + { + "cagr_mean": 0.17510533119246677, + "cagr_std": 0.004016874944567741, + "cost_multiplier": 2.0, + "max_drawdown_mean": -0.2163894227841404, + "max_drawdown_std": 0.006597528135344145, + "method": "lightgbm", + "rank_ic_mean_mean": 0.01927641945934083, + "rank_ic_mean_std": 0.0007683204110161005, + "runs": 3, + "sharpe_mean": 1.2902901485744873, + "sharpe_std": 0.022632613405177564 + }, + { + "cagr_mean": 0.12849801912232928, + "cagr_std": 0.0, + "cost_multiplier": 1.0, + "max_drawdown_mean": -0.22057911037309896, + "max_drawdown_std": 0.0, + "method": "ridge", + "rank_ic_mean_mean": 0.01122386777239804, + "rank_ic_mean_std": 0.0, + "runs": 3, + "sharpe_mean": 1.004732725413232, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.12202151659455684, + "cagr_std": 0.0, + "cost_multiplier": 2.0, + "max_drawdown_mean": -0.22203119656499348, + "max_drawdown_std": 0.0, + "method": "ridge", + "rank_ic_mean_mean": 0.01122386777239804, + "rank_ic_mean_std": 0.0, + "runs": 3, + "sharpe_mean": 0.9597243979273143, + "sharpe_std": 1.1102230246251565e-16 + }, + { + "cagr_mean": 0.12242485200147213, + "cagr_std": 0.0, + "cost_multiplier": 1.0, + "max_drawdown_mean": -0.21682071436381445, + "max_drawdown_std": 0.0, + "method": "ridge_without_size_feature", + "rank_ic_mean_mean": 0.008857962167016904, + "rank_ic_mean_std": 0.0, + "runs": 3, + "sharpe_mean": 0.9547716733118214, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.11583195079079611, + "cagr_std": 0.0, + "cost_multiplier": 2.0, + "max_drawdown_mean": -0.21843486637732423, + "max_drawdown_std": 0.0, + "method": "ridge_without_size_feature", + "rank_ic_mean_mean": 0.008857962167016904, + "rank_ic_mean_std": 0.0, + "runs": 3, + "sharpe_mean": 0.9091243208432253, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.11375000726678941, + "cagr_std": 0.0, + "cost_multiplier": 1.0, + "max_drawdown_mean": -0.2340494321905633, + "max_drawdown_std": 0.0, + "method": "single_momentum", + "rank_ic_mean_mean": 0.0016129404992156642, + "rank_ic_mean_std": 0.0, + "runs": 1, + "sharpe_mean": 0.870788198164501, + "sharpe_std": 0.0 + }, + { + "cagr_mean": 0.10950242495160722, + "cagr_std": 0.0, + "cost_multiplier": 2.0, + "max_drawdown_mean": -0.234700406651433, + "max_drawdown_std": 0.0, + "method": "single_momentum", + "rank_ic_mean_mean": 0.0016129404992156642, + "rank_ic_mean_std": 0.0, + "runs": 1, + "sharpe_mean": 0.8419717273276843, + "sharpe_std": 0.0 + } + ], + "cases": [ + { + "alpha_ann": 0.06400516464836423, + "ann_vol": 0.12859325367043897, + "avg_turnover": 0.8341489292928463, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7246827621496951, + "cagr": 0.12849801912232928, + "calmar": 0.5825484512335781, + "cost_drag_cagr": 0.00659806491674364, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.1286665274721246, + "gross_cagr": 0.13509608403907292, + "gross_calmar": 0.6161997717360378, + "gross_max_drawdown": -0.2192407239270192, + "gross_sharpe": 1.0495623603489088, + "gross_sortino": 1.4609738122587594, + "gross_total_return": 0.8440497746578677, + "hit_rate": 0.4396055875102712, + "icir": 0.11077674858956181, + "information_ratio": 0.4350860416950542, + "max_drawdown": -0.22057911037309896, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 11, + "sharpe": 1.004732725413232, + "sortino": 1.3953939459613223, + "total_return": 0.7928565220112447 + }, + { + "alpha_ann": 0.05824360984515073, + "ann_vol": 0.12862917360798956, + "avg_turnover": 0.8343953476036271, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7247745833450341, + "cagr": 0.12202151659455684, + "calmar": 0.5495692428916782, + "cost_drag_cagr": 0.01316102367551375, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.12874788458471878, + "gross_cagr": 0.1351825402700706, + "gross_calmar": 0.6162672866990959, + "gross_max_drawdown": -0.2193569952319666, + "gross_sharpe": 1.0495726145464614, + "gross_sortino": 1.4609915906058084, + "gross_total_return": 0.8447281806657327, + "hit_rate": 0.4371405094494659, + "icir": 0.11077674858956181, + "information_ratio": 0.3711556418881977, + "max_drawdown": -0.22203119656499348, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 11, + "sharpe": 0.9597243979273145, + "sortino": 1.3299354866024526, + "total_return": 0.7437088759892885 + }, + { + "alpha_ann": 0.06400516464836423, + "ann_vol": 0.12859325367043897, + "avg_turnover": 0.8341489292928463, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7246827621496951, + "cagr": 0.12849801912232928, + "calmar": 0.5825484512335781, + "cost_drag_cagr": 0.00659806491674364, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.1286665274721246, + "gross_cagr": 0.13509608403907292, + "gross_calmar": 0.6161997717360378, + "gross_max_drawdown": -0.2192407239270192, + "gross_sharpe": 1.0495623603489088, + "gross_sortino": 1.4609738122587594, + "gross_total_return": 0.8440497746578677, + "hit_rate": 0.4396055875102712, + "icir": 0.11077674858956181, + "information_ratio": 0.4350860416950542, + "max_drawdown": -0.22057911037309896, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 29, + "sharpe": 1.004732725413232, + "sortino": 1.3953939459613223, + "total_return": 0.7928565220112447 + }, + { + "alpha_ann": 0.05824360984515073, + "ann_vol": 0.12862917360798956, + "avg_turnover": 0.8343953476036271, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7247745833450341, + "cagr": 0.12202151659455684, + "calmar": 0.5495692428916782, + "cost_drag_cagr": 0.01316102367551375, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.12874788458471878, + "gross_cagr": 0.1351825402700706, + "gross_calmar": 0.6162672866990959, + "gross_max_drawdown": -0.2193569952319666, + "gross_sharpe": 1.0495726145464614, + "gross_sortino": 1.4609915906058084, + "gross_total_return": 0.8447281806657327, + "hit_rate": 0.4371405094494659, + "icir": 0.11077674858956181, + "information_ratio": 0.3711556418881977, + "max_drawdown": -0.22203119656499348, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 29, + "sharpe": 0.9597243979273145, + "sortino": 1.3299354866024526, + "total_return": 0.7437088759892885 + }, + { + "alpha_ann": 0.06400516464836423, + "ann_vol": 0.12859325367043897, + "avg_turnover": 0.8341489292928463, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7246827621496951, + "cagr": 0.12849801912232928, + "calmar": 0.5825484512335781, + "cost_drag_cagr": 0.00659806491674364, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.1286665274721246, + "gross_cagr": 0.13509608403907292, + "gross_calmar": 0.6161997717360378, + "gross_max_drawdown": -0.2192407239270192, + "gross_sharpe": 1.0495623603489088, + "gross_sortino": 1.4609738122587594, + "gross_total_return": 0.8440497746578677, + "hit_rate": 0.4396055875102712, + "icir": 0.11077674858956181, + "information_ratio": 0.4350860416950542, + "max_drawdown": -0.22057911037309896, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 42, + "sharpe": 1.004732725413232, + "sortino": 1.3953939459613223, + "total_return": 0.7928565220112447 + }, + { + "alpha_ann": 0.05824360984515073, + "ann_vol": 0.12862917360798956, + "avg_turnover": 0.8343953476036271, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7247745833450341, + "cagr": 0.12202151659455684, + "calmar": 0.5495692428916782, + "cost_drag_cagr": 0.01316102367551375, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.12874788458471878, + "gross_cagr": 0.1351825402700706, + "gross_calmar": 0.6162672866990959, + "gross_max_drawdown": -0.2193569952319666, + "gross_sharpe": 1.0495726145464614, + "gross_sortino": 1.4609915906058084, + "gross_total_return": 0.8447281806657327, + "hit_rate": 0.4371405094494659, + "icir": 0.11077674858956181, + "information_ratio": 0.3711556418881977, + "max_drawdown": -0.22203119656499348, + "method": "ridge", + "n_rebalances": 45, + "rank_ic_mean": 0.01122386777239804, + "seed": 42, + "sharpe": 0.9597243979273145, + "sortino": 1.3299354866024526, + "total_return": 0.7437088759892885 + }, + { + "alpha_ann": 0.106840066620979, + "ann_vol": 0.13161726095889753, + "avg_turnover": 0.905309825287292, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7305365202511559, + "cagr": 0.1780238094323885, + "calmar": 0.8499798921189075, + "cost_drag_cagr": 0.007446802814900622, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.13169056955101677, + "gross_cagr": 0.1854706122472891, + "gross_calmar": 0.8925617586785181, + "gross_max_drawdown": -0.20779583086988573, + "gross_sharpe": 1.358322684194003, + "gross_sortino": 1.9369339036334832, + "gross_total_return": 1.2742887970761, + "hit_rate": 0.44207066557107644, + "icir": 0.2528670523803887, + "information_ratio": 0.8855211491992206, + "max_drawdown": -0.20944473049661738, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.019865709064018168, + "seed": 11, + "sharpe": 1.3110999966631693, + "sortino": 1.8645039106154508, + "total_return": 1.2061191895168832 + }, + { + "alpha_ann": 0.10063737859783992, + "ann_vol": 0.13166540404347488, + "avg_turnover": 0.9055999993970426, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7307339841091748, + "cagr": 0.170756948083435, + "calmar": 0.8072623739450959, + "cost_drag_cagr": 0.014851633753978577, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.1317802430972225, + "gross_cagr": 0.18560858183741358, + "gross_calmar": 0.8927100592571888, + "gross_max_drawdown": -0.20791586239305493, + "gross_sharpe": 1.3583718901820283, + "gross_sortino": 1.9370216228059136, + "gross_total_return": 1.275567368543514, + "hit_rate": 0.44124897288414133, + "icir": 0.2528670523803887, + "information_ratio": 0.8189642237955739, + "max_drawdown": -0.2115259593345653, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.019865709064018168, + "seed": 11, + "sharpe": 1.2636460472613429, + "sortino": 1.7922257778076478, + "total_return": 1.14116877772552 + }, + { + "alpha_ann": 0.10936071144743903, + "ann_vol": 0.1313035357613569, + "avg_turnover": 0.8788130473087776, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7318323998086893, + "cagr": 0.18117970621526136, + "calmar": 0.8622818965002308, + "cost_drag_cagr": 0.007247557720413189, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.13138128367171562, + "gross_cagr": 0.18842726393567455, + "gross_calmar": 0.9024006071952294, + "gross_max_drawdown": -0.20880666794022817, + "gross_sharpe": 1.3801939471434774, + "gross_sortino": 1.968737115959602, + "gross_total_return": 1.301813260372016, + "hit_rate": 0.4437140509449466, + "icir": 0.2494739043742614, + "information_ratio": 0.9207503385828961, + "max_drawdown": -0.21011656043182725, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.019772361592348513, + "seed": 29, + "sharpe": 1.3343176106682244, + "sortino": 1.8985565735470926, + "total_return": 1.234808182847567 + }, + { + "alpha_ann": 0.10334846798556419, + "ann_vol": 0.13134246033129526, + "avg_turnover": 0.8790710605867381, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7319865758443684, + "cagr": 0.17411383171132244, + "calmar": 0.8215806720491827, + "cost_drag_cagr": 0.014455395516418168, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.1314676026985098, + "gross_cagr": 0.1885692272277406, + "gross_calmar": 0.9026170266152419, + "gross_max_drawdown": -0.20891388226395813, + "gross_sharpe": 1.3802834078505006, + "gross_sortino": 1.9688842743805863, + "gross_total_return": 1.30314145864519, + "hit_rate": 0.4428923582580115, + "icir": 0.2494739043742614, + "information_ratio": 0.855730315688115, + "max_drawdown": -0.21192542331485054, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.019772361592348513, + "seed": 29, + "sharpe": 1.288252456940464, + "sortino": 1.8285782130139536, + "total_return": 1.1709810188293543 + }, + { + "alpha_ann": 0.11464126527462654, + "ann_vol": 0.13242946647501735, + "avg_turnover": 0.8911360298345471, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7355107801222112, + "cagr": 0.1876470252107607, + "calmar": 0.836945201923181, + "cost_drag_cagr": 0.007389847208807421, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.13250144294048496, + "gross_cagr": 0.19503687241956813, + "gross_calmar": 0.8753469794749738, + "gross_max_drawdown": -0.22281092754389775, + "gross_sharpe": 1.411520399944314, + "gross_sortino": 2.0195858510874203, + "gross_total_return": 1.3642999579983695, + "hit_rate": 0.45110928512736237, + "icir": 0.22741309824507214, + "information_ratio": 0.9723686877997519, + "max_drawdown": -0.22420467287413148, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.018191187721655802, + "seed": 42, + "sharpe": 1.3653464541110114, + "sortino": 1.9486046720917776, + "total_return": 1.2945242413056 + }, + { + "alpha_ann": 0.10854318007830074, + "ann_vol": 0.13247609776905764, + "avg_turnover": 0.8914036720295224, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7357135082544666, + "cagr": 0.1804452137826429, + "calmar": 0.7994316119533469, + "cost_drag_cagr": 0.014738629348552745, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.13258935087666976, + "gross_cagr": 0.19518384313119563, + "gross_calmar": 0.8755294089372784, + "gross_max_drawdown": -0.2229323665644889, + "gross_sharpe": 1.4116007502259589, + "gross_sortino": 2.019722205568994, + "gross_total_return": 1.3657045311710778, + "hit_rate": 0.45028759244042726, + "icir": 0.22741309824507214, + "information_ratio": 0.9070455285986505, + "max_drawdown": -0.2257168857030053, + "method": "lightgbm", + "n_rebalances": 45, + "rank_ic_mean": 0.018191187721655802, + "seed": 42, + "sharpe": 1.318971941521655, + "sortino": 1.877762416020701, + "total_return": 1.2281049460575093 + }, + { + "alpha_ann": 0.05808712525889852, + "ann_vol": 0.12983863647340832, + "avg_turnover": 0.8544216117998843, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7322683240452622, + "cagr": 0.12242485200147213, + "calmar": 0.5646363280403609, + "cost_drag_cagr": 0.006712566045787405, + "cost_multiplier": 1.0, + "features": 23, + "gross_ann_vol": 0.12990886553601438, + "gross_cagr": 0.12913741804725953, + "gross_calmar": 0.599701613700191, + "gross_max_drawdown": -0.21533611899170113, + "gross_sharpe": 1.0002429794192376, + "gross_sortino": 1.3840379133855978, + "gross_total_return": 0.7977676110958178, + "hit_rate": 0.44124897288414133, + "icir": 0.08666738333965751, + "information_ratio": 0.37614814804730384, + "max_drawdown": -0.21682071436381445, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 11, + "sharpe": 0.9547716733118214, + "sortino": 1.3180534756663251, + "total_return": 0.7467380769870151 + }, + { + "alpha_ann": 0.052192792925675455, + "ann_vol": 0.12988151280826885, + "avg_turnover": 0.8546842578021412, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.732340821002718, + "cagr": 0.11583195079079611, + "calmar": 0.5302814184925408, + "cost_drag_cagr": 0.013388721983526475, + "cost_multiplier": 2.0, + "features": 23, + "gross_ann_vol": 0.12999325797781444, + "gross_cagr": 0.1292206727743226, + "gross_calmar": 0.5997185615706021, + "gross_max_drawdown": -0.21546885665153792, + "gross_sharpe": 1.0002457299332892, + "gross_sortino": 1.3840498654914128, + "gross_total_return": 0.798407857285804, + "hit_rate": 0.4396055875102712, + "icir": 0.08666738333965751, + "information_ratio": 0.31085084060917056, + "max_drawdown": -0.21843486637732423, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 11, + "sharpe": 0.9091243208432253, + "sortino": 1.2522011615567512, + "total_return": 0.6977429983617107 + }, + { + "alpha_ann": 0.05808712525889852, + "ann_vol": 0.12983863647340832, + "avg_turnover": 0.8544216117998843, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7322683240452622, + "cagr": 0.12242485200147213, + "calmar": 0.5646363280403609, + "cost_drag_cagr": 0.006712566045787405, + "cost_multiplier": 1.0, + "features": 23, + "gross_ann_vol": 0.12990886553601438, + "gross_cagr": 0.12913741804725953, + "gross_calmar": 0.599701613700191, + "gross_max_drawdown": -0.21533611899170113, + "gross_sharpe": 1.0002429794192376, + "gross_sortino": 1.3840379133855978, + "gross_total_return": 0.7977676110958178, + "hit_rate": 0.44124897288414133, + "icir": 0.08666738333965751, + "information_ratio": 0.37614814804730384, + "max_drawdown": -0.21682071436381445, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 29, + "sharpe": 0.9547716733118214, + "sortino": 1.3180534756663251, + "total_return": 0.7467380769870151 + }, + { + "alpha_ann": 0.052192792925675455, + "ann_vol": 0.12988151280826885, + "avg_turnover": 0.8546842578021412, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.732340821002718, + "cagr": 0.11583195079079611, + "calmar": 0.5302814184925408, + "cost_drag_cagr": 0.013388721983526475, + "cost_multiplier": 2.0, + "features": 23, + "gross_ann_vol": 0.12999325797781444, + "gross_cagr": 0.1292206727743226, + "gross_calmar": 0.5997185615706021, + "gross_max_drawdown": -0.21546885665153792, + "gross_sharpe": 1.0002457299332892, + "gross_sortino": 1.3840498654914128, + "gross_total_return": 0.798407857285804, + "hit_rate": 0.4396055875102712, + "icir": 0.08666738333965751, + "information_ratio": 0.31085084060917056, + "max_drawdown": -0.21843486637732423, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 29, + "sharpe": 0.9091243208432253, + "sortino": 1.2522011615567512, + "total_return": 0.6977429983617107 + }, + { + "alpha_ann": 0.05808712525889852, + "ann_vol": 0.12983863647340832, + "avg_turnover": 0.8544216117998843, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7322683240452622, + "cagr": 0.12242485200147213, + "calmar": 0.5646363280403609, + "cost_drag_cagr": 0.006712566045787405, + "cost_multiplier": 1.0, + "features": 23, + "gross_ann_vol": 0.12990886553601438, + "gross_cagr": 0.12913741804725953, + "gross_calmar": 0.599701613700191, + "gross_max_drawdown": -0.21533611899170113, + "gross_sharpe": 1.0002429794192376, + "gross_sortino": 1.3840379133855978, + "gross_total_return": 0.7977676110958178, + "hit_rate": 0.44124897288414133, + "icir": 0.08666738333965751, + "information_ratio": 0.37614814804730384, + "max_drawdown": -0.21682071436381445, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 42, + "sharpe": 0.9547716733118214, + "sortino": 1.3180534756663251, + "total_return": 0.7467380769870151 + }, + { + "alpha_ann": 0.052192792925675455, + "ann_vol": 0.12988151280826885, + "avg_turnover": 0.8546842578021412, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.732340821002718, + "cagr": 0.11583195079079611, + "calmar": 0.5302814184925408, + "cost_drag_cagr": 0.013388721983526475, + "cost_multiplier": 2.0, + "features": 23, + "gross_ann_vol": 0.12999325797781444, + "gross_cagr": 0.1292206727743226, + "gross_calmar": 0.5997185615706021, + "gross_max_drawdown": -0.21546885665153792, + "gross_sharpe": 1.0002457299332892, + "gross_sortino": 1.3840498654914128, + "gross_total_return": 0.798407857285804, + "hit_rate": 0.4396055875102712, + "icir": 0.08666738333965751, + "information_ratio": 0.31085084060917056, + "max_drawdown": -0.21843486637732423, + "method": "ridge_without_size_feature", + "n_rebalances": 45, + "rank_ic_mean": 0.008857962167016904, + "seed": 42, + "sharpe": 0.9091243208432253, + "sortino": 1.2522011615567512, + "total_return": 0.6977429983617107 + }, + { + "alpha_ann": 0.06760678334561901, + "ann_vol": 0.11054939563518908, + "avg_turnover": 0.07063301370457535, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.6504673183190346, + "cagr": 0.12747027098593877, + "calmar": 0.6816769511600029, + "cost_drag_cagr": 0.0005251833309654064, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.11055005618379983, + "gross_cagr": 0.12799545431690418, + "gross_calmar": 0.6848207094879702, + "gross_max_drawdown": -0.18690359760382302, + "gross_sharpe": 1.1451156455557616, + "gross_sortino": 1.5998571065208835, + "gross_total_return": 0.7890038989028143, + "hit_rate": 0.44207066557107644, + "icir": null, + "information_ratio": 0.445336613265992, + "max_drawdown": -0.18699513129939904, + "method": "equal_weight", + "n_rebalances": 45, + "rank_ic_mean": null, + "seed": null, + "sharpe": 1.1409073225701978, + "sortino": 1.5937506090921907, + "total_return": 0.7849849012643737 + }, + { + "alpha_ann": 0.06714741076202517, + "ann_vol": 0.11055882575792245, + "avg_turnover": 0.0706409243163589, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.6505026079364971, + "cagr": 0.1269550983762857, + "calmar": 0.6785680424417304, + "cost_drag_cagr": 0.0010503768389573676, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.11055911049509211, + "gross_cagr": 0.12800547521524308, + "gross_calmar": 0.6848524033819003, + "gross_max_drawdown": -0.18690958020024973, + "gross_sharpe": 1.1451113068922971, + "gross_sortino": 1.5998646202039242, + "gross_total_return": 0.7890806541400734, + "hit_rate": 0.44207066557107644, + "icir": null, + "information_ratio": 0.43969281742268385, + "max_drawdown": -0.18709265753137427, + "method": "equal_weight", + "n_rebalances": 45, + "rank_ic_mean": null, + "seed": null, + "sharpe": 1.1366837192823027, + "sortino": 1.5876249630772774, + "total_return": 0.7810494703280884 + }, + { + "alpha_ann": 0.05055281113531381, + "ann_vol": 0.13408918719385457, + "avg_turnover": 0.5618111476483862, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7359513182474047, + "cagr": 0.11375000726678941, + "calmar": 0.4860084735184234, + "cost_drag_cagr": 0.004316158196006281, + "cost_multiplier": 1.0, + "features": 24, + "gross_ann_vol": 0.13409941518308496, + "gross_cagr": 0.11806616546279569, + "gross_calmar": 0.505667546234715, + "gross_max_drawdown": -0.23348574837743907, + "gross_sharpe": 0.8995860058491596, + "gross_sortino": 1.2477145842423036, + "gross_total_return": 0.7142228393418086, + "hit_rate": 0.4486442070665571, + "icir": 0.01029025630928, + "information_ratio": 0.2800742962310092, + "max_drawdown": -0.2340494321905633, + "method": "single_momentum", + "n_rebalances": 45, + "rank_ic_mean": 0.0016129404992156642, + "seed": null, + "sharpe": 0.870788198164501, + "sortino": 1.206036863113215, + "total_return": 0.6824996421436609 + }, + { + "alpha_ann": 0.046734103368734986, + "ann_vol": 0.13414790490937456, + "avg_turnover": 0.5619319358802547, + "backtest_end": "2019-10-31", + "backtest_start": "2015-01-02", + "beta": 0.7359976610719443, + "cagr": 0.10950242495160722, + "calmar": 0.4665625701886216, + "cost_drag_cagr": 0.008617752503440324, + "cost_multiplier": 2.0, + "features": 24, + "gross_ann_vol": 0.13415632280535472, + "gross_cagr": 0.11812017745504755, + "gross_calmar": 0.5057089509783171, + "gross_max_drawdown": -0.23357343631458105, + "gross_sharpe": 0.8996217122413772, + "gross_sortino": 1.2477730567980523, + "gross_total_return": 0.7146228026723886, + "hit_rate": 0.4486442070665571, + "icir": 0.01029025630928, + "information_ratio": 0.24002286182939522, + "max_drawdown": -0.234700406651433, + "method": "single_momentum", + "n_rebalances": 45, + "rank_ic_mean": 0.0016129404992156642, + "seed": null, + "sharpe": 0.8419717273276843, + "sortino": 1.164462361719082, + "total_return": 0.6517367152896028 + } + ], + "data_kind": "exploratory_real_market", + "dataset": { + "features": [ + "adv_21d", + "amihud_illiquidity", + "beta_252d", + "dollar_volume_ratio", + "downside_volatility", + "idiosyncratic_volatility", + "industry_momentum", + "log_adv_21d", + "log_market_cap", + "log_price", + "max_drawdown_252d", + "mom_120d", + "mom_12_1", + "mom_20d", + "mom_60d", + "mom_6_1", + "residual_momentum", + "return_skew_126d", + "rev_21d", + "rev_5d", + "turnover_21d", + "volatility_252d", + "volatility_60d", + "zero_trading_days" + ], + "forecast_end": "2019-10-16", + "forecast_start": "2014-01-02", + "horizon_sessions": 21, + "oos_forecasts": 551698, + "samples": 840736 + }, + "dataset_sha256": "8906ace6a84d22bdc78a05fedcd75498d964dd56555da00492892f5eb7a9a7cd", + "environment": { + "numpy": "2.3.5", + "pandas": "2.2.3", + "python": "3.12.14", + "scikit_learn": "1.8.0" + }, + "limitations": [ + "Seed variation is not market sampling uncertainty or proof of tradable alpha.", + "Cost inputs are fixed scenarios, not measured execution costs.", + "The equal-weight baseline differs in diversification; its holdings rule is explicit.", + "Source hashes establish identity, not complete historical data quality." + ], + "protocol": { + "ablation": "drop explicit log_market_cap predictor only; earlier size neutralization remains", + "backtest": { + "allow_short": false, + "benchmark": "SP500", + "end_date": "2019-10-31", + "execution_lag_days": 1, + "initial_capital": 10000000, + "rebalance": "monthly", + "start_date": "2015-01-01" + }, + "cost": { + "commission_bps": 2.0, + "impact_coeff_bps": 10.0, + "min_commission": 0.0, + "participation_cap": 0.1, + "slippage_bps": 5.0 + }, + "cost_multipliers": [ + 1.0, + 2.0 + ], + "model_params": { + "lightgbm": { + "bagging_fraction": 0.8, + "bagging_freq": 1, + "feature_fraction": 0.7, + "lambda_l2": 1.0, + "learning_rate": 0.05, + "min_data_in_leaf": 200, + "n_estimators": 100, + "num_leaves": 15, + "num_threads": 1 + }, + "ridge": { + "alpha": 1.0 + } + }, + "model_selection": "none; all seeds/cost cells reported; failed cells raise", + "portfolio": "long-only equal-weight top 50; equal-weight baseline holds all forecastable eligible names", + "seeds": [ + 11, + 29, + 42 + ], + "signal_availability": "forecast panel forward-fill only; no backfill", + "ties": "score descending then symbol ascending", + "walk_forward": { + "embargo_days": 21, + "expanding": true, + "purge_days": 21, + "step_years": 1, + "test_years": 1, + "train_years": 4 + } + }, + "source_assurance": { + "data_kind": "public_market_mirror", + "effective_end": "2019-11-14", + "effective_start": "2010-01-04", + "limitations": [ + "Historical membership is incomplete; survivorship/selection bias remains.", + "Financial availability is an assumed lag, not actual publication history.", + "Size uses a dollar-volume proxy, not measured market capitalization." + ], + "price_sessions": 2485, + "processed_sha256": { + "benchmark.parquet": "c9ee2d26849c9584c6a0ec04f2ba4637e720c69692aab906bae49ead3e8aaed9", + "constituents.parquet": "80e9a1bcc1c88643efe85f28b6e94625103241e578fed0de6e0e93a74a927ce4", + "fundamentals.parquet": "49bcca1878cec46605872c96898d5e7d383c45477ea8a875ea850f5952528eac", + "industry.parquet": "8048708f49570a5e94c96162def4aa0458a8059344d26f9a8dcc36dbc00e7ea6", + "macro.parquet": "af9920d0db6ae238d47f6ba5560f98880f7734f33060dc8bfb552308524bc588", + "prices.parquet": "dd0ff0099ac803ff810fe419a67f4279ea40cf615141a014829f60a3d0fc4fad" + }, + "raw_sha256": { + "sp500_constituents.parquet": "813b095291414dab8fc278920e7e829190c5bc448dccf1f1dc0901ab3f1c458a", + "sp500_daily.parquet": "3ef81acc674028769126b4264bebd03d6897eada1f0205fd6f6d3c28afc97c8b", + "sp500_daily_ratios.zip": "a5f7ae23ee3871544d734e34ce1f429074084c6582c559b0778dfd34b6ac59c3", + "spy_daily.jsonl": "e20cd9764743799d39259dffa54286fc4a9cd5eefd5af7feca0cc6a595b930fa" + }, + "requested_end": "2019-11-14", + "requested_start": "2010-01-01", + "schema_version": 1, + "scope": "Identity and declared source properties; not independent economic validation", + "source_declarations": { + "actual_filing_availability": false, + "actual_market_capitalization": false, + "complete_historical_universe": false, + "fundamental_availability": "fiscal period end plus fixed lag, not actual filing date", + "fundamental_lag_days": 90, + "market_capitalization": "unavailable; panel substitutes a dollar-volume proxy", + "universe_history": "current members with entry dates; removed/delisted names incomplete" + }, + "source_urls": { + "benchmark": "https://huggingface.co/datasets/mmirmomeni/spy_daily/resolve/main/train.jsonl", + "constituents": "https://huggingface.co/datasets/jwigginton/index-constituents-sp500/resolve/main/data/train-00000-of-00001.parquet", + "fundamentals": "https://huggingface.co/datasets/pmoe7/SP_500_Stocks_Data-ratios_news_price_10_yrs/resolve/main/sp500_daily_ratios_20yrs.zip", + "prices": "https://huggingface.co/datasets/jwigginton/timeseries-daily-sp500/resolve/main/data/train-00000-of-00001.parquet" + }, + "status": "exploratory" + } +} diff --git a/docs/modules/backtesting.md b/docs/modules/backtesting.md index 6213efc..da371f6 100644 --- a/docs/modules/backtesting.md +++ b/docs/modules/backtesting.md @@ -70,6 +70,18 @@ assumptions fail before a run rather than crediting NAV. The broker's `allow_fractional_shares` accepts booleans or explicit `"true"`/`"false"` strings; `"false"` enforces integer fills. Minimum trade value is finite and nonnegative. +Large finite notionals and basis-point rates are scaled before multiplying so +a representable fee does not overflow only because of the calculation order. +Nonfinite trade values, unrepresentable cost components or totals, and fees +greater than the available NAV raise `ValueError`. The engine applies the same +fee boundary to custom brokers and forced liquidation. Fees equal to NAV are +allowed: they produce a zero-capital bankruptcy, with zero subsequent returns. +That boundary is absorbing; a later price recovery cannot recreate capital. +Negative or nonfinite NAV and nonfinite reconstructed gross NAV are rejected. +Finite positive NAV that rounds to a -100% daily return is also rejected, as +the exported float return cannot represent that remaining wealth. Execution +rejects share quantities outside the finite float range. + > The benchmark is stored as a daily *return* series (the ETL layer converts > provider price levels exactly once). `ensure_returns` makes that contract > explicit: a return series is never double-differenced (an earlier bug turned a @@ -93,6 +105,11 @@ implementations differ: * Annualisation is **inferred from the index**, never assumed, and reported alongside the numbers that depend on it. +Compounded wealth rejects infinite returns, returns below -100%, and a curve +outside the finite float range instead of treating them as a successful run or +a fabricated wipeout. A return of exactly -100% legally reduces wealth to zero. +Missing returns retain the existing flat-period convention. + ## Output — `BacktestResult` `equity`, `returns`, end-of-day `weights`, rebalance-date `target_weights`, diff --git a/docs/research-comparison.md b/docs/research-comparison.md new file mode 100644 index 0000000..e877401 --- /dev/null +++ b/docs/research-comparison.md @@ -0,0 +1,77 @@ +# Real-market comparison with declared source limits + +[Open the complete comparison](experiments/real-sp500/index.html) + +This exploratory experiment uses 1,173,649 real mirrored price rows, a frozen +840,736-row model dataset with 24 usable features, and 551,698 shared out-of-sample +forecasts. It compares equal weight, a fixed 12–1 momentum signal, Ridge, +LightGBM and Ridge without the explicit size predictor. Model runs use seeds +11, 29 and 42; every method uses the same forecast coverage, dated outcomes, +monthly rebalance, one-session execution lag and declared cost rules. + +| At base assumed costs | Net CAGR | Net Sharpe | Maximum drawdown | +| --- | --- | --- | --- | +| Equal-weight baseline | 12.75% | 1.141 | −18.70% | +| Single-momentum baseline | 11.38% | 0.871 | −23.40% | +| Ridge | 12.85% | 1.005 | −22.06% | +| LightGBM, seed mean | 18.23% | 1.337 | −21.46% | +| Ridge without size predictor | 12.24% | 0.955 | −21.68% | + +These are the measured results of the declared protocol, **not estimates of +tradable alpha**. In this sample Ridge has lower Sharpe than the simpler +equal-weight baseline. Three LightGBM seeds show variation; deterministic Ridge +does not. All 22 seed/cost cases, including doubled-cost scenarios, are retained. +Seed variation does not estimate uncertainty across market histories. + +## Source and comparison boundaries + +The source lists current S&P 500 members with entry dates, without complete +removed/delisted histories. Fundamentals use assumed publication lags, and +market capitalization is unavailable, so size uses a dollar-volume proxy. +Historical source vintages and classification changes are not independently +verified. These limits are recorded in the downloadable JSON and report banner. +`--require-complete-sources` rejects the bundled mirror's incomplete source +contract rather than treating real prices as proof of point-in-time correctness. + +The forecast window is January 2014–October 2019; portfolio results cover January +2015–October 2019. The dataset ends November 14, 2019, where the benchmark source +ends. Benchmark coverage is checked for every price session. Forecast failures, +nonfinite predictions and differences in forecast coverage cannot be silently +dropped from a comparison. + +All score-based portfolios equal weight their top 50 eligible names, with +symbol-based tie breaking and no optimizer. The equal-weight baseline holds all +forecastable eligible names, so its diversification differs. The size ablation +removes only `log_market_cap` from model inputs; it does not undo earlier size +neutralization. This experiment neither tunes parameters nor chooses a seed or +model for deployment based on the held-out results. + +## Reproduction and verification + +```bash +python scripts/fetch_real_data.py --start 2010-01-01 --end 2019-11-14 +python scripts/run_comparison.py --out research/comparison +python scripts/verify_comparison.py research/comparison +``` + +The builder records raw/processed file hashes and source properties in +`DATA_ASSURANCE.json`. Comparison outputs include dataset identity, exact +parameters, per-fold metrics, per-run metrics, daily returns and an artifact +manifest. The verifier recomputes portfolio headlines and seed aggregates from +the saved return CSV without refitting models. Repeat with the recorded library +versions; vendor mirror updates can change source hashes. + +Run real-data research commands from the repository root so the audited local +tables are the same tables that the provider reads. `run_real_research.py` +rejects a different working directory before loading data; `--help` remains +available from any directory. + +The published bundle is a byte-hashed record. Its `.gitattributes` rule disables +Git text conversion for `docs/experiments/real-sp500/`, preserving the original +CSV, JSON, HTML and gzip bytes with both LF and CRLF checkout settings. Do not +rewrite the manifest to accommodate a changed checkout: the verifier continues +to reject altered bytes and independently checks the financial headlines. + +Real runs default to `research/real_runs` and comparisons to +`research/comparison`. They are separate from the published synthetic +`research/case_study.md` and its JSON; that case retains its synthetic identity. diff --git a/mkdocs.yml b/mkdocs.yml index 3cf4bfd..07bf8cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ theme: nav: - Home: index.md - Reproduce the sample: sample-run.md + - Real-market comparison: research-comparison.md - Validation & limitations: validation.md - Architecture: architecture.md - Quickstart: quickstart.md diff --git a/scripts/fetch_real_data.py b/scripts/fetch_real_data.py index 5f3ef39..4c4251d 100644 --- a/scripts/fetch_real_data.py +++ b/scripts/fetch_real_data.py @@ -44,6 +44,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "src")) +from alphaforge.data.assurance import write_mirror_assurance # noqa: E402 from alphaforge.data.providers.base import ( # noqa: E402 CONSTITUENT_COLUMNS, MACRO_COLUMNS, @@ -80,7 +81,7 @@ # spy 2000-01-03 -> 2019-11-14 # The benchmark is the binding constraint, so the default build starts in 2000. DEFAULT_START = "2000-01-01" -DEFAULT_END = "2019-12-31" +DEFAULT_END = "2019-11-14" @dataclass @@ -426,6 +427,8 @@ def main(argv: list[str] | None = None) -> int: help="never hit the network; fail if a cached copy is missing", ) args = ap.parse_args(argv) + if args.lag_days < 0: + ap.error("--lag-days must be nonnegative") if args.offline: missing = [ @@ -496,6 +499,26 @@ def main(argv: list[str] | None = None) -> int: fundamentals, args.lag_days, ) + raw_sources = { + name: RAW_DIR / name + for name in ( + "sp500_daily.parquet", + "sp500_constituents.parquet", + "spy_daily.jsonl", + "sp500_daily_ratios.zip", + ) + } + write_mirror_assurance( + PROCESSED_DIR, + raw_sources, + { + "prices": PRICES_URL, + "constituents": CONSTITUENTS_URL, + "benchmark": BENCHMARK_URL, + "fundamentals": RATIOS_URL, + }, + args.lag_days, + ) log.info( f"Real dataset ready: {stats.symbols} symbols | {stats.rows:,} rows | " diff --git a/scripts/run_comparison.py b/scripts/run_comparison.py new file mode 100644 index 0000000..a202226 --- /dev/null +++ b/scripts/run_comparison.py @@ -0,0 +1,48 @@ +"""Reproduce a same-dataset model/baseline comparison, with source limitations.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from alphaforge.comparison import run_comparison +from alphaforge.data.assurance import audit_local_dataset +from alphaforge.pipeline import ResearchPipeline +from alphaforge.utils.config import Config + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", default="configs/real_sp500.yaml") + parser.add_argument("--start", default="2010-01-01") + parser.add_argument("--end", default="2019-11-14") + parser.add_argument("--backtest-start", default="2015-01-01") + parser.add_argument("--backtest-end", default="2019-10-31") + parser.add_argument("--seeds", type=int, nargs="+", default=[11, 29, 42]) + parser.add_argument("--out", type=Path, default=Path("research/comparison")) + parser.add_argument("--require-complete-sources", action="store_true") + args = parser.parse_args() + cfg = Config.load( + args.config, + overrides={ + "data": {"start_date": args.start, "end_date": args.end}, + "backtest": {"start_date": args.backtest_start, "end_date": args.backtest_end}, + }, + ) + assurance = None + if cfg.get("data.provider") == "local": + assurance = audit_local_dataset( + "data/processed", + args.start, + args.end, + require_complete_sources=args.require_complete_sources, + ) + cfg.raw["data"]["assurance"] = assurance + state = ResearchPipeline(cfg).run(report_dir=str(args.out / "base-report")) + result = run_comparison(state, args.out, seeds=tuple(args.seeds), assurance=assurance) + print(json.dumps(result["aggregates"], indent=2, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_real_research.py b/scripts/run_real_research.py index e165fd5..c99e9b0 100644 --- a/scripts/run_real_research.py +++ b/scripts/run_real_research.py @@ -1,10 +1,8 @@ """Run the full AlphaForge stack on the **real** S&P 500 dataset. -This is the script that produces the numbers in ``research/case_study.md``. -Nothing here is hard-coded: every figure in the case study is read back from -the returned :class:`~alphaforge.pipeline.ResearchState` and dumped to -``research/case_study_data.json``, so the narrative and the engine can never -drift apart. +Runs are exploratory when historical membership, filing dates or actual market +capitalization are unavailable. Output is kept in ``research/real_runs`` so a +real-data experiment cannot silently replace the published synthetic case. Prerequisites:: @@ -28,6 +26,7 @@ import pandas as pd # noqa: E402 +from alphaforge.data.assurance import audit_local_dataset # noqa: E402 from alphaforge.pipeline import ResearchPipeline # noqa: E402 from alphaforge.utils.config import Config # noqa: E402 from alphaforge.utils.logging import configure_logging, get_logger # noqa: E402 @@ -37,7 +36,7 @@ CONFIG_PATH = REPO_ROOT / "configs" / "real_sp500.yaml" DATA_DIR = REPO_ROOT / "data" / "processed" -OUT_JSON = REPO_ROOT / "research" / "case_study_data.json" +OUT_JSON = REPO_ROOT / "research" / "real_runs" / "case_study_data.json" def _require_dataset() -> None: @@ -143,10 +142,18 @@ def main(argv: list[str] | None = None) -> int: ) ap.add_argument("--start", default=None) ap.add_argument("--end", default=None) - ap.add_argument("--report-dir", default=str(REPO_ROOT / "research" / "reports")) + ap.add_argument("--report-dir", default=str(REPO_ROOT / "research" / "real_runs" / "report")) + ap.add_argument("--out-json", type=Path, default=OUT_JSON) + ap.add_argument( + "--require-complete-sources", + action="store_true", + help="reject incomplete universe, assumed filing dates or proxy market cap", + ) ap.add_argument("--no-json", action="store_true", help="skip writing case_study_data.json") args = ap.parse_args(argv) + if Path.cwd().resolve() != REPO_ROOT.resolve(): + raise SystemExit(f"Run real-data research from the repository root: {REPO_ROOT}") _require_dataset() overrides: dict = {} @@ -156,6 +163,15 @@ def main(argv: list[str] | None = None) -> int: overrides.setdefault("portfolio", {})["method"] = args.method cfg = Config.load(args.config, overrides=overrides) + if cfg.get("data.provider") != "local": + raise ValueError("This real-data script requires a local dataset with a source contract") + assurance = audit_local_dataset( + DATA_DIR, + args.start or cfg.get("data.start_date"), + args.end or cfg.get("data.end_date"), + require_complete_sources=args.require_complete_sources, + ) + cfg.raw.setdefault("data", {})["assurance"] = assurance log.info(f"Running real-data research with provider={cfg.get('data.provider')}") state = ResearchPipeline(cfg).run( start=args.start, @@ -165,17 +181,21 @@ def main(argv: list[str] | None = None) -> int: ) payload = collect(state, cfg.raw) + payload["data_assurance"] = assurance if not args.no_json: - OUT_JSON.parent.mkdir(parents=True, exist_ok=True) - OUT_JSON.write_text(json.dumps(payload, indent=2, default=str)) - log.info(f"wrote {OUT_JSON.relative_to(REPO_ROOT)}") + args.out_json.parent.mkdir(parents=True, exist_ok=True) + args.out_json.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8") + log.info(f"wrote {args.out_json}") # -- console summary ------------------------------------------------- bt = state.backtest s = bt.summary() if bt is not None else {} print("\n" + "=" * 72) print("ALPHAFORGE · REAL-DATA RESEARCH RUN") + print(f"Source status : {assurance['status']}") + for limitation in assurance["limitations"]: + print(f" - {limitation}") print("=" * 72) m = payload["meta"] print( @@ -206,7 +226,7 @@ def main(argv: list[str] | None = None) -> int: print("=" * 72) if state.report_path: print(f"HTML report : {state.report_path}") - print("Case study in : research/case_study.md (regenerate narrative from the JSON above)") + print("The published synthetic case study is separate from this exploratory real-data run.") return 0 diff --git a/scripts/verify_comparison.py b/scripts/verify_comparison.py new file mode 100644 index 0000000..ed96f4a --- /dev/null +++ b/scripts/verify_comparison.py @@ -0,0 +1,14 @@ +"""Check saved comparison hashes and independently recompute return metrics.""" + +from __future__ import annotations + +import argparse +import json + +from alphaforge.comparison import verify_comparison + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory") + args = parser.parse_args() + print(json.dumps(verify_comparison(args.directory), indent=2)) diff --git a/src/alphaforge/backtest/engine.py b/src/alphaforge/backtest/engine.py index 2adb077..28d6bf2 100644 --- a/src/alphaforge/backtest/engine.py +++ b/src/alphaforge/backtest/engine.py @@ -40,13 +40,31 @@ from alphaforge.features.panel import MarketPanel from alphaforge.utils.config import config_boolean, config_integer, config_number from alphaforge.utils.logging import Timer, get_logger -from alphaforge.utils.math_utils import ensure_returns +from alphaforge.utils.math_utils import compound, ensure_returns log = get_logger("backtest.engine") WeightFn = Callable[[pd.Timestamp, pd.Series | None], pd.Series | None] +def _checked_nav(nav: float) -> float: + if not np.isfinite(nav) or nav < 0: + raise ValueError("NAV must be finite and nonnegative") + return nav + + +def _checked_cost(cost: float, nav: float) -> None: + if not np.isfinite(cost) or cost < 0 or cost > nav: + raise ValueError("Transaction cost must be finite, nonnegative and cannot exceed NAV") + + +def _daily_return(nav: float, previous: float) -> float: + value = nav / previous - 1.0 if previous > 0 else 0.0 + if not np.isfinite(value) or (nav > 0 and value == -1.0): + raise ValueError("Daily returns must be finite and representable") + return value + + @dataclass class BacktestConfig: """Backtest mechanics - everything that is not alpha.""" @@ -276,6 +294,14 @@ def run(self) -> BacktestResult: raw_px = panel.close.loc[date] mark_px = valuation_px.loc[date] held = shares.abs() > 1e-12 + valued = shares != 0 + if not np.isfinite(shares.to_numpy()).all(): + raise ValueError("Held shares must be finite") + if not np.isfinite(mark_px[valued].to_numpy()).all() or (mark_px[valued] < 0).any(): + raise ValueError("Held prices must be finite and nonnegative") + with np.errstate(over="ignore", invalid="ignore"): + holdings_value = float((shares[valued] * mark_px[valued]).sum()) + nav_t = _checked_nav(holdings_value + cash) # -- 1. force-liquidate names that have gone dark ----------- dead = held & (stale.loc[date] >= cfg.delist_grace_days) @@ -285,18 +311,17 @@ def run(self) -> BacktestResult: cost = self.cost_model.estimate( notional, float(adv.loc[date].reindex(symbols[dead]).median() or 0.0) ).total + _checked_cost(cost, nav_t) shares[dead] = 0.0 cash += proceeds - cost cost_rows[date] = cost_rows.get(date, 0.0) + cost log.debug(f"{date.date()}: liquidated {int(dead.sum())} delisted names") # -- 2. mark to market on the pre-trade book ---------------- - holdings_value = float( - np.nansum(shares.to_numpy(dtype=float) * mark_px.to_numpy(dtype=float)) - ) - nav_t = holdings_value + cash - if prev_nav > 0: - rets[date] = nav_t / prev_nav - 1.0 + with np.errstate(over="ignore", invalid="ignore"): + holdings_value = float((shares[valued] * mark_px[valued]).sum()) + nav_t = _checked_nav(holdings_value + cash) + rets[date] = _daily_return(nav_t, prev_nav) equity[date] = nav_t # -- 3. execute anything signaled `lag` sessions ago -------- @@ -313,6 +338,7 @@ def run(self) -> BacktestResult: prices=raw_px, adv_value=adv.loc[date], ) + _checked_cost(result.cost_total, nav_t) if not result.trades.empty: frame = result.trades.copy() frame.insert(0, "date", date) @@ -331,24 +357,32 @@ def run(self) -> BacktestResult: / raw_px[tradable].to_numpy(dtype=float) ) new_shares[~tradable] = shares[~tradable] - shares = new_shares.replace([np.inf, -np.inf], np.nan).fillna(0.0) + if not np.isfinite(new_shares.to_numpy()).all(): + raise ValueError("Post-trade shares must be finite") + shares = new_shares cash = nav_after - float( np.nansum(shares.to_numpy(dtype=float) * mark_px.to_numpy(dtype=float)) ) + if not np.isfinite(cash): + raise ValueError("Post-trade cash must be finite") nav_t = nav_after # The day's return must be earned on the *post-cost* book, so that # transaction costs actually depress the return series and the equity # curve stays consistent with it. Without this, costs are deducted # from NAV (step 2 above used the pre-trade book) but never reflected # in `returns`, which inflates the net Sharpe/CAGR. - if prev_nav > 0: - rets[date] = nav_after / prev_nav - 1.0 + rets[date] = _daily_return(nav_after, prev_nav) n_rebalances += 1 # -- 4. record end-of-day book ------------------------------ if nav_t > 0: held_w = (shares * mark_px) / nav_t weight_rows[date] = held_w + else: + # Bankruptcy is legal but absorbing: a later price recovery + # must not resurrect positions with no remaining capital. + shares[:] = 0.0 + cash = 0.0 equity[date] = nav_t prev_nav = nav_t @@ -442,7 +476,10 @@ def run(self) -> BacktestResult: if bench is not None: metrics["gross_benchmark_cagr"] = gross_metrics.get("benchmark_cagr", float("nan")) # Pre-cost (gross) equity curve for the cost-drag overlay in the report. - gross_equity = (1.0 + gross_returns).cumprod() * float(cfg.initial_capital) + with np.errstate(over="ignore", invalid="ignore"): + gross_equity = compound(gross_returns) * float(cfg.initial_capital) + if not np.isfinite(gross_equity.to_numpy()).all(): + raise ValueError("Gross NAV must be finite") log.info( f"Backtest {returns_s.index[0].date()} -> {returns_s.index[-1].date()} | " diff --git a/src/alphaforge/comparison.py b/src/alphaforge/comparison.py new file mode 100644 index 0000000..6f43fd5 --- /dev/null +++ b/src/alphaforge/comparison.py @@ -0,0 +1,465 @@ +"""A frozen dataset, identical forecast coverage and explicit cost scenarios.""" + +from __future__ import annotations + +import copy +import gzip +import hashlib +import html +import json +import math +import platform +from dataclasses import replace +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import sklearn + +from alphaforge.backtest.engine import BacktestConfig, BacktestEngine +from alphaforge.backtest.metrics import performance_stats +from alphaforge.models.evaluation import evaluate_predictions +from alphaforge.models.pipeline import AlphaModelPipeline, signal_panel + +MODEL_PARAMS = { + "ridge": {"alpha": 1.0}, + "lightgbm": { + "n_estimators": 100, + "num_leaves": 15, + "min_data_in_leaf": 200, + "num_threads": 1, + "feature_fraction": 0.7, + "bagging_fraction": 0.8, + "bagging_freq": 1, + "learning_rate": 0.05, + "lambda_l2": 1.0, + }, +} + + +def dataset_digest(dataset) -> str: + digest = hashlib.sha256() + for frame in ( + dataset.features, + pd.DataFrame( + { + "date": dataset.dates, + "symbol": dataset.symbols, + "target": dataset.target, + "forward_return": dataset.forward_returns, + } + ), + ): + digest.update(pd.util.hash_pandas_object(frame, index=False).to_numpy().tobytes()) + digest.update(json.dumps(list(frame.columns)).encode()) + return digest.hexdigest() + + +def validate_coverage(reference: pd.DataFrame, candidate: pd.DataFrame) -> None: + keys = ["date", "symbol", "fold", "realised"] + if candidate.duplicated(["date", "symbol"]).any(): + raise ValueError("Comparison has overlapping/duplicate out-of-sample forecasts") + if not np.isfinite(candidate["prediction"].to_numpy(dtype=float)).all(): + raise ValueError("Comparison has nonfinite predictions") + if not reference[keys].reset_index(drop=True).equals(candidate[keys].reset_index(drop=True)): + raise ValueError("Methods do not share the same forecast dates, names, folds and outcomes") + + +def _clean(value): + if isinstance(value, dict): + return {key: _clean(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_clean(item) for item in value] + if isinstance(value, (np.integer, np.floating)): + value = value.item() + if isinstance(value, float) and not math.isfinite(value): + return None + return value + + +def ranked_weights(panel, scores: pd.DataFrame, top_n: int | None): + """Equal weight top-N scores with symbol ties; no optimizer or size proxy.""" + + def weights(decision, previous): + available = scores.loc[decision].where(panel.universe.loc[decision]).dropna() + if available.empty: + return None + ordered = sorted(available.index, key=lambda name: (-float(available[name]), str(name))) + selected = ordered if top_n is None else ordered[:top_n] + return pd.Series(1 / len(selected), index=selected, dtype=float) + + return weights + + +def run_comparison( + state, + destination: str | Path, + *, + seeds=(11, 29, 42), + cost_multipliers=(1.0, 2.0), + top_n=50, + assurance=None, +): + if ( + not seeds + or len(set(seeds)) != len(seeds) + or any(type(seed) is not int or not 0 <= seed < 2**32 for seed in seeds) + ): + raise ValueError("choose distinct uint32 seeds") + if not cost_multipliers or any(not np.isfinite(x) or x <= 0 for x in cost_multipliers): + raise ValueError("cost multipliers must be finite and positive") + if type(top_n) is not int or top_n < 1: + raise ValueError("top_n must be a positive integer") + provider = str(state.config.get("data", {}).get("provider", "sample")) + synthetic = provider in {"sample", "synthetic", ""} + if not synthetic and not assurance: + raise ValueError("Real-data comparisons need an explicit source assurance record") + dataset, panel = state.dataset, state.panel + output = Path(destination) + output.mkdir(parents=True, exist_ok=True) + config = copy.deepcopy(state.config) + reference = None + cases, forecasts, folds = [], {}, [] + daily: list[dict[str, Any]] = [] + variants = [("ridge", "ridge", dataset), ("lightgbm", "lightgbm", dataset)] + if "log_market_cap" in dataset.features.columns: + reduced = replace( + dataset, + features=dataset.features.drop(columns="log_market_cap"), + feature_names=[name for name in dataset.feature_names if name != "log_market_cap"], + ) + variants.append(("ridge_without_size_feature", "ridge", reduced)) + for name, model_type, inputs in variants: + for seed in seeds: + cfg = copy.deepcopy(config) + cfg.setdefault("project", {})["seed"] = seed + cfg["model"]["seed"] = seed + cfg["model"]["params"] = MODEL_PARAMS[model_type] + wf = AlphaModelPipeline.from_config(inputs, cfg).run(model_type) + predictions = wf.predictions + if reference is None: + reference = predictions + validate_coverage(reference, predictions) + forecasts[name, seed] = predictions + for row in wf.fold_metrics.to_dict(orient="records"): + folds.append({"method": name, "seed": seed, **row}) + assert reference is not None + for name in ("equal_weight", "single_momentum"): + predictions = reference.copy() + if name == "equal_weight": + predictions["prediction"] = 0.0 + else: + if "mom_12_1" not in dataset.features.columns: + raise ValueError("The fixed single-factor baseline requires mom_12_1") + lookup = pd.DataFrame( + { + "date": dataset.dates, + "symbol": dataset.symbols, + "momentum": dataset.features["mom_12_1"].to_numpy(), + } + ) + predictions = predictions.drop(columns="prediction").merge( + lookup, on=["date", "symbol"], how="left", validate="one_to_one" + ) + predictions = predictions.rename(columns={"momentum": "prediction"}) + validate_coverage(reference, predictions) + forecasts[name, None] = predictions + shared_index = None + for (name, seed), predictions in forecasts.items(): + scores = signal_panel(predictions, panel.dates, panel.symbols).ffill() + evaluation = evaluate_predictions( + predictions, + model_name=name, + horizon=int(dataset.metadata["horizon"]), + fold_id=predictions["fold"], + ) + for multiplier in cost_multipliers: + cost = dict(config.get("cost", {})) + for key in ("commission_bps", "slippage_bps", "impact_coeff_bps"): + cost[key] = ( + float( + cost.get( + key, + {"commission_bps": 2, "slippage_bps": 5, "impact_coeff_bps": 10}[key], + ) + ) + * multiplier + ) + bt = BacktestEngine( + panel, + weight_fn=ranked_weights(panel, scores, None if name == "equal_weight" else top_n), + cost_model=cost, + config=BacktestConfig.from_dict(config["backtest"]), + ).run() + if bt.diagnostics["n_skipped_rebalances"]: + raise ValueError( + f"{name} has failed rebalances; comparison cannot silently drop them" + ) + if shared_index is None: + shared_index = bt.returns.index + if not bt.returns.index.equals(shared_index): + raise ValueError("Comparison backtest calendars differ") + cases.append( + { + "method": name, + "seed": seed, + "cost_multiplier": multiplier, + "features": len(dataset.feature_names) + - int(name == "ridge_without_size_feature"), + "rank_ic_mean": evaluation.summary.get("rank_ic_mean"), + "icir": evaluation.summary.get("icir"), + **bt.summary(), + "n_rebalances": bt.diagnostics["n_rebalances"], + "backtest_start": str(bt.returns.index.min().date()), + "backtest_end": str(bt.returns.index.max().date()), + } + ) + assert bt.gross_equity is not None + gross = bt.gross_equity.pct_change().fillna(0.0) + daily.extend( + { + "method": name, + "seed": seed, + "cost_multiplier": multiplier, + "date": str(day.date()), + "net_return": float(bt.returns.loc[day]), + "gross_return": float(gross.loc[day]), + } + for day in bt.returns.index + ) + frame = pd.DataFrame(cases) + aggregates = [] + for (name, multiplier), group in frame.groupby(["method", "cost_multiplier"], sort=True): + aggregates.append( + { + "method": name, + "cost_multiplier": multiplier, + "runs": len(group), + **{ + f"{metric}_{stat}": ( + float(getattr(group[metric], stat)(ddof=0)) + if stat == "std" + else float(group[metric].mean()) + ) + for metric in ("cagr", "sharpe", "max_drawdown", "rank_ic_mean") + for stat in ("mean", "std") + }, + } + ) + summary = _clean( + { + "data_kind": "synthetic" if synthetic else "exploratory_real_market", + "source_assurance": assurance, + "dataset_sha256": dataset_digest(dataset), + "dataset": { + "samples": len(dataset), + "features": dataset.feature_names, + "oos_forecasts": len(reference), + "horizon_sessions": dataset.metadata["horizon"], + "forecast_start": str(reference["date"].min().date()), + "forecast_end": str(reference["date"].max().date()), + }, + "protocol": { + "seeds": list(seeds), + "model_params": MODEL_PARAMS, + "walk_forward": config["model"]["walk_forward"], + "backtest": config["backtest"], + "cost": config["cost"], + "cost_multipliers": list(cost_multipliers), + "portfolio": f"long-only equal-weight top {top_n}; equal-weight baseline holds all forecastable eligible names", + "ties": "score descending then symbol ascending", + "signal_availability": "forecast panel forward-fill only; no backfill", + "ablation": "drop explicit log_market_cap predictor only; earlier size neutralization remains", + "model_selection": "none; all seeds/cost cells reported; failed cells raise", + }, + "environment": { + "python": platform.python_version(), + "numpy": np.__version__, + "pandas": pd.__version__, + "scikit_learn": sklearn.__version__, + }, + "limitations": [ + "Seed variation is not market sampling uncertainty or proof of tradable alpha.", + "Cost inputs are fixed scenarios, not measured execution costs.", + "The equal-weight baseline differs in diversification; its holdings rule is explicit.", + "Source hashes establish identity, not complete historical data quality.", + ], + "aggregates": aggregates, + "cases": cases, + } + ) + (output / "summary.json").write_text( + json.dumps(summary, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8" + ) + frame.to_csv(output / "metrics.csv", index=False, lineterminator="\n") + pd.DataFrame(folds).to_csv(output / "folds.csv", index=False, lineterminator="\n") + pd.DataFrame(daily).to_csv(output / "daily_returns.csv", index=False, lineterminator="\n") + (output / "daily_returns.csv.gz").write_bytes( + gzip.compress((output / "daily_returns.csv").read_bytes(), mtime=0) + ) + table = "".join( + f"{html.escape(row['method'])}{row['cost_multiplier']:g}×" + f"{row['runs']}{row['cagr_mean']:.2%}" + f"{row['sharpe_mean']:.3f} ± {row['sharpe_std']:.3f}" + f"{row['max_drawdown_mean']:.2%}" + for row in summary["aggregates"] + ) + notes = list(summary["limitations"]) + (assurance or {}).get("limitations", []) + report = f""" +AlphaForge · Frozen comparison + +

{summary["data_kind"].upper()}

One dataset.
Declared comparisons.

+

{len(dataset):,} samples · {len(dataset.feature_names)} usable features · {len(reference):,} shared forecasts. +Models, the fixed momentum baseline and equal-weight baseline share dates, outcomes, execution lag and cost rules.

+ +

All methods and cost scenarios

{table}
MethodCostsRunsNet CAGRNet Sharpe mean ± seed stdMax drawdown
+

Source identity, exact protocol and all runs · Metrics CSV · Fold results · Daily returns (gzip CSV) · Hashes

+

This is a protocol comparison. Its real-market variant remains exploratory until the declared source limitations are resolved. No test result selects a model or seed for deployment.

""" + (output / "index.html").write_text(report, encoding="utf-8") + names = ("summary.json", "metrics.csv", "folds.csv", "daily_returns.csv.gz", "index.html") + manifest = { + "dataset_sha256": summary["dataset_sha256"], + "files_sha256": { + name: hashlib.sha256((output / name).read_bytes()).hexdigest() for name in names + }, + } + (output / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return summary + + +def _comparison_case_key(row): + seed = None if pd.isna(row["seed"]) else row["seed"] + return row["method"], seed, row["cost_multiplier"] + + +def verify_comparison(directory: str | Path): + """Recompute portfolio headlines from exported returns, without fitting models.""" + directory = Path(directory) + manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) + names = {"summary.json", "metrics.csv", "folds.csv", "daily_returns.csv.gz", "index.html"} + if set(manifest["files_sha256"]) != names: + raise ValueError("Unexpected comparison artifact inventory") + for name, expected in manifest["files_sha256"].items(): + if hashlib.sha256((directory / name).read_bytes()).hexdigest() != expected: + raise ValueError(f"Comparison artifact digest mismatch: {name}") + summary = json.loads((directory / "summary.json").read_text(encoding="utf-8")) + dataset_sha256 = manifest.get("dataset_sha256") + if not isinstance(dataset_sha256, str) or summary.get("dataset_sha256") != dataset_sha256: + raise ValueError("Comparison dataset identity mismatch") + daily = pd.read_csv(directory / "daily_returns.csv.gz") + daily["date"] = pd.to_datetime(daily["date"]) + if not np.isfinite(daily[["net_return", "gross_return"]].to_numpy()).all(): + raise ValueError("Nonfinite comparison returns") + grouped = daily.groupby(["method", "seed", "cost_multiplier"], dropna=False, sort=False) + case_keys = [_comparison_case_key(case) for case in summary["cases"]] + daily_keys = { + _comparison_case_key(row) + for row in daily[["method", "seed", "cost_multiplier"]].drop_duplicates().to_dict("records") + } + if ( + not case_keys + or len(set(case_keys)) != len(case_keys) + or set(case_keys) != daily_keys + or len(grouped) != len(case_keys) + ): + raise ValueError("Comparison case coverage mismatch") + shared_dates = None + verified_cases = [] + metrics = pd.read_csv(directory / "metrics.csv") + metric_keys = [_comparison_case_key(row) for row in metrics.to_dict("records")] + if len(set(metric_keys)) != len(metric_keys) or set(metric_keys) != set(case_keys): + raise ValueError("Comparison metrics CSV coverage mismatch") + for case in summary["cases"]: + rows = daily[ + (daily["method"] == case["method"]) + & (daily["cost_multiplier"] == case["cost_multiplier"]) + ] + rows = ( + rows[rows["seed"].isna()] + if case["seed"] is None + else rows[rows["seed"] == case["seed"]] + ) + if rows.empty or rows["date"].duplicated().any(): + raise ValueError("Missing or duplicate comparison return dates") + dates = pd.DatetimeIndex(rows["date"]) + if shared_dates is None: + shared_dates = dates + if not dates.equals(shared_dates): + raise ValueError("Comparison return calendars disagree") + actual = performance_stats(pd.Series(rows["net_return"].to_numpy(), index=dates)) + gross = performance_stats(pd.Series(rows["gross_return"].to_numpy(), index=dates)) + stated_csv = metrics[ + (metrics["method"] == case["method"]) + & (metrics["cost_multiplier"] == case["cost_multiplier"]) + ] + stated_csv = ( + stated_csv[stated_csv["seed"].isna()] + if case["seed"] is None + else stated_csv[stated_csv["seed"] == case["seed"]] + ) + if len(stated_csv) != 1: + raise ValueError("Comparison metrics CSV coverage mismatch") + for key in ("cagr", "sharpe", "max_drawdown", "ann_vol", "total_return"): + for title, computed in ((key, actual[key]), ("gross_" + key, gross[key])): + if title not in case or title not in metrics.columns: + raise ValueError(f"Missing required comparison headline: {title}") + try: + computed_value = float(computed) + stated_value = float(case[title]) + csv_value = float(stated_csv.iloc[0][title]) + except (TypeError, ValueError) as exc: + raise ValueError(f"Comparison {title} must be finite") from exc + if not all( + math.isfinite(value) for value in (computed_value, stated_value, csv_value) + ): + raise ValueError(f"Comparison {title} must be finite") + if ( + abs(computed_value - stated_value) > 1e-8 + or abs(computed_value - csv_value) > 1e-8 + ): + raise ValueError(f"Comparison {title} disagrees with exported returns") + verified_cases.append( + { + **case, + "cagr": actual["cagr"], + "sharpe": actual["sharpe"], + "max_drawdown": actual["max_drawdown"], + } + ) + verified = pd.DataFrame(verified_cases) + aggregate_keys = [(row["method"], row["cost_multiplier"]) for row in summary["aggregates"]] + expected_aggregate_keys = {(method, multiplier) for method, _seed, multiplier in case_keys} + if ( + len(set(aggregate_keys)) != len(aggregate_keys) + or set(aggregate_keys) != expected_aggregate_keys + ): + raise ValueError("Comparison aggregate coverage mismatch") + for stated in summary["aggregates"]: + group = verified[ + (verified["method"] == stated["method"]) + & (verified["cost_multiplier"] == stated["cost_multiplier"]) + ] + if len(group) != stated["runs"]: + raise ValueError("Comparison aggregate run count mismatch") + for key in ("cagr", "sharpe", "max_drawdown"): + try: + actual_mean, actual_std = float(group[key].mean()), float(group[key].std(ddof=0)) + stated_mean, stated_std = float(stated[key + "_mean"]), float(stated[key + "_std"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("Comparison aggregates must be finite") from exc + if not all( + math.isfinite(value) for value in (actual_mean, actual_std, stated_mean, stated_std) + ): + raise ValueError("Comparison aggregates must be finite") + if abs(actual_mean - stated_mean) > 1e-8 or abs(actual_std - stated_std) > 1e-8: + raise ValueError("Comparison aggregates disagree with exported returns") + assert shared_dates is not None + return { + "status": "verified", + "cases": len(verified_cases), + "return_dates_per_case": len(shared_dates), + "dataset_sha256": manifest["dataset_sha256"], + } diff --git a/src/alphaforge/data/assurance.py b/src/alphaforge/data/assurance.py new file mode 100644 index 0000000..9214bdc --- /dev/null +++ b/src/alphaforge/data/assurance.py @@ -0,0 +1,120 @@ +"""Source declarations and byte-level identity for exploratory local market data. + +Hashes establish reproducibility, not the truth of a vendor's financial data. +The bundled mirror builder does not provide complete historical membership or +actual filing availability, so its real-market runs remain exploratory. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import numpy as np +import pandas as pd + +TABLES = ("prices", "benchmark", "constituents", "fundamentals", "industry", "macro") + + +def file_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_mirror_assurance( + root: Path, sources: dict[str, Path], urls: dict[str, str], lag_days: int +): + if type(lag_days) is not int or lag_days < 0: + raise ValueError("fundamental lag must be a nonnegative integer") + payload = { + "schema_version": 1, + "data_kind": "public_market_mirror", + "source_declarations": { + "universe_history": "current members with entry dates; removed/delisted names incomplete", + "complete_historical_universe": False, + "fundamental_availability": "fiscal period end plus fixed lag, not actual filing date", + "actual_filing_availability": False, + "fundamental_lag_days": lag_days, + "market_capitalization": "unavailable; panel substitutes a dollar-volume proxy", + "actual_market_capitalization": False, + }, + "source_urls": urls, + "raw_sha256": {name: file_digest(path) for name, path in sources.items()}, + "processed_sha256": { + f"{name}.parquet": file_digest(root / f"{name}.parquet") for name in TABLES + }, + "scope": "Identity and declared source properties; not independent economic validation", + } + (root / "DATA_ASSURANCE.json").write_text( + json.dumps(payload, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8" + ) + return payload + + +def audit_local_dataset(root: str | Path, start: str, end: str, *, require_complete_sources=False): + root = Path(root) + path = root / "DATA_ASSURANCE.json" + if not path.is_file(): + raise ValueError( + "Missing DATA_ASSURANCE.json; rebuild the mirror or supply a source contract" + ) + assurance = json.loads(path.read_text(encoding="utf-8")) + expected = {f"{name}.parquet" for name in TABLES} + if set(assurance.get("processed_sha256", {})) != expected: + raise ValueError("Data assurance has an incomplete table inventory") + for name, digest in assurance["processed_sha256"].items(): + if file_digest(root / name) != digest: + raise ValueError(f"Source identity changed: {name}; regenerate the assurance record") + prices = pd.read_parquet(root / "prices.parquet", columns=["date", "symbol"]) + benchmark = pd.read_parquet(root / "benchmark.parquet") + dates = pd.DatetimeIndex(pd.to_datetime(prices["date"])).unique().sort_values() + dates = dates[(dates >= pd.Timestamp(start)) & (dates <= pd.Timestamp(end))] + if len(dates) == 0 or pd.Timestamp(start) > pd.Timestamp(end): + raise ValueError("Requested data window has no price observations") + benchmark_dates = pd.DatetimeIndex(pd.to_datetime(benchmark["date"])) + if ( + benchmark_dates.has_duplicates + or not np.isfinite(benchmark["value"].to_numpy(dtype=float)).all() + ): + raise ValueError("Benchmark dates or values are invalid") + missing = dates.difference(benchmark_dates) + if len(missing): + raise ValueError( + f"Benchmark is missing {len(missing)} price sessions; first {missing[0].date()}. " + "Choose a shared window; missing benchmark sessions cannot be forward-filled." + ) + declaration = assurance["source_declarations"] + flags = ( + "complete_historical_universe", + "actual_filing_availability", + "actual_market_capitalization", + ) + if any(type(declaration.get(key)) is not bool for key in flags): + raise ValueError("Source properties require explicit boolean declarations") + limitations = [] + if not declaration["complete_historical_universe"]: + limitations.append( + "Historical membership is incomplete; survivorship/selection bias remains." + ) + if not declaration["actual_filing_availability"]: + limitations.append( + "Financial availability is an assumed lag, not actual publication history." + ) + if not declaration["actual_market_capitalization"]: + limitations.append("Size uses a dollar-volume proxy, not measured market capitalization.") + if require_complete_sources and limitations: + raise ValueError("Complete-source validation failed: " + " ".join(limitations)) + return { + **assurance, + "status": "exploratory" if limitations else "source_contract_checked", + "effective_start": str(dates.min().date()), + "effective_end": str(dates.max().date()), + "requested_start": start, + "requested_end": end, + "price_sessions": len(dates), + "limitations": limitations, + } diff --git a/src/alphaforge/execution/broker.py b/src/alphaforge/execution/broker.py index 4ef0862..e1fd21b 100644 --- a/src/alphaforge/execution/broker.py +++ b/src/alphaforge/execution/broker.py @@ -112,6 +112,9 @@ def rebalance( Average daily traded value, used for participation and impact. """ cfg = self.config + nav = float(nav) + if not np.isfinite(nav) or nav < 0: + raise ValueError("NAV must be finite and nonnegative") target = target_weights.astype(float) current = ( current_weights.reindex(target.index).fillna(0.0).astype(float) @@ -119,6 +122,8 @@ def rebalance( else pd.Series(0.0, index=target.index) ) current = current.reindex(target.index).fillna(0.0) + if not np.isfinite(target.to_numpy()).all() or not np.isfinite(current.to_numpy()).all(): + raise ValueError("Target and current weights must be finite") px = prices.reindex(target.index) # Cash drag: the analyser wants to see it, but it is not an order. @@ -170,6 +175,13 @@ def rebalance( ) costs = self.cost_model.estimate_frame(active, adv) + with np.errstate(over="ignore"): + cost_total = float(costs["cost_total"].sum()) + traded_notional = float(active.abs().sum()) + if not np.isfinite(cost_total) or cost_total < 0 or cost_total > nav: + raise ValueError("Transaction cost must be finite, nonnegative and cannot exceed NAV") + if not np.isfinite(traded_notional): + raise ValueError("Traded notional must be finite") participation = [ self.cost_model.participation(v, a) for v, a in zip( @@ -183,6 +195,13 @@ def rebalance( ) ] + with np.errstate(over="ignore", divide="ignore", invalid="ignore"): + traded_shares = active.to_numpy(dtype=float) / px.reindex(active.index).to_numpy( + dtype=float + ) + if not np.isfinite(traded_shares).all(): + raise ValueError("Traded shares must be finite and representable") + trades = pd.DataFrame( { "side": np.where(active.to_numpy(dtype=float) > 0, "BUY", "SELL"), @@ -191,24 +210,25 @@ def rebalance( "target_weight": effective_target.reindex(active.index).to_numpy(dtype=float), "trade_weight": active.to_numpy(dtype=float) / float(nav), "trade_value": active.to_numpy(dtype=float), - "shares": active.to_numpy(dtype=float) - / px.reindex(active.index).to_numpy(dtype=float), + "shares": traded_shares, "participation": participation, "days_to_complete": days_needed, **{k: costs[k].to_numpy(dtype=float) for k in costs.columns}, }, index=active.index, ) - cost_total = float(trades["cost_total"].sum()) - traded_notional = float(trades["trade_value"].abs().sum()) - # Post-trade book: target weights, with costs taken out of NAV. The # weight vector is what the portfolio holds; the cash deduction shows up # in NAV, so weights are re-expressed on the smaller NAV. realised_weights = current.add(trade_weight, fill_value=0.0) nav_after = float(nav) - cost_total if nav_after > 0 and abs(float(nav)) > 0: - realised_weights = realised_weights * (float(nav) / nav_after) + with np.errstate(over="ignore", invalid="ignore"): + realised_weights = realised_weights * (float(nav) / nav_after) + if not np.isfinite(realised_weights.to_numpy()).all(): + raise ValueError("Post-cost weights must be finite") + elif nav_after == 0: + realised_weights = pd.Series(0.0, index=current.index) log.debug( f"rebalance: {len(trades)} orders | turnover {trades['trade_weight'].abs().sum():.3f} | " diff --git a/src/alphaforge/execution/costs.py b/src/alphaforge/execution/costs.py index fccb6eb..0b10c40 100644 --- a/src/alphaforge/execution/costs.py +++ b/src/alphaforge/execution/costs.py @@ -101,7 +101,17 @@ class TradeCost: @property def total(self) -> float: - return self.commission + self.slippage + self.impact + total = self.commission + self.slippage + self.impact + if not np.isfinite([self.commission, self.slippage, self.impact, total]).all(): + raise ValueError("Transaction cost components and total must be finite") + if min(self.commission, self.slippage, self.impact) < 0: + raise ValueError("Transaction cost components must be nonnegative") + return total + + +def _currency_cost(value: float, bps: float) -> float: + """Scale the larger factor first, avoiding an unnecessary intermediate overflow.""" + return (value / 10_000.0) * bps if value >= bps else value * (bps / 10_000.0) class CostModel: @@ -128,13 +138,18 @@ def estimate(self, trade_value: float, adv_value: float) -> TradeCost: """Cost of trading ``trade_value`` (signed) against ``adv_value`` of ADV.""" cfg = self.config value = abs(float(trade_value)) + if not np.isfinite(value): + raise ValueError("trade_value must be finite") if value <= 0: return TradeCost(0.0, 0.0, 0.0) participation = self.participation(value, adv_value) - commission = max(value * cfg.commission_bps / 10_000.0, cfg.min_commission) - slippage = value * cfg.slippage_bps / 10_000.0 - impact = value * self.impact_bps(participation) / 10_000.0 - return TradeCost(commission=commission, slippage=slippage, impact=impact) + commission = max(_currency_cost(value, cfg.commission_bps), cfg.min_commission) + slippage = _currency_cost(value, cfg.slippage_bps) + impact = _currency_cost(value, self.impact_bps(participation)) + cost = TradeCost(commission=commission, slippage=slippage, impact=impact) + # Validate even callers that only inspect one of the returned components. + _ = cost.total + return cost def estimate_frame(self, trade_values: pd.Series, adv_values: pd.Series) -> pd.DataFrame: """Vectorised cost estimate; returns a frame of currency costs.""" diff --git a/src/alphaforge/pipeline.py b/src/alphaforge/pipeline.py index 4b196a0..3276d57 100644 --- a/src/alphaforge/pipeline.py +++ b/src/alphaforge/pipeline.py @@ -339,8 +339,12 @@ def _notes(state: ResearchState) -> list[str]: q = state.config.get("data", {}) if q.get("survivorship_bias_disclaimer"): notes.append( - "Survivorship bias disclaimer is ON: the sample provider supplies point-in-time membership only." + "Survivorship bias: source coverage is incomplete when current-member datasets omit historical removals." ) + assurance = q.get("assurance", {}) + if assurance: + notes.append(f"Source contract status: {assurance.get('status', 'unknown')}.") + notes.extend(assurance.get("limitations", [])) if state.backtest is not None: diag = state.backtest.diagnostics notes.append( diff --git a/src/alphaforge/utils/math_utils.py b/src/alphaforge/utils/math_utils.py index 5e2704b..b0eb5d2 100644 --- a/src/alphaforge/utils/math_utils.py +++ b/src/alphaforge/utils/math_utils.py @@ -214,18 +214,24 @@ def compound(returns: pd.Series) -> pd.Series: long horizons — a naive ``cumprod`` silently leaves the float64 range (overflowing to ``inf`` or underflowing to ``0``) on multi-thousand-session backtests, which then poisons every downstream statistic (CAGR, drawdown, - Sharpe) with ``nan`` instead of a number. + Sharpe) with ``nan`` instead of a number. Nonfinite returns, losses below + -100%, and wealth outside the finite float range are rejected. A -100% + return is a legal, absorbing zero-wealth boundary; missing returns retain + the existing flat-period convention. The input must be *returns*, not return spreads. See :func:`alphaforge.backtest.metrics._relative_stats` for the trap this guards. """ r = returns.fillna(0.0).to_numpy(dtype=float) - with np.errstate(divide="ignore", invalid="ignore"): + if not np.isfinite(r).all() or (r < -1.0).any(): + raise ValueError("returns must be finite and at least -1") + with np.errstate(divide="ignore", invalid="ignore", over="ignore"): log_growth = np.log1p(r) - # r <= -1 means the position is wiped out; clamp to zero wealth rather than - # propagating inf/nan through the whole curve. - log_growth = np.where(np.isfinite(log_growth), log_growth, -np.inf) - return pd.Series(np.exp(np.cumsum(log_growth)), index=returns.index, name=returns.name) + wealth = np.exp(np.cumsum(log_growth)) + wiped_out = np.logical_or.accumulate(r == -1.0) + if not np.isfinite(wealth).all() or ((wealth == 0.0) & ~wiped_out).any(): + raise ValueError("Compounded wealth must be finite and representable") + return pd.Series(wealth, index=returns.index, name=returns.name) def max_drawdown(returns: pd.Series) -> tuple[float, pd.Timestamp | None]: diff --git a/tests/unit/test_accounting_limits.py b/tests/unit/test_accounting_limits.py new file mode 100644 index 0000000..9bcd2dc --- /dev/null +++ b/tests/unit/test_accounting_limits.py @@ -0,0 +1,206 @@ +"""Numerical boundaries must fail clearly without corrupting accounting.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from alphaforge.backtest.engine import BacktestEngine +from alphaforge.execution.broker import BrokerSimulator +from alphaforge.execution.costs import CostModel, TradeCost +from alphaforge.features.panel import MarketPanel +from alphaforge.utils.math_utils import compound + + +def _costs(**kwargs): + return {"commission_bps": 0, "slippage_bps": 0, "impact_coeff_bps": 0, **kwargs} + + +@pytest.mark.parametrize("component", ["commission_bps", "slippage_bps", "impact_coeff_bps"]) +def test_cost_scaling_keeps_a_representable_large_result(component): + model = CostModel(_costs(**{component: 10_000}, participation_cap=1)) + assert model.estimate(1e308, 1e308).total == pytest.approx(1e308) + + +@pytest.mark.parametrize( + "settings", + [ + {"commission_bps": 20_000}, + {"slippage_bps": 20_000}, + {"impact_coeff_bps": 20_000, "participation_cap": 1}, + {"commission_bps": 10_000, "slippage_bps": 10_000}, + ], +) +def test_costs_outside_float_range_are_rejected(settings): + with pytest.raises(ValueError, match="finite"): + CostModel(_costs(**settings)).estimate(1e308, 1e308) + + +@pytest.mark.parametrize("value", [np.inf, -np.inf, np.nan]) +def test_nonfinite_trade_values_are_rejected(value): + with pytest.raises(ValueError, match="trade_value"): + CostModel().estimate(value, 1e6) + + +def test_broker_rejects_fees_larger_than_available_nav(): + broker = BrokerSimulator(_costs(commission_bps=20_000)) + with pytest.raises(ValueError, match="cost.*NAV"): + broker.rebalance(pd.Series({"A": 1.0}), None, 100.0, pd.Series({"A": 10.0})) + + +def test_broker_allows_fees_that_exactly_exhaust_nav(): + result = BrokerSimulator(_costs(commission_bps=10_000)).rebalance( + pd.Series({"A": 1.0}), None, 100.0, pd.Series({"A": 10.0}) + ) + assert result.cost_total == 100.0 + assert np.isfinite(result.weights).all() + + +def test_broker_keeps_representable_short_weights_without_intermediate_overflow(): + result = BrokerSimulator(_costs()).rebalance( + pd.Series({"A": -1.9}), + pd.Series({"A": -2.0}), + 1e308, + pd.Series({"A": 1.0}), + pd.Series({"A": 1e308}), + ) + assert result.weights.loc["A"] == pytest.approx(-1.9) + + +def test_broker_rejects_unrepresentable_traded_share_quantity(): + with pytest.raises(ValueError, match="Traded shares.*representable"): + BrokerSimulator(_costs()).rebalance( + pd.Series({"A": 1.0}), + None, + 1.0, + pd.Series({"A": np.nextafter(0.0, 1.0)}), + ) + + +@pytest.mark.parametrize("nav", [np.inf, -np.inf, np.nan, -1.0]) +def test_broker_rejects_invalid_nav(nav): + with pytest.raises(ValueError, match="NAV"): + BrokerSimulator(_costs()).rebalance( + pd.Series({"A": 1.0}), None, nav, pd.Series({"A": 10.0}) + ) + + +@pytest.mark.parametrize("value", [np.inf, -np.inf]) +def test_compound_rejects_nonfinite_returns_instead_of_a_fake_wipeout(value): + with pytest.raises(ValueError, match="returns"): + compound(pd.Series([0.1, value])) + + +def test_compound_rejects_losses_below_total_capital(): + with pytest.raises(ValueError, match="returns"): + compound(pd.Series([0.1, -1.1])) + + +def test_compound_rejects_unrepresentable_wealth(): + with pytest.raises(ValueError, match="wealth"): + compound(pd.Series([1e200, 1e200])) + + +def test_compound_rejects_underflow_instead_of_a_zero_curve_that_resurrects(): + with pytest.raises(ValueError, match="wealth.*representable"): + compound(pd.Series([-0.999999] * 60 + [1e308, 1e308])) + + +def test_compound_keeps_zero_wealth_after_a_legal_total_loss(): + assert compound(pd.Series([0.1, -1.0, 0.1])).tolist() == pytest.approx([1.1, 0.0, 0.0]) + + +def _panel(close_values): + dates = pd.bdate_range("2024-01-01", periods=len(close_values)) + close = pd.DataFrame({"A": close_values}, index=dates) + volume = pd.DataFrame(1e6, index=dates, columns=["A"]) + return MarketPanel( + dates=dates, + close=close, + raw_close=close.copy(), + returns=close.pct_change(fill_method=None), + volume=volume, + dollar_volume=close * volume, + market_cap=pd.DataFrame(1e9, index=dates, columns=["A"]), + universe=close.notna(), + industry=pd.DataFrame("Other", index=dates, columns=["A"]), + ) + + +def _engine(panel, *, target=1.0, initial_capital=100.0, cost_model=None, allow_short=False): + return BacktestEngine( + panel, + weight_fn=lambda _date, _weights: pd.Series({"A": target}), + cost_model=_costs() if cost_model is None else cost_model, + config={ + "rebalance": "daily", + "min_history_days": 0, + "initial_capital": initial_capital, + "allow_short": allow_short, + }, + ) + + +def test_engine_rejects_an_extreme_finite_fee_with_a_clear_value_error(): + with pytest.raises(ValueError, match="cost.*NAV"): + _engine(_panel([10.0] * 8), cost_model=_costs(commission_bps=1e308)).run() + + +@pytest.mark.parametrize("fee", [-1.0, np.inf, 101.0]) +def test_custom_broker_cannot_bypass_the_engine_fee_boundary(fee): + class FixedFeeBroker(BrokerSimulator): + def rebalance(self, **kwargs): + result = super().rebalance(**kwargs) + result.cost_total = fee + return result + + engine = _engine(_panel([10.0] * 8)) + engine.broker = FixedFeeBroker(_costs()) + with pytest.raises(ValueError, match="cost.*NAV"): + engine.run() + + +def test_forced_liquidation_cannot_charge_unpayable_fees(): + class LiquidationCostModel(CostModel): + calls = 0 + + def estimate(self, trade_value, adv_value): + self.calls += 1 + return TradeCost(0.0 if self.calls == 1 else 101.0, 0.0, 0.0) + + engine = _engine(_panel([10.0, 10.0, np.nan, np.nan, np.nan])) + engine.cost_model = LiquidationCostModel(_costs()) + engine.broker = BrokerSimulator(engine.cost_model) + engine.config.delist_grace_days = 1 + with pytest.raises(ValueError, match="cost.*NAV"): + engine.run() + + +def test_engine_rejects_mark_to_market_nav_overflow(): + with pytest.raises(ValueError, match="NAV.*finite"): + _engine(_panel([10.0, 10.0, 1000.0, 1000.0, 1000.0]), initial_capital=1e308).run() + + +def test_engine_rejects_negative_nav(): + with pytest.raises(ValueError, match="NAV.*nonnegative"): + _engine(_panel([10.0, 10.0, 30.0, 30.0, 30.0]), target=-1.0, allow_short=True).run() + + +def test_engine_preserves_zero_nav_as_a_legal_bankruptcy(): + result = _engine(_panel([10.0] * 8), cost_model=_costs(commission_bps=10_000)).run() + assert result.equity.tolist() == pytest.approx([100.0] + [0.0] * 7) + assert result.returns.tolist() == pytest.approx([0.0, -1.0] + [0.0] * 6) + assert result.metrics["max_drawdown"] == pytest.approx(-1.0) + assert np.isfinite(result.gross_equity).all() + + +def test_a_zero_price_wipeout_cannot_resurrect_capital(): + result = _engine(_panel([10.0, 10.0, 0.0, 20.0, 20.0])).run() + assert result.equity.tolist() == pytest.approx([100.0, 100.0, 0.0, 0.0, 0.0]) + assert result.returns.tolist() == pytest.approx([0.0, 0.0, -1.0, 0.0, 0.0]) + + +def test_positive_nav_cannot_be_reported_as_a_total_loss_then_resurrect(): + with pytest.raises(ValueError, match="Daily returns.*representable"): + _engine(_panel([10.0, 10.0, 1e-20, 10.0, 10.0, 10.0])).run() diff --git a/tests/unit/test_real_research_entrypoint.py b/tests/unit/test_real_research_entrypoint.py new file mode 100644 index 0000000..581241b --- /dev/null +++ b/tests/unit/test_real_research_entrypoint.py @@ -0,0 +1,40 @@ +"""The real-data command cannot audit one root while loading another.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).parents[2] / "scripts/run_real_research.py" + + +def test_real_research_help_remains_available_from_another_directory(tmp_path): + result = subprocess.run( + [sys.executable, str(SCRIPT), "--help"], + cwd=tmp_path, + text=True, + capture_output=True, + encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + assert result.returncode == 0, result.stderr + assert "--require-complete-sources" in result.stdout + + +def test_real_research_rejects_a_foreign_working_directory_before_loading_data(tmp_path): + foreign_data = tmp_path / "data/processed" + foreign_data.mkdir(parents=True) + (foreign_data / "prices.parquet").write_bytes(b"a different dataset must never be read") + result = subprocess.run( + [sys.executable, str(SCRIPT), "--no-json"], + cwd=tmp_path, + text=True, + capture_output=True, + encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + assert result.returncode != 0 + assert "from the repository root" in result.stderr + assert "Real dataset not found" not in result.stderr diff --git a/tests/unit/test_research_comparison.py b/tests/unit/test_research_comparison.py new file mode 100644 index 0000000..c8abfef --- /dev/null +++ b/tests/unit/test_research_comparison.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pandas as pd +import pytest + +from alphaforge.comparison import ranked_weights, validate_coverage, verify_comparison +from alphaforge.data.assurance import audit_local_dataset, write_mirror_assurance + +HEADLINES = ( + "cagr", + "sharpe", + "max_drawdown", + "ann_vol", + "total_return", + "gross_cagr", + "gross_sharpe", + "gross_max_drawdown", + "gross_ann_vol", + "gross_total_return", +) + + +@pytest.fixture +def saved_comparison(tmp_path): + saved = Path(__file__).parents[2] / "docs/experiments/real-sp500" + destination = tmp_path / "comparison" + shutil.copytree(saved, destination) + return destination + + +def rehash_artifact(directory, name): + manifest_path = directory / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files_sha256"][name] = hashlib.sha256((directory / name).read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + +def write_summary(directory, summary): + (directory / "summary.json").write_text(json.dumps(summary), encoding="utf-8") + rehash_artifact(directory, "summary.json") + + +def source_bundle(root): + dates = pd.to_datetime(["2020-01-02", "2020-01-03"]) + for name in ("constituents", "fundamentals", "industry", "macro"): + pd.DataFrame({"unused": []}).to_parquet(root / f"{name}.parquet") + pd.DataFrame({"date": dates, "symbol": ["A", "A"]}).to_parquet(root / "prices.parquet") + pd.DataFrame({"date": dates, "value": [0.01, -0.02]}).to_parquet(root / "benchmark.parquet") + raw = root / "source.txt" + raw.write_text("fixture") + write_mirror_assurance(root, {"raw": raw}, {"raw": "https://example.test/fixture"}, 90) + + +def test_incomplete_real_sources_are_exploratory_and_strict_mode_rejects(tmp_path): + source_bundle(tmp_path) + result = audit_local_dataset(tmp_path, "2020-01-01", "2020-01-03") + assert result["status"] == "exploratory" + assert len(result["limitations"]) == 3 + with pytest.raises(ValueError, match="Complete-source validation failed"): + audit_local_dataset(tmp_path, "2020-01-01", "2020-01-03", require_complete_sources=True) + + +def test_changed_source_bytes_and_missing_benchmark_dates_fail(tmp_path): + source_bundle(tmp_path) + pd.DataFrame({"date": pd.to_datetime(["2020-01-02"]), "value": [0.01]}).to_parquet( + tmp_path / "benchmark.parquet" + ) + with pytest.raises(ValueError, match="Source identity changed"): + audit_local_dataset(tmp_path, "2020-01-01", "2020-01-03") + raw = tmp_path / "source.txt" + write_mirror_assurance(tmp_path, {"raw": raw}, {"raw": "https://example.test/fixture"}, 90) + with pytest.raises(ValueError, match="missing 1 price sessions"): + audit_local_dataset(tmp_path, "2020-01-01", "2020-01-03") + + +def test_coverage_rejects_a_method_that_omits_a_hard_forecast(): + frame = pd.DataFrame( + { + "date": pd.to_datetime(["2020-01-02", "2020-01-02"]), + "symbol": ["A", "B"], + "fold": [0, 0], + "realised": [0.1, -0.1], + "prediction": [0.2, -0.2], + } + ) + validate_coverage(frame, frame.copy()) + with pytest.raises(ValueError, match="same forecast"): + validate_coverage(frame, frame.iloc[:1]) + with pytest.raises(ValueError, match="overlapping/duplicate"): + validate_coverage(frame, pd.concat([frame, frame.iloc[:1]])) + + +def test_top_names_use_only_eligible_scores_and_symbol_ties(): + day = pd.Timestamp("2020-01-02") + scores = pd.DataFrame([[100.0, 0.5, 0.5, -1.0]], index=[day], columns=["future", "B", "A", "C"]) + panel = SimpleNamespace( + universe=pd.DataFrame([[False, True, True, True]], index=[day], columns=scores.columns) + ) + weights = ranked_weights(panel, scores, 1)(day, None) + assert weights.to_dict() == {"A": 1.0} + all_weights = ranked_weights(panel, scores, None)(day, None) + assert set(all_weights.index) == {"A", "B", "C"} + assert all_weights.sum() == pytest.approx(1.0) + + +def test_saved_return_evidence_detects_rehashed_financial_headline(tmp_path): + saved = Path(__file__).parents[2] / "docs/experiments/real-sp500" + destination = tmp_path / "comparison" + shutil.copytree(saved, destination) + assert verify_comparison(destination)["cases"] == 22 + path = destination / "summary.json" + summary = json.loads(path.read_text()) + summary["cases"][0]["cagr"] += 0.1 + path.write_text(json.dumps(summary)) + manifest_path = destination / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["files_sha256"]["summary.json"] = hashlib.sha256(path.read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="cagr disagrees"): + verify_comparison(destination) + + +@pytest.mark.parametrize("autocrlf", ["false", "true"]) +@pytest.mark.parametrize("eol", ["lf", "crlf"]) +def test_frozen_evidence_survives_git_checkout_line_ending_settings(tmp_path, autocrlf, eol): + root = Path(__file__).parents[2] + relative = Path("docs/experiments/real-sp500") + repository = tmp_path / "repository" + checkout = tmp_path / "checkout" + repository.mkdir() + shutil.copytree(root / relative, repository / relative) + shutil.copyfile(root / ".gitattributes", repository / ".gitattributes") + subprocess.run(["git", "init", "-q"], cwd=repository, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "core.autocrlf=false", "add", "."], + cwd=repository, + check=True, + capture_output=True, + ) + subprocess.run( + [ + "git", + "-c", + f"core.autocrlf={autocrlf}", + "-c", + f"core.eol={eol}", + "checkout-index", + "--all", + "--force", + f"--prefix={checkout.as_posix()}/", + ], + cwd=repository, + check=True, + capture_output=True, + ) + manifest = json.loads((root / relative / "manifest.json").read_text()) + for name in ("manifest.json", *manifest["files_sha256"]): + assert (checkout / relative / name).read_bytes() == (root / relative / name).read_bytes() + assert verify_comparison(checkout / relative)["cases"] == 22 + + +def test_saved_evidence_rejects_line_ending_changes_without_rehashing(tmp_path): + saved = Path(__file__).parents[2] / "docs/experiments/real-sp500" + destination = tmp_path / "comparison" + shutil.copytree(saved, destination) + path = destination / "folds.csv" + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + with pytest.raises(ValueError, match="artifact digest mismatch: folds.csv"): + verify_comparison(destination) + + +@pytest.mark.parametrize("title", HEADLINES) +def test_rehashed_nan_metrics_csv_cannot_bypass_headline_comparison(saved_comparison, title): + path = saved_comparison / "metrics.csv" + metrics = pd.read_csv(path) + metrics.loc[0, title] = float("nan") + metrics.to_csv(path, index=False, lineterminator="\n") + rehash_artifact(saved_comparison, "metrics.csv") + with pytest.raises(ValueError, match=f"{title} must be finite"): + verify_comparison(saved_comparison) + + +@pytest.mark.parametrize("title", HEADLINES) +def test_rehashed_missing_case_headline_cannot_skip_verification(saved_comparison, title): + summary = json.loads((saved_comparison / "summary.json").read_text(encoding="utf-8")) + del summary["cases"][0][title] + write_summary(saved_comparison, summary) + with pytest.raises(ValueError, match=f"Missing required comparison headline: {title}"): + verify_comparison(saved_comparison) + + +@pytest.mark.parametrize("metric", ["cagr", "sharpe", "max_drawdown"]) +@pytest.mark.parametrize("stat", ["mean", "std"]) +def test_rehashed_nan_aggregates_cannot_bypass_comparison(saved_comparison, metric, stat): + summary = json.loads((saved_comparison / "summary.json").read_text(encoding="utf-8")) + summary["aggregates"][0][f"{metric}_{stat}"] = float("nan") + write_summary(saved_comparison, summary) + with pytest.raises(ValueError, match="aggregates must be finite"): + verify_comparison(saved_comparison) + + +def test_rehashed_summary_must_match_manifest_dataset_identity(saved_comparison): + summary = json.loads((saved_comparison / "summary.json").read_text(encoding="utf-8")) + summary["dataset_sha256"] = "0" * 64 + write_summary(saved_comparison, summary) + with pytest.raises(ValueError, match="dataset identity mismatch"): + verify_comparison(saved_comparison) + + +def test_rehashed_duplicate_deterministic_seed_cannot_replace_a_case(saved_comparison): + summary = json.loads((saved_comparison / "summary.json").read_text(encoding="utf-8")) + indices = [ + index + for index, case in enumerate(summary["cases"]) + if case["method"] == "ridge" and case["cost_multiplier"] == 1.0 + ] + summary["cases"][indices[1]] = copy.deepcopy(summary["cases"][indices[0]]) + write_summary(saved_comparison, summary) + with pytest.raises(ValueError, match="case coverage mismatch"): + verify_comparison(saved_comparison) + + +def test_rehashed_unused_metrics_row_is_rejected(saved_comparison): + path = saved_comparison / "metrics.csv" + metrics = pd.read_csv(path) + extra = metrics.iloc[[0]].copy() + extra["seed"] = 777 + pd.concat([metrics, extra], ignore_index=True).to_csv(path, index=False, lineterminator="\n") + rehash_artifact(saved_comparison, "metrics.csv") + with pytest.raises(ValueError, match="metrics CSV coverage mismatch"): + verify_comparison(saved_comparison) + + +@pytest.mark.parametrize("change", ["missing", "duplicate"]) +def test_rehashed_aggregate_inventory_must_match_all_cases(saved_comparison, change): + summary = json.loads((saved_comparison / "summary.json").read_text(encoding="utf-8")) + if change == "missing": + summary["aggregates"].pop() + else: + summary["aggregates"].append(copy.deepcopy(summary["aggregates"][0])) + write_summary(saved_comparison, summary) + with pytest.raises(ValueError, match="aggregate coverage mismatch"): + verify_comparison(saved_comparison)