Strasmore Research
Learn Matt ConnorBy Matt Connor · data as of September 13, 2026 · refreshed weekly

Can a Death Cross Be Bullish? The SPY Record

Can a death cross be bullish? Every SPY 50/200-day cross in our daily data, with 1, 3, 6 and 12 month forward returns and how often the index was higher.

Can a death cross be bullish? Judged only by what came next, often yes: across the 11 SPY death crosses in our daily data, the index was higher a year later in 9 of the 11 cases with a full twelve months of history behind them, with a median twelve-month move of 19.1%. The cross itself forecasts nothing: it is a description of the past 200 sessions, computed entirely from closes that have already printed. Every number on this page is one index over one stated window, and the window is not extended.

What is a death cross?

A death cross is the session on which a stock's or index's 50-day simple moving average closes below its 200-day simple moving average. A simple moving average (SMA) is the arithmetic mean of the last N closing prices, recomputed each session as the newest close enters and the oldest one drops out. The 50-day average covers roughly the last ten weeks of trading; the 200-day covers roughly the last ten months. When the shorter average dips under the longer one, chart readers call it a death cross and read it as the recent trend turning down relative to the long-run trend.

The golden cross is the mirror image: the 50-day SMA closing back above the 200-day. The two always alternate. Once a death cross has printed, the next crossing event can only be a golden cross, and the pair bracket every stretch in which the index sat in what traders call a death-cross regime.

Both are built from nothing except past closes. No volume and no news feed enters the calculation. That matters for the question in the title: the cross can only describe the relationship between two averages of history, and what followed each one is a separate, empirical question with an answer that lives in the data.

Why a death cross is late by construction

Averages move slowly by design, and that slowness is the whole mechanism of the lag. Take a hypothetical 50-day average sitting at 300 while the price drops to 250 overnight and stays there. Each new session swaps one old close of 300 out for a new close of 250, so the average falls by 50 divided by 50, or one point per session. It takes the full 50 sessions for the average to reach 250. The 200-day average moves at a quarter of that pace, one two-hundredth of the gap per session.

Now combine the two. For the 50-day average to fall below the 200-day, the recent ten weeks of closes must average less than the past ten months of closes. After a long rise the 200-day sits well under the price, and a decline has to run for weeks before the faster average catches down to the slower one. A crash that takes five weeks can be mostly finished before the averages meet. The March 2020 case further down shows that in numbers.

Every SPY death cross in our daily data

The daily bar table behind this post, the same one used in the cost of missing the market's best days and how markets recover from crashes, starts on Sep 10, 2003 and runs through Sep 11, 2026, 5788 sessions in all. The window is exactly that and nothing earlier. The method needs 200 closes before it can compute a 200-day average, so the first cross it can detect sits about ten months into the window. The full list comes to 11 death crosses, from Aug 18, 2004 to Apr 14, 2025, alongside 11 golden crosses over the same span.

QueryEvery SPY death cross in the daily data, with forward returns
eventoff_1y_high_pctfwd_1m_pctfwd_3m_pctfwd_6m_pctfwd_12m_pct
Aug 18, 20045.52.87.110.211.1
Jul 19, 20065.23.58.513.622.1
Dec 21, 20075.3-8.9-10.1-11.4-41.2
Jul 6, 201015.69.810.623.530.1
Aug 12, 201113.4-0.35.214.619.2
Aug 28, 20156.7-5.65.2-0.69.6
Jan 11, 201610-3.67.211.917.9
Dec 7, 201810.2-2.169.619.1
Mar 30, 202022.712.116.427.750.9
Mar 14, 202212.75.1-10.1-5.7-6.6
Apr 14, 202512915.822.830.1
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        row_number() OVER (ORDER BY date) AS rn,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200,
        max(px) OVER (ORDER BY date ROWS BETWEEN 251 PRECEDING AND CURRENT ROW) AS high_1y,
        leadInFrame(px, 21) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_1m,
        leadInFrame(px, 63) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_3m,
        leadInFrame(px, 126) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_6m,
        leadInFrame(px, 252) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_12m
    FROM bars
),
states AS
(
    SELECT
        *,
        (avg_50 < avg_200) AS below,
        lagInFrame((avg_50 < avg_200), 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_below
    FROM smas
),
flags AS
(
    SELECT
        *,
        (rn >= 201 AND below = 1 AND prev_below = 0) AS is_death,
        (rn >= 201 AND below = 0 AND prev_below = 1) AS is_golden
    FROM states
)
SELECT
    concat(formatDateTime(date, '%b'), ' ', toString(toDayOfMonth(date)), ', ', toString(toYear(date))) AS event,
    round((1 - px / high_1y) * 100, 1)                        AS off_1y_high_pct,
    if(px_1m > 0,  round((px_1m / px - 1) * 100, 1), NULL)    AS fwd_1m_pct,
    if(px_3m > 0,  round((px_3m / px - 1) * 100, 1), NULL)    AS fwd_3m_pct,
    if(px_6m > 0,  round((px_6m / px - 1) * 100, 1), NULL)    AS fwd_6m_pct,
    if(px_12m > 0, round((px_12m / px - 1) * 100, 1), NULL)   AS fwd_12m_pct
FROM flags
WHERE is_death
ORDER BY date
Run this yourself

The off_1y_high_pct column is the lag made visible. It measures how far SPY's close on the cross date already sat under its highest close of the trailing 252 sessions. The median death cross printed with SPY 10.2% off that high, the closest 5.2% and the farthest 22.7%. By the time the two averages crossed, part of the decline was already on the tape. The four fwd_ columns show where SPY closed 21, 63, 126 and 252 sessions later, in percent: about one, three, six and twelve months of trading.

What happened to SPY after each death cross?

Tabulating those columns across every cross gives the forward record. An empty cell in the table above means the horizon has not arrived yet for the most recent cross; the summary below counts only cases with the full stretch of data.

QueryForward record after a SPY death cross, by horizon
horizoncase_counthigher_counthigher_pctall_sessions_higher_pctavg_return_pctmedian_return_pctworst_pctbest_pct
1 month116556622.8-8.912.1
3 months11982725.67.1-10.116.4
6 months118737610.611.9-11.427.7
12 months119828214.719.1-41.250.9
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        row_number() OVER (ORDER BY date) AS rn,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200,
        max(px) OVER (ORDER BY date ROWS BETWEEN 251 PRECEDING AND CURRENT ROW) AS high_1y,
        leadInFrame(px, 21) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_1m,
        leadInFrame(px, 63) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_3m,
        leadInFrame(px, 126) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_6m,
        leadInFrame(px, 252) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_12m
    FROM bars
),
states AS
(
    SELECT
        *,
        (avg_50 < avg_200) AS below,
        lagInFrame((avg_50 < avg_200), 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_below
    FROM smas
),
flags AS
(
    SELECT
        *,
        (rn >= 201 AND below = 1 AND prev_below = 0) AS is_death,
        (rn >= 201 AND below = 0 AND prev_below = 1) AS is_golden
    FROM states
),
unrolled AS
(
    SELECT
        is_death,
        tupleElement(h, 1) AS horizon_order,
        tupleElement(h, 2) AS horizon,
        tupleElement(h, 3) AS fwd_px,
        px
    FROM flags
    ARRAY JOIN [(1, '1 month', px_1m), (2, '3 months', px_3m), (3, '6 months', px_6m), (4, '12 months', px_12m)] AS h
    WHERE rn >= 201
      AND tupleElement(h, 3) > 0
)
SELECT
    horizon,
    countIf(is_death)                                                            AS case_count,
    countIf(is_death AND fwd_px > px)                                            AS higher_count,
    toInt32(round(100 * countIf(is_death AND fwd_px > px) / countIf(is_death)))  AS higher_pct,
    toInt32(round(100 * countIf(fwd_px > px) / count()))                         AS all_sessions_higher_pct,
    round(avgIf((fwd_px / px - 1) * 100, is_death), 1)                           AS avg_return_pct,
    round(quantileExactIf((fwd_px / px - 1) * 100, is_death), 1)                 AS median_return_pct,
    round(minIf((fwd_px / px - 1) * 100, is_death), 1)                           AS worst_pct,
    round(maxIf((fwd_px / px - 1) * 100, is_death), 1)                           AS best_pct
FROM unrolled
GROUP BY horizon_order, horizon
HAVING countIf(is_death) > 0
ORDER BY horizon_order
Run this yourself

One month after a death cross, SPY was higher in 6 of 11 cases (55%), with a median move of 2.8%. At twelve months the count was 9 of 11 (82%): an average move of 14.7%, a median of 19.1%, a worst case of -41.2% and a best case of 50.9%.

The all_sessions_higher_pct column is the base rate, and it is the number most write-ups leave out. It asks the same question of every session in the window rather than only the cross dates: how often was SPY higher one, three, six or twelve months later, full stop? At twelve months that figure is 82%. Read the death-cross rate next to it before drawing anything from the death-cross rate alone. The honest answer to "can a death cross be bullish" is that, in hindsight, it often was, and that the same 11-event sample holds both the 50.9% best case and the -41.2% worst case. With that few cases the spread between best and worst carries more information than the average. Buy when others are fearful looks at the same stretches of the tape from the drawdown side.

March 2020: the death cross that printed after the low

The March 2020 COVID crash is the cleanest worked example, since the whole decline fit inside five weeks. The chart below draws SPY's close against its 50-day and 200-day averages from early February through the end of July 2020. Watch where the two averages meet relative to where the price bottomed.

QuerySPY close versus its 50-day and 200-day averages, February to July 2020
126 rows (showing 20)
session_dateclosesma_50sma_200
2020-02-03324.12320.94301.01
2020-02-04329.06321.31301.21
2020-02-05332.86321.76301.42
2020-02-06333.98322.22301.63
2020-02-07332.2322.6301.83
2020-02-10334.68323.01302.04
2020-02-11335.26323.4302.25
2020-02-12337.42323.87302.47
2020-02-13337.06324.38302.68
2020-02-14337.6324.94302.91
2020-02-18336.73325.44303.14
2020-02-19338.34325.97303.36
2020-02-20336.95326.41303.58
2020-02-21333.48326.8303.81
2020-02-24322.42326.98303.98
2020-02-25312.65326.94304.11
2020-02-26311.5326.83304.23
2020-02-27297.51326.44304.31
2020-02-28296.26325.97304.38
2020-03-02309.09325.76304.5
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)  AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200
    FROM bars
)
SELECT
    toString(date)    AS session_date,
    round(px, 2)      AS close,
    round(avg_50, 2)  AS sma_50,
    round(avg_200, 2) AS sma_200
FROM smas
WHERE date BETWEEN toDate('2020-02-03') AND toDate('2020-07-31')
ORDER BY date
Run this yourself

The receipt panel pins the dates.

QueryThe 2020 peak, low, death cross and golden cross, dated from the same bars
peak_daypeak_closelow_daylow_closecross_daycross_closegolden_daygolden_closepeak_to_low_drop_pctsessions_peak_to_lowsessions_low_to_crosslow_to_cross_gain_pctcross_off_peak_pctsessions_cross_to_goldencross_to_golden_gain_pctfwd_12m_pct
Feb 19, 2020338.34Mar 23, 2020222.95Mar 30, 2020261.65Jul 9, 2020314.3834.123517.422.77020.250.9
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        row_number() OVER (ORDER BY date) AS rn,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200,
        max(px) OVER (ORDER BY date ROWS BETWEEN 251 PRECEDING AND CURRENT ROW) AS high_1y,
        leadInFrame(px, 21) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_1m,
        leadInFrame(px, 63) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_3m,
        leadInFrame(px, 126) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_6m,
        leadInFrame(px, 252) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS px_12m
    FROM bars
),
states AS
(
    SELECT
        *,
        (avg_50 < avg_200) AS below,
        lagInFrame((avg_50 < avg_200), 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_below
    FROM smas
),
flags AS
(
    SELECT
        *,
        (rn >= 201 AND below = 1 AND prev_below = 0) AS is_death,
        (rn >= 201 AND below = 0 AND prev_below = 1) AS is_golden
    FROM states
),
cross_row AS
(
    SELECT date AS cross_date, px AS cross_px, rn AS cross_rn, px_12m AS cross_px_12m
    FROM flags
    WHERE is_death
      AND date BETWEEN toDate('2020-02-01') AND toDate('2020-05-31')
    ORDER BY date
    LIMIT 1
),
golden_row AS
(
    SELECT date AS golden_date, px AS golden_px, rn AS golden_rn
    FROM flags
    WHERE is_golden
      AND date BETWEEN toDate('2020-04-01') AND toDate('2020-12-31')
    ORDER BY date
    LIMIT 1
),
peak_row AS
(
    SELECT date AS peak_date, px AS peak_px, rn AS peak_rn
    FROM flags
    WHERE date BETWEEN toDate('2020-01-01') AND toDate('2020-03-31')
    ORDER BY px DESC
    LIMIT 1
),
low_row AS
(
    SELECT date AS low_date, px AS low_px, rn AS low_rn
    FROM flags
    WHERE date BETWEEN toDate('2020-02-01') AND toDate('2020-06-30')
    ORDER BY px ASC
    LIMIT 1
)
SELECT
    concat(formatDateTime(peak_date, '%b'), ' ', toString(toDayOfMonth(peak_date)), ', ', toString(toYear(peak_date)))       AS peak_day,
    round(peak_px, 2)                                  AS peak_close,
    concat(formatDateTime(low_date, '%b'), ' ', toString(toDayOfMonth(low_date)), ', ', toString(toYear(low_date)))           AS low_day,
    round(low_px, 2)                                   AS low_close,
    concat(formatDateTime(cross_date, '%b'), ' ', toString(toDayOfMonth(cross_date)), ', ', toString(toYear(cross_date)))     AS cross_day,
    round(cross_px, 2)                                 AS cross_close,
    concat(formatDateTime(golden_date, '%b'), ' ', toString(toDayOfMonth(golden_date)), ', ', toString(toYear(golden_date))) AS golden_day,
    round(golden_px, 2)                                AS golden_close,
    round((1 - low_px / peak_px) * 100, 1)             AS peak_to_low_drop_pct,
    toInt64(low_rn) - toInt64(peak_rn)                 AS sessions_peak_to_low,
    toInt64(cross_rn) - toInt64(low_rn)                AS sessions_low_to_cross,
    round((cross_px / low_px - 1) * 100, 1)            AS low_to_cross_gain_pct,
    round((1 - cross_px / peak_px) * 100, 1)           AS cross_off_peak_pct,
    toInt64(golden_rn) - toInt64(cross_rn)             AS sessions_cross_to_golden,
    round((golden_px / cross_px - 1) * 100, 1)         AS cross_to_golden_gain_pct,
    round((cross_px_12m / cross_px - 1) * 100, 1)      AS fwd_12m_pct
FROM cross_row, peak_row, low_row, golden_row
Run this yourself

SPY's highest close of early 2020 came on Feb 19, 2020 at $338.34. Its lowest close of the episode came 23 sessions later, on Mar 23, 2020 at $222.95, a drop of 34.1%. The death cross printed on Mar 30, 2020, 5 sessions after that low, with SPY at $261.65: already 17.4% above the bottom, and still 22.7% under the February high. The indicator described a decline that, measured close to close, had already ended. Twelve months after the cross date, SPY was 50.9% higher.

None of that was knowable on the cross date. What the arithmetic guarantees is only that a five-week crash finishes moving the price long before it finishes moving a 200-day average.

How long until a golden cross undid it?

Every death cross in the window was eventually followed by a golden cross, apart from any still in force at the end of the data. The panel pairs each death cross with the golden cross that ended it, counts the sessions between them and shows where SPY stood at the second cross relative to the first.

QueryEach SPY death cross and the golden cross that ended it
death_crossgolden_crosssession_countchange_pct
Aug 18, 2004Nov 5, 2004566.6
Jul 19, 2006Sep 11, 2006373.8
Dec 21, 2007Jun 23, 2009377-39.7
Jul 6, 2010Oct 22, 20107715
Aug 12, 2011Jan 31, 201211711.2
Aug 28, 2015Dec 17, 2015772.8
Jan 11, 2016Apr 25, 2016728.6
Dec 7, 2018Apr 1, 2019778.4
Mar 30, 2020Jul 9, 20207020.2
Mar 14, 2022Feb 2, 2023224-0.1
Apr 14, 2025Jul 1, 20255314.6
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        row_number() OVER (ORDER BY date) AS rn,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200
    FROM bars
),
states AS
(
    SELECT
        *,
        (avg_50 < avg_200) AS below,
        lagInFrame((avg_50 < avg_200), 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_below
    FROM smas
),
flags AS
(
    SELECT
        *,
        (rn >= 201 AND below = 1 AND prev_below = 0) AS is_death,
        (rn >= 201 AND below = 0 AND prev_below = 1) AS is_golden
    FROM states
),
events AS
(
    SELECT date, px, rn, is_death
    FROM flags
    WHERE is_death OR is_golden
),
paired AS
(
    SELECT
        date,
        px,
        rn,
        is_death,
        leadInFrame(date, 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_date,
        leadInFrame(px, 1)   OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_px,
        leadInFrame(rn, 1)   OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_rn
    FROM events
)
SELECT
    concat(formatDateTime(date, '%b'), ' ', toString(toDayOfMonth(date)), ', ', toString(toYear(date))) AS death_cross,
    if(next_rn > 0,
       concat(formatDateTime(next_date, '%b'), ' ', toString(toDayOfMonth(next_date)), ', ', toString(toYear(next_date))),
       'none yet')                                                    AS golden_cross,
    if(next_rn > 0, toInt64(next_rn) - toInt64(rn), NULL)             AS session_count,
    if(next_rn > 0, round((next_px / px - 1) * 100, 1), NULL)         AS change_pct
FROM paired
WHERE is_death
ORDER BY date
Run this yourself

The shortest death-cross regime in the window lasted 37 sessions and the longest 377. The median was 77 sessions. A short regime is what traders call a whipsaw: the averages cross down and cross back up within weeks, and anyone who acted on the first cross was acting on a trend that reversed before the slower average could catch up. In 2020 the golden cross arrived on Jul 9, 2020, 70 sessions after the death cross, with SPY 20.2% above the death-cross close. Both crosses confirmed a move that was largely done by the time they printed, once on the way down and once on the way up.

Why your platform may show a different cross date

If your broker's chart marks a different death cross date from the ones listed here, that is expected, and it is the same story as why RSI differs between platforms. Four choices move the cross date:

  • Simple versus exponential. An exponential moving average (EMA) weights recent closes more heavily, turns sooner than a simple average and crosses on a different day. This post uses simple averages only.
  • Adjusted versus raw closes. Dividend-adjusted price series shift older closes down slightly, which nudges both averages. This post uses SPY's raw daily closes.
  • The index versus the ETF. The S&P 500 index and SPY track each other closely but do not close at identical relative levels every day, and a close-run cross can land on different dates for the two.
  • Close-only versus intraday. Some charting packages update the averages during the session and mark the cross intraday; this post evaluates both averages at the daily close and counts the first session on which the 50-day sits below the 200-day.

Each version is a legitimate definition of the same idea. A cross that shifts by a few days under a change of convention is a reminder of how much the "event" depends on arithmetic choices rather than on anything that happened in the market that day.

FAQ

Is a death cross bullish or bearish?

By definition it is a bearish-looking chart pattern: the 50-day average has fallen below the 200-day. For SPY over the window on this page, the index was higher twelve months later in 82% of cases, so the pattern's name and its forward record point in different directions. Neither one is a forecast.

What is the difference between a death cross and a golden cross?

A death cross is the 50-day simple moving average closing below the 200-day; a golden cross is the 50-day closing back above it. They alternate: each death cross is ended by the next golden cross, and the sessions between them mark a stretch when the shorter average sat under the longer one.

How reliable is the death cross as a sell signal?

It is a lagging description of the last 200 closes and carries no forecast of its own. In our SPY data the cross printed a median 10.2% under the trailing one-year high, after part of the decline had already happened, and what followed over the next year ranged from -41.2% to 50.9%. What a trader does with that is a separate decision, and this page makes no recommendation.

What happened after the March 2020 death cross?

SPY's death cross printed on Mar 30, 2020, 5 sessions after the Mar 23, 2020 low, with the index already 17.4% off the bottom. Twelve months later SPY was 50.9% higher, and the golden cross that ended the regime arrived 70 sessions after the death cross.

Why does my chart show a different death cross date?

Platforms differ on simple versus exponential averages, dividend-adjusted versus raw prices, the index versus the ETF, and close-only versus intraday evaluation. Any one of those can move a cross by days. This page uses simple averages of SPY's raw daily closes, evaluated at the close.


Every panel above carries the exact query beneath it. To rerun the cross detection on a different ticker or a different pair of averages, ask for it in plain English on the Strasmore terminal.

Data notes: the window and the summary statistics

The window and the summary statistics quoted in the text come from this receipt. first_session is the earliest SPY daily bar in the table; nothing earlier is used, and the first detectable cross falls 200 sessions in. Forward horizons are fixed session counts (21, 63, 126 and 252) rather than calendar months, and the all_sessions_higher_pct base rate uses every session from the 201st onward to cover the same span as the cross list. A cross is counted on the first close where the 50-day average sits below the 200-day after sitting at or above it, using simple averages of raw closes.

QueryThe window and the cross counts behind the text
first_sessionlast_sessionsession_countmedian_off_high_pctmin_off_high_pctmax_off_high_pctdeath_cross_countgolden_cross_countmin_sessions_to_goldenmedian_sessions_to_goldenmax_sessions_to_golden
Sep 10, 2003Sep 11, 2026578810.25.222.711113777377
The exact SQL behind every number
WITH
bars AS
(
    SELECT
        date,
        toFloat64(argMax(close, _ingest_time)) AS px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
    GROUP BY date
),
smas AS
(
    SELECT
        date,
        px,
        row_number() OVER (ORDER BY date) AS rn,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS avg_50,
        avg(px) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS avg_200,
        max(px) OVER (ORDER BY date ROWS BETWEEN 251 PRECEDING AND CURRENT ROW) AS high_1y
    FROM bars
),
states AS
(
    SELECT
        *,
        (avg_50 < avg_200) AS below,
        lagInFrame((avg_50 < avg_200), 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS prev_below
    FROM smas
),
flags AS
(
    SELECT
        *,
        (rn >= 201 AND below = 1 AND prev_below = 0) AS is_death,
        (rn >= 201 AND below = 0 AND prev_below = 1) AS is_golden
    FROM states
),
events AS
(
    SELECT date, rn, is_death
    FROM flags
    WHERE is_death OR is_golden
),
paired AS
(
    SELECT
        rn,
        is_death,
        leadInFrame(rn, 1) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS next_rn
    FROM events
),
cross_stats AS
(
    SELECT
        countIf(is_death)                                                           AS death_cross_count,
        countIf(NOT is_death)                                                       AS golden_cross_count,
        minIf(toInt64(next_rn) - toInt64(rn), is_death AND next_rn > 0)            AS min_sessions_to_golden,
        quantileExactIf(toInt64(next_rn) - toInt64(rn), is_death AND next_rn > 0)  AS median_sessions_to_golden,
        maxIf(toInt64(next_rn) - toInt64(rn), is_death AND next_rn > 0)            AS max_sessions_to_golden
    FROM paired
),
span AS
(
    SELECT
        concat(formatDateTime(min(date), '%b'), ' ', toString(toDayOfMonth(min(date))), ', ', toString(toYear(min(date)))) AS first_session,
        concat(formatDateTime(max(date), '%b'), ' ', toString(toDayOfMonth(max(date))), ', ', toString(toYear(max(date)))) AS last_session,
        count()                                                        AS session_count,
        round(quantileExactIf((1 - px / high_1y) * 100, is_death), 1)  AS median_off_high_pct,
        round(minIf((1 - px / high_1y) * 100, is_death), 1)            AS min_off_high_pct,
        round(maxIf((1 - px / high_1y) * 100, is_death), 1)            AS max_off_high_pct
    FROM flags
)
SELECT *
FROM span, cross_stats
Run this yourself
#death cross#golden cross#moving averages#technical analysis#spy#market timing