Detecting Look-Ahead Bias from a Shifted Detrended Price Oscillator
Summary
The document examines an apparent performance bias in a simple rolling walk-forward backtest. It uses lagged indicators to predict next-day returns, fits a linear regression on each training window, and evaluates it on the adjacent test window. The accepted explanation identifies the detrended price oscillator as the source of future information: its default shift moves the moving average relative to the price series in a way that can expose future data. Setting the shift to zero is suggested as the correction.
The example illustrates why indicator functions need to be checked for their internal timing conventions, even when the surrounding backtest explicitly lags features and separates training from testing. The document provides no independent rerun or broader validation of the proposed fix, and the conclusion depends on the behavior of the specific DPO implementation used. Researchers should verify signal timestamps and indicator definitions before trusting out-of-sample results.
Key ideas
- A walk-forward split does not prevent look-ahead bias introduced inside an indicator calculation.
- The DPO function’s default shift is identified as the source of future information in this example.
- Setting the DPO shift to zero is proposed to remove the bias.
- Indicator timing conventions should be checked alongside feature lags and train-test boundaries.
Tags
Full text
# Where is the bias in this simple backtesting framework?
# Where is the bias in this simple backtesting framework?
I usually write my backtests in Python or a dedicated backtesting environment, but I wanted to experiment with some of R's predictive analytics functionality. I wrote an extremely simple backtester for doing some quick and dirty research, but wind up with some obvious bias in my results. I assume the bias is related to future-peeking, but I am not sure.
The code uses a rolling window approach and creates a list of adjacent training/testing windows, as per a standard walk-forward approach. For each training window, it fits a simple linear regression model based on some lagged variables and the next-day's return. It then tests the model on the adjacent testing window. After looping through the list of windows, the results are aggregated and plotted.
Based on my code and output shown below, is it obvious where the bias is coming from? Looking at the results, I must have somehow mixed my training and testing windows, or possibly I am not understanding the correct way to lag the dependent variables. Either way, I am stuck and any advice would be greatly appreciated.
```
## Libraries
library(caret);library(quantmod);library(TTR);require(ggplot2);require(reshape)
## Data Acquisition and Wrangling
# download data from yahoo/google
getSymbols("SPY", from="2010-01-01")
# create returns series and some indicators
# remember to lag indicators to prevent look-ahead bias!
ret <- Delt(Op(SPY), Cl(SPY)) #day session return
atr <- ATR(SPY[, c(2:4)], 14)$atr
rsi <- RSI(Cl(SPY), n=14, maType="SMA") #relative strength index
dpo <- DPO(Cl(SPY)) #de-trended price oscillator
dat <- cbind(ret, lag(atr, 1), lag(rsi, 1), lag(dpo, 1))
colnames(dat) <- c("ret", "atr", "rsi", "dpo")
dat <- as.data.frame(dat[complete.cases(dat), ]) #get rid of rows with NA, NaN etc
## Set up train-test windows
# create indexes for TSCV windows
init = 200 #initial window
horiz = 50 #prediction horizon
wdw <- createTimeSlices(1:nrow(dat), initialWindow = init, horizon = horiz,
skip = horiz-1, fixedWindow = TRUE)
trainSlices <- wdw[[1]]
testSlices <- wdw[[2]]
# verify visually correct window setup:
trainSlices[[length(trainSlices)]]
testSlices[[length(testSlices)]]
# train and test linear regression model on rolling window
ModelReturn <- list() #store return of each out-of-sample window
BHReturn <- list() #store buy and hold returns for comparison
for(i in c(1:length(testSlices)))
{
model <- lm(ret ~., data=dat[trainSlices[[i]], ])
preds <- predict(model, newdata=dat[testSlices[[i]], -1])
returns <- ifelse(preds>0, dat[testSlices[[i]], 1], -dat[testSlices[[i]], 1])
ModelReturn[[i]] <- returns
BHReturn[[i]] <- dat[testSlices[[i]], "ret"]
}
# plot results
results <- data.frame(unlist(ModelReturn), unlist(BHReturn))
colnames(results) <- c("Model", "BuyHold")
plot(cumprod(1+results$Model)-1, type='l', col='blue', ylab="Cum.Return")
lines(cumprod(1+results$BuyHold)-1, col='red')
```
And the output:
## Answer by ManChild (score 2, accepted)
https://quant.stackexchange.com/a/33526
The DPO() function shifts the series into the future, thus creating the look-ahead bias that causes the outlandish results. Setting `shift` to zero takes care of this.
From the `?DPO` documentation:
#### Description
The Detrended Price Oscillator (DPO) removes the trend in prices - or other series - by subtracting a moving average of the price from the price.
DPO(x, n = 10, maType, shift = n/2 + 1, percent = FALSE, ...)
#### Arguments:
shift: The number of periods to shift the moving average.Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.