Skip to content

Building an XGBoost Return Forecast With Point-in-Time Features

Original question

How do I build an XGBoost model for predicting short-term stock returns using technical and fundamental features?

Define a tradeable forward-return target, build technical features from trailing observations and join fundamentals by release time. Train a shallow boosted-tree regressor on earlier dates, use a later block for early stopping and keep the last block for a final test. Compare against a zero-return and a regularised linear forecast before converting predictions into positions.

Build a row that could exist at the decision time

Use trailing momentum and volatility as technical features. As a fundamental feature, use the latest reported year-on-year revenue growth, released no later than the decision. With a decision at close tt and execution at the next open, predict the return from open t+1t+1 to open t+2t+2. Use squared-error loss for the first experiment and evaluate test mean squared error relative to a zero-return forecast. If you later trade the forecast, evaluate turnover and net returns separately. Prediction improvement and trade profitability can diverge.

Limit tree depth and set a small learning rate. Set early stopping on a validation block later than training. If you compare multiple hyperparameter settings, choose them without looking at the final test outcomes.

Interpret forecast error and P&L separately

Suppose test mean squared error is 0.000096 for the model and 0.000100 for the zero forecast. The out-of-sample R2R^2 relative to zero is 1−0.000096/0.000100=4%1-0.000096/0.000100=4\%. That measures squared-error improvement for those observations. A rule that trades every tiny positive prediction may still lose money after costs. On development data, define a trading threshold and size rule, then test those fixed decisions on fresh dates.

Use the same stock-day observations for the boosted model and the zero forecast. When there are many securities per day, split the panel by date so the same market session cannot land on both sides. Tune tree depth, learning rate and early stopping using development dates only. Then keep the forecasting comparison separate from the trading rule. A small improvement in mean squared error can come from days or names that never pass the execution threshold. The trading test should show where the improvement becomes a different order.

Chronological XGBoost baseline

Use one stock for this illustration. Supply daily bars with decision_at, open and close columns, and a fundamentals CSV with available_at and revenue_growth. Times must be accurate for your feed.

import numpy as np import pandas as pd from xgboost import XGBRegressor bars = pd.read_csv("bars.csv", parse_dates=["decision_at"]).sort_values("decision_at") reports = pd.read_csv("fundamentals.csv", parse_dates=["available_at"]) data = pd.merge_asof(bars, reports.sort_values("available_at"), left_on="decision_at", right_on="available_at") data["momentum"] = data["close"].pct_change(20) data["volatility"] = data["close"].pct_change().rolling(20).std() data["target"] = data["open"].shift(-2) / data["open"].shift(-1) - 1 data["label_end_bound"] = data["decision_at"].shift(-2) features = ["momentum", "volatility", "revenue_growth"] data = data.dropna(subset=["target", "momentum", "volatility"]) a, b = int(0.6 * len(data)), int(0.8 * len(data)) train, valid, test = data.iloc[:a], data.iloc[a:b], data.iloc[b:] train = train[train["label_end_bound"] < valid["decision_at"].iloc[0]] valid = valid[valid["label_end_bound"] < test["decision_at"].iloc[0]] model = XGBRegressor(n_estimators=500, max_depth=2, learning_rate=0.03, early_stopping_rounds=25, random_state=7, n_jobs=2) model.fit(train[features], train["target"], eval_set=[(valid[features], valid["target"])], verbose=False) error = np.mean((test["target"] - model.predict(test[features])) ** 2) print("Test R² against zero:", 1 - error / np.mean(test["target"] ** 2))

The label end bound uses the close of the exit session as a conservative proxy for the earlier exit open. Use exact execution timestamps when available.

References and next steps