Tune model config for price-only training and add walk-forward CV

Downsize GNN hidden channels/heads (128→64, 16→4) to match the 30-stock
price-only universe before alternative data processors are ready.

Add USE_ALTERNATIVE_DATA flag (default False) that skips news/social
branches so the model trains on real data rather than zero-filled stubs.

Standardize target returns per-batch in the data pipeline to stabilize
training.

Introduce walk-forward cross-validation with expanding windows:
configurable fold count and out-of-sample years.

Add IC loss weighting (0.7) to complement MSE, and wire it into the
trainer alongside the new loss function.
This commit is contained in:
2026-05-26 15:36:11 +02:00
parent 2673e27a40
commit 19ed77f4a0
4 changed files with 262 additions and 116 deletions
+65 -2
View File
@@ -779,7 +779,12 @@ class StockDataPipeline:
)
node_tickers = valid_tickers_for_y
y = torch.tensor(y_vals, dtype=torch.float32).unsqueeze(1)
y_arr = np.array(y_vals, dtype=np.float32)
if len(y_arr) > 1:
mu, sigma = y_arr.mean(), y_arr.std()
if sigma > 1e-8:
y_arr = (y_arr - mu) / sigma
y = torch.tensor(y_arr, dtype=torch.float32).unsqueeze(1)
data = Data(x=x, edge_index=edge_index, edge_attr=edge_weight, y=y)
data.date = date
@@ -1001,7 +1006,12 @@ class StockDataPipeline:
ret = future_price / current_price - 1
y_vals.append(ret)
y = torch.tensor(y_vals, dtype=torch.float32).unsqueeze(1)
y_arr = np.array(y_vals, dtype=np.float32)
if len(y_arr) > 1:
mu, sigma = y_arr.mean(), y_arr.std()
if sigma > 1e-8:
y_arr = (y_arr - mu) / sigma
y = torch.tensor(y_arr, dtype=torch.float32).unsqueeze(1)
# Create Data object
data = Data(x=x, edge_index=edge_index, edge_attr=edge_weight, y=y)
@@ -1180,3 +1190,56 @@ class StockDataPipeline:
"""Create a validation dataset for the given date range."""
logger.info(f"Creating validation dataset {start_date}{end_date}")
return self._create_dataset(start_date, end_date)
def create_walk_forward_splits(self) -> List[Tuple]:
"""
Build walk-forward CV splits as expanding-window train / fixed OOS val pairs.
Each fold's training window starts at config.START_DATE and ends at a boundary
that advances by config.WALK_FORWARD_TEST_YEARS each fold. The val window is
the following config.WALK_FORWARD_TEST_YEARS of data. Folds that would push
the val end beyond config.TRAIN_END_DATE (or available data) are dropped.
Returns a list of (train_dataset, val_dataset, meta_dict) tuples.
"""
from datetime import datetime, timedelta
train_start = config.START_DATE
full_end = datetime.strptime(config.TRAIN_END_DATE, "%Y-%m-%d")
test_years = config.WALK_FORWARD_TEST_YEARS
n_folds = config.WALK_FORWARD_FOLDS
splits: List[Tuple] = []
for fold in range(n_folds):
# Each fold's val window is offset by fold * test_years from the full_end
# minus (n_folds - fold) * test_years so folds are evenly spaced
offset_years = (n_folds - fold) * test_years
val_end_dt = full_end - timedelta(days=int((offset_years - test_years) * 365))
train_end_dt = val_end_dt - timedelta(days=int(test_years * 365))
val_start_dt = train_end_dt + timedelta(days=1)
if train_end_dt <= datetime.strptime(train_start, "%Y-%m-%d"):
logger.warning(f"Skipping fold {fold}: train window too short")
continue
train_end_str = train_end_dt.strftime("%Y-%m-%d")
val_start_str = val_start_dt.strftime("%Y-%m-%d")
val_end_str = val_end_dt.strftime("%Y-%m-%d")
logger.info(
f"Walk-forward fold {fold + 1}/{n_folds}: "
f"train {train_start}{train_end_str}, val {val_start_str}{val_end_str}"
)
train_ds = self._create_dataset(train_start, train_end_str)
val_ds = self._create_dataset(val_start_str, val_end_str)
meta = {
"fold": fold,
"train_start": train_start,
"train_end": train_end_str,
"val_start": val_start_str,
"val_end": val_end_str,
}
splits.append((train_ds, val_ds, meta))
return splits