ifieryarrows commited on
Commit
e74897d
·
verified ·
1 Parent(s): 4d13aee

Sync from GitHub (tests passed)

Browse files
app/quality_gate.py CHANGED
@@ -51,6 +51,8 @@ def evaluate_quality_gate(
51
  reasons.append("Missing weekly_magnitude_ratio")
52
  elif weekly_magnitude_ratio < 0.65 or weekly_magnitude_ratio > 1.35:
53
  reasons.append(f"WeeklyMagnitudeRatio={weekly_magnitude_ratio:.4f} outside [0.65, 1.35]")
 
 
54
 
55
  if weekly_tail_capture_rate is None:
56
  reasons.append("Missing weekly_tail_capture_rate")
 
51
  reasons.append("Missing weekly_magnitude_ratio")
52
  elif weekly_magnitude_ratio < 0.65 or weekly_magnitude_ratio > 1.35:
53
  reasons.append(f"WeeklyMagnitudeRatio={weekly_magnitude_ratio:.4f} outside [0.65, 1.35]")
54
+ if weekly_magnitude_ratio > 3.0:
55
+ reasons.append(f"WeeklyMagnitudeExplosion={weekly_magnitude_ratio:.4f} > 3.0")
56
 
57
  if weekly_tail_capture_rate is None:
58
  reasons.append("Missing weekly_tail_capture_rate")
deep_learning/config.py CHANGED
@@ -136,11 +136,13 @@ class ASROConfig:
136
 
137
  @dataclass(frozen=True)
138
  class WeeklyLossConfig:
139
- lambda_weekly_quantile: float = 0.35
140
- lambda_t1_quantile: float = 0.15
141
- lambda_directional: float = 0.25
142
- lambda_magnitude: float = 0.15
143
- lambda_vol: float = 0.05
 
 
144
 
145
 
146
  @dataclass(frozen=True)
 
136
 
137
  @dataclass(frozen=True)
138
  class WeeklyLossConfig:
139
+ lambda_weekly_quantile: float = 0.55
140
+ lambda_t1_quantile: float = 0.10
141
+ lambda_directional: float = 0.15
142
+ lambda_magnitude: float = 0.35
143
+ lambda_vol: float = 0.15
144
+ lambda_crossing: float = 5.0
145
+ lambda_sanity: float = 0.10
146
 
147
 
148
  @dataclass(frozen=True)
deep_learning/models/tft_copper.py CHANGED
@@ -131,13 +131,16 @@ try:
131
  def __init__(
132
  self,
133
  quantiles: list,
134
- lambda_weekly_quantile: float = 0.35,
135
- lambda_t1_quantile: float = 0.15,
136
- lambda_directional: float = 0.25,
137
- lambda_magnitude: float = 0.15,
138
- lambda_vol: float = 0.05,
139
- lambda_crossing: float = 1.0,
 
140
  sharpe_eps: float = 1e-6,
 
 
141
  ):
142
  super().__init__(quantiles=quantiles)
143
  self.lambda_weekly_quantile = lambda_weekly_quantile
@@ -146,7 +149,10 @@ try:
146
  self.lambda_magnitude = lambda_magnitude
147
  self.lambda_vol = lambda_vol
148
  self.lambda_crossing = lambda_crossing
 
149
  self.sharpe_eps = sharpe_eps
 
 
150
  self.median_idx = len(quantiles) // 2
151
  q = list(quantiles)
152
  self._q10_idx = q.index(0.10) if 0.10 in q else 1
@@ -196,7 +202,17 @@ try:
196
  )
197
  target_spread = 2.0 * actual_weekly.std()
198
  vol_loss = torch.abs(weekly_spread.mean() - target_spread)
199
- crossing_loss = quantile_crossing_penalty(y_pred)
 
 
 
 
 
 
 
 
 
 
200
 
201
  def _to_scalar(x: torch.Tensor) -> torch.Tensor:
202
  # pytorch_forecasting metrics can return per-sample tensors;
@@ -211,6 +227,7 @@ try:
211
  + self.lambda_magnitude * _to_scalar(magnitude_loss)
212
  + self.lambda_vol * _to_scalar(vol_loss)
213
  + self.lambda_crossing * _to_scalar(crossing_loss)
 
214
  )
215
 
216
  except ImportError:
@@ -250,16 +267,18 @@ def create_tft_model(
250
  lambda_directional=cfg.weekly_loss.lambda_directional,
251
  lambda_magnitude=cfg.weekly_loss.lambda_magnitude,
252
  lambda_vol=cfg.weekly_loss.lambda_vol,
253
- lambda_crossing=cfg.asro.lambda_crossing,
 
254
  )
255
  logger.info(
256
- "Using weekly ASRO loss | weekly_q=%.2f t1_q=%.2f dir=%.2f mag=%.2f vol=%.2f crossing=%.2f",
257
  cfg.weekly_loss.lambda_weekly_quantile,
258
  cfg.weekly_loss.lambda_t1_quantile,
259
  cfg.weekly_loss.lambda_directional,
260
  cfg.weekly_loss.lambda_magnitude,
261
  cfg.weekly_loss.lambda_vol,
262
- cfg.asro.lambda_crossing,
 
263
  )
264
  elif use_asro and ASROPFLoss is not None:
265
  loss = ASROPFLoss(
 
131
  def __init__(
132
  self,
133
  quantiles: list,
134
+ lambda_weekly_quantile: float = 0.55,
135
+ lambda_t1_quantile: float = 0.10,
136
+ lambda_directional: float = 0.15,
137
+ lambda_magnitude: float = 0.35,
138
+ lambda_vol: float = 0.15,
139
+ lambda_crossing: float = 5.0,
140
+ lambda_sanity: float = 0.10,
141
  sharpe_eps: float = 1e-6,
142
+ daily_log_return_bound: float = 0.08,
143
+ weekly_log_return_bound: float = 0.20,
144
  ):
145
  super().__init__(quantiles=quantiles)
146
  self.lambda_weekly_quantile = lambda_weekly_quantile
 
149
  self.lambda_magnitude = lambda_magnitude
150
  self.lambda_vol = lambda_vol
151
  self.lambda_crossing = lambda_crossing
152
+ self.lambda_sanity = lambda_sanity
153
  self.sharpe_eps = sharpe_eps
154
+ self.daily_log_return_bound = daily_log_return_bound
155
+ self.weekly_log_return_bound = weekly_log_return_bound
156
  self.median_idx = len(quantiles) // 2
157
  q = list(quantiles)
158
  self._q10_idx = q.index(0.10) if 0.10 in q else 1
 
202
  )
203
  target_spread = 2.0 * actual_weekly.std()
204
  vol_loss = torch.abs(weekly_spread.mean() - target_spread)
205
+ daily_crossing_loss = quantile_crossing_penalty(y_pred)
206
+ weekly_crossing_loss = quantile_crossing_penalty(pred_weekly_quantiles.unsqueeze(1))
207
+ crossing_loss = daily_crossing_loss + weekly_crossing_loss
208
+
209
+ daily_bound_loss = torch.relu(
210
+ median_path.abs() - self.daily_log_return_bound
211
+ ).pow(2).mean()
212
+ weekly_bound_loss = torch.relu(
213
+ pred_weekly_median.abs() - self.weekly_log_return_bound
214
+ ).pow(2).mean()
215
+ sanity_loss = daily_bound_loss + weekly_bound_loss
216
 
217
  def _to_scalar(x: torch.Tensor) -> torch.Tensor:
218
  # pytorch_forecasting metrics can return per-sample tensors;
 
227
  + self.lambda_magnitude * _to_scalar(magnitude_loss)
228
  + self.lambda_vol * _to_scalar(vol_loss)
229
  + self.lambda_crossing * _to_scalar(crossing_loss)
230
+ + self.lambda_sanity * _to_scalar(sanity_loss)
231
  )
232
 
233
  except ImportError:
 
267
  lambda_directional=cfg.weekly_loss.lambda_directional,
268
  lambda_magnitude=cfg.weekly_loss.lambda_magnitude,
269
  lambda_vol=cfg.weekly_loss.lambda_vol,
270
+ lambda_crossing=cfg.weekly_loss.lambda_crossing,
271
+ lambda_sanity=cfg.weekly_loss.lambda_sanity,
272
  )
273
  logger.info(
274
+ "Using weekly ASRO loss | weekly_q=%.2f t1_q=%.2f dir=%.2f mag=%.2f vol=%.2f crossing=%.2f sanity=%.2f",
275
  cfg.weekly_loss.lambda_weekly_quantile,
276
  cfg.weekly_loss.lambda_t1_quantile,
277
  cfg.weekly_loss.lambda_directional,
278
  cfg.weekly_loss.lambda_magnitude,
279
  cfg.weekly_loss.lambda_vol,
280
+ cfg.weekly_loss.lambda_crossing,
281
+ cfg.weekly_loss.lambda_sanity,
282
  )
283
  elif use_asro and ASROPFLoss is not None:
284
  loss = ASROPFLoss(
deep_learning/training/hyperopt.py CHANGED
@@ -32,6 +32,7 @@ from deep_learning.config import (
32
  TFTASROConfig,
33
  TFTModelConfig,
34
  TrainingConfig,
 
35
  get_tft_config,
36
  )
37
 
@@ -53,6 +54,11 @@ KNOWN_GOOD_TRIAL_PARAMS = {
53
  "lambda_vol": 0.30,
54
  "lambda_quantile": 0.25,
55
  "lambda_madl": 0.40,
 
 
 
 
 
56
  "batch_size": 32,
57
  }
58
 
@@ -121,6 +127,7 @@ def _build_prune_diagnostics(study) -> tuple[dict[str, int], list[dict]]:
121
  "median_prune": 0,
122
  "fold_sharpe_prune": 0,
123
  "weekly_magnitude_collapse": 0,
 
124
  "error": 0,
125
  }
126
  fold_diagnostics: list[dict] = []
@@ -242,6 +249,16 @@ def create_trial_config(trial, base_cfg: TFTASROConfig) -> TFTASROConfig:
242
  risk_free_rate=0.0,
243
  )
244
 
 
 
 
 
 
 
 
 
 
 
245
  training_cfg = TrainingConfig(
246
  # CI budget: 3h limit @ CPU-only.
247
  # 15 trials × 3 folds × 25 epochs ≈ 108 min → leaves 70 min for final trainer.
@@ -270,7 +287,7 @@ def create_trial_config(trial, base_cfg: TFTASROConfig) -> TFTASROConfig:
270
  training=training_cfg,
271
  feature_store=base_cfg.feature_store,
272
  forecast=base_cfg.forecast,
273
- weekly_loss=base_cfg.weekly_loss,
274
  )
275
 
276
 
@@ -410,14 +427,16 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
410
  pred_np = np.array(pred_tensor)
411
 
412
  median_idx = len(trial_cfg.model.quantiles) // 2
413
- if pred_np.ndim == 3:
414
- pred_t1 = pred_np[:, 0, :]
415
- y_pred = pred_t1[:, median_idx]
416
- fold_crossing_rate = quantile_crossing_rate(pred_t1)
417
- _, fold_median_gap = quantile_median_sort_gap(pred_t1, median_idx)
418
- else:
419
- pred_t1 = None
420
- y_pred = pred_np.flatten()
 
 
421
 
422
  y_actual_parts = []
423
  for batch in fold_val_dl:
@@ -446,32 +465,32 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
446
  sr_std = float(strategy_returns.std()) + 1e-9
447
  fold_sharpe = sr_mean / sr_std
448
 
449
- if pred_np.ndim == 3:
450
- n_path = min(len(y_actual_path), len(pred_np))
451
- weekly = compute_weekly_metrics(
452
- y_actual_path[:n_path],
453
- pred_np[:n_path],
454
- quantiles=trial_cfg.model.quantiles,
455
- horizon=trial_cfg.forecast.primary_horizon_days,
456
- )
457
- weekly_pinball = _weekly_pinball_loss(
458
- y_actual_path[:n_path],
459
- pred_np[:n_path],
460
- tuple(trial_cfg.model.quantiles),
461
- horizon=trial_cfg.forecast.primary_horizon_days,
462
- )
463
- fold_weekly_mr = float(weekly.get("weekly_magnitude_ratio", 1.0))
464
- fold_weekly_objective = (
465
- 0.35 * weekly_pinball
466
- + 0.20 * (1.0 - float(weekly.get("weekly_directional_accuracy", 0.5)))
467
- + 0.20 * abs(np.log(fold_weekly_mr + 1e-8))
468
- + 0.15 * max(0.0, abs(float(weekly.get("weekly_pi80_coverage", 0.0)) - 0.80) - 0.06)
469
- + 0.10 * float(weekly.get("weekly_quantile_crossing_rate", 0.0))
470
- )
471
  except Exception as exc:
472
- logger.debug(
473
  "Trial %d fold %d metrics failed: %s", trial.number, fold_idx, exc
474
  )
 
475
 
476
  fold_vr_list.append(fold_vr)
477
  fold_da_list.append(fold_da)
@@ -524,6 +543,14 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
524
  trial.set_user_attr("prune_reason", "weekly_magnitude_collapse")
525
  raise optuna.exceptions.TrialPruned()
526
 
 
 
 
 
 
 
 
 
527
  # Report running average so MedianPruner can kill bad trials early
528
  running_avg = float(np.mean(fold_scores))
529
  trial.report(running_avg, fold_idx)
 
32
  TFTASROConfig,
33
  TFTModelConfig,
34
  TrainingConfig,
35
+ WeeklyLossConfig,
36
  get_tft_config,
37
  )
38
 
 
54
  "lambda_vol": 0.30,
55
  "lambda_quantile": 0.25,
56
  "lambda_madl": 0.40,
57
+ "lambda_weekly_quantile": 0.55,
58
+ "lambda_t1_quantile": 0.10,
59
+ "lambda_directional": 0.15,
60
+ "lambda_magnitude": 0.35,
61
+ "weekly_lambda_vol": 0.15,
62
  "batch_size": 32,
63
  }
64
 
 
127
  "median_prune": 0,
128
  "fold_sharpe_prune": 0,
129
  "weekly_magnitude_collapse": 0,
130
+ "weekly_magnitude_explosion": 0,
131
  "error": 0,
132
  }
133
  fold_diagnostics: list[dict] = []
 
249
  risk_free_rate=0.0,
250
  )
251
 
252
+ weekly_loss_cfg = WeeklyLossConfig(
253
+ lambda_weekly_quantile=trial.suggest_float("lambda_weekly_quantile", 0.45, 0.65, step=0.05),
254
+ lambda_t1_quantile=trial.suggest_float("lambda_t1_quantile", 0.10, 0.20, step=0.05),
255
+ lambda_directional=trial.suggest_float("lambda_directional", 0.10, 0.25, step=0.05),
256
+ lambda_magnitude=trial.suggest_float("lambda_magnitude", 0.25, 0.50, step=0.05),
257
+ lambda_vol=trial.suggest_float("weekly_lambda_vol", 0.10, 0.25, step=0.05),
258
+ lambda_crossing=base_cfg.weekly_loss.lambda_crossing,
259
+ lambda_sanity=base_cfg.weekly_loss.lambda_sanity,
260
+ )
261
+
262
  training_cfg = TrainingConfig(
263
  # CI budget: 3h limit @ CPU-only.
264
  # 15 trials × 3 folds × 25 epochs ≈ 108 min → leaves 70 min for final trainer.
 
287
  training=training_cfg,
288
  feature_store=base_cfg.feature_store,
289
  forecast=base_cfg.forecast,
290
+ weekly_loss=weekly_loss_cfg,
291
  )
292
 
293
 
 
427
  pred_np = np.array(pred_tensor)
428
 
429
  median_idx = len(trial_cfg.model.quantiles) // 2
430
+ if pred_np.ndim != 3:
431
+ raise ValueError(f"Expected quantile prediction tensor [n,horizon,q], got {pred_np.shape}")
432
+ if pred_np.shape[1] < trial_cfg.forecast.primary_horizon_days:
433
+ raise ValueError(
434
+ f"Prediction horizon too short: {pred_np.shape[1]} < {trial_cfg.forecast.primary_horizon_days}"
435
+ )
436
+ pred_t1 = pred_np[:, 0, :]
437
+ y_pred = pred_t1[:, median_idx]
438
+ fold_crossing_rate = quantile_crossing_rate(pred_t1)
439
+ _, fold_median_gap = quantile_median_sort_gap(pred_t1, median_idx)
440
 
441
  y_actual_parts = []
442
  for batch in fold_val_dl:
 
465
  sr_std = float(strategy_returns.std()) + 1e-9
466
  fold_sharpe = sr_mean / sr_std
467
 
468
+ n_path = min(len(y_actual_path), len(pred_np))
469
+ weekly = compute_weekly_metrics(
470
+ y_actual_path[:n_path],
471
+ pred_np[:n_path],
472
+ quantiles=trial_cfg.model.quantiles,
473
+ horizon=trial_cfg.forecast.primary_horizon_days,
474
+ )
475
+ weekly_pinball = _weekly_pinball_loss(
476
+ y_actual_path[:n_path],
477
+ pred_np[:n_path],
478
+ tuple(trial_cfg.model.quantiles),
479
+ horizon=trial_cfg.forecast.primary_horizon_days,
480
+ )
481
+ fold_weekly_mr = float(weekly.get("weekly_magnitude_ratio", 1.0))
482
+ fold_weekly_objective = (
483
+ 0.40 * weekly_pinball
484
+ + 0.15 * (1.0 - float(weekly.get("weekly_directional_accuracy", 0.5)))
485
+ + 0.35 * abs(np.log(fold_weekly_mr + 1e-8))
486
+ + 0.20 * max(0.0, abs(float(weekly.get("weekly_pi80_coverage", 0.0)) - 0.80) - 0.06)
487
+ + 0.20 * float(weekly.get("weekly_quantile_crossing_rate", 0.0))
488
+ )
 
489
  except Exception as exc:
490
+ logger.warning(
491
  "Trial %d fold %d metrics failed: %s", trial.number, fold_idx, exc
492
  )
493
+ return float("inf")
494
 
495
  fold_vr_list.append(fold_vr)
496
  fold_da_list.append(fold_da)
 
543
  trial.set_user_attr("prune_reason", "weekly_magnitude_collapse")
544
  raise optuna.exceptions.TrialPruned()
545
 
546
+ if fold_weekly_mr > 3.0 and fold_idx >= 1 and not protect_trial:
547
+ logger.warning(
548
+ "Trial %d PRUNED at fold %d: weekly_magnitude_ratio=%.4f > 3.0",
549
+ trial.number, fold_idx + 1, fold_weekly_mr,
550
+ )
551
+ trial.set_user_attr("prune_reason", "weekly_magnitude_explosion")
552
+ raise optuna.exceptions.TrialPruned()
553
+
554
  # Report running average so MedianPruner can kill bad trials early
555
  running_avg = float(np.mean(fold_scores))
556
  trial.report(running_avg, fold_idx)
deep_learning/training/trainer.py CHANGED
@@ -250,6 +250,11 @@ def train_tft_model(
250
  cfg.weekly_loss.lambda_magnitude,
251
  cfg.weekly_loss.lambda_vol,
252
  )
 
 
 
 
 
253
  else:
254
  logger.info(
255
  "Training data | samples=%d batch_size=%d batches/epoch=%d "
@@ -403,6 +408,13 @@ def train_tft_model(
403
  "lambda_quantile": cfg.asro.lambda_quantile,
404
  "lambda_madl": cfg.asro.lambda_madl,
405
  "lambda_crossing": cfg.asro.lambda_crossing,
 
 
 
 
 
 
 
406
  "max_encoder_length": cfg.model.max_encoder_length,
407
  "max_prediction_length": cfg.model.max_prediction_length,
408
  "forecast_contract_version": FORECAST_CONTRACT_VERSION,
@@ -603,11 +615,24 @@ def _overlay_training_config(cfg: TFTASROConfig, params: dict) -> TFTASROConfig:
603
  training_overrides = {
604
  k: params[k] for k in ("batch_size",) if k in params
605
  }
 
 
 
 
 
 
 
 
606
 
607
  new_model = replace(cfg.model, **model_overrides) if model_overrides else cfg.model
608
  new_asro = replace(cfg.asro, **asro_overrides) if asro_overrides else cfg.asro
 
 
 
 
 
609
  new_training = replace(cfg.training, **training_overrides) if training_overrides else cfg.training
610
- return replace(cfg, model=new_model, asro=new_asro, training=new_training)
611
 
612
 
613
  def _persist_tft_metadata(symbol: str, result: dict) -> None:
 
250
  cfg.weekly_loss.lambda_magnitude,
251
  cfg.weekly_loss.lambda_vol,
252
  )
253
+ logger.info(
254
+ "Weekly guards | crossing=%.2f sanity=%.2f",
255
+ cfg.weekly_loss.lambda_crossing,
256
+ cfg.weekly_loss.lambda_sanity,
257
+ )
258
  else:
259
  logger.info(
260
  "Training data | samples=%d batch_size=%d batches/epoch=%d "
 
408
  "lambda_quantile": cfg.asro.lambda_quantile,
409
  "lambda_madl": cfg.asro.lambda_madl,
410
  "lambda_crossing": cfg.asro.lambda_crossing,
411
+ "lambda_weekly_quantile": cfg.weekly_loss.lambda_weekly_quantile,
412
+ "lambda_t1_quantile": cfg.weekly_loss.lambda_t1_quantile,
413
+ "lambda_directional": cfg.weekly_loss.lambda_directional,
414
+ "lambda_magnitude": cfg.weekly_loss.lambda_magnitude,
415
+ "weekly_lambda_vol": cfg.weekly_loss.lambda_vol,
416
+ "weekly_lambda_crossing": cfg.weekly_loss.lambda_crossing,
417
+ "lambda_sanity": cfg.weekly_loss.lambda_sanity,
418
  "max_encoder_length": cfg.model.max_encoder_length,
419
  "max_prediction_length": cfg.model.max_prediction_length,
420
  "forecast_contract_version": FORECAST_CONTRACT_VERSION,
 
615
  training_overrides = {
616
  k: params[k] for k in ("batch_size",) if k in params
617
  }
618
+ weekly_loss_overrides = {
619
+ k: params[k] for k in (
620
+ "lambda_weekly_quantile", "lambda_t1_quantile", "lambda_directional",
621
+ "lambda_magnitude", "lambda_crossing", "lambda_sanity",
622
+ ) if k in params
623
+ }
624
+ if "weekly_lambda_vol" in params:
625
+ weekly_loss_overrides["lambda_vol"] = params["weekly_lambda_vol"]
626
 
627
  new_model = replace(cfg.model, **model_overrides) if model_overrides else cfg.model
628
  new_asro = replace(cfg.asro, **asro_overrides) if asro_overrides else cfg.asro
629
+ new_weekly_loss = (
630
+ replace(cfg.weekly_loss, **weekly_loss_overrides)
631
+ if weekly_loss_overrides
632
+ else cfg.weekly_loss
633
+ )
634
  new_training = replace(cfg.training, **training_overrides) if training_overrides else cfg.training
635
+ return replace(cfg, model=new_model, asro=new_asro, weekly_loss=new_weekly_loss, training=new_training)
636
 
637
 
638
  def _persist_tft_metadata(symbol: str, result: dict) -> None: