1 Introduction

Algorithmic (rule-based) trading exploits systematic market inefficiencies by generating buy and sell signals from quantitative indicators. Before deploying any strategy with real capital, practitioners back test it: they ask, “What if I had used this strategy in the past?” This assignment implements and evaluates one such strategy built on the DV Intermediate Oscillator (DVI), a bounded (0–1) momentum-and-stretch indicator created by David Varadi.

1.1 The DVI Indicator

The DVI combines two components:

  • Magnitude – a smoothed measure of recent price returns relative to a longer lookback window.
  • Stretch – the relative proportion of up-close versus down-close days over multiple horizons.

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 libraries
library(yfR)
library(TTR)
library(xts)
library(ggplot2)
library(knitr)
library(kableExtra)
# Helper: 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

This function downloads price data for a given ticker and date range, computes the DVI indicator, generates long/short signals based on the DVI threshold, and returns a summary of the trading results.

backtest_dvi <- function(ticker, begin, end, dvi_threshold = 0.5, prices_xts = NULL) {

  # Download price data via yfR (skip if pre-loaded xts supplied)
  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

  # Build summary data frame
  summary_df <- data.frame(
    Metric = c("Total Long Trades", "Total Short Trades",
               "Percent Time Long (%)", "Percent Time Short (%)",
               "Cumulative Return (%)"),
    Value  = round(c(long_trades, short_trades, pct_long, pct_short, cum_return), 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,
    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 = "Function 1: DVI Back Test Summary for TSLA (2020–2024)",
      align = "lr") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Function 1: 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

Interpretation: The table above shows the results of applying the DVI-based long/short strategy to Tesla (TSLA) from January 2020 through December 2024. The number of long and short trades reflects how often the DVI signal changed direction, while the percent-of-time metrics indicate the overall stance of the portfolio. The cumulative return represents the total gain (or loss) from following this strategy over the full period.

4 Function 2: Simulate Multiple Back Test Periods

Function 2 calls Function 1 repeatedly for every possible sub-period of a given length within the overall date range. It then reports the averages across all iterations and plots the cumulative return from each sub-period.

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

  # 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 (%)"),
    Value  = round(c(mean_long_trades, mean_short_trades,
                     mean_pct_long, mean_pct_short, mean_cum_return), 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 = "Function 2: Mean Results Across 3-Year Sub-Periods for TSLA",
      align = "lr") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Function 2: 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
print(f2$plot)

Interpretation: The table reports the average values of the five key metrics across all 3-year rolling windows within 2018–2024. The bar chart visualizes how the cumulative return varied across sub-periods. This rolling-window analysis helps assess the robustness and consistency of the DVI strategy: if returns are positive across most sub-periods, the strategy appears more reliable than if performance is concentrated in a single window.

5 Function 3: Simulate Multiple DVI Thresholds

Function 3 sweeps through a range of DVI thresholds and records how the strategy’s performance changes with each threshold value.

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 = "Function 3: 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")
Function 3: 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. The plot of cumulative return versus threshold shows the “sweet spot” — the threshold value that would have maximized returns over this particular period. Note that the optimal threshold in-sample may not generalize out-of-sample, highlighting the importance of robustness checks like those performed in Function 2.

6 Discussion

The results across all three functions provide several insights:

Function 1 establishes the baseline performance of the DVI strategy on TSLA over a five-year window. The split between long and short positions reveals how the DVI indicator interpreted Tesla’s volatile price history during a period that included the COVID crash, the subsequent rally, and significant drawdowns.

Function 2 demonstrates that strategy performance is not uniform across time. By examining multiple 3-year rolling windows, we can see whether the strategy’s profitability is consistent or concentrated in specific market regimes. If some windows show large gains while others show losses, this suggests the strategy may be regime-dependent — it works better in some market conditions (e.g., range-bound markets) than in others (e.g., strong trending markets).

Function 3 is perhaps the most revealing analysis. The relationship between DVI threshold and cumulative return shows the sensitivity of the strategy to its key parameter. A sharp peak at a specific threshold would suggest overfitting risk, whereas a broad plateau of positive returns across many thresholds would suggest a more robust signal.

From a behavioral finance perspective, these findings align with research showing that mean-reversion effects vary in strength across different market conditions. During periods of heightened investor sentiment and herding (such as the meme stock era), overreaction may be stronger, creating more opportunity for mean-reversion strategies. Conversely, during persistent trend regimes driven by fundamental changes, the DVI strategy may underperform.

7 Conclusion

This assignment implemented and evaluated a DVI-based algorithmic trading strategy through three complementary back-testing functions. Function 1 provided the core engine, computing the DVI indicator and simulating a long/short strategy. Function 2 assessed the strategy’s consistency across rolling time windows, while Function 3 explored its sensitivity to the DVI threshold parameter.

The DVI indicator, rooted in behavioral finance concepts of overreaction, herding, and mean reversion, offers a systematic framework for identifying potential trading opportunities. However, back testing alone is not sufficient for validating a strategy. Key limitations include: look-ahead bias in parameter selection, the absence of transaction costs and slippage, and the assumption that short selling is always feasible. Future work could incorporate transaction costs, compare multiple tickers and asset classes, and evaluate the strategy’s performance relative to a buy-and-hold benchmark.

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.