@@ -54,6 +54,9 @@ def run_live_forward_snapshot(
5454 optuna_timeout_seconds : int | None = None ,
5555 selection_metric : str = "top40_raw_excess" ,
5656 model_family : str = "auto" ,
57+ preselected_model_params : dict [str , Any ] | None = None ,
58+ preselected_feature_count : int | None = None ,
59+ preselected_trial : int | None = None ,
5760) -> dict [str , Any ]:
5861 """Retrain a live-forward ranker and save today's paper-trading scores."""
5962 decision_date = pd .Timestamp (as_of ).normalize () if as_of else pd .Timestamp .today ().normalize ()
@@ -113,7 +116,33 @@ def run_live_forward_snapshot(
113116 label_horizon_sessions ,
114117 )
115118 )
116- if tune_hyperparameters :
119+ recovery_refit = preselected_model_params is not None
120+ if recovery_refit :
121+ model_name = model_family
122+ if model_name not in {"ridge" , "xgb_ranker" }:
123+ raise ValueError ("A recovery refit requires an explicit model family" )
124+ exclude_prefixes = rank_cfg .get ("features" , {}).get ("exclude_prefixes" , [])
125+ features = _top_features (train , preselected_feature_count , exclude_prefixes )
126+ model_params = dict (preselected_model_params )
127+ model = _make_live_model (model_name , model_params ).fit (
128+ train [features ], train ["target" ]
129+ )
130+ selection_path = output / "model_selection.csv"
131+ tuning = (
132+ pd .read_csv (selection_path )
133+ if selection_path .exists () else pd .DataFrame ()
134+ )
135+ if preselected_trial is not None and not tuning .empty :
136+ chosen = tuning .loc [
137+ tuning ["row_type" ].eq ("aggregate" )
138+ & tuning ["trial" ].eq (preselected_trial )
139+ ]
140+ tuning = pd .concat (
141+ [chosen , tuning .drop (index = chosen .index )],
142+ ignore_index = True ,
143+ )
144+ tuning .to_csv (selection_path , index = False )
145+ elif tune_hyperparameters :
117146 model , model_name , model_params , features , tuning = _select_and_fit_live_model (
118147 train ,
119148 rank_cfg ,
@@ -148,6 +177,12 @@ def run_live_forward_snapshot(
148177 )
149178 live_frame ["score" ] = model .predict (live_frame .reindex (columns = features ))
150179 live_frame = live_frame .replace ([np .inf , - np .inf ], np .nan ).dropna (subset = ["score" ])
180+ live_score_unique = int (live_frame ["score" ].nunique ())
181+ if live_score_unique < 2 :
182+ raise RuntimeError (
183+ f"{ model_name } produced constant live scores; refusing to freeze an "
184+ "arbitrary ticker-ordered portfolio"
185+ )
151186 selected = sector_diversified_top_k (
152187 live_frame ["score" ].droplevel ("date" ),
153188 live_frame ["sector" ].droplevel ("date" ),
@@ -199,9 +234,10 @@ def run_live_forward_snapshot(
199234 "split_event_cache" : str (split_cache_file ),
200235 "split_features_status" : "candidate_only_pending_event_study" ,
201236 "live_scored_names" : int (ranked ["ticker" ].nunique ()),
237+ "live_unique_scores" : live_score_unique ,
202238 "holdings" : int (holdings ),
203239 "model_selection" : {
204- "enabled" : bool (tune_hyperparameters ),
240+ "enabled" : bool (tune_hyperparameters or recovery_refit ),
205241 "validation_days" : int (validation_days ) if tune_hyperparameters else None ,
206242 "tuning_folds" : int (tuning_folds ) if tune_hyperparameters else None ,
207243 "purge_days" : (
@@ -216,6 +252,8 @@ def run_live_forward_snapshot(
216252 "optuna_timeout_seconds" : optuna_timeout_seconds if tune_hyperparameters else None ,
217253 "selection_metric" : selection_metric if tune_hyperparameters else None ,
218254 "model_family" : model_family ,
255+ "recovery_refit" : recovery_refit ,
256+ "preselected_trial" : preselected_trial ,
219257 "search_profile" : rank_cfg .get ("live_tuning" , {}).get ("profile" , "broad_default" ),
220258 "search_space" : rank_cfg .get ("live_tuning" , {}),
221259 "report" : str (output / "model_selection.csv" ) if tune_hyperparameters else None ,
@@ -1094,7 +1132,10 @@ def objective(trial: Any) -> float:
10941132 trial .set_user_attr (key , value )
10951133 if selection_metric not in aggregate :
10961134 raise ValueError (f"Unknown live selection metric: { selection_metric } " )
1097- return float (aggregate [selection_metric ])
1135+ return (
1136+ float (aggregate [selection_metric ])
1137+ if aggregate ["selection_eligible" ] else float ("-inf" )
1138+ )
10981139
10991140 sampler = optuna .samplers .TPESampler (seed = 42 )
11001141 study = optuna .create_study (direction = "maximize" , sampler = sampler )
@@ -1111,6 +1152,13 @@ def objective(trial: Any) -> float:
11111152 na_position = "last" ,
11121153 ).reset_index (drop = True )
11131154 aggregate_table = table .loc [table ["row_type" ].eq ("aggregate" )].copy ()
1155+ aggregate_table = aggregate_table .loc [
1156+ aggregate_table ["selection_eligible" ].fillna (False ).astype (bool )
1157+ ]
1158+ if aggregate_table .empty :
1159+ raise RuntimeError (
1160+ "No model-selection trial produced non-constant scores in every fold"
1161+ )
11141162 aggregate_table = aggregate_table .sort_values (
11151163 [selection_metric , "rank_ic" ],
11161164 ascending = False ,
@@ -1295,6 +1343,14 @@ def _make_live_model(
12951343def _aggregate_trial_rows (rows : list [dict [str , Any ]], trial : int ) -> dict [str , Any ]:
12961344 frame = pd .DataFrame (rows )
12971345 first = rows [0 ]
1346+ validation_score_dates = (
1347+ frame ["validation_score_dates" ]
1348+ if "validation_score_dates" in frame else pd .Series (1 , index = frame .index )
1349+ )
1350+ constant_score_dates = (
1351+ frame ["constant_score_dates" ]
1352+ if "constant_score_dates" in frame else pd .Series (0 , index = frame .index )
1353+ )
12981354 aggregate = {
12991355 "row_type" : "aggregate" ,
13001356 "trial" : trial ,
@@ -1313,7 +1369,18 @@ def _aggregate_trial_rows(rows: list[dict[str, Any]], trial: int) -> dict[str, A
13131369 "top40_target_excess" : float (frame ["top40_target_excess" ].mean ()),
13141370 "validation_start" : str (frame ["validation_start" ].min ()),
13151371 "validation_end" : str (frame ["validation_end" ].max ()),
1372+ "fold_count" : int (len (frame )),
1373+ "valid_rank_ic_folds" : int (frame ["rank_ic" ].notna ().sum ()),
1374+ "constant_score_dates" : int (constant_score_dates .sum ()),
1375+ "score_date_coverage" : float (
1376+ (validation_score_dates - constant_score_dates ).sum ()
1377+ / max (1 , validation_score_dates .sum ())
1378+ ),
13161379 }
1380+ aggregate ["selection_eligible" ] = bool (
1381+ aggregate ["valid_rank_ic_folds" ] == aggregate ["fold_count" ]
1382+ and aggregate ["constant_score_dates" ] == 0
1383+ )
13171384 if first ["model" ] == "xgb_ranker" and "effective_n_estimators" in frame :
13181385 effective = pd .to_numeric (
13191386 frame ["effective_n_estimators" ],
@@ -1376,6 +1443,7 @@ def _validation_row(
13761443 holdings : int ,
13771444) -> dict [str , Any ]:
13781445 daily_ic = _daily_rank_ic (validation ["target" ], scores )
1446+ daily_unique = scores .groupby (level = "date" ).nunique ()
13791447 top_raw = _topk_daily_mean (validation ["raw_forward_return" ], scores , holdings )
13801448 universe_raw = validation ["raw_forward_return" ].groupby (level = "date" ).mean ().mean ()
13811449 top_target = _topk_daily_mean (validation ["target" ], scores , holdings )
@@ -1387,6 +1455,9 @@ def _validation_row(
13871455 "resolved_feature_count" : len (feature_names ),
13881456 "rank_ic" : mean_rank_ic (validation ["target" ], scores ),
13891457 "positive_ic_rate" : float (daily_ic .gt (0.0 ).mean ()) if not daily_ic .empty else np .nan ,
1458+ "validation_score_dates" : int (len (daily_unique )),
1459+ "constant_score_dates" : int (daily_unique .le (1 ).sum ()),
1460+ "score_date_coverage" : float (daily_unique .gt (1 ).mean ()),
13901461 "top40_raw_forward_return" : float (top_raw ),
13911462 "universe_raw_forward_return" : float (universe_raw ),
13921463 "top40_raw_excess" : float (top_raw - universe_raw ),
0 commit comments