Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

Implied Volatility Heatmap: How to Read It

An implied volatility heatmap puts moneyness on one axis and expiry on the other. Read a row for skew, a column for term structure, with live SQL.

An implied volatility heatmap is one picture of a whole options chain: moneyness along one axis, expiry along the other, and each contract's implied volatility as the colour of the cell. Read across a row and you are looking at skew. Read down a column and you are looking at term structure. The grid is the easy part. The colour scale decides whether it shows you anything.

What goes on each axis of an IV heatmap

Implied volatility, IV, is the volatility input that makes an option pricing model agree with the option's traded price. It is quoted as an annualized percentage: an IV of 30 describes a one standard deviation move of roughly 30% over a year. Every listed contract carries its own, so one stock on one session produces hundreds of IV numbers.

The heatmap arranges that pile into a grid with three readable dimensions. The horizontal axis is moneyness, the strike expressed relative to spot, running from deep downside strikes through at the money and out to the upside wing. The vertical axis is time to expiry, ordered from the front contract to the back. Colour is the IV level in the cell, almost always a median across the contracts that fall in that bucket. One cell is one bucket of contracts, never one contract.

Every panel below is built from per-contract daily IV and greeks in global_markets.options_greeks, which carries one row per contract per session back to August 2021 with implied_volatility, iv_converged, delta, gamma, vega, theta, strike_price, expiration_date and days_to_expiry on each row. Two filters apply throughout: iv_converged = 1 keeps only rows where the solver settled on a number, and volume > 0 keeps only contracts that actually traded that day.

Why moneyness beats strike on the x axis

A strike is a fixed dollar number. Its position on the volatility curve is not fixed. A $210 strike can sit at the money in one session and 7% out of the money two weeks later, after spot has moved, with no change to the contract itself. Pin the x axis to strikes and a multi-session average quietly mixes cells from different parts of the curve, which smears the smile flat.

Moneyness repairs that. Define it as the strike divided by the underlying close, minus one. The at the money bucket is then whatever strike sits nearest spot in each session, and the wings stay wings. The panel below buckets June 2026 AAPL contracts with 25 to 35 days to expiry that way, taking the out of the money side on each end: puts at or below spot, calls above it, which is the convention a skew curve is normally read in.

QueryOne row of the heatmap: AAPL IV by moneyness, 25 to 35 days to expiry, June 2026
moneynessmedian_iv_pctcontracts
10% below spot28.882
5% below spot25.186
at the money23.985
5% above spot24.387
10% above spot24.785
The exact SQL behind every number
SELECT
    moneyness,
    round(100 * quantileDeterministic(iv, det), 1) AS median_iv_pct,
    count()                                        AS contracts
FROM
(
    SELECT
        toFloat64(implied_volatility)                             AS iv,
        cityHash64(ticker)                                        AS det,
        toFloat64(strike_price) / toFloat64(underlying_close) - 1  AS m,
        multiIf(m < -0.075, '10% below spot',
                m < -0.025, '5% below spot',
                m <  0.025, 'at the money',
                m <  0.075, '5% above spot',
                            '10% above spot')                     AS moneyness
    FROM global_markets.options_greeks
    WHERE underlying_symbol = 'AAPL'
      AND date BETWEEN '2026-06-01' AND '2026-06-30'
      AND iv_converged = 1
      AND volume > 0
      AND days_to_expiry BETWEEN 25 AND 35
      AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) <= 0.125
      AND ((lower(option_type) IN ('put', 'p')  AND toFloat64(strike_price) <= toFloat64(underlying_close))
        OR (lower(option_type) IN ('call', 'c') AND toFloat64(strike_price) >  toFloat64(underlying_close)))
)
GROUP BY moneyness
ORDER BY min(m)
Run this yourself

That single row of the grid is the skew. Over this window the 10% below spot bucket printed a median IV of 28.8%, against 23.9% at the money across 85 at the money contract sessions. Whether that line sits flat, tilts down to the right, or bends up at both ends is the subject of our guide to volatility skew, including why the downside wing of an index usually carries the higher number.

Reading a column: the term structure

Hold moneyness still, walk down the expiry axis, and the same grid answers a different question: what is priced for next week against what is priced for next year.

QueryOne column of the heatmap: near the money AAPL IV by time to expiry, June 2026
tenormedian_iv_pctcontracts
1-7 days29.61376
8-20 days25.81729
21-45 days24.6953
46-90 days26.2426
91-180 days26.7757
181-365 days27.1717
The exact SQL behind every number
SELECT
    tenor,
    round(100 * quantileDeterministic(iv, det), 1) AS median_iv_pct,
    count()                                        AS contracts
FROM
(
    SELECT
        toFloat64(implied_volatility) AS iv,
        cityHash64(ticker)            AS det,
        days_to_expiry,
        multiIf(days_to_expiry <=   7, '1-7 days',
                days_to_expiry <=  20, '8-20 days',
                days_to_expiry <=  45, '21-45 days',
                days_to_expiry <=  90, '46-90 days',
                days_to_expiry <= 180, '91-180 days',
                                       '181-365 days') AS tenor
    FROM global_markets.options_greeks
    WHERE underlying_symbol = 'AAPL'
      AND date BETWEEN '2026-06-01' AND '2026-06-30'
      AND iv_converged = 1
      AND volume > 0
      AND days_to_expiry BETWEEN 1 AND 365
      AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.05
)
GROUP BY tenor
ORDER BY min(days_to_expiry)
Run this yourself

Near the money contracts in the 1-7 days bucket carried a median IV of 29.6%, against 27.1% in the 181-365 days bucket. The slope of that line is the whole topic of IV term structure. A gentle upward slope is the common resting state, and a front end sitting above the back end tends to coincide with a dated event inside the near window.

The colour scale is the real argument

This is where most heatmaps fail. Colour has one job: map a number to a shade. Hand it raw IV across several names and the name with the highest absolute volatility takes the top of the scale, while everything else collapses into one indistinguishable band.

QueryA raw IV colour scale across six names, week of June 15, 2026
symbolmedian_iv_pctcontracts
AMD72.4254
NVDA36.3128
MSFT30.9232
AAPL22.5174
KO19.4165
SPY14.31919
The exact SQL behind every number
SELECT
    underlying_symbol AS symbol,
    round(100 * quantileDeterministic(toFloat64(implied_volatility), cityHash64(ticker)), 1) AS median_iv_pct,
    count() AS contracts
FROM global_markets.options_greeks
WHERE underlying_symbol IN ('NVDA', 'AMD', 'AAPL', 'MSFT', 'SPY', 'KO')
  AND date BETWEEN '2026-06-15' AND '2026-06-19'
  AND iv_converged = 1
  AND volume > 0
  AND days_to_expiry BETWEEN 20 AND 45
  AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.05
GROUP BY symbol
ORDER BY median_iv_pct DESC
Run this yourself

Same week, six names, near the money, 20 to 45 days to expiry. AMD sits at the top at 72.4%, and SPY at the bottom at 14.3%. Stretch a single colour ramp over that span and the names in the middle separate by a shade or two. The grid stays legible only at its extremes.

Normalizing changes the question each cell answers: not how high this IV is, but how high it is for this name. The cheapest version is the percentile of the name's own trailing year.

QueryThe same week normalized: each name's IV against its own trailing year
symbolweek_iv_pctiv_percentile_pctsample_size
AMD72.297251
MSFT31.786251
KO19.463251
SPY14.336251
NVDA36.324251
AAPL22.818251
The exact SQL behind every number
WITH
    daily AS
    (
        SELECT
            underlying_symbol AS symbol,
            date,
            quantileDeterministic(toFloat64(implied_volatility), cityHash64(ticker)) AS iv
        FROM global_markets.options_greeks
        WHERE underlying_symbol IN ('NVDA', 'AMD', 'AAPL', 'MSFT', 'SPY', 'KO')
          AND date BETWEEN '2025-06-20' AND '2026-06-19'
          AND iv_converged = 1
          AND volume > 0
          AND days_to_expiry BETWEEN 20 AND 45
          AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.05
        GROUP BY symbol, date
    ),
    week AS
    (
        SELECT
            symbol,
            avg(iv) AS week_iv
        FROM daily
        WHERE date BETWEEN '2026-06-15' AND '2026-06-19'
        GROUP BY symbol
    )
SELECT
    d.symbol                                             AS symbol,
    round(100 * w.week_iv, 1)                            AS week_iv_pct,
    round(100 * countIf(d.iv <= w.week_iv) / count(), 0) AS iv_percentile_pct,
    count()                                              AS sample_size
FROM daily AS d
INNER JOIN week AS w ON w.symbol = d.symbol
GROUP BY symbol, w.week_iv
ORDER BY iv_percentile_pct DESC
Run this yourself

Each name's week is now scored against its own previous 251 sessions. On that measure AMD tops the panel at the 97th percentile of its own year, on an IV level of 72.2%, while the raw panel put AMD first at 72.4%. The two panels hold identical data for identical dates. Only the scale changed, and each scale produces its own ordering. A per expiry z-score does the same job inside one name, holding every column to its own mean, which keeps the front month from swallowing the picture. The gap between percentile and rank scoring, which trips plenty of people up, is laid out in IV rank vs IV percentile.

The working rule is short. Colour a raw scale when the absolute level is the question. Colour a normalized scale when the shape is the question.

Two ways an IV heatmap lies to you

The first failure mode lives in the wings. A cell averaging two barely traded contracts is not a measurement, it is noise wearing a colour.

QueryDownside wing cells: SPY put IV dispersion by contract volume, June 2026
volume_bucketmedian_iv_pctiv_range_pctcontracts
1 contract27.111215
2-527.412.8431
6-2527.511.7668
26-10026.811.5664
101-10002712.6817
over 100025.811.9126
The exact SQL behind every number
SELECT
    volume_bucket,
    round(100 * quantileDeterministic(iv, det), 1) AS median_iv_pct,
    round(100 * (quantileDeterministic(0.9)(iv, det) - quantileDeterministic(0.1)(iv, det)), 1) AS iv_range_pct,
    count() AS contracts
FROM
(
    SELECT
        toFloat64(implied_volatility) AS iv,
        cityHash64(ticker)            AS det,
        volume,
        multiIf(volume =    1, '1 contract',
                volume <=   5, '2-5',
                volume <=  25, '6-25',
                volume <= 100, '26-100',
                volume <= 1000, '101-1000',
                                'over 1000') AS volume_bucket
    FROM global_markets.options_greeks
    WHERE underlying_symbol = 'SPY'
      AND date BETWEEN '2026-06-01' AND '2026-06-30'
      AND iv_converged = 1
      AND volume > 0
      AND days_to_expiry BETWEEN 20 AND 45
      AND lower(option_type) IN ('put', 'p')
      AND toFloat64(strike_price) / toFloat64(underlying_close) - 1 BETWEEN -0.20 AND -0.08
)
GROUP BY volume_bucket
ORDER BY min(volume)
Run this yourself

These are SPY puts 8% to 20% below spot, one month of sessions, split by how many contracts traded. The single contract bucket shows a median IV of 27.1% with 11 IV points between its 10th and 90th percentile, across 215 contract sessions. The busiest bucket spans 11.9 points. A cell whose colour rests on one print is reporting that print, not a market consensus.

The second failure mode is staleness dressed up as structure. A daily IV row is solved from that session's last traded option price. A contract whose only trade printed at 10:14 a.m. carries 10:14 a.m. volatility into a grid the reader takes as one snapshot of the close. Line several of those cells up along a row and an apparent kink in the smile can be a timing artifact. Two habits contain it: set a volume floor per cell, and show the contract count next to the colour so thin cells announce themselves.

The companion view is liquidity itself. A liquidity heatmap grids the same chain by trading activity rather than IV, which makes it the natural overlay for deciding which IV cells have earned their colour. If you want to build your own archive of these grids, historical implied volatility data covers what per contract coverage looks like and where the gaps sit.

How these panels were filtered

All five panels keep only rows with iv_converged = 1 and at least one contract traded that session. Moneyness is the strike divided by the same row's underlying close, minus one, so each session is measured against its own spot. Cell values are deterministic medians, which keeps two renderings of the same statistic from drifting apart. Dates are pinned to June 2026, so the numbers above do not move.

FAQ

What does each cell in an IV heatmap show?

One cell holds the implied volatility of a bucket of contracts: one expiry window crossed with one moneyness band, usually the median IV of the contracts that traded inside it. Colour encodes the level. The number of contracts behind the cell is a separate figure worth printing beside it.

Should an IV heatmap use strikes or moneyness?

Moneyness, in nearly every case. A fixed strike changes its position on the volatility curve every time spot moves, so a strike axis smears the smile across sessions. Moneyness recentres each session on its own spot and keeps the wings in the same place.

How do you normalize an IV heatmap colour scale?

Two common choices. Score each cell as a percentile of that name's own IV history, or take a z-score within each expiry column so the front month stops dominating. Both answer "high for this cell" rather than "high in absolute terms".

Why do the edges of an IV heatmap look noisy?

The wings hold the least liquid contracts in the chain. The dispersion panel above measures it directly: the single contract bucket spans 11 IV points between its 10th and 90th percentile, so colour in those cells carries far less information than colour near the money.

Is an IV heatmap the same thing as a volatility surface?

They describe the same object. A volatility surface is the mathematical version, a function of moneyness and expiry; the heatmap is one flat rendering of it, with colour standing in for the third dimension.


Every panel on this page carries the exact SQL that produced it, one expander away. To rebuild either colour scale for a name you follow, ask the question in plain English on the Strasmore terminal.