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
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 relative to zero is . 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
- XGBoost estimator documentation describes fitting and early stopping.
- How to Predict Stock Returns Without Leaking Future Information
- Dispatch · Feature engineering without data leakage