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):
  #   Signal at day t uses SMA values from day t-1,
  #   so the position is determined at t-1 close and applied to day t return.
  #   This is equivalent to: generate signal at t, trade at t+1.
  #   +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 results above summarize the performance of the 50/200-day crossover strategy on TSLA over the full six-year window. The total number of long and short trades reflects how many times the two moving averages crossed during this period, with each crossover triggering a full position reversal (long to short or vice versa). The percent of time long versus short reveals which regime dominated: a figure heavily skewed toward long would suggest Tesla’s price trend kept the 50-day SMA above the 200-day SMA for most of the period, consistent with its sustained long-run appreciation. The cumulative return is the net outcome of holding a long position during golden-cross regimes and a short position during death-cross regimes. Notably, these figures do not account for transaction costs — each crossover represents a complete position reversal that would incur bid-ask spreads and brokerage commissions in practice, reducing the true net return.

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. Each sub-period spans exactly three calendar years (e.g., 2018–2020, 2019–2021, …, 2024–2026).

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.
  # A test_years-year window starting at yr covers yr through yr+test_years-1,
  # so the last valid start year is end_year - test_years + 1.
  # FIX: previously used (end_year - test_years) as the loop upper bound and
  # (yr + test_years) as the end year, which produced windows one year too long
  # and missed the final valid period entirely.
  results_list  <- list()
  period_labels <- c()
  cum_returns   <- c()

  for (yr in start_year:(end_year - test_years + 1)) {
    begin <- paste0(yr, "0101")
    end   <- paste0(yr + test_years - 1, "1231")   # FIX: was yr + test_years
    label <- paste0(yr, "-", yr + test_years - 1)  # FIX: was 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 2.43
Mean Short Trades 2.14
Mean Percent Time Long (%) 65.52
Mean Percent Time Short (%) 34.48
Mean Cumulative Return (%) 190.41
print(f2$plot)

Interpretation: The bar chart and summary table show how the death-cross / golden-cross strategy performed across seven rolling 3-year windows (2018–2020 through 2024–2026), each representing a distinct market regime. The mean cumulative return in the table provides a single summary of average profitability over this span. Windows that include Tesla’s explosive 2020 rally are expected to show strong positive returns because the golden-cross regime would have remained in force for an extended period, compounding gains. Conversely, windows dominated by the 2022 drawdown or the 2018–2019 consolidation may show weaker or even negative performance due to whipsaw losses, where the moving averages cross back and forth without sustained follow-through. Variation across windows is itself informative: a strategy that earns large positive returns in some periods and large negative returns in others is highly regime-dependent and not reliably profitable — a critical consideration before deploying real capital.

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 (20 days) makes the strategy more reactive, generating more frequent crossover signals and therefore more total trades. While this increases the potential to capture short-lived trends, it also amplifies exposure to whipsaw losses — situations where the averages cross back and forth without establishing a durable trend. A longer short window (80 days) smooths out noise but introduces meaningful lag, causing the strategy to enter and exit positions well after a genuine trend has already developed. The trade count column illustrates this trade-off directly: shorter windows produce more signals while longer windows converge toward the low-signal behavior of the standard 50-day convention. Whether cumulative returns rise or fall monotonically across window lengths reveals whether Tesla’s price history favors responsiveness or patience, and whether the conventional 50-day window sits near any apparent optimum or is simply arbitrary.

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 at each crossover, so its performance depends not only on Tesla’s overall trajectory but on the timing and frequency of signal changes. Importantly, transaction costs are not included in this analysis. Each crossover involves a complete position reversal — closing a long and opening a short, or vice versa — which in practice incurs bid-ask spreads and commissions that would reduce the reported cumulative return. For a stock as actively traded as Tesla, these costs are relatively modest per trade, but they compound with turnover.

Function 2 demonstrates the importance of not relying on a single time window. By rolling 3-year sub-periods across 2018–2026, we can assess whether the strategy performs consistently across different market regimes or whether its success is highly dependent on the specific window chosen. Tesla’s price history includes extreme momentum phases (the 2020 bull run), extended drawdowns (2022), and range-bound consolidation, each of which presents different challenges for a trend-following signal. A strategy that only works during the momentum phases is not economically meaningful in a general sense.

Function 3 addresses parameter sensitivity. The choice of 50 days for the short moving average is a widely adopted convention, not an optimization. By sweeping across 20, 40, 60, and 80 days, we can assess whether the strategy’s performance is robust to this design choice. If cumulative returns vary dramatically across windows, it suggests the signal may be over-fitted to a particular lookback rather than capturing a genuine behavioral dynamic. If returns are relatively stable, it lends more credibility to the underlying trend-following logic.

From a behavioral finance perspective, the death-cross and golden-cross signals are interesting precisely because they are so widely followed. When a large proportion of market participants watch the same 50-day and 200-day moving averages, crossover events can become self-fulfilling: a golden cross triggers buying, which pushes prices higher, which reinforces the signal. This herding dynamic is distinct from the signal capturing fundamental value — it captures a coordination mechanism among technical traders. This distinction matters for interpreting the results: a strategy that profits from investor behavior rather than economic fundamentals is inherently fragile, as its effectiveness depends on the continued participation of traders who follow the same indicators.

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. Lagging execution is a particular concern: in practice, the signal can only be acted upon after the market close at which the crossover is confirmed, meaning the opening-price gap on the following day introduces additional execution slippage not captured in this backtest.

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 and the prevailing market regime. 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. Variation in performance across rolling windows and across short MA choices illustrates that the strategy is not unconditionally profitable and should not be relied upon without further filtering. 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.