March 24, 2020: The Day the Market Turned
March 24, 2020, replayed from the minute tape: the crash and the dash for cash, the Fed package that differed, sector breadth, and the retest that never came.
On March 23, 2020, the Federal Reserve announced open-ended asset purchases, QE without a stated limit, plus something it had never done before: facilities to buy corporate bonds. March 24, 2020 was the market's answer: the COVID stimulus rally, one of the largest single-day US equity gains on record, and, visible only in hindsight, the second day of a new bull market. The bottom itself had closed the afternoon before the announcement landed.
The crash before the turn
The crash itself has its own page; here is the receipt this one stands on, every session from the all-time high to the bottom, with the 10-year Treasury yield alongside in basis points:
| session | close_usd | drawdown_pct | y10_bps |
|---|---|---|---|
| 2020-02-19 | 338.31 | 0 | 156 |
| 2020-02-20 | 336.98 | -0.4 | 152 |
| 2020-02-21 | 333.49 | -1.4 | 146 |
| 2020-02-24 | 322.41 | -4.7 | 138 |
| 2020-02-25 | 312.7 | -7.6 | 133 |
| 2020-02-26 | 311.55 | -7.9 | 133 |
| 2020-02-27 | 297.55 | -12 | 130 |
| 2020-02-28 | 296.16 | -12.5 | 113 |
| 2020-03-02 | 309.08 | -8.6 | 110 |
| 2020-03-03 | 300.34 | -11.2 | 102 |
| 2020-03-04 | 312.91 | -7.5 | 102 |
| 2020-03-05 | 302.45 | -10.6 | 92 |
| 2020-03-06 | 297.42 | -12.1 | 74 |
| 2020-03-09 | 274.4 | -18.9 | 54 |
| 2020-03-10 | 288.44 | -14.7 | 76 |
| 2020-03-11 | 274.36 | -18.9 | 82 |
| 2020-03-12 | 248.1 | -26.7 | 88 |
| 2020-03-13 | 270.93 | -19.9 | 94 |
| 2020-03-16 | 239.41 | -29.2 | 73 |
| 2020-03-17 | 254.19 | -24.9 | 102 |
The exact SQL behind every number
WITH daily AS (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS et_date,
round(argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 2) AS close_usd
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-02-19 00:00:00') AND window_start < toDateTime('2020-03-24 04:00:00')
GROUP BY et_date
HAVING et_date >= toDate('2020-02-19') AND et_date <= toDate('2020-03-23') AND close_usd > 0
)
SELECT
toString(d.et_date) AS session,
d.close_usd,
round((d.close_usd / (SELECT max(close_usd) FROM daily) - 1) * 100, 1) AS drawdown_pct,
round(t.yield_10_year * 100) AS y10_bps
FROM daily AS d
LEFT JOIN global_markets.treasury_yields AS t ON t.date = d.et_date
ORDER BY d.et_dateFrom the all-time-high close of $338.31 on 2020-02-19, SPY fell to $222.51 by 2020-03-23, a -34.2% drawdown, with every session of the slide inside the panel's 24 rows.
The yield column is the tell. Through 2020-03-09 the 10-year yield collapsed from 156 basis points to a then-record 54, the classic flight to safety, money leaving stocks for bonds. Then the pattern broke: by 2020-03-18 the yield had more than doubled to 118 basis points while SPY's drawdown deepened from -18.9% to -28.8%. Stocks and Treasuries falling together is the signature of forced selling, holders raising cash by selling whatever still has a bid. By mid-March the crisis was about the plumbing of credit itself, not the price of stocks.
What the Fed actually announced, and what hadn't worked
Rate cuts had already been tried. The dated sequence, each entry readable in the panel above:
- 2020-03-03, an emergency half-point rate cut, the first between-meeting cut since 2008. SPY closed at $300.34, -11.2% off the high, and the slide continued.
- March 15, a Sunday, rates cut to zero plus a $700 billion asset-purchase program. The next session, 2020-03-16, closed -29.2% below the February high, among the worst single sessions of the entire crash.
- March 23, 8:00 am ET, the Fed removed the ceiling: purchases of Treasuries and mortgage-backed securities would continue "in the amounts needed," open-ended. Attached were the PMCCF and SMCCF, the Fed's first-ever corporate-bond-buying facilities, and a revived TALF backstopping consumer credit. SPY still closed at the bear's low that afternoon.
- March 24, the session below. Overnight, Congress converged on the roughly $2 trillion CARES Act; it was signed on March 27, inside the three-day burst receipted further down.
Two design differences separated the March 23 package from the attempts before it. A fixed-size program has an end date a stressed market can see past; "in the amounts needed" does not. And the corporate facilities placed a public buyer directly inside the credit markets where the plumbing had seized. Whether those differences are what mattered is a judgment call; the tape records the sequence, two escalations, continued decline, a third design, then the turn.
The day, on one row
| prior_close | rth_open | gap_pct | rth_low | low_et | rth_high | rth_close | day_change_pct | low_vs_prior_pct | day_shares_m | rth_minute_bars |
|---|---|---|---|---|---|---|---|---|---|---|
| 222.51 | 234.42 | 5.4 | 233.8 | 09:31 | 244.1 | 243.59 | 9.5 | 5.1 | 232.4 | 390 |
The exact SQL behind every number
WITH
(
SELECT argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-03-23 00:00:00') AND window_start < toDateTime('2020-03-24 04:00:00')
) AS prior_rth_close
SELECT
round(prior_rth_close, 2) AS prior_close,
round(toFloat64(argMinIf(open, window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)), 2) AS rth_open,
round((toFloat64(argMinIf(open, window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)) / prior_rth_close - 1) * 100, 1) AS gap_pct,
round(minIf(toFloat64(low), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 2) AS rth_low,
formatDateTime(toTimeZone(argMinIf(window_start, (toFloat64(low), toInt64(toUnixTimestamp(window_start))), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 'America/New_York'), '%H:%i') AS low_et,
round(maxIf(toFloat64(high), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 2) AS rth_high,
round(toFloat64(argMaxIf(close, window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)), 2) AS rth_close,
round((toFloat64(argMaxIf(close, window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)) / prior_rth_close - 1) * 100, 1) AS day_change_pct,
round((minIf(toFloat64(low), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) / prior_rth_close - 1) * 100, 1) AS low_vs_prior_pct,
round(toFloat64(sum(volume)) / 1e6, 1) AS day_shares_m,
countIf((toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) AS rth_minute_bars
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-03-24 04:00:00') AND window_start < toDateTime('2020-03-24 23:59:00')From the March 23 close of $222.51, the bear market's final close, SPY gapped +5.4% to open at $234.42, printed its session low of $233.8 in the opening minutes (09:31 ET), and never came back for it: the close at $243.59 was +9.5% on the day. A rally whose low prints in the first minute is the mirror image of a crash that closes on its low. One row, though, flattens the path, the half-hour tape below shows the fade the session had to absorb.
The shape of the session
| et_time | bucket_close | bucket_low | shares_m |
|---|---|---|---|
| 09:30 | 235.51 | 233.8 | 27.3 |
| 10:00 | 238.45 | 234.6 | 17.4 |
| 10:30 | 237.46 | 237.04 | 13.2 |
| 11:00 | 238.36 | 236.16 | 11.5 |
| 11:30 | 240.43 | 237.58 | 12.5 |
| 12:00 | 242.24 | 239.65 | 14.2 |
| 12:30 | 239.65 | 239.57 | 12.9 |
| 13:00 | 239.41 | 237.86 | 16.1 |
| 13:30 | 237.25 | 236.76 | 10.7 |
| 14:00 | 237.38 | 235.05 | 16.9 |
| 14:30 | 240.58 | 236.61 | 11.5 |
| 15:00 | 241.45 | 240.21 | 12.1 |
| 15:30 | 243.59 | 240.41 | 34.7 |
The exact SQL behind every number
SELECT
formatDateTime(toStartOfInterval(toTimeZone(window_start, 'America/New_York'), INTERVAL 30 MINUTE), '%H:%i') AS et_time,
round(toFloat64(argMax(close, window_start)), 2) AS bucket_close,
round(min(toFloat64(low)), 2) AS bucket_low,
round(toFloat64(sum(volume)) / 1e6, 1) AS shares_m
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-03-24 04:00:00') AND window_start < toDateTime('2020-03-24 23:59:00')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY et_time
ORDER BY et_timeThe panel is honest: the path bent. The low of $233.8 printed in the opening minute and was never revisited, that much of the one-directional story is real. But after reaching $242.24 by the noon bucket, the tape gave ground for two hours, the 13:30 bucket closed at $237.25, the 14:00 bucket printed the afternoon's deepest low, $235.05, the stretch where the day could have become one more failed bounce. Instead the give-back held far above the opening low, and the final half hour delivered the session's highest bucket close, $243.59, on 34.7 million shares, the heaviest bucket of the 232.4-million-share day, above even the open's 27.3 million. A midday dip that finds buyers, then a close on the highs at peak volume.
Broad rally, or narrow?
An index can be dragged higher by a handful of mega-caps. That is not what this was. Twelve funds, the three major index trackers plus nine S&P sector funds, on the same two closes:
| ticker | mar23_close | mar24_close | change_pct |
|---|---|---|---|
| XLE | 23.53 | 27.39 | 16.4 |
| XLI | 48.75 | 54.97 | 12.8 |
| XLF | 17.62 | 19.85 | 12.7 |
| DIA | 185.87 | 206.96 | 11.3 |
| XLU | 44.92 | 49.76 | 10.8 |
| XLK | 70.2 | 77.64 | 10.6 |
| XLY | 87.42 | 95.77 | 9.6 |
| SPY | 222.51 | 243.59 | 9.5 |
| IWM | 99.83 | 109.17 | 9.4 |
| QQQ | 170.61 | 183.79 | 7.7 |
| XLV | 74.8 | 80.31 | 7.4 |
| XLP | 48.66 | 51.12 | 5.1 |
The exact SQL behind every number
SELECT
ticker,
round(argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959 AND toDate(toTimeZone(window_start, 'America/New_York')) = toDate('2020-03-23')), 2) AS mar23_close,
round(argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959 AND toDate(toTimeZone(window_start, 'America/New_York')) = toDate('2020-03-24')), 2) AS mar24_close,
round((argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959 AND toDate(toTimeZone(window_start, 'America/New_York')) = toDate('2020-03-24')) / argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959 AND toDate(toTimeZone(window_start, 'America/New_York')) = toDate('2020-03-23')) - 1) * 100, 1) AS change_pct
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'DIA', 'QQQ', 'IWM', 'XLE', 'XLI', 'XLF', 'XLK', 'XLY', 'XLV', 'XLU', 'XLP')
AND window_start >= toDateTime('2020-03-23 00:00:00') AND window_start < toDateTime('2020-03-25 00:00:00')
GROUP BY ticker
ORDER BY change_pct DESCAll 12 closed higher. Energy (XLE) led at +16.4%, with industrials (XLI, +12.8%) and financials (XLF, +12.7%) right behind, the most economically-sensitive corners of the market, the ones a shutdown threatens first. The Dow tracker DIA gained 11.3%, the move behind that week's "best day since 1933" headlines, a superlative that belongs to the Dow's longer price history. The smallest gains sat in the classic defensives, consumer staples (XLP, +5.1%) and healthcare (XLV, +7.4%), and the Nasdaq-100's QQQ (+7.7%) trailed SPY's +9.5%. A cyclicals-led, everything-green tape is the opposite shape of a narrow mega-cap squeeze, breadth was the day's signature.
How historic was it, really?
"Biggest day since 1933" is a Dow statistic from that week's newspapers. Here is what our own tape can verify, scoped to what it actually covers:
| sessions_measured | first_session | mar24_gain_pct | bigger_days | mar24_rank | bigger_day_dates | burst3_gain_pct | bigger_bursts | vol_vs_30d | vol_vs_250d | mar24_shares_m |
|---|---|---|---|---|---|---|---|---|---|---|
| 5613 | 2003-09-10 | 9.5 | 2 | 3 | 2008-10-13, 2008-10-28 | 17.5 | 0 | 1.12 | 2.86 | 232 |
The exact SQL behind every number
SELECT
countIf(isFinite(day_pct)) AS sessions_measured,
toString(min(et_date)) AS first_session,
round(mar24_d, 1) AS mar24_gain_pct,
countIf(isFinite(day_pct) AND day_pct > mar24_d) AS bigger_days,
countIf(isFinite(day_pct) AND day_pct > mar24_d) + 1 AS mar24_rank,
arrayStringConcat(arraySort(groupArrayIf(toString(et_date), isFinite(day_pct) AND day_pct > mar24_d)), ', ') AS bigger_day_dates,
round(mar26_b, 1) AS burst3_gain_pct,
countIf(isFinite(burst3_pct) AND burst3_pct > mar26_b) AS bigger_bursts,
round(mar24_r30, 2) AS vol_vs_30d,
round(mar24_r250, 2) AS vol_vs_250d,
round(mar24_shares / 1e6, 0) AS mar24_shares_m
FROM (
SELECT
et_date, day_pct, burst3_pct,
max(if(et_date = toDate('2020-03-24'), day_pct, -999)) OVER () AS mar24_d,
max(if(et_date = toDate('2020-03-26'), burst3_pct, -999)) OVER () AS mar26_b,
max(if(et_date = toDate('2020-03-24'), shares / vol_avg_30, -999)) OVER () AS mar24_r30,
max(if(et_date = toDate('2020-03-24'), shares / vol_avg_250, -999)) OVER () AS mar24_r250,
max(if(et_date = toDate('2020-03-24'), shares, -999)) OVER () AS mar24_shares
FROM (
SELECT
et_date,
(close_usd / lagInFrame(close_usd, 1) OVER (ORDER BY et_date ASC ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) - 1) * 100 AS day_pct,
(close_usd / lagInFrame(close_usd, 3) OVER (ORDER BY et_date ASC ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) - 1) * 100 AS burst3_pct,
shares,
avg(shares) OVER (ORDER BY et_date ASC ROWS BETWEEN 30 PRECEDING AND 1 PRECEDING) AS vol_avg_30,
avg(shares) OVER (ORDER BY et_date ASC ROWS BETWEEN 250 PRECEDING AND 1 PRECEDING) AS vol_avg_250
FROM (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS et_date,
argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) AS close_usd,
toFloat64(sum(volume)) AS shares
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY' AND window_start < toDateTime('2026-01-01 00:00:00')
GROUP BY et_date
HAVING close_usd > 0
)
)
)
GROUP BY mar24_d, mar26_b, mar24_r30, mar24_r250, mar24_sharesSince 2003-09-10, the first session of our minute data, SPY has printed 5613 close-to-close daily returns. March 24, 2020's +9.5% ranks #3: exactly 2 sessions beat it (2008-10-13, 2008-10-28), both inside the October 2008 panic. The three-day sequence is rarer still: the +17.5% run from the March 23 close to the March 26 close has 0 larger three-session gains anywhere in the sample. Within everything this warehouse can check, nothing matches the burst.
Volume tells the quieter story: 232 million SPY shares was 2.86× the trailing-year average, and only 1.12× the average of the prior 30 sessions. Against any normal stretch of history the activity was extraordinary; against the crash weeks preceding it, it was just another day inside the storm. Historic turns do not announce themselves with historic volume.
The three-day burst
| session | close_usd | change_pct | shares_m |
|---|---|---|---|
| 2020-03-23 | 222.51 | -2.8 | 322.4 |
| 2020-03-24 | 243.59 | 9.5 | 232.4 |
| 2020-03-25 | 246.77 | 1.3 | 297.4 |
| 2020-03-26 | 261.39 | 5.9 | 253.8 |
The exact SQL behind every number
SELECT
toString(et_date) AS session,
close_usd,
round(if(prev_close = 0, NULL, (close_usd / prev_close - 1) * 100), 1) AS change_pct,
shares_m
FROM (
SELECT et_date, close_usd, shares_m,
lagInFrame(close_usd) OVER (ORDER BY et_date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
FROM (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS et_date,
round(argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 2) AS close_usd,
round(toFloat64(sum(volume)) / 1e6, 1) AS shares_m
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-03-20 00:00:00') AND window_start < toDateTime('2020-03-26 23:59:00')
GROUP BY et_date
)
)
WHERE et_date >= toDate('2020-03-23') AND et_date <= toDate('2020-03-26')
ORDER BY et_dateThe turn compounded immediately: +9.5% on the 24th, +1.3% on the 25th, +5.9% on the 26th, a three-session sequence that undid weeks of collapse, with no larger one anywhere in the sample above. The March 2020 pattern rhymes deliberately with March 2009: in both, the decisive policy response arrived near the low, the first rally leg was violent, and the day of maximum opportunity looked, that morning, indistinguishable from another bear-market bounce.
Did the rally hold?
In real time, "bear-market bounce" was the consensus reading, and the skeptics had receipts of their own: eleven days earlier the tape had already produced a rally almost this size, from $248.1 on 2020-03-12 to $270.93 on 2020-03-13, and the session after that closed at $239.41, the worst day of the whole crash. One green day proved nothing. What distinguishes March 24 is what came after:
| feb19_ath_close | mar23_bottom_close | recovery_date | sessions_to_new_high | days_low_to_new_high | round_trip_days | lowest_close_after | lowest_close_date | closest_retest_pct | peak_2007_date | recovery_2013_date | round_trip_2008_days |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 338.31 | 222.51 | 2020-08-18 | 103 | 148 | 181 | 243.59 | 2020-03-24 | 9.5 | 2007-10-09 | 2013-03-14 | 1983 |
The exact SQL behind every number
SELECT
round(ath_close, 2) AS feb19_ath_close,
round(bottom_close, 2) AS mar23_bottom_close,
toString(rec_date) AS recovery_date,
countIf(et_date > toDate('2020-03-23') AND et_date <= rec_date) AS sessions_to_new_high,
dateDiff('day', toDate('2020-03-23'), rec_date) AS days_low_to_new_high,
dateDiff('day', toDate('2020-02-19'), rec_date) AS round_trip_days,
round(minIf(close_usd, et_date > toDate('2020-03-23') AND et_date <= rec_date), 2) AS lowest_close_after,
toString(argMinIf(et_date, (close_usd, et_date), et_date > toDate('2020-03-23') AND et_date <= rec_date)) AS lowest_close_date,
round((minIf(close_usd, et_date > toDate('2020-03-23') AND et_date <= rec_date) / bottom_close - 1) * 100, 1) AS closest_retest_pct,
toString(peak07_date) AS peak_2007_date,
toString(rec07_date) AS recovery_2013_date,
dateDiff('day', peak07_date, rec07_date) AS round_trip_2008_days
FROM (
SELECT
et_date, close_usd, ath_close, bottom_close, peak07_date,
min(if(et_date > toDate('2020-03-23') AND close_usd >= ath_close, et_date, toDate('2099-01-01'))) OVER () AS rec_date,
min(if(et_date >= toDate('2008-01-01') AND close_usd >= peak07_close, et_date, toDate('2099-01-01'))) OVER () AS rec07_date
FROM (
SELECT
et_date, close_usd,
max(if(et_date = toDate('2020-02-19'), close_usd, -999)) OVER () AS ath_close,
max(if(et_date = toDate('2020-03-23'), close_usd, -999)) OVER () AS bottom_close,
max(if(et_date >= toDate('2007-01-01') AND et_date < toDate('2008-01-01'), (close_usd, et_date), (-999., toDate('1970-01-01')))) OVER () .1 AS peak07_close,
max(if(et_date >= toDate('2007-01-01') AND et_date < toDate('2008-01-01'), (close_usd, et_date), (-999., toDate('1970-01-01')))) OVER () .2 AS peak07_date
FROM (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS et_date,
argMaxIf(toFloat64(close), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) AS close_usd
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY' AND window_start < toDateTime('2021-01-01 00:00:00')
GROUP BY et_date
HAVING close_usd > 0
)
)
)
GROUP BY ath_close, bottom_close, rec_date, peak07_date, rec07_dateThe retest never came. After March 23, the lowest close SPY ever printed was $243.59, on 2020-03-24, the stimulus-rally day itself. The market never again closed within 9.5% of the bottom. The pre-COVID high of $338.31 was recovered on 2020-08-18, 103 trading sessions after the low, a 181-day round trip from all-time high back to all-time high. The 2008 comparison, computed the same way: SPY's 2007-10-09 peak did not see a new closing high until 2013-03-14, 1983 days later. Two crises, two speeds, one market that had changed its metabolism in between.
What this day teaches
Three receipts worth internalizing. First, the gap did the work: much of the day's gain existed before the open, anyone waiting for a regular-hours entry paid up all day, the overnight gap mechanics at maximum amplitude. Second, the bottom preceded the confirmation: March 23's close was the low, visible as such only after sessions like this one refused to revisit it. Third, velocity cuts both ways: the same market structure that compressed a -34.2% drawdown into the 24 rows above compressed the initial recovery into three sessions. Speed is a property of the era's market plumbing, not of good news or bad.
The skeptics of that week were not fools: the breadth, the burst, the retest that never came, every receipt separating March 24 from the failed rallies is measurable only in hindsight. The difference between a bear-market rally and a new bull market is invisible on day one and unmistakable within months.
COVID rally FAQ
What happened on March 24, 2020?
The first full session after the Fed's unlimited-QE announcement: SPY gapped +5.4% at the open and closed +9.5%, the #3 single-day gain in the 5613 sessions our minute tape covers, behind only two days in October 2008.
What did the Fed announce on March 23, 2020?
Open-ended purchases of Treasury and mortgage-backed securities, "in the amounts needed", plus the PMCCF and SMCCF, its first-ever facilities for buying corporate bonds, and a revived TALF for consumer credit. The statement went out at 8:00 am ET, before the open; SPY still closed that day at its bear-market low.
Was March 23, 2020 the bottom of the COVID crash?
Yes, by closing price: SPY's low close of $222.51 printed on March 23, hours after the Fed's announcement. It was never revisited, the lowest close afterward came on 2020-03-24, 9.5% above the bottom.
When did the stock market recover its pre-COVID high?
SPY first closed above its February 19, 2020 all-time-high close on 2020-08-18, 103 trading sessions after the bottom, a 181-day round trip, against 1983 days for the 2008 crash's equivalent journey.
Could you have bought after the news and still done well?
The open gapped +5.4% before regular trading began, and the day kept climbing, every later entry cost more than the prior close, and still sat far below where the market stood at the August 2020 recovery.
Data notes
- Closes are regular-session (9:30 am-4:00 pm ET) last prints from the minute tape; they can differ from official auction closes by a few cents.
- Recovery dates are price-only; a total-return measure (dividends reinvested) recovers sooner by construction.
- The 10-year yield joins the Treasury's daily series on trading dates; basis points are hundredths of a percentage point.
- The rank panel's sample starts 2003-09-10, "since 2003" on this page means that window, not all recorded market history.
Every panel above is a stored, versioned query over the historical tape, expand the SQL to see each measurement. Want to feel this day instead of reading it? It is one of the playable scenarios in the Strasmore Labs trading simulator.