COVID Crash 2020: Four Halts, Peak to Trough
The COVID crash of 2020, receipted: the March 9 halt minutes missing from the tape, four circuit breakers in eight days, and the peak-to-trough map on SPY.
The COVID crash of 2020 carried the S&P 500 from a record close on February 19 to a -34.2% peak-to-trough loss in 23 trading days, with four market-wide circuit-breaker halts along the way, the first since 1997. This page replays the whole arc from the tape: the crash's map, the March 9 halt session minute by minute (including the minutes that are missing from the data, which are the best receipt on the page), the names that fell hardest, and what Treasuries and the options tape did alongside. Every number is a stored query; expand any panel for the SQL.
The whole crash, on one row
Before the famous single day, the map. One query over SPY's daily regular-session closes pins the peak, the trough, the speed, and the recovery, plus the same measurement run on the 2008 bear market for scale.
| peak_date | peak_close | trough_date | trough_close | decline_pct | sessions_peak_to_trough | calendar_days | sessions_to_minus_30 | gfc_2008_sessions_to_minus_30 | recovery_date | sessions_trough_to_recovery |
|---|---|---|---|---|---|---|---|---|---|---|
| 2020-02-19 | 338.31 | 2020-03-23 | 222.51 | -34.2 | 23 | 33 | 22 | 250 | 2020-08-18 | 103 |
The exact SQL behind every number
WITH dailies AS (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
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 c
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-02-01 00:00:00') AND window_start < toDateTime('2020-09-01 00:00:00')
GROUP BY d
),
peak AS (SELECT argMax(d, (c, -toInt32(d))) AS pd, max(c) AS pc FROM dailies WHERE d < toDate('2020-03-01')),
trough AS (SELECT argMin(d, (c, toInt32(d))) AS td, min(c) AS tc FROM dailies WHERE d < toDate('2020-04-01')),
gfc AS (
SELECT
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
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 c
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2007-06-01 00:00:00') AND window_start < toDateTime('2009-01-01 00:00:00')
GROUP BY d
),
gpeak AS (SELECT argMax(d, (c, -toInt32(d))) AS pd, max(c) AS pc FROM gfc WHERE d < toDate('2008-01-01'))
SELECT
toString((SELECT pd FROM peak)) AS peak_date,
round((SELECT pc FROM peak), 2) AS peak_close,
toString((SELECT td FROM trough)) AS trough_date,
round((SELECT tc FROM trough), 2) AS trough_close,
round(((SELECT tc FROM trough) / (SELECT pc FROM peak) - 1) * 100, 1) AS decline_pct,
countIf(d > (SELECT pd FROM peak) AND d <= (SELECT td FROM trough)) AS sessions_peak_to_trough,
dateDiff('day', (SELECT pd FROM peak), (SELECT td FROM trough)) AS calendar_days,
countIf(d > (SELECT pd FROM peak) AND d <= (SELECT min(d) FROM dailies WHERE c <= 0.7 * (SELECT pc FROM peak))) AS sessions_to_minus_30,
(SELECT countIf(d > (SELECT pd FROM gpeak) AND d <= (SELECT min(d) FROM gfc WHERE c <= 0.7 * (SELECT pc FROM gpeak))) FROM gfc) AS gfc_2008_sessions_to_minus_30,
toString((SELECT min(d) FROM dailies WHERE d > (SELECT td FROM trough) AND c >= (SELECT pc FROM peak))) AS recovery_date,
countIf(d > (SELECT td FROM trough) AND d <= (SELECT min(d) FROM dailies WHERE d > (SELECT td FROM trough) AND c >= (SELECT pc FROM peak))) AS sessions_trough_to_recovery
FROM dailiesSPY's peak close was $338.31 on 2020-02-19. The trough close, $222.51 on 2020-03-23, sits -34.2% below it, a bear market compressed into 23 trading sessions, 33 calendar days. The often-quoted "fastest thirty-percent drawdown on record" claim is receipted in the same row: SPY's close first breached 30% below the peak just 22 sessions in. The 2008 bear market, measured identically from its October 2007 peak close, needed 250 sessions to lose the same 30%. What took roughly a year in 2008 took about a month in 2020.
The dated skeleton of the crash, every number sourced from the panels on this page:
- February 19, 2020, SPY's record close, $338.31.
- March 9, the first market-wide circuit breaker since 1997; SPY closes -7.7% down. Receipted minute by minute below.
- March 12, 16, and 18, three more Level-1 halts. March 16 closes -11.6%, the worst single session of the crash.
- March 23, the closing low: $222.51, -34.2% from the peak.
- March 24, the turn. The stimulus rally of March 24 carries that tape.
- 2020-08-18, SPY closes above the February peak again, 103 sessions after the low.
March 9, 2020: the session that tripped the breaker
Market-wide circuit breakers key off the S&P 500, so this panel reads the halt through SPY, the S&P 500 ETF, rather than any other proxy. Over the prior weekend, pandemic repricing had collided with an oil-supply shock (more on that below), and the market never really opened in an orderly way.
| prior_close | rth_open | gap_pct | halt_first_missing_et | halt_missing_minutes | rth_low | low_et | rth_close | day_change_pct | day_shares_m | rth_minute_bars |
|---|---|---|---|---|---|---|---|---|---|---|
| 297.42 | 275.3 | -7.4 | 09:35 | 14 | 273.45 | 14:44 | 274.4 | -7.7 | 304.6 | 376 |
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-06 00:00:00') AND window_start < toDateTime('2020-03-09 04:00:00')
) AS prior_rth_close,
(
SELECT (formatDateTime(toTimeZone(addMinutes(prev_bar, 1), 'America/New_York'), '%H:%i'), gap_minutes - 1)
FROM (
SELECT
lagInFrame(window_start) OVER (ORDER BY window_start ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_bar,
dateDiff('minute', prev_bar, window_start) AS gap_minutes
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND window_start >= toDateTime('2020-03-09 04:00:00') AND window_start < toDateTime('2020-03-09 23:59:00')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
)
WHERE prev_bar > toDateTime('2020-03-09 00:00:00')
ORDER BY gap_minutes DESC
LIMIT 1
) AS halt_gap
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,
halt_gap.1 AS halt_first_missing_et,
halt_gap.2 AS halt_missing_minutes,
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(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(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-09 04:00:00') AND window_start < toDateTime('2020-03-09 23:59:00')Two numbers up front. First, the gap: SPY opened at $275.3, -7.4% below Friday's close of $297.42, and within the first minutes the S&P 500's decline reached the 7% Level-1 threshold. Second, and better: 376 regular-session minute bars instead of the usual 390. The tape stops after 9:34 am: starting at 09:35 ET, 14 consecutive minutes print no bars at all. Nothing traded anywhere. The gap in the data is the circuit breaker. The session closed at $274.4, -7.7% down, on 304.6 million shares.
The shape of a halted session
| et_time | bucket_close | bucket_low | shares_m |
|---|---|---|---|
| 09:30 | 279.93 | 273.5 | 17.4 |
| 10:00 | 280.72 | 275.6 | 23.4 |
| 10:30 | 278.61 | 275.5 | 19.1 |
| 11:00 | 281.03 | 275.5 | 17.2 |
| 11:30 | 282.69 | 280.06 | 19.1 |
| 12:00 | 281.01 | 280.95 | 17.2 |
| 12:30 | 279.71 | 275.95 | 13.4 |
| 13:00 | 278.59 | 278.24 | 11.5 |
| 13:30 | 275.84 | 275.34 | 19.4 |
| 14:00 | 274.95 | 274.06 | 21.2 |
| 14:30 | 276.59 | 273.45 | 25.9 |
| 15:00 | 277.9 | 275.55 | 24.5 |
| 15:30 | 274.4 | 274.37 | 46.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-09 04:00:00') AND window_start < toDateTime('2020-03-09 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 opening bucket contains the crash, the halt, and the resumption all at once: its low of $273.5 is within pennies of the full day's low of $273.45, the afternoon print at 14:44 ET merely retested the morning's level. After the reopening the tape went almost nowhere: every half-hour close sat inside a few dollars of the reopening price, and the day's damage was done in the first minutes of clock time. Volume told the same story in reverse, the final half hour, at 46.7 million shares, was the heaviest bucket of the day as closing flows crossed. Circuit breakers are designed to produce exactly this shape: stop the waterfall, force a reopening auction, and let continuous trading resume at the repriced level. The market's clock has this machinery permanently embedded now.
Four halts in eight sessions
March 9 was only the first.
| session | close_usd | change_pct | shares_m |
|---|---|---|---|
| 2020-03-09 | 274.4 | -7.7 | 304.6 |
| 2020-03-12 | 248.1 | -9.6 | 389.3 |
| 2020-03-16 | 239.41 | -11.6 | 291.5 |
| 2020-03-18 | 241.01 | -5.2 | 324.7 |
The exact SQL behind every number
SELECT
toString(et_date) AS session,
close_usd,
change_pct,
shares_m
FROM (
SELECT et_date, close_usd, shares_m,
round((close_usd / lagInFrame(close_usd) OVER (ORDER BY et_date ASC ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) - 1) * 100, 1) AS change_pct
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-06 00:00:00') AND window_start < toDateTime('2020-03-19 00:00:00')
GROUP BY et_date
)
)
WHERE toString(et_date) IN ('2020-03-09', '2020-03-12', '2020-03-16', '2020-03-18')
ORDER BY et_dateLevel-1 breakers also tripped on March 12 (closed -9.6%), March 16 (closed -11.6% at $239.41, the S&P's largest one-day percentage drop since 1987), and March 18 (halted intraday, closed -5.2%). Four market-wide halts in eight trading days, after twenty-three years without one. Note what never happened: every halt was Level 1, the 7% tier. The 13% and 20% tiers were never reached, even on March 16, the cascade slowed after the halt and the session finished inside the Level-2 line.
Who fell hardest
An index number hides the dispersion underneath it. The same daily-close arithmetic, run across a handful of representative large-caps, shows where the crash actually landed, both on March 9 itself and across the full February 19 to March 23 slide.
| ticker | mar9_pct | crash_pct |
|---|---|---|
| CCL | -19.9 | -72.3 |
| UAL | -10.4 | -66.9 |
| XOM | -12.2 | -47.9 |
| JPM | -13.7 | -42.6 |
| SPY | -7.7 | -34.2 |
| AAPL | -7.9 | -31 |
| WMT | -0.1 | -2.9 |
The exact SQL behind every number
WITH dailies AS (
SELECT
ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
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 c
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('UAL', 'CCL', 'XOM', 'JPM', 'AAPL', 'WMT', 'SPY')
AND window_start >= toDateTime('2020-02-18 00:00:00') AND window_start < toDateTime('2020-03-25 00:00:00')
GROUP BY ticker, d
)
SELECT
ticker,
round((anyIf(c, d = toDate('2020-03-09')) / anyIf(c, d = toDate('2020-03-06')) - 1) * 100, 1) AS mar9_pct,
round((anyIf(c, d = toDate('2020-03-23')) / anyIf(c, d = toDate('2020-02-19')) - 1) * 100, 1) AS crash_pct
FROM dailies
GROUP BY ticker
ORDER BY crash_pct ASCThe hardest-hit name in the panel, CCL (Carnival, the cruise operator), lost -19.9% on March 9 alone and -72.3% peak to trough. United Airlines gave up -66.9% over the crash; ExxonMobil -47.9%; JPMorgan -42.6%. Against them, SPY's -34.2% looks almost gentle, Apple fell roughly with the market at -31%, and Walmart, groceries, essentials, open through lockdowns, finished the same window at just -2.9%. Travel and energy repriced for a stopped world; staples barely moved.
The oil-price war, briefly
The energy row above deserves its one-paragraph explainer, since "oil-price war" carries the March 9 story's other half. OPEC+ production talks between Saudi Arabia and Russia ended without a deal on Friday, March 6, 2020. Over the weekend, Saudi Arabia cut its official selling prices and announced plans to raise output into a world where travel demand was already shutting down, a supply shock stacked on a demand shock. Monday's equity open carried both at once, and the receipt is in the panel above: ExxonMobil fell -12.2% that Monday against SPY's -7.7%.
The flight to safety, and the week it broke
Equities were only half the story. The other half was the Treasury market, where the classic flight to safety ran to a record, and then, for one remarkable week, ran backwards.
| date | ten_year_pct | three_month_pct |
|---|---|---|
| 2020-02-18 | 1.55 | 1.58 |
| 2020-02-19 | 1.56 | 1.58 |
| 2020-02-20 | 1.52 | 1.58 |
| 2020-02-21 | 1.46 | 1.56 |
| 2020-02-24 | 1.38 | 1.53 |
| 2020-02-25 | 1.33 | 1.53 |
| 2020-02-26 | 1.33 | 1.53 |
| 2020-02-27 | 1.3 | 1.45 |
| 2020-02-28 | 1.13 | 1.27 |
| 2020-03-02 | 1.1 | 1.13 |
| 2020-03-03 | 1.02 | 0.95 |
| 2020-03-04 | 1.02 | 0.72 |
| 2020-03-05 | 0.92 | 0.62 |
| 2020-03-06 | 0.74 | 0.45 |
| 2020-03-09 | 0.54 | 0.33 |
| 2020-03-10 | 0.76 | 0.44 |
| 2020-03-11 | 0.82 | 0.42 |
| 2020-03-12 | 0.88 | 0.33 |
| 2020-03-13 | 0.94 | 0.28 |
| 2020-03-16 | 0.73 | 0.24 |
The exact SQL behind every number
SELECT
toString(date) AS date,
round(yield_10_year, 2) AS ten_year_pct,
round(yield_3_month, 2) AS three_month_pct
FROM global_markets.treasury_yields
WHERE date >= '2020-02-18' AND date <= '2020-03-31'
AND isNotNull(yield_10_year)
ORDER BY dateThe 10-year yield stood at 1.56% on the February 19 equity peak. By March 9, the halt session, it closed at 0.54%, an all-time low, with buyers accepting half a percent a year for a decade in exchange for safety. Then the pattern inverted: into March 18 the 10-year yield rose to 1.18% while equities kept falling, the "dash for cash," a week when funds, companies, and foreign holders sold whatever was liquid, Treasuries included, to raise dollars. Meanwhile the 3-month bill closed that day at 0.02%, cash equivalents pinned to zero. When the safest asset and the riskiest asset fall together, the thing being bought is liquidity itself. The yield curve carries this episode permanently in its history.
What the options tape shows
SPY's listed options, the market's hedging venue of record, left their own receipt of the panic, and its timing is the interesting part.
| session | put_contracts_m | call_contracts_m | put_call_ratio |
|---|---|---|---|
| 2020-03-02 | 3.74 | 2.54 | 1.47 |
| 2020-03-03 | 3.14 | 2.24 | 1.4 |
| 2020-03-04 | 3.06 | 2.27 | 1.35 |
| 2020-03-05 | 3.14 | 1.82 | 1.73 |
| 2020-03-06 | 4.64 | 2.38 | 1.95 |
| 2020-03-09 | 4.35 | 2.38 | 1.83 |
| 2020-03-10 | 3.61 | 2.4 | 1.5 |
| 2020-03-11 | 3.75 | 2.44 | 1.54 |
| 2020-03-12 | 4.53 | 3.43 | 1.32 |
| 2020-03-13 | 4.63 | 3.27 | 1.41 |
The exact SQL behind every number
SELECT
toString(toDate(toTimeZone(sip_timestamp, 'America/New_York'))) AS session,
round(sumIf(toFloat64(size), substring(ticker, 12, 1) = 'P') / 1e6, 2) AS put_contracts_m,
round(sumIf(toFloat64(size), substring(ticker, 12, 1) = 'C') / 1e6, 2) AS call_contracts_m,
round(sumIf(toFloat64(size), substring(ticker, 12, 1) = 'P') / sumIf(toFloat64(size), substring(ticker, 12, 1) = 'C'), 2) AS put_call_ratio
FROM global_markets.options_trades
WHERE ticker >= 'O:SPY2' AND ticker < 'O:SPY3'
AND sip_timestamp >= toDateTime('2020-03-02 00:00:00') AND sip_timestamp < toDateTime('2020-03-14 00:00:00')
GROUP BY session
ORDER BY sessionThe put/call volume ratio peaked at 1.95, nearly two puts traded for every call, on Friday, March 6, before the halt session. On March 9 itself, 4.35 million puts traded against 2.38 million calls, a ratio of 1.83. By March 12, a -9.6% session, the ratio had eased to 1.32 as call volume picked up alongside the puts. The heaviest put skew came at the front of the cascade, not the bottom of it: protection was bought early, and the later, larger down days traded on a more two-sided options tape.
How a market-wide halt actually works
The mechanics, since they run rarely enough to be forgotten between uses: market-wide breakers key off the S&P 500's decline from the prior close, 7% (Level 1) and 13% (Level 2) each trigger a 15-minute halt if hit before 3:25 pm ET; 20% (Level 3) ends the session outright. During the halt nothing trades on any exchange; orders queue for a reopening auction, which concentrates the accumulated interest into one price the way the opening auction does each morning. This design dates from the aftermath of the 2010 flash crash, which exposed the older thresholds as too loose to fire even in a genuine cascade.
March 2020 was that design's first, and so far only, live test, and the verdict is in the panels above: four Level-1 halts, each followed by an orderly reopening, no Level 2 or Level 3 ever reached, and a functioning market on every one of the 23 sessions of the fastest large drawdown in the index's history. The rules were left standing afterward: the same 7/13/20 thresholds, referenced to the S&P 500, remain in force today. The system bent; the plumbing held.
COVID crash FAQ
What triggered the circuit breaker on March 9, 2020?
The S&P 500 falling 7% from its prior close, the Level-1 market-wide threshold, within the opening minutes. Trading halted for 15 minutes market-wide, then reopened via auction. The halt is visible in this page's minute tape as 14 consecutive missing bars starting at 09:35 ET.
How much did the stock market fall in the COVID crash?
Measured on SPY's daily closes, -34.2%, from $338.31 on February 19, 2020 to $222.51 on March 23, 2020, across 23 trading sessions.
How many circuit breakers were hit in March 2020?
Four market-wide Level-1 halts: March 9, 12, 16, and 18. They were the first since October 1997.
Were Level 2 or Level 3 circuit breakers ever hit in 2020?
No. All four 2020 halts were Level 1 (7%). No Level-2 (13%) or Level-3 (20%) market-wide halt has ever fired under the current design, not even on March 16, the crash's worst session at -11.6%.
Did the market fully recover from the COVID crash?
Yes. SPY closed above its February 19, 2020 peak on 2020-08-18, 103 trading sessions after the March 23 low, one of the fastest full recoveries from a bear market of this depth.
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.