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"
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)