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.
# 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)
}
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")
# --- Warm-up buffer fix ---
# SMAs require a "warm-up" period before they produce valid values.
# A 200-day SMA needs at least 200 trading days of prior data.
# Strategy: compute SMAs on a larger dataset that includes a buffer period
# before begin_date, then subset the results to the requested window.
# This ensures valid SMA values exist on the very first day of the test.
#
# When prices_xts is supplied (by Functions 2 and 3), it already contains
# buffer data from an earlier download, so we compute SMAs on the full
# object before subsetting. When downloading fresh data, we pad the start
# date by ~1.5x the long MA window in calendar days (~200 trading days).
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 the FULL dataset (buffer + test window)
sma_short_full <- SMA(close_prices_full, n = short_ma)
sma_long_full <- SMA(close_prices_full, n = long_ma)
# Subset everything 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]
# Compute 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 the single leading NA on day 1 of returns
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's 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" here is defined as a distinct directional position entered.
# Each crossover event triggers a full reversal (closing one position and
# opening the opposite), counted as one new long or one new short trade.
# The initial position at the start of the back-test window is also counted
# as the first trade in that 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 (geometric compounding: 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)
}
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 approximately −79.92% over the six-year window, a striking result for a stock that ranked among the best-performing large-caps of the era. 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 two most violent recoveries. After the COVID crash of early 2020, TSLA surged more than 700% from its trough — but the 50/200-day 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 month the strategy remained short on a rising stock, it generated a return equal to the negative of that day’s gain. These two extended periods of “short during a bull run” almost certainly account for the bulk of the −79.92% 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 a one-year buffer before start_year so that sub-periods
# beginning at start_year already have sufficient warm-up data for the
# long moving average. backtest_ma will compute SMAs on the full object
# and subset to the requested window before evaluating the strategy.
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")))
# 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.
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 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))
}
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.08 |
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 in the table 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 so SMAs are valid from begin_date.
# The same buffered dataset is 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
}
# 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))
}
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 approximately −79.92% 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.