1 Introduction

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?

1.1 The DVI Indicator

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).

2 Setup and Libraries

# 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)
}

3 Function 1: Base-Level Back Test

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)
}

3.1 Function 1 Results: TSLA, 2020-01-01 to 2024-12-31, DVI = 0.5

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)
DVI Back Test Summary for TSLA (2020–2024)
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.

4 Function 2: Simulate Multiple Back Test Periods

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))
}

4.1 Function 2 Results: TSLA, 3-Year Windows, 2018–2024, DVI = 0.5

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)
Mean Results Across 3-Year Sub-Periods for TSLA
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.

5 Function 3: Simulate Multiple DVI Thresholds

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))
}

5.1 Function 3 Results: TSLA, 2020–2024, Thresholds 0.2 to 0.8

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")
Strategy Performance Across DVI Thresholds (every 5th shown)
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.

6 Discussion

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.

7 Conclusion

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.

8 References

  • Varadi, D. (2009). The DVI Indicator. CSS Analytics.
  • Foster, J. (2011). How to Backtest a Strategy in R. FOSS Trading Blog.
  • TTR: Technical Trading Rules. R Package. CRAN.
  • Shiller, R. J. (2000). Irrational Exuberance. Princeton University Press.
  • De Bondt, W. F. M., & Thaler, R. (1985). Does the Stock Market Overreact? Journal of Finance, 40(3), 793–805.