Return Prediction Baselines (Beat Naive First)¶
Time-ordered linear and forest forecasts vs a zero-return naive baseline — the honest rewrite of the copied LSTM price-prediction notebooks.
Part 33 of 36 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup — no broker account needed¶
!pip install -q yfinance
import yfinance as yf
import pandas as pd
import numpy as np
# No broker account, no API key, no ServLoci setup needed for this chapter —
# yfinance reads public end-of-day data from Yahoo Finance. Indian tickers take
# an ".NS" suffix (NSE) or ".BO" (BSE); index tickers are prefixed with "^"
# (^NSEI = Nifty 50, ^BSESN = Sensex, ^GSPC = S&P 500).
NSE_TICKER = "RELIANCE.NS"
US_TICKER = "AAPL"
INDEX_TICKER = "^NSEI"
print("yfinance", yf.__version__)
Most "stock prediction" notebooks predict yesterday¶
The most-copied Kaggle notebook in this genre is Stock Market Analysis + Prediction using LSTM (Fares Sayah): 60 days of prices in, next day's price out, a line chart that hugs the actual series, impressive-looking RMSE.
That chart is usually a visual trick. Prices are a random-ish walk plus drift. A model that outputs "tomorrow ≈ today" will overlay almost perfectly on a price plot and still have no trading edge. Related copies use SVR / Random Forest / KNN / Prophet on the same leaked setup:
- Advanced stock prediction using SVR, RFR, KNN, LSTM
- Yahoo Stock Forecasting 60 Days | LSTM | ARIMA | Prophet
This chapter does not train an LSTM. It does the test those notebooks skip: a naive forecast (tomorrow's return = 0, tomorrow's price = today's close) on a time-ordered split, then a linear model and a small Random Forest that are only allowed to see information available at the close of day T to forecast the return of day T+1.
If they cannot beat naive on returns, they are not predictors. They are smoothers.
!pip install -q scikit-learn matplotlib
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error
hist = yf.download(
NSE_TICKER, period="5y", interval="1d",
auto_adjust=True, progress=False, multi_level_index=False,
)
close = hist["Close"].dropna()
ret = close.pct_change(fill_method=None)
print(f"{NSE_TICKER}: {len(close)} sessions, {close.index.min().date()} -> {close.index.max().date()}")
Feature frame — only lags, no future¶
ret_fwd is tomorrow's return, the thing we are allowed to forecast. Features are lags of return plus a few trailing statistics. No shift(-1) on a feature. No shuffled train_test_split. The first 70% of time is train; the last 30% is test.
feat = pd.DataFrame({"ret": ret})
for lag in (1, 2, 3, 5, 10):
feat[f"lag_{lag}"] = ret.shift(lag)
feat["vol_20"] = ret.rolling(20).std()
feat["mom_10"] = close.pct_change(10)
feat["ret_fwd"] = ret.shift(-1)
feat = feat.dropna()
split = int(len(feat) * 0.70)
train, test = feat.iloc[:split], feat.iloc[split:]
x_cols = [c for c in feat.columns if c != "ret_fwd"]
X_train, y_train = train[x_cols], train["ret_fwd"]
X_test, y_test = test[x_cols], test["ret_fwd"]
print(f"train {train.index.min().date()} -> {train.index.max().date()} ({len(train)} rows)")
print(f"test {test.index.min().date()} -> {test.index.max().date()} ({len(test)} rows)")
print("features:", x_cols)
Three forecasts, one honest scoreboard¶
- Naive: predict 0 return (price unchanged). This is the LSTM-hugging-the-price-chart in return space.
- Linear: ordinary least squares on the lag features.
- Forest: 200 shallow trees. Easy to overfit; we keep
max_depth=4on purpose.
Score on returns, not prices. Then, only for the picture people expect, reconstruct a price path from the predicted returns so you can see how "almost the same line" still happens.
naive = pd.Series(0.0, index=y_test.index)
lin = LinearRegression().fit(X_train, y_train)
pred_lin = pd.Series(lin.predict(X_test), index=y_test.index)
rf = RandomForestRegressor(n_estimators=200, max_depth=4, random_state=7, n_jobs=-1)
rf.fit(X_train, y_train)
pred_rf = pd.Series(rf.predict(X_test), index=y_test.index)
def score(name, pred):
mae = mean_absolute_error(y_test, pred)
rmse = mean_squared_error(y_test, pred) ** 0.5
# Directional accuracy is what a long/short rule would actually use.
mask = y_test != 0
direction = float((np.sign(pred[mask]) == np.sign(y_test[mask])).mean()) if mask.any() else float("nan")
return {"model": name, "MAE": mae, "RMSE": rmse, "dir_acc": direction}
board = pd.DataFrame([
score("naive (0 return)", naive),
score("linear lags", pred_lin),
score("random forest", pred_rf),
])
print(board.to_string(index=False))
print()
print("If MAE/RMSE are not clearly better than naive, the model has no forecast content.")
print("dir_acc near 0.50 is a coin flip. 0.53 on one test window is not a strategy.")
print("linear intercept:", float(lin.intercept_), " coefs:")
print(pd.Series(lin.coef_, index=x_cols).sort_values(key=np.abs, ascending=False).head(6))
Why the price overlay looks "accurate" anyway¶
Start from the first test close and compound each model's predicted return. The three lines will sit on top of the actual price path if predicted returns are small — which they are, because daily moves are a few tenths of a percent and every cautious model shrinks toward zero. That is the Fares Sayah chart, without the LSTM.
start = float(close.loc[y_test.index[0]])
actual_path = (1 + y_test).cumprod() * start
naive_path = (1 + naive).cumprod() * start
lin_path = (1 + pred_lin).cumprod() * start
rf_path = (1 + pred_rf).cumprod() * start
fig, axes = plt.subplots(2, 1, figsize=(11, 6.2), sharex=True,
gridspec_kw={"height_ratios": [2, 1]})
axes[0].plot(actual_path.index, actual_path, color="#1a1a1a", lw=1.2, label="Actual")
axes[0].plot(naive_path.index, naive_path, color="#94a3b8", lw=1, label="Naive (0 ret)")
axes[0].plot(lin_path.index, lin_path, color="#2563eb", lw=1, label="Linear")
axes[0].plot(rf_path.index, rf_path, color="#7c3aed", lw=1, label="Forest")
axes[0].set_title(f"{NSE_TICKER} test window — price reconstructed from predicted returns")
axes[0].legend(frameon=False, fontsize=8)
axes[0].grid(True, alpha=0.25)
axes[1].plot(y_test.index, y_test, color="#1a1a1a", lw=0.8, label="actual return")
axes[1].plot(pred_lin.index, pred_lin, color="#2563eb", lw=0.8, label="linear pred")
axes[1].set_title("Same window in return space — this is where the model has to win")
axes[1].legend(frameon=False, fontsize=8)
axes[1].grid(True, alpha=0.25)
fig.tight_layout()
plt.show()
What those Kaggle notebooks get wrong, in one list¶
- Predicting price, scoring price. A walk-plus-drift series makes any "tomorrow ≈ today" model look like a fit. Score returns, or score a trading rule after costs (notebook 29).
- Shuffled train/test.
train_test_splitwithoutshuffle=Falseleaks the future into the past. - Window leakage. Using a scaler fit on the whole series, or a 60-day window that includes the target day, is the same bug with more lines of Keras.
- No naive baseline. If you did not print the zero-return MAE, you do not know whether you beat a coin that always says "unchanged."
- One ticker, one split. A 3-point directional edge on Reliance's last 18 months is not a result. It is a number that will move when you change the date.
A real forecast research project starts where this notebook ends: walk-forward folds, costs, a rule that can be flat, and a pre-declared test window you are not allowed to peek at while you "tune." Notebook 29 is that discipline applied to a simpler rule.
Next: notebook 34 — many names at once, weights, an efficient-frontier cloud, and why the "optimal" portfolio on this sample is not a recommendation.
« Previous: Stock Correlation, Clusters and Pairs
Next: Portfolio Analytics: Weights, Frontier, Drawdown »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP