1 Introduction

In the world of quantitative trading, trend-following strategies have attracted practitioners for decades on the premise that price momentum contains predictive information about future returns. One of the best-known trend-following signals is the death cross, which occurs when a short-term moving average falls below a long-term moving average, commonly interpreted as a bearish signal indicating downward momentum. Its mirror counterpart, the golden cross, occurs when the short-term average rises above the long-term average, signaling bullish momentum. This project explores algorithmic backtesting through the lens of the death-cross and golden-cross trading signal.

The goal here isn’t just to see if the rule works, but to ask the “what if” questions. If we had systematically followed the death-cross and golden-cross signal on Tesla (TSLA) over recent years, would we have captured its explosive trends, or would whipsaws and false signals have eroded our returns?

1.1 The Death-Cross / Golden-Cross Signal

The signal is built from two simple moving averages (SMAs). A short-term SMA, which by convention uses a 50-day window, captures recent price momentum, while a long-term SMA, typically 200 days, represents the broader trend. When the 50-day SMA crosses above the 200-day SMA (a golden cross), the strategy enters a long position, betting that upward momentum will persist. When the 50-day SMA crosses below the 200-day SMA (a death cross), the strategy switches to a short position, anticipating continued downside. In this way, the trader always holds a directional position: long when the trend is up, short when the trend is down.

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::SMA)
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 run the first simulation on TSLA from January 2020 through the end of March 2026 using the standard 50-day and 200-day moving-average windows.

backtest_ma <- function(ticker, begin, end, short_ma = 50, long_ma = 200,
                        prices_xts = NULL) {

  # Download price data via yfR (or use pre-loaded xts)
  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 {
    close_prices <- prices_xts[paste0(as.Date(begin, format = "%Y%m%d"), "/",
                                      as.Date(end,   format = "%Y%m%d"))]
  }

  # Compute short-term and long-term simple moving averages
  sma_short <- SMA(close_prices, n = short_ma)
  sma_long  <- SMA(close_prices, n = long_ma)

  # Compute daily arithmetic returns: (P_t / P_{t-1}) - 1
  daily_returns <- diff(close_prices) / lag(close_prices, 1)
  colnames(daily_returns) <- "returns"

  # Align SMAs and returns (remove leading NAs from the longer MA warm-up)
  combined <- merge(daily_returns, sma_short, sma_long)
  colnames(combined) <- c("returns", "sma_short", "sma_long")
  combined <- na.omit(combined)

  # Generate signal using LAGGED moving averages (no look-ahead bias):
  #   +1  (long)  when short MA >= long MA  (golden-cross regime)
  #   -1  (short) when short MA <  long MA  (death-cross regime)
  combined$signal <- ifelse(lag(combined$sma_short, 1) >= lag(combined$sma_long, 1),
                            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 2026-03-31, SMA(50, 200)

f1 <- backtest_ma("TSLA", "20200101", "20260331", 50, 200)

kable(f1$summary,
      caption = "Death-Cross / Golden-Cross Back Test Summary for TSLA (2020–2026)",
      align = "lr") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Death-Cross / Golden-Cross Back Test Summary for TSLA (2020–2026)
Metric Value
Total Long Trades 6.00
Total Short Trades 5.00
Percent Time Long (%) 62.79
Percent Time Short (%) 37.21
Cumulative Return (%) -79.92

Interpretation: The strategy clearly partitions time between long and short regimes based on the moving-average crossover. The cumulative return summarizes the net result of holding long during golden-cross periods and short during death-cross periods. Given Tesla’s strong upward momentum over much of this window, the golden-cross regime likely captured the bulk of the gains, while death-cross periods may have contributed additional return during sell-offs or, alternatively, created drag during false signals and whipsaws. The trade count reflects how many times the 50-day and 200-day averages crossed during this period, each crossover representing a directional switch that in practice would incur transaction costs not accounted for here.

4 Function 2: Simulate Multiple Back-Test Periods

It’s easy to get a “lucky” or “unlucky” result by picking one specific window. To assess whether the death-cross signal is robust, we break the 2018–2026 period into rolling 3-year sub-periods.

backtest_multi_period <- function(ticker, test_years, date_range,
                                  short_ma = 50, long_ma = 200) {

  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_ma(ticker, begin, end, short_ma, long_ma,
                       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  |  SMA(",
                          short_ma, ",", long_ma, ")", sep = ""),
         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–2026, SMA(50, 200)

f2 <- backtest_multi_period("TSLA", 3, c("2018", "2026"), 50, 200)

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 3.17
Mean Short Trades 3.00
Mean Percent Time Long (%) 63.12
Mean Percent Time Short (%) 36.88
Mean Cumulative Return (%) 225.43
print(f2$plot)

Interpretation: The bar chart shows how the death-cross / golden-cross strategy performed across different market regimes. By examining multiple 3-year windows, we can see whether the signal consistently captures trends or whether its success is regime-dependent. Windows that include Tesla’s explosive 2020 rally, for instance, are likely to show strong positive cumulative returns because the golden-cross regime would have remained long for an extended period. Windows dominated by range-bound or corrective price action may show weaker performance due to whipsaw losses, where the moving averages cross back and forth without sustained follow-through. The mean cumulative return across all sub-periods provides a single summary metric of whether trend-following via this signal has been historically profitable for Tesla on average.

5 Function 3: Simulate Multiple Short Moving-Average Windows

Finally, we want to see how sensitive the strategy is to the choice of the short moving-average window. Holding the long moving average fixed at 200 days, we vary the short window from 20 to 80 days in increments of 20.

backtest_multi_short_ma <- function(ticker, begin, end, low_short, high_short,
                                     ma_increment, long_ma = 200) {

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

  short_windows  <- seq(low_short, high_short, by = ma_increment)
  long_trades    <- numeric(length(short_windows))
  short_trades   <- numeric(length(short_windows))
  cum_returns    <- numeric(length(short_windows))

  for (i in seq_along(short_windows)) {
    current_short <- short_windows[i]
    res <- backtest_ma(ticker, begin, end, current_short, long_ma,
                       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(
    Short_MA_Window    = short_windows,
    Long_Trades        = long_trades,
    Short_Trades       = short_trades,
    Cumulative_Return  = round(cum_returns, 2)
  )

  # Plot short MA window vs cumulative return
  p <- ggplot(results_df, aes(x = Short_MA_Window, y = Cumulative_Return)) +
    geom_line(color = "steelblue", linewidth = 1) +
    geom_point(color = "steelblue", size = 2.5) +
    geom_text(aes(label = paste0(round(Cumulative_Return, 1), "%")),
              vjust = -1, size = 3.2) +
    geom_hline(yintercept = 0, linetype = "dashed", color = "red") +
    labs(title = paste("Cumulative Return vs. Short MA Window:", ticker),
         subtitle = paste("Long MA fixed at", long_ma,
                          "days  |  Back Test:", begin, "to", end),
         x = "Short Moving-Average Window (days)",
         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–2026, Short MA 20–80 (step 20), Long MA 200

f3 <- backtest_multi_short_ma("TSLA", "20200101", "20260331", 20, 80, 20, 200)

kable(f3$table,
      caption = "Strategy Performance Across Short MA Windows (Long MA = 200)",
      row.names = FALSE, align = "cccc") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Strategy Performance Across Short MA Windows (Long MA = 200)
Short_MA_Window Long_Trades Short_Trades Cumulative_Return
20 8 8 -90.40
40 7 6 -88.89
60 6 5 -69.26
80 5 4 -53.29
print(f3$plot)

Interpretation: The table and plot reveal how sensitive the death-cross / golden-cross strategy is to the choice of the short moving-average window. A shorter window (e.g., 20 days) makes the strategy more reactive, generating more frequent crossover signals and therefore more trades, which increases both the potential to capture short-lived trends and the exposure to whipsaw losses. A longer short window (e.g., 80 days) smooths out noise but can lag behind genuine trend changes, entering and exiting positions too late. The results show whether there is a “sweet spot” for the short MA window with Tesla, or whether the strategy’s performance is relatively stable across this range. Note that the standard 50-day window is the most widely followed and therefore the most likely to produce self-reinforcing crossover effects.

6 Discussion

After examining the results from all three functions, several key findings emerge.

Function 1 provides the baseline result for the standard 50/200-day crossover on Tesla over a six-year window. The cumulative return reflects the net outcome of being long during golden-cross regimes and short during death-cross regimes. Unlike a pure buy-and-hold approach, this strategy actively switches direction, which means its performance depends not only on Tesla’s overall trajectory but also on the timing and frequency of crossover signals. Transaction costs are not included in this analysis, and with each crossover representing a full position reversal, the actual net return would be somewhat lower.

Function 2 demonstrates the importance of not relying on a single time window. By rolling 3-year sub-periods across 2018–2026, we can see whether the strategy performs consistently or is highly dependent on the specific market regime. Tesla’s price history includes extreme momentum phases (the 2020 rally), extended drawdowns, and range-bound consolidation, each of which presents different challenges for a trend-following signal.

Function 3 addresses parameter sensitivity. The choice of 50 days for the short moving average is a convention, not a law. By sweeping across 20, 40, 60, and 80 days, we can assess whether the strategy’s performance is robust or fragile with respect to this design choice. If cumulative returns vary dramatically, the signal may be over-fitted to a particular lookback; if returns are stable, it suggests the underlying trend-following logic is sound regardless of the specific window.

From a behavioral finance perspective, the death-cross and golden-cross signals are interesting precisely because they are so widely followed. When millions of traders watch the same 50-day and 200-day moving averages, crossover events can become self-fulfilling prophecies: a golden cross triggers buying, which pushes prices higher, which confirms the signal. This mechanism is a form of herding and trend chasing that reinforces the very trends the signal attempts to capture. Conversely, the strategy’s vulnerability to whipsaws reflects the periods when investor sentiment is uncertain and prices oscillate without establishing a clear trend, a condition that challenges any momentum-based approach.

Whether the death-cross signal is “economically meaningful” ultimately depends on the magnitude of returns after accounting for turnover, slippage, and the opportunity cost of capital. Even if the strategy generates positive raw returns, the relatively infrequent but large position changes (each crossover involves a full reversal from long to short or vice versa) can create significant execution costs in practice.

7 Conclusion

This backtest demonstrates how a widely followed technical indicator, the 50/200-day moving-average crossover, performs when applied systematically to Tesla. The death-cross and golden-cross signals capture the essence of trend-following: ride momentum when it confirms, and reverse when the trend breaks. The three functions we built provide a complete analytical toolkit, examining a single base case, testing robustness across time periods, and assessing parameter sensitivity.

The key takeaway is that the success of a trend-following strategy depends heavily on the asset’s behavior. For a stock like Tesla, which has exhibited extreme directional moves, the death-cross / golden-cross signal can capture large swings but is also exposed to whipsaw losses during transitions. Moving forward, potential improvements could include adding a confirmation filter (e.g., requiring the crossover to persist for several days before acting), incorporating volume signals, or combining the moving-average crossover with volatility-based position sizing to manage risk during uncertain regimes.

8 References

  • Murphy, J. J. (1999). Technical Analysis of the Financial Markets. New York Institute of Finance.
  • Brock, W., Lakonishok, J., & LeBaron, B. (1992). Simple Technical Trading Rules and the Stochastic Properties of Stock Returns. Journal of Finance, 47(5), 1731–1764.
  • 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.
  • TTR: Technical Trading Rules. R Package. CRAN.