In the world of quantitative trading, we often hear that “the trend is your friend,” but many of the most profitable strategies actually bet on the opposite: that markets eventually overextend and must snap back to reality. This project explores algorithmic backtesting through the lens of the DV Intermediate Oscillator (DVI).
The goal here isn’t just to see if a rule works, but to ask the “what if” questions. If we had treated Tesla (TSLA) as a mean-reverting asset over the last few years, would we have actually made money, or would we have been “steamrolled” by its momentum?
The DVI (created by David Varadi) isn’t your standard RSI or MACD. It’s a bit more nuanced because it looks at both Magnitude (how far we’ve moved) and Stretch (how many days we’ve been heading in one direction). By combining these, it tries to find those “exhaustion points” where a stock has simply gone too far, too fast.
These two sub-indicators are weighted (default 80 % magnitude, 20 % stretch) and rescaled to oscillate between 0 and 1. A reading below the threshold (default 0.5) suggests the market is oversold (favoring a long position), while a reading above the threshold suggests the market is overbought (favoring a short position).
Why should this work? From a behavioral standpoint, we’re essentially trading against human nature:
The Overreaction Gap: Investors usually push good news too high and bad news too low. The DVI’s magnitude component tries to quantify that “too high/too low” gap.
Herding & FOMO: When everyone jumps on a trend (herding), the “Stretch” component of the DVI starts red-lining. We’re betting that the crowd will eventually run out of steam.
Anchoring: Many traders get stuck on old price targets. When a stock deviates significantly from its historical norm, the DVI signals that a reversion is likely because those old price “anchors” still exert a psychological pull.
# load packages
library(yfR)
library(TTR)
library(xts)
library(ggplot2)
library(knitr)
library(kableExtra)
# Download data via yfR and convert to xts (needed by TTR::DVI)
get_prices_xts <- function(ticker, first_date, last_date) {
df <- yf_get(tickers = ticker,
first_date = first_date,
last_date = last_date,
be_quiet = TRUE)
# Build xts from adjusted closing prices
price_xts <- xts(df$price_adjusted, order.by = df$ref_date)
colnames(price_xts) <- "Close"
return(price_xts)
}
We ran the first simulation on TSLA from 2020 through the end of 2024 using the standard 0.5 threshold.
backtest_dvi <- function(ticker, begin, end, dvi_threshold = 0.5, prices_xts = NULL) {
# Download price data via yfR
if (is.null(prices_xts)) {
close_prices <- get_prices_xts(ticker,
as.Date(begin, format = "%Y%m%d"),
as.Date(end, format = "%Y%m%d"))
} else {
# Subset pre-loaded xts to the requested date range
close_prices <- prices_xts[paste0(as.Date(begin, format = "%Y%m%d"), "/",
as.Date(end, format = "%Y%m%d"))]
}
# Compute DVI indicator
dvi <- DVI(close_prices)
# Compute daily arithmetic returns: (P_t / P_{t-1}) - 1
daily_returns <- diff(close_prices) / lag(close_prices, 1)
colnames(daily_returns) <- "returns"
# Align DVI and returns (remove leading NAs from both DVI warm-up and first return)
dvi_values <- dvi$dvi
combined <- merge(daily_returns, dvi_values)
combined <- na.omit(combined)
colnames(combined) <- c("returns", "dvi")
# Generate signal: +1 (long) when DVI < threshold, -1 (short) otherwise
# Use lagged DVI so signal is based on *previous* day's DVI (no look-ahead)
combined$signal <- ifelse(lag(combined$dvi, 1) < dvi_threshold, 1, -1)
combined <- na.omit(combined)
# Compute strategy returns
signal <- combined$signal
strategy_returns <- combined$returns * signal
# Count long and short trades (a "trade" = a change in position direction)
positions <- as.numeric(coredata(signal))
pos_changes <- c(positions[1], diff(positions))
long_trades <- sum(pos_changes == 2) + ifelse(positions[1] == 1, 1, 0)
short_trades <- sum(pos_changes == -2) + ifelse(positions[1] == -1, 1, 0)
# Percent of time long and short
n_days <- length(positions)
pct_long <- sum(positions == 1) / n_days * 100
pct_short <- sum(positions == -1) / n_days * 100
# Cumulative return (product of (1 + r_t) - 1)
cum_return <- as.numeric(prod(1 + strategy_returns) - 1) * 100
# --- Risk Metrics ---
# Annualized Sharpe Ratio (assuming ~252 trading days/year, risk-free rate = 0)
sr_daily <- as.numeric(coredata(strategy_returns))
sharpe_ratio <- (mean(sr_daily) / sd(sr_daily)) * sqrt(252)
# Maximum Drawdown: largest peak-to-trough decline in cumulative equity curve
cum_equity <- cumprod(1 + strategy_returns)
running_max <- cummax(cum_equity)
drawdowns <- (cum_equity - running_max) / running_max
max_drawdown <- as.numeric(min(drawdowns)) * 100 # negative %
# Build summary data frame
summary_df <- data.frame(
Metric = c("Total Long Trades", "Total Short Trades",
"Percent Time Long (%)", "Percent Time Short (%)",
"Cumulative Return (%)",
"Annualized Sharpe Ratio", "Maximum Drawdown (%)"),
Value = round(c(long_trades, short_trades, pct_long, pct_short,
cum_return, sharpe_ratio, max_drawdown), 2)
)
# Also return numeric results for use in Functions 2 and 3
results <- list(
summary = summary_df,
long_trades = long_trades,
short_trades = short_trades,
pct_long = pct_long,
pct_short = pct_short,
cum_return = cum_return,
sharpe_ratio = sharpe_ratio,
max_drawdown = max_drawdown,
strategy_ret = strategy_returns
)
return(results)
}
f1 <- backtest_dvi("TSLA", "20200101", "20241231", 0.5)
kable(f1$summary, caption = "DVI Back Test Summary for TSLA (2020–2024)",
align = "lr") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| Metric | Value |
|---|---|
| Total Long Trades | 39.00 |
| Total Short Trades | 39.00 |
| Percent Time Long (%) | 49.22 |
| Percent Time Short (%) | 50.78 |
| Cumulative Return (%) | -89.40 |
| Annualized Sharpe Ratio | -0.76 |
| Maximum Drawdown (%) | -93.12 |
Interpretation: The numbers are honestly a bit of a wake-up call. While the strategy was active — splitting time almost perfectly between long and short positions — the cumulative return was deeply negative. The Sharpe Ratio confirms this: a negative value tells us the strategy’s returns didn’t compensate for the risk taken. The Maximum Drawdown shows just how painful the ride was — at its worst point, the strategy’s equity curve fell dramatically from its peak.
This highlights a massive risk in rule-based trading: systematic momentum. During the period where Tesla was becoming a global powerhouse, a mean-reversion strategy like the DVI was essentially trying to “short the future.” Every time the DVI said Tesla was “overbought,” the stock just kept climbing. Note that this analysis does not account for transaction costs (slippage or commissions); with roughly 78 trades over 4 years, those costs would make the actual return even lower.
It’s easy to get a “lucky” or “unlucky” result by picking one specific five-year window. To see if the DVI is actually robust, we broke the 2018–2024 period into rolling 3-year windows.
backtest_multi_period <- function(ticker, test_years, date_range, dvi_threshold = 0.5) {
start_year <- as.numeric(date_range[1])
end_year <- as.numeric(date_range[2])
# Download full date range once to avoid repeated API calls
all_prices <- get_prices_xts(ticker,
as.Date(paste0(start_year, "-01-01")),
as.Date(paste0(end_year, "-12-31")))
# Generate all possible start years for sub-periods of length test_years
results_list <- list()
period_labels <- c()
cum_returns <- c()
for (yr in start_year:(end_year - test_years)) {
begin <- paste0(yr, "0101")
end <- paste0(yr + test_years, "1231")
label <- paste0(yr, "-", yr + test_years)
res <- backtest_dvi(ticker, begin, end, dvi_threshold, prices_xts = all_prices)
results_list[[label]] <- res
period_labels <- c(period_labels, label)
cum_returns <- c(cum_returns, res$cum_return)
}
# Compute means across all iterations
mean_long_trades <- mean(sapply(results_list, function(x) x$long_trades))
mean_short_trades <- mean(sapply(results_list, function(x) x$short_trades))
mean_pct_long <- mean(sapply(results_list, function(x) x$pct_long))
mean_pct_short <- mean(sapply(results_list, function(x) x$pct_short))
mean_cum_return <- mean(sapply(results_list, function(x) x$cum_return))
mean_sharpe <- mean(sapply(results_list, function(x) x$sharpe_ratio))
mean_max_dd <- mean(sapply(results_list, function(x) x$max_drawdown))
# Summary table
summary_df <- data.frame(
Metric = c("Mean Long Trades", "Mean Short Trades",
"Mean Percent Time Long (%)", "Mean Percent Time Short (%)",
"Mean Cumulative Return (%)",
"Mean Annualized Sharpe Ratio", "Mean Maximum Drawdown (%)"),
Value = round(c(mean_long_trades, mean_short_trades,
mean_pct_long, mean_pct_short, mean_cum_return,
mean_sharpe, mean_max_dd), 2)
)
# Plot cumulative return by sub-period
plot_df <- data.frame(Period = period_labels, Cumulative_Return = cum_returns)
plot_df$Period <- factor(plot_df$Period, levels = period_labels)
p <- ggplot(plot_df, aes(x = Period, y = Cumulative_Return, fill = Period)) +
geom_bar(stat = "identity", width = 0.6) +
geom_text(aes(label = paste0(round(Cumulative_Return, 1), "%")),
vjust = -0.5, size = 3.5) +
labs(title = paste("Cumulative Return by Sub-Period:", ticker),
subtitle = paste(test_years, "-Year Testing Windows"),
x = "Period", y = "Cumulative Return (%)") +
theme_minimal() +
theme(legend.position = "none",
plot.title = element_text(face = "bold"))
return(list(summary = summary_df, plot = p, details = results_list))
}
f2 <- backtest_multi_period("TSLA", 3, c("2018", "2024"), 0.5)
kable(f2$summary,
caption = "Mean Results Across 3-Year Sub-Periods for TSLA",
align = "lr") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
| Metric | Value |
|---|---|
| Mean Long Trades | 27.50 |
| Mean Short Trades | 27.75 |
| Mean Percent Time Long (%) | 49.61 |
| Mean Percent Time Short (%) | 50.39 |
| Mean Cumulative Return (%) | -69.54 |
| Mean Annualized Sharpe Ratio | -0.56 |
| Mean Maximum Drawdown (%) | -85.06 |
print(f2$plot)
Interpretation: The bar chart shows a clear story: the strategy didn’t just fail once; it struggled consistently across different regimes. Even with 3-year windows, the mean cumulative return was deeply negative and the mean Sharpe Ratio negative across the board. This suggests that Tesla’s volatility is “trending volatility” rather than “mean-reverting volatility.” In short, the DVI was consistently on the wrong side of the move.
Finally, we wanted to see if the 0.5 threshold was just the wrong setting for a stock as aggressive as Tesla, so we ran a “sweep” of 60 different thresholds from 0.2 to 0.8.
backtest_multi_threshold <- function(ticker, begin, end, low_dvi, high_dvi, dvi_increment) {
# Download data once to avoid repeated API calls across thresholds
all_prices <- get_prices_xts(ticker,
as.Date(begin, format = "%Y%m%d"),
as.Date(end, format = "%Y%m%d"))
thresholds <- seq(low_dvi, high_dvi, by = dvi_increment)
long_trades <- numeric(length(thresholds))
short_trades <- numeric(length(thresholds))
cum_returns <- numeric(length(thresholds))
for (i in seq_along(thresholds)) {
current_DVI <- thresholds[i]
res <- backtest_dvi(ticker, begin, end, current_DVI, prices_xts = all_prices)
long_trades[i] <- res$long_trades
short_trades[i] <- res$short_trades
cum_returns[i] <- res$cum_return
}
# Build results table
results_df <- data.frame(
DVI_Threshold = thresholds,
Long_Trades = long_trades,
Short_Trades = short_trades,
Cumulative_Return = round(cum_returns, 2)
)
# Plot threshold vs cumulative return
p <- ggplot(results_df, aes(x = DVI_Threshold, y = Cumulative_Return)) +
geom_line(color = "steelblue", linewidth = 1) +
geom_point(color = "steelblue", size = 1, alpha = 0.5) +
geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
labs(title = paste("Cumulative Return vs. DVI Threshold:", ticker),
subtitle = paste("Back Test Period:", begin, "to", end),
x = "DVI Threshold", y = "Cumulative Return (%)") +
theme_minimal() +
theme(plot.title = element_text(face = "bold"))
return(list(table = results_df, plot = p))
}
f3 <- backtest_multi_threshold("TSLA", "20200101", "20241231", 0.2, 0.8, 0.01)
# Show a sample of the table (every 5th row for readability)
display_rows <- seq(1, nrow(f3$table), by = 5)
kable(f3$table[display_rows, ],
caption = "Strategy Performance Across DVI Thresholds (every 5th shown)",
row.names = FALSE, align = "cccc") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
scroll_box(height = "400px")
| DVI_Threshold | Long_Trades | Short_Trades | Cumulative_Return |
|---|---|---|---|
| 0.20 | 27 | 28 | -87.98 |
| 0.25 | 32 | 33 | -82.91 |
| 0.30 | 34 | 35 | -94.23 |
| 0.35 | 38 | 39 | -88.42 |
| 0.40 | 40 | 40 | -92.62 |
| 0.45 | 37 | 37 | -85.98 |
| 0.50 | 39 | 39 | -89.40 |
| 0.55 | 41 | 41 | -91.17 |
| 0.60 | 41 | 40 | -84.41 |
| 0.65 | 39 | 38 | -86.36 |
| 0.70 | 34 | 33 | -81.22 |
| 0.75 | 30 | 29 | -84.21 |
| 0.80 | 24 | 23 | -89.32 |
print(f3$plot)
Interpretation: The table and plot above reveal how sensitive the DVI strategy is to the choice of threshold. A lower threshold means the portfolio spends more time short (requiring a stronger oversold signal to go long), while a higher threshold means it spends more time long. Looking at the plot, there isn’t a “magic number” that saves this strategy. Even as we move the threshold, the returns stay deep in the red. This is actually a very important finding as it suggests the failure isn’t due to a poorly tuned parameter, but rather a fundamental mismatch between a mean-reversion indicator and a high-growth momentum stock.
After looking at the results from all three functions, a few things stand out.
Function 1 proves that just because a strategy is “algorithmic” doesn’t mean it’s safe. The deeply negative cumulative return on one of the most liquid stocks in the world is a brutal result, and the negative Sharpe Ratio confirms the risk-adjusted picture is just as bad.
Function 2 and Function 3 really drove home the point that this wasn’t a fluke. The strategy is incredibly sensitive to the type of market it’s in. In behavioral terms, “herding” in Tesla was so strong that the DVI’s “stretch” signals were triggered way too early, over and over again.
An interesting counterpoint: if we had flipped the signal — going long when DVI is high and short when it’s low — we would effectively have a “trend-following” version of the same indicator. Given Tesla’s strong momentum profile, such a reversal would likely have outperformed significantly over this period. This observation reinforces the idea that the DVI signal itself contains useful information; the question is whether you trade with or against it, and that depends entirely on the asset’s regime.
If we were to take this further, we’d want to test this on a more “boring” or range-bound asset, like a utility stock or a stable index, where mean reversion actually has a chance to play out.
This backtest was a great exercise in seeing how a mathematically sound indicator (DVI) can still fail when applied to the wrong asset. We’ve built a solid engine here that handles data fetching, signal generation, and sensitivity analysis, but the data tells us to be very careful.
The biggest lesson? Don’t fall in love with the math. Even a strategy rooted in solid behavioral concepts like “overreaction” can get crushed if the “overreaction” lasts longer than your capital does. Moving forward, we’d suggest adding a stop-loss or a trend-filter to this code to prevent the kind of unlimited downside we saw with Tesla.