← Blog/blog/lgbm-stock-returns

LGBM for stock returns, read closely

01

What it predicts

The task is deliberately narrow: using the last 10 days of data, predict a stock's next-day return (not price — return, which is far harder to fake through autocorrelation). The dataset is every S&P 500 company plus the index itself, 2014–2017, about 497,000 rows. Eighteen features per row: raw OHLCV, the S&P 500 index as a market-context signal, and a handful of technical indicators.

02

The features, from scratch

The indicators are the interesting part, because they're small enough to implement and watch. Here are two of them — the chart below is drawn by this code, not a screenshot. (Toggle the language: the site runs the TypeScript; the Python and C++ are line-for-line translations.)

def bollinger(prices, n, k=2):
    mid = sma(prices, n)
    bands = []
    for i in range(len(prices)):
        if i < n - 1:
            bands.append((None, None, None))
            continue
        m = mid[i]
        var = sum((prices[j] - m) ** 2 for j in range(i - n + 1, i + 1)) / n
        sd = var ** 0.5                       # population sd over the window
        bands.append((m, m + k * sd, m - k * sd))
    return bands
closeMA20±2σ band
Fig 1. A seeded synthetic price series (geometric random walk, seed 1729) with its 20-day moving average and ±2σ Bollinger band. The band widens when the window's volatility rises — the paper's “overbought / oversold” signal.

RSI is the other workhorse — Wilder's smoothed ratio of average gains to average losses, squashed into 0–100. Above 70 is “overbought,” below 30 “oversold.”

def rsi(prices, n):
    out = [None] * len(prices)
    if len(prices) <= n:
        return out
    gain = sum(max(prices[i] - prices[i - 1], 0) for i in range(1, n + 1))
    loss = sum(max(prices[i - 1] - prices[i], 0) for i in range(1, n + 1))
    avg_gain, avg_loss = gain / n, loss / n          # Wilder's seed
    out[n] = 100 - 100 / (1 + avg_gain / avg_loss)
    for i in range(n + 1, len(prices)):
        ch = prices[i] - prices[i - 1]
        avg_gain = (avg_gain * (n - 1) + max(ch, 0)) / n
        avg_loss = (avg_loss * (n - 1) + max(-ch, 0)) / n
        out[i] = 100 - 100 / (1 + avg_gain / avg_loss)
    return out
RSI(14)7030
Fig 2. RSI(14) on the same series, with the conventional 30/70 reference levels. Both indicators pass reference-value unit tests in indicators.test.ts.
03

The one methodology choice that matters

With time series, the split is everything. Shuffle the rows and you leak the future into training; your R² soars and means nothing. The paper uses expanding-window cross-validation: each fold trains only on the past and tests on the next unseen block.

# Expanding-window time-series CV: every fold trains only on the past.
def time_series_splits(n, folds):
    size = n // (folds + 1)
    splits = []
    for f in range(1, folds + 1):
        splits.append({
            "train": range(0, f * size),              # everything up to here
            "test": range(f * size, (f + 1) * size),  # the next block, unseen
        })
    return splits
Fig 3. Expanding-window folds — train (filled) always precedes test (hollow).
04

The result

Across the five models, LightGBM has the lowest error and the highest explained variance on next-day returns. Gradient boosting on tabular features beating an LSTM is the expected outcome here, and it's worth internalizing: for tabular data, boosted trees remain the one to beat.

Fig 4. R² on next-day return prediction (Table 2). Higher is better.
modelMAE
LGBM0.00170.7377
Random forest0.00220.6108
Decision tree0.00240.5352
XGBoost0.00240.4959
LSTM0.00250.4906
Table 2, reproduced. Mean daily return magnitude is ~1.24% on our synthetic series, which puts an MAE of 0.0017 in context.
05

From returns to a portfolio

The second half turns predicted returns into a 20-stock portfolio, weighted for risk parity and optimized for alpha. The headline numbers look strong: alpha of +10.6% over the benchmark, +19.1% total return.

Fig 5. Reported portfolio metrics (Table 4). VaR/CVaR are 95% one-period losses.
06

The number the abstract doesn't lead with

To its credit, the paper computes the Kelly criterion — the growth-optimal bet size given a win probability and payoff. It plugs in the model's R² as the win probability:

# f* = p - (1 - p) / b   ->   negative means: do not bet.
p = 0.7377   # win prob, taken from the model's R^2
b = 1        # net odds
f = p - (1 - p) / b   # = -0.6327
07

What I'd probe next

  • R² on returns is fragile — a model that always predicts “tiny positive” scores well when most days are tiny and positive. Directional accuracy (did we get the sign?) is the number that would move Kelly.
  • Risk parity here collapsed to equal 5% weights. With a real covariance estimate the weights and the VaR would both shift; that's a clean follow-up experiment.
  • The honest headline isn't “LGBM predicts the market.” It's “LGBM explains next-day return variance well in-sample, and even then the bet doesn't clear the Kelly bar.”

The model at the center of this paper is on this site — go bend the LightGBM decision surface yourself, then come back and reread the R² table.

References

  1. Zhu, H. (2025). LGBM on Stock Returns Prediction and Portfolio Construction. ICDSE 2025
  2. Ke, G., Meng, Q., Finley, T., et al. (2017). LightGBM: A Highly Efficient Gradient Boosting Decision Tree. NeurIPS 30
  3. Wilder, J. W. (1978). New Concepts in Technical Trading Systems. (RSI, ADX)
  4. Kelly, J. L. (1956). A New Interpretation of Information Rate. Bell System Technical Journal