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?
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.
Why should a moving-average crossover strategy capture real market returns? From a behavioral standpoint, the signal exploits several well-documented investor biases:
Trend Chasing and Herding: Investors tend to pile into rising stocks and flee falling ones, creating self-reinforcing trends. The golden-cross signal effectively rides this herding behavior by entering long when momentum is confirmed.
Underreaction to New Information: Behavioral research shows that investors are often slow to incorporate new information into prices. Moving averages, by their very construction, detect this gradual price adjustment and signal trend changes only after sustained movement, precisely when underreaction has had time to accumulate.
Overreaction and Reversals: When sentiment eventually swings too far, the death cross signals the beginning of a bearish reversal, capturing the period of corrective price action that follows collective overreaction. By systematically switching direction at these crossover points, the strategy attempts to stay aligned with the dominant behavioral regime.
Anchoring: Traders and investors anchor to the moving averages themselves. The 50-day and 200-day SMAs are among the most widely watched technical levels, meaning that crossovers can become self-fulfilling prophecies as large numbers of market participants act on the same signal.
library(yfR)
library(TTR)
library(xts)
library(ggplot2)
library(knitr)
library(kableExtra)
# Download daily adjusted prices via yfR and convert to xts
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)
price_xts <- xts(df$price_adjusted, order.by = df$ref_date)
colnames(price_xts) <- "Close"
return(price_xts)
}
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) {
begin_date <- as.Date(begin, format = "%Y%m%d")
end_date <- as.Date(end, format = "%Y%m%d")
# Download with warm-up buffer, or use pre-loaded xts from Functions 2/3
if (is.null(prices_xts)) {
buffer_start <- begin_date - ceiling(long_ma * 1.5)
close_prices_full <- get_prices_xts(ticker, buffer_start, end_date)
} else {
close_prices_full <- prices_xts
}
# Compute SMAs on full dataset so values are valid from begin_date
sma_short_full <- SMA(close_prices_full, n = short_ma)
sma_long_full <- SMA(close_prices_full, n = long_ma)
# Subset to the requested test window after SMA computation
date_range_str <- paste0(begin_date, "/", end_date)
close_prices <- close_prices_full[date_range_str]
sma_short <- sma_short_full[date_range_str]
sma_long <- sma_long_full[date_range_str]
# Daily arithmetic returns: (P_t - P_{t-1}) / P_{t-1}
daily_returns <- diff(close_prices) / lag(close_prices, 1)
colnames(daily_returns) <- "returns"
# Align returns and SMAs, drop leading NA
combined <- merge(daily_returns, sma_short, sma_long)
colnames(combined) <- c("returns", "sma_short", "sma_long")
combined <- na.omit(combined)
# Lagged signal avoids look-ahead bias: position at t uses SMAs from t-1
combined$signal <- ifelse(lag(combined$sma_short, 1) >= lag(combined$sma_long, 1),
1, -1)
combined <- na.omit(combined)
# Strategy returns
signal <- combined$signal
strategy_returns <- combined$returns * signal
# Count trades (each distinct directional position entered, including initial)
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 via geometric compounding
cum_return <- as.numeric(prod(1 + strategy_returns) - 1) * 100
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)
)
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)
}
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)
| Metric | Value |
|---|---|
| Total Long Trades | 6.00 |
| Total Short Trades | 5.00 |
| Percent Time Long (%) | 67.50 |
| Percent Time Short (%) | 32.50 |
| Cumulative Return (%) | 1.75 |
Interpretation: The strategy produces a cumulative return of 1.75% over the six-year window, spending 67.5% of the time long and 32.5% short across 6 long and 5 short trades. This outcome highlights a fundamental vulnerability of slow moving-average strategies applied to high-volatility momentum stocks. The 200-day SMA is, by construction, a lagging indicator: it takes sustained price movement over many months before the long average shifts enough to trigger a crossover. In Tesla’s case, this lag proved catastrophic during the stock’s most violent recoveries. After the COVID crash of early 2020, TSLA surged more than 700% from its trough — but the death cross triggered by the initial sell-off kept the strategy short for much of that rally, compounding losses as the short position worked directly against the price appreciation. A nearly identical dynamic played out during the 2023 recovery after the 2022 drawdown. Each day the strategy remained short on a rising stock, it generated a return equal to the negative of that day’s gain. These extended periods of “short during a bull run” account for the bulk of the cumulative loss. The trade count reflects how many full crossover events occurred — each one representing a complete directional reversal that would also incur transaction costs in practice, further eroding returns.
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). To ensure valid SMA values from the first day of each sub-period, the initial data download includes a one-year buffer before the overall start year.
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 with one-year buffer for SMA warm-up
buffer_start <- as.Date(paste0(start_year - 1, "-01-01"))
all_prices <- get_prices_xts(ticker,
buffer_start,
as.Date(paste0(end_year, "-12-31")))
# Roll test_years-year windows; last valid start = end_year - test_years + 1
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")
label <- paste0(yr, "-", yr + test_years - 1)
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_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)
)
# Bar chart of 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))
}
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)
| Metric | Value |
|---|---|
| Mean Long Trades | 3.14 |
| Mean Short Trades | 2.71 |
| Mean Percent Time Long (%) | 60.77 |
| Mean Percent Time Short (%) | 39.23 |
| Mean Cumulative Return (%) | 275.07 |
print(f2$plot)
Interpretation: The bar chart shows cumulative returns across seven rolling 3-year windows (2018–2020 through 2024–2026), each representing a distinct market regime in Tesla’s history. The mean cumulative return of 275.07% provides a single summary of average profitability across all windows. Considerable variation across sub-periods is the expected and informative result: windows that coincide with Tesla’s explosive directional runs (upward or downward) may show large positive returns if the strategy happened to be on the correct side, while windows that include major V-shaped recoveries — where the 200-day SMA’s lag forces the strategy to remain short through an aggressive rebound — are likely to show the worst losses. A strategy whose sign of profitability depends heavily on the specific three-year window tested is not reliably profitable; the mean return and the spread across windows together characterize whether the death-cross signal is a durable edge or a regime-specific artifact.
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) {
begin_date <- as.Date(begin, format = "%Y%m%d")
end_date <- as.Date(end, format = "%Y%m%d")
# Download once with warm-up buffer, reused across all short MA iterations
buffer_start <- begin_date - ceiling(long_ma * 1.5)
all_prices <- get_prices_xts(ticker, buffer_start, end_date)
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
}
results_df <- data.frame(
Short_MA_Window = short_windows,
Long_Trades = long_trades,
Short_Trades = short_trades,
Cumulative_Return = round(cum_returns, 2)
)
# Line plot of cumulative return vs short MA window
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))
}
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)
| Short_MA_Window | Long_Trades | Short_Trades | Cumulative_Return |
|---|---|---|---|
| 20 | 8 | 8 | -51.39 |
| 40 | 7 | 6 | -43.70 |
| 60 | 6 | 5 | 55.75 |
| 80 | 5 | 4 | 136.64 |
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, triggering a series of poorly-timed reversals. A longer short window (80 days) smooths out noise but introduces meaningful additional lag on top of the already-slow 200-day average, causing the strategy to enter and exit positions well after a genuine trend has developed. The trade count column illustrates this trade-off directly: shorter windows generate many more signals, while longer windows converge toward the minimal-signal behavior of the standard 50-day convention. If cumulative returns remain uniformly negative across all windows tested, it suggests that the underperformance is not a quirk of the 50-day parameter choice but a structural feature of applying a slow crossover strategy to Tesla’s volatile price history.
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 of 1.75% is the defining result of this analysis, and it is best understood not as a failure of the death-cross concept generally, but as an illustration of the strategy’s specific vulnerability on high-volatility momentum stocks. The 200-day SMA’s inherent lag — which is both its feature and its flaw — meant that the strategy frequently found itself short precisely during Tesla’s most aggressive upward moves. Because the strategy is always in a directional position (never in cash), every day spent short on a rising stock compounds losses at the same rate the buy-and-hold investor was accumulating gains. Transaction costs are not included in this figure; each crossover represents a full position reversal that would incur bid-ask spreads and brokerage commissions in practice, reducing the reported cumulative return further.
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 or is highly dependent on the specific market regime. Tesla’s price history includes extreme momentum phases (the 2020 rally), extended drawdowns (2022), and range-bound consolidation, each of which presents different challenges for a trend-following signal. A strategy that earns positive returns in some periods and large negative returns in others is regime-dependent and not reliably profitable — a critical consideration before deploying real capital.
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 remain negative across the full range of short windows tested, it confirms that the result from Function 1 is not an artifact of the specific 50-day parameterization but a structural feature of the crossover approach on this asset.
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, and its effectiveness depends on the continued participation of traders acting on the same indicators.
On the question of economic meaningfulness: the strategy’s raw returns are strongly negative on TSLA over this period, and accounting for turnover and execution lag would only worsen that figure. Each crossover involves a full position reversal, meaning slippage on both the close and the open. For a stock as liquid as Tesla, per-trade costs are modest, but they are always additive losses on top of an already-negative strategy return. Lagging execution — where the signal is confirmed only after the market close and the trade is executed at the following day’s open — introduces additional gap risk that is not captured in this backtest.
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 death-cross strategy fails on TSLA over this period not because trend-following is conceptually unsound, but because the 200-day SMA’s lag is too slow for a stock that recovers violently and quickly from its drawdowns. The strategy’s always-invested nature — it is never in cash — means that being on the wrong side of a major move is particularly costly. Moving forward, potential improvements could include adding a confirmation filter (requiring the crossover to persist for several days before acting), incorporating a cash or exit option during ambiguous regimes, using exponential rather than simple moving averages to reduce lag, or combining the crossover signal with volatility-based position sizing to limit downside during uncertain market conditions.