Strasmore Research
Market Recap

Market Recap: H1 2026

Matt ConnorBy Matt Connor Updated 2026-07-26

The first half of 2026 was two different markets stitched together: a first quarter in which every major index ETF fell or went nowhere, and a second quarter that more than repaired it. SPY finished the half up 8.8%; QQQ up 18.7%; and IWM's 21.3% was the best first half the small-cap ETF has printed on the tape we hold, the receipt is below, recomputed against every prior year in one query. The rates market told its own story: the 2s10s spread flattened from 72 basis points to 30 across the half without ever inverting. This page is the half's full ledger, the scoreboard, the history rank, the monthly staircase, the whole yield curve maturity by maturity, the eleven sectors, the options tape's month-by-month evolution, and the half's notable single names. Every number here is a stored query result; expand any panel for the SQL.

The half on the board

QueryH1 2026: half, Q1, and Q2 returns for the four index ETFs, computed in one query
The exact SQL behind every number
SELECT ticker,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-01-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS h1_return_pct,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-03-31') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-01-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS q1_return_pct,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-04-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS q2_return_pct,
    round(argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959), 2) AS h1_close
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ', 'DIA', 'IWM')
  AND window_start >= toDateTime('2026-01-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')
GROUP BY ticker
ORDER BY ticker
Run this yourself

The two-quarter split is the half's defining shape: SPY -5.2% then 14.1%; QQQ -6.9% then 26.5%. IWM was the half's quiet leader at 21.3%, small caps beat every large-cap index over the six months. The quarter detail lives in the Q2 recap. A note on the arithmetic of halves: SPY's 8.8% for the six months is far less than its Q2 alone, since the half chains a drawdown and a recovery, the compounding of -5.2% and 14.1% lands where the table says, and any 'first-half return' headline quietly encodes that whole round trip.

The half against every first half on the tape

Is a 8.8% half unusual? The panels below recompute the SAME first-half return, first regular-hours open to last regular-hours close, January through June, for every year of minute history, in one query per ETF pair with identical arithmetic for every year, then rank this half against the rest. The minute tape's earliest date is verified in the methodology below; the first complete first half on file is the one the receipts name.

QueryEvery first half on the tape: SPY and QQQ, recomputed identically by year (per-year session counts shown)
The exact SQL behind every number
SELECT toYear(toTimeZone(window_start, 'America/New_York')) AS y,
       ticker,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions,
       round((argMax(toFloat64(close), window_start) / argMin(toFloat64(open), window_start) - 1) * 100, 1) AS h1_return_pct
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ')
  AND window_start >= toDateTime('2003-01-01 00:00:00')
  AND window_start < toDateTime('2026-07-01 00:00:00')
  AND toMonth(toTimeZone(window_start, 'America/New_York')) <= 6
  AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY y, ticker
HAVING sessions >= 100
ORDER BY y ASC, ticker ASC
Run this yourself
QueryThe rank receipts: this half against every prior one (SPY and QQQ; rank 1 = best; self-excluded)
The exact SQL behind every number
SELECT ticker,
       round(anyIf(ret, y = 2026), 1) AS h1_2026_pct,
       arrayCount(x -> x > anyIf(ret, y = 2026), groupArrayIf(ret, y != 2026)) + 1 AS rank_best,
       count() AS halves_compared,
       min(y) AS first_year,
       anyIf(sessions, y = 2026) AS sessions_2026
FROM (
    SELECT toYear(toTimeZone(window_start, 'America/New_York')) AS y,
           ticker,
           uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions,
           (argMax(toFloat64(close), window_start) / argMin(toFloat64(open), window_start) - 1) * 100 AS ret
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('SPY', 'QQQ')
      AND window_start >= toDateTime('2003-01-01 00:00:00')
      AND window_start < toDateTime('2026-07-01 00:00:00')
      AND toMonth(toTimeZone(window_start, 'America/New_York')) <= 6
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY y, ticker
    HAVING sessions >= 100
)
GROUP BY ticker
ORDER BY ticker ASC
Run this yourself

SPY's 8.8% ranks 6 of 23 first halves since 2004 (rank 1 = best), upper third, not a record. QQQ's 18.7% ranks 3 of 16 comparable halves, the QQQ series is shorter, the fund traded under a different root symbol for a stretch of years, and any year with fewer than one hundred first-half sessions for a symbol is excluded by a session-count guard shown in the table, so exclusions are visible rather than silent.

QueryEvery first half on the tape: DIA and IWM, same arithmetic
The exact SQL behind every number
SELECT toYear(toTimeZone(window_start, 'America/New_York')) AS y,
       ticker,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions,
       round((argMax(toFloat64(close), window_start) / argMin(toFloat64(open), window_start) - 1) * 100, 1) AS h1_return_pct
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('DIA', 'IWM')
  AND window_start >= toDateTime('2003-01-01 00:00:00')
  AND window_start < toDateTime('2026-07-01 00:00:00')
  AND toMonth(toTimeZone(window_start, 'America/New_York')) <= 6
  AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY y, ticker
HAVING sessions >= 100
ORDER BY y ASC, ticker ASC
Run this yourself
QueryThe rank receipts: DIA and IWM against every prior first half (rank 1 = best; self-excluded)
The exact SQL behind every number
SELECT ticker,
       round(anyIf(ret, y = 2026), 1) AS h1_2026_pct,
       arrayCount(x -> x > anyIf(ret, y = 2026), groupArrayIf(ret, y != 2026)) + 1 AS rank_best,
       count() AS halves_compared,
       min(y) AS first_year,
       anyIf(sessions, y = 2026) AS sessions_2026
FROM (
    SELECT toYear(toTimeZone(window_start, 'America/New_York')) AS y,
           ticker,
           uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions,
           (argMax(toFloat64(close), window_start) / argMin(toFloat64(open), window_start) - 1) * 100 AS ret
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker IN ('DIA', 'IWM')
      AND window_start >= toDateTime('2003-01-01 00:00:00')
      AND window_start < toDateTime('2026-07-01 00:00:00')
      AND toMonth(toTimeZone(window_start, 'America/New_York')) <= 6
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY y, ticker
    HAVING sessions >= 100
)
GROUP BY ticker
ORDER BY ticker ASC
Run this yourself

The small-cap row is the half's headline: IWM ranks 1 of 23 first halves since 2004, the best on record for the fund, ahead of every recovery half in the table. DIA's 8.4% ranks 4 of 23. Basis, stated plainly: regular-hours open-to-close within each calendar first half, identical arithmetic every year, upper bound pinned at this period's end so the comparison set cannot silently grow when later years are ingested.

Month by month

QueryThe half, month by month (SPY and QQQ, recomputed identically in one query)
The exact SQL behind every number
SELECT toString(toStartOfMonth(toDate(toTimeZone(window_start, 'America/New_York')))) AS period_start, 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) / argMinIf(toFloat64(open), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS month_return_pct,
    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 month_close
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'QQQ')
  AND window_start >= toDateTime('2026-01-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')
GROUP BY period_start, ticker
ORDER BY period_start, ticker
Run this yourself

The staircase runs up a little, down, down, sharply up, up, down a little: April was the half's pivot (SPY 9.9%, QQQ 14.8%, QQQ's best month of the six), and June closed the half quietly at -1.2% for SPY. June's own recap, with the breadth split behind that quiet number, is here. Reading the table month by month: the year opened nearly flat (SPY 0.9% in January), slid through February (-0.5%) and March (-4.2%), then April reversed the run in a single month, May extended it (4.9%), and June eased. Three of six months were negative, a coin-flip month count for a half that finished solidly positive, which is the usual shape of equity halves and worth remembering when a single month reads as a verdict.

Rates: the whole curve, maturity by maturity

The Treasury curve spent the half twisting, not shifting. The very front end, the maturities that track policy most closely, barely moved; the two-year rose two-thirds of a percentage point; the thirty-year barely noticed. The panel lays the whole curve out, start of half against end.

QuerySeven maturities: yield at the start of the half vs the end, and the change
The exact SQL behind every number
SELECT maturity, start_pct, end_pct, round((end_pct - start_pct) * 100, 0) AS change_bp FROM (
    SELECT '1-month' AS maturity, (SELECT round(argMin(yield_1_month, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_1_month)) AS start_pct, (SELECT round(argMax(yield_1_month, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_1_month)) AS end_pct, 1 AS o
    UNION ALL SELECT '3-month', (SELECT round(argMin(yield_3_month, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_3_month)), (SELECT round(argMax(yield_3_month, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_3_month)), 2
    UNION ALL SELECT '1-year', (SELECT round(argMin(yield_1_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_1_year)), (SELECT round(argMax(yield_1_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_1_year)), 3
    UNION ALL SELECT '2-year', (SELECT round(argMin(yield_2_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_2_year)), (SELECT round(argMax(yield_2_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_2_year)), 4
    UNION ALL SELECT '5-year', (SELECT round(argMin(yield_5_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_5_year)), (SELECT round(argMax(yield_5_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_5_year)), 5
    UNION ALL SELECT '10-year', (SELECT round(argMin(yield_10_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_10_year)), (SELECT round(argMax(yield_10_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_10_year)), 6
    UNION ALL SELECT '30-year', (SELECT round(argMin(yield_30_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_30_year)), (SELECT round(argMax(yield_30_year, date), 2) FROM global_markets.treasury_yields WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30') AND isNotNull(yield_30_year)), 7
) ORDER BY o
Run this yourself

Read the change column top to bottom and the twist is unmistakable: the 1-month moved -2 basis points in six months while the 2-year rose 67 and the 30-year just 5. A parallel shift moves every maturity together; this half moved the middle of the curve and pinned both ends, short rates anchored near policy, long rates barely repriced.

What that did to the slope

QuerySlope receipts: start, end, minimum, the spread never inverted this half
The exact SQL behind every number
SELECT
    round((argMin(yield_10_year - yield_2_year, date)) * 100, 0) AS start_bp,
    round((argMax(yield_10_year - yield_2_year, date)) * 100, 0) AS end_bp,
    round(min((yield_10_year - yield_2_year)) * 100, 0) AS min_bp,
    count() AS prints
FROM global_markets.treasury_yields
WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30')
  AND isNotNull(yield_10_year) AND isNotNull(yield_2_year)
Run this yourself

The short end rising faster than the long end is what a flattening IS: the 2s10s spread ground down from 72 to 30 basis points across 124 prints, and its lowest print of the half was 27, so the curve flattened without ever inverting. Whether a flatter curve "means" anything about what comes next is a forecast, and this table doesn't make forecasts.

QueryThe 2s10s spread, every print of the half
The exact SQL behind every number
SELECT toString(date) AS d, round((yield_10_year - yield_2_year) * 100, 0) AS spread_2s10s_bp
FROM global_markets.treasury_yields
WHERE date >= toDate('2026-01-01') AND date <= toDate('2026-06-30')
  AND isNotNull(yield_10_year) AND isNotNull(yield_2_year)
ORDER BY date
Run this yourself

The daily series shows the flattening was not one event but a grind, 124 prints stepping down through the half, with the spread starting the year at 72 basis points and ending at 30.

Sectors: the dispersion the index hid

Which parts of the market actually made the half's money? The eleven SPDR sector ETFs, half and quarter split, one query.

QueryThe eleven sector ETFs: H1 2026 return, Q1 and Q2 split, and H1 dollar volume
The exact SQL behind every number
SELECT ticker,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-01-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS h1_return_pct,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-03-31') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-01-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS q1_return_pct,
    round((argMaxIf(toFloat64(close), window_start, toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959)
         / argMinIf(toFloat64(open), window_start, toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2026-04-01') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS q2_return_pct,
    round(sumIf(toFloat64(close) * toFloat64(volume), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) / 1e9, 1) AS h1_dollar_bn
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('XLB', 'XLC', 'XLE', 'XLF', 'XLI', 'XLK', 'XLP', 'XLRE', 'XLU', 'XLV', 'XLY')
  AND window_start >= toDateTime('2026-01-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')
GROUP BY ticker
ORDER BY ticker
Run this yourself

The dispersion is the story: technology (XLK) finished the half up 30.8%, with a 42% second quarter, while communication services (XLC) finished -9.3%, a spread of roughly forty percentage points between the best and worst sector in six months. Owning "the market" meant owning both ends of that. Energy is the panel's cautionary tale about long windows: XLE shows a healthy 18.8% half, built from a 36.9% FIRST quarter followed by a -11% second. It was the market's strongest large sector in the quarter the indexes fell, and among its weakest in the quarter they recovered. Financials (XLF) ran the same movie backwards: -9.9% in Q1, then 7.7% in Q2. A single H1 number flattens both round trips; the quarter split is the honest view.

What it costs to trade each sector

Return tables are everywhere; quote tapes are not. This panel measures each sector ETF's median bid-ask spread across one full representative session, 123 sessions in the half, and June 29 is the one measured here, labeled as such, from every NBBO update that day.

QueryMedian quoted spread by sector ETF, one representative session (June 29, 2026), regular hours
The exact SQL behind every number
SELECT ticker,
    round(quantileDeterministicIf(0.5)((toFloat64(ask_price) - toFloat64(bid_price)) / ((toFloat64(ask_price) + toFloat64(bid_price)) / 2) * 10000, toUInt64(toUnixTimestamp64Micro(sip_timestamp)), bid_price > 0 AND ask_price >= bid_price), 2) AS med_spread_bps,
    round(count() / 1e6, 2) AS quote_updates_m,
    countIf(NOT (bid_price > 0 AND ask_price > 0 AND ask_price >= bid_price)) AS invalid_dropped
FROM global_markets.cache_stocks_quotes
WHERE ticker IN ('XLB', 'XLC', 'XLE', 'XLF', 'XLI', 'XLK', 'XLP', 'XLRE', 'XLU', 'XLV', 'XLY')
  AND sip_timestamp >= toDateTime64('2026-06-29 13:30:00', 9) AND sip_timestamp < toDateTime64('2026-06-29 20:00:00', 9)
GROUP BY ticker
ORDER BY med_spread_bps
Run this yourself

Every one of the eleven quotes at a few basis points or less, the cheapest at 0.62 bps (XLV), the widest at 2.23 bps (XLRE), which is the practical meaning of "sector ETFs are liquid": the cost of entering any sector view is measured in hundredths of a percent. For scale against individual stocks, the June 29 deep-dive measures a thin name's spread at hundreds of times these levels, on the same session.

Breadth across the half

Breadth at half scale comes in two grains here. The coarse grain: SPY logged 64 up sessions against 59 down across the half's 123, very nearly a coin flip of days underneath a solidly positive half, which is what a grind higher looks like from the daily tape. The fine grain, every ticker counted, lives at month scale: June's advancer/decliner split is in the June recap (more tickers fell than rose there), and the quarter-boundary count is in the Q2 recap. A single six-month whole-market advancer scan exceeds the query budget this page generates under; that bound is disclosed here rather than silently narrowed.

QuerySPY sessions up vs down across the half, one cheap receipt
The exact SQL behind every number
SELECT countIf(day_ret > 0) AS up_sessions,
       countIf(day_ret < 0) AS down_sessions,
       countIf(day_ret = 0) AS flat_sessions,
       count() AS sessions_total
FROM (
    SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
           round((argMax(toFloat64(close), window_start) / argMin(toFloat64(open), window_start) - 1) * 100, 2) AS day_ret
    FROM global_markets.delayed_stocks_minute_aggs
    WHERE ticker = 'SPY'
      AND window_start >= toDateTime('2026-01-01 00:00:00')
      AND window_start < toDateTime('2026-07-01 00:00:00')
      AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
    GROUP BY d
)
Run this yourself

The options tape, month by month

What only a half-year page can show: how the options tape itself evolved. Each panel below is one whole-tape scan of a month, total contract volume, and the share that traded on its own expiry day (0DTE). The measured story: volume ran from 1316.1 million contracts in January to 1477.9 million in June, the half's busiest options month, and the same-day-expiry share climbed from 26.5% in January to 34.3% in June, the highest month of the half. A third of June's options volume was traded on contracts expiring that same day.

QueryJanuary: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-01-01 00:00:00')
  AND window_start < toDateTime('2026-02-01 00:00:00')
Run this yourself
QueryFebruary: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-02-01 00:00:00')
  AND window_start < toDateTime('2026-03-01 00:00:00')
Run this yourself
QueryMarch: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-03-01 00:00:00')
  AND window_start < toDateTime('2026-04-01 00:00:00')
Run this yourself
QueryApril: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-04-01 00:00:00')
  AND window_start < toDateTime('2026-05-01 00:00:00')
Run this yourself
QueryMay: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-05-01 00:00:00')
  AND window_start < toDateTime('2026-06-01 00:00:00')
Run this yourself
QueryJune: whole-tape options contract volume and same-day-expiry share (one scan)
The exact SQL behind every number
SELECT round(sum(toFloat64(volume)) / 1e6, 1) AS contracts_mm,
       round(100.0 * sumIf(toFloat64(volume), toDateOrNull(concat('20', substring(ticker, length(ticker) - 14, 6))) = toDate(toTimeZone(window_start, 'America/New_York'))) / sum(toFloat64(volume)), 1) AS zero_dte_pct,
       uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) AS sessions
FROM global_markets.options_minute_aggs
WHERE window_start >= toDateTime('2026-06-01 00:00:00')
  AND window_start < toDateTime('2026-07-01 00:00:00')
Run this yourself

The cost to trade, sampled monthly

QuerySPY median quoted spread on one labeled sample session per month (second Wednesdays), every NBBO update
The exact SQL behind every number
SELECT toDate(toTimeZone(sip_timestamp, 'America/New_York')) AS session,
       round(quantileDeterministicIf(0.5)((toFloat64(ask_price) - toFloat64(bid_price)) / ((toFloat64(ask_price) + toFloat64(bid_price)) / 2) * 10000, toUInt64(toUnixTimestamp64Micro(sip_timestamp)), bid_price > 0 AND ask_price >= bid_price), 3) AS med_spread_bps,
       round(count() / 1e6, 2) AS quote_updates_m,
       countIf(NOT (bid_price > 0 AND ask_price > 0 AND ask_price >= bid_price)) AS invalid_dropped
FROM global_markets.cache_stocks_quotes
WHERE ticker = 'SPY'
  AND ((sip_timestamp >= toDateTime64('2026-01-14 14:30:00', 9) AND sip_timestamp < toDateTime64('2026-01-14 21:00:00', 9))
    OR (sip_timestamp >= toDateTime64('2026-02-11 14:30:00', 9) AND sip_timestamp < toDateTime64('2026-02-11 21:00:00', 9))
    OR (sip_timestamp >= toDateTime64('2026-03-11 13:30:00', 9) AND sip_timestamp < toDateTime64('2026-03-11 20:00:00', 9))
    OR (sip_timestamp >= toDateTime64('2026-04-15 13:30:00', 9) AND sip_timestamp < toDateTime64('2026-04-15 20:00:00', 9))
    OR (sip_timestamp >= toDateTime64('2026-05-13 13:30:00', 9) AND sip_timestamp < toDateTime64('2026-05-13 20:00:00', 9))
    OR (sip_timestamp >= toDateTime64('2026-06-10 13:30:00', 9) AND sip_timestamp < toDateTime64('2026-06-10 20:00:00', 9)))
GROUP BY session
ORDER BY session ASC
Run this yourself

SPY's median quoted spread on the second Wednesday of each month: 0.145 bps in the January sample, 0.144 in February's, roughly double that through the spring samples, and 0.409 bps in the June sample, the widest of the six. Basis, disclosed: these are single labeled sample sessions (the second Wednesday of each month), not month-wide medians, a six-month whole-quote-tape scan exceeds this page's query budget. The sample dates are in the panel, and invalid quotes are counted there, not silently dropped.

The half's notable names

Measured notability, not editorial pick: the set below is the top of the quarter-scale dollar-volume leaderboard, recomputed here so this page carries its own receipt, and each name that earned a deep-dive links to it.

QueryQ2 regular-hours dollar volume, whole tape (one reused-symbol listing excluded pending entity verification)
The exact SQL behind every number
SELECT ticker,
    round(sum(toFloat64(close) * toFloat64(volume)) / 1e9, 1) AS dollar_bn,
    round(100 * sum(toFloat64(close) * toFloat64(volume)) / max(sum(toFloat64(close) * toFloat64(volume))) OVER (), 1) AS pct_of_leader
FROM global_markets.delayed_stocks_minute_aggs
WHERE window_start >= toDateTime('2026-04-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')
  AND (toHour(window_start) * 60 + toMinute(window_start)) BETWEEN 810 AND 1199
  AND ticker NOT IN ('SPCX')
GROUP BY ticker
ORDER BY dollar_bn DESC
LIMIT 8
Run this yourself

MU out-traded SPY across the second quarter ($2047.5 billion against $2032 billion), the same receipt the Q2 recap carries. For the six single names in that leader set, the half:

QueryThe leader set's half and Q2 returns plus half dollar volume: one ticker-filtered query
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)
         / argMinIf(toFloat64(open), window_start, (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS h1_return_pct,
    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)
         / argMinIf(toFloat64(open), window_start, window_start >= toDateTime('2026-04-01 00:00:00') AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) - 1) * 100, 1) AS q2_return_pct,
    round(sumIf(toFloat64(close) * toFloat64(volume), (toHour(toTimeZone(window_start, 'America/New_York')) * 60 + toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959) / 1e9, 1) AS h1_dollar_bn
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('MU', 'NVDA', 'TSLA', 'SNDK', 'AMD', 'INTC')
  AND window_start >= toDateTime('2026-01-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')
GROUP BY ticker
ORDER BY ticker
Run this yourself

The memory-and-semiconductor complex owns the half: SNDK returned 830.1% in six months, MU 290%, its June is dissected tick by tick here, and INTC 269.3%. NVDA, the set's biggest tape at $3287.9 billion of half-year turnover, returned 5.2%, single digits while the memory names multiplied; its June deep-dive carries that divergence session by session. TSLA is the set's one decliner at -8.2%. The June mega-listing trades under a reused symbol and is excluded from leader scans pending entity verification, its first month has its own receipt-by-receipt post. Basis: the leader SET is chosen by Q2 regular-hours dollar volume (the scan above); the return columns are the half's, first regular-hours open to last regular-hours close.

The half's IPO wave, month by month

QueryNew listings per month across the half
The exact SQL behind every number
SELECT toString(toStartOfMonth(listing_date)) AS m, count() AS listings
FROM global_markets.stocks_ipos
WHERE listing_date >= toDate('2026-01-01') AND listing_date <= toDate('2026-06-30')
GROUP BY m
ORDER BY m
Run this yourself

The listing wave was front-loaded and uneven: February was the half's busiest month at 47 listings and March its quietest at 17, the famous June mega-listing arrived in a month of otherwise ordinary volume (35 listings).

The half's calendar

QueryH1 2026's corporate calendar (all three month-end filing-index gaps disclosed)
The exact SQL behind every number
SELECT
    (SELECT count() FROM global_markets.stocks_dividends WHERE ex_dividend_date >= toDate('2026-01-01') AND ex_dividend_date <= toDate('2026-06-30')) AS ex_div_events,
    (SELECT count() FROM global_markets.stocks_splits WHERE execution_date >= toDate('2026-01-01') AND execution_date <= toDate('2026-06-30')) AS splits,
    (SELECT count() FROM global_markets.stocks_ipos WHERE listing_date >= toDate('2026-01-01') AND listing_date <= toDate('2026-06-30')) AS ipos,
    (SELECT uniqExact(accession_number) FROM global_markets.stocks_sec_edgar_index WHERE filing_date >= toDate('2026-01-01') AND filing_date <= toDate('2026-06-30')) AS h1_filings,
    (SELECT uniqExact(accession_number) FROM global_markets.stocks_sec_edgar_index WHERE filing_date = toDate('2026-03-31')) AS filings_mar31,
    (SELECT uniqExact(accession_number) FROM global_markets.stocks_sec_edgar_index WHERE filing_date = toDate('2026-04-30')) AS filings_apr30,
    (SELECT uniqExact(accession_number) FROM global_markets.stocks_sec_edgar_index WHERE filing_date = toDate('2026-06-30')) AS filings_jun30
Run this yourself

205 companies came public in the half, a listing wave whose largest June debut has its own receipt-by-receipt post, alongside 830 splits and 28356 ex-dividend events. For scale, that is more than one new listing per trading session across the half. The filing total carries a triple disclosure: the SEC index is near-empty on every month-end of the half whose last calendar day was a weekday (55 filings indexed on March 31, 34 on April 30, and 31 on June 30, against thousands on neighboring days), so half-year filing counts are understated until the feed backfills, the month-end gap has its own diagnostic note.

The session receipt

Query123 sessions in the half, verified from the tape
The exact SQL behind every number
SELECT
    (SELECT uniqExact(toDate(toTimeZone(window_start, 'America/New_York'))) FROM global_markets.delayed_stocks_minute_aggs
     WHERE ticker = 'SPY' AND window_start >= toDateTime('2026-01-01 00:00:00') AND window_start < toDateTime('2026-07-01 00:00:00')) AS h1_sessions
Run this yourself

Data notes

Full data notes
  • Short interest ends at the June 15 settlement as of generation (end-of-June print pending, full disclosure in the June recap; this post regenerates when it lands).
  • Three 2026 month-end filing-index days are near-empty (March 31, April 30, June 30, every month-end of the half whose last calendar day was a weekday); disclosed inline with the counts, diagnosed in the month-end gap note. Prior-year month-ends carry thousands of filings, so this is a feed gap, not a filing holiday.
  • The leaderboard at half scale is bounded: a six-month market-wide scan exceeds the query budget this page generates under, so the notable-names set is chosen by the quarter-scale leader scan shown inline, and the half's returns are computed for that fixed set, the basis is stated where it is used. The Q2 and June recaps carry the full leader tables at their scales.
  • Whole-market breadth at half scale is likewise bounded, the day-grain split is shown; the ticker-grain counts live at month and quarter scale, linked in the breadth section.
  • The QQQ history series is shorter than SPY's: the fund traded under a different root symbol for a stretch of years; the session-count guard excludes those years visibly (per-year session counts are in the table).
  • The six options panels are whole-tape monthly scans and the spread trend samples one labeled session per month, both run on the batch path; sample bases are stated inline.
  • Microstructure lives in the June 29 deep-dive.

Methodology

  • The period is January 1 – June 30, 2026, 123 sessions, verified from observed bars. Returns are first regular-hours open to last regular-hours close within each window, all computed in the same queries shown.
  • Historical comparisons are recomputed LIVE against the full minute history at generation time, never read from stored values. Depth verification: the earliest minute-bar date in the delayed view used here matches the base table's (September 2003), checked at authoring, so the first complete first half on file is 2004's. Every history query pins its upper bound at this period's end (the comparison set cannot grow when later years are ingested) and applies a minimum-session guard with per-year session counts shown.
  • Timestamps stored UTC with raw UTC bounds. Multi-year history blocks filter regular hours on the Eastern wall clock (9:30–15:59, converted per row, the only DST-safe convention across decades); single-window blocks in EDT-only ranges use the equivalent raw-UTC band. Both conventions are stated here.
  • Generation runs through the gated read-only path; the public page never queries live. Warehouse state as of July 5, 2026.

This is the first edition of the standing semiannual recap; the full-year edition follows in January. Quarter detail: Q2 2026. The macro releases the half traded against are in the macro picture.