Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

How to Read an Event Contract Ladder

An event contract ladder is a row of threshold prices. Difference the legs into bucket odds, normalise, and read the implied mean and a rough interval.

An event contract ladder is the whole row of yes or no contracts written on one release, each at a different threshold, and reading an event contract ladder means collapsing that row into a single distribution. A single contract price reads as one probability about one cutoff. The ladder carries much more: difference the legs and you recover where the market centres the number, plus how wide it thinks the range is. The arithmetic below is addition, subtraction and one division.

What a ladder is, and what one rung cannot tell you

Take any dated release that publishes a single headline figure. A ladder on it is a set of contracts on the same figure at rising cutoffs: above zero, above 25,000, above 50,000, and so on. Each leg settles at 1 if the published number clears its threshold and at 0 if it does not, and the settlement source and timing live in the contract rules rather than in the price.

Two structural facts do all the work. First, a higher threshold is harder to clear, so its yes price sits at or below the price of every lower rung. Second, a rung's price is a cumulative probability, the chance of finishing above that threshold, never the chance of landing near it. A reader who treats one rung as "the market's forecast" has read a tail, not a centre.

How to read an event contract ladder, step by step

The ladder in this section is a worked example built to show the arithmetic. These are not live quotes from any venue. Say the yes prices on a payrolls print are 0.96 above zero, 0.88 above 25,000, 0.74 above 50,000, 0.27 above 100,000 and 0.10 above 150,000.

  1. Difference the adjacent legs. The 0.88 rung minus the 0.74 rung is 0.14, the probability on the 25,000 to 50,000 bucket. Down the whole ladder: 0.04 below zero, 0.08 on zero to 25,000, 0.14 on 25,000 to 50,000, 0.47 on 50,000 to 100,000, 0.17 on 100,000 to 150,000, and 0.10 above 150,000. A monotone ladder with both open tails included sums to exactly 1, which is a free check on your subtraction.
  2. Clip, then renormalise. Now suppose the top rung last printed at 0.31 while the 100,000 rung sits at 0.27. Differencing gives a bucket of negative 0.04, which no probability can be. Clip that bucket to zero and the column totals 1.04, so divide every bucket by 1.04: the big middle bucket moves from 0.47 to 0.45. Devigging a sportsbook line is this same normalising step applied to two sided odds.
  3. Weight the midpoints. Multiply each bucket's probability by the middle of its range and add them up. Using 12,500 for the first bucket, then 37,500, 75,000 and 125,000, with a judgement call of 190,000 for the open top tail and negative 25,000 for the open bottom tail, the clean ladder implies about 81,000. Quote the tail assumption alongside the number: the two open buckets hold 0.14 of the weight between them, so a different tail guess moves the mean by a few thousand.
  4. Walk the cumulative column. Adding from the bottom gives 0.04 through zero, 0.12 through 25,000, 0.26 through 50,000, 0.73 through 100,000 and 0.90 through 150,000. The 10% mark falls three quarters of the way into the zero to 25,000 bucket, near 19,000. The 90% mark lands on 150,000. An 80% band of roughly 19,000 to 150,000 is the honest width of this forecast.
  5. Compare with the consensus. If the published survey consensus for that report is 75,000, the ladder centres about 6,000 above it, inside a band wide enough to contain both. That gap is the interesting part, since a price weighted mean and a survey median are built differently.

The same arithmetic on a real strike ladder

Event contract quotes are not in the data behind these panels, and a strike ladder in the options market is the same object: a row of prices that falls as the threshold rises. The payoff differences between the two instruments matter for sizing, not for this arithmetic. The panel pins one SPY expiration on a single session in late May 2026 and lists the call price at every tenth strike within 8% of where the fund closed.

QueryA real strike ladder: SPY call prices by threshold, one pinned session
strikecall_priceas_of_label
70059.13May 29, 2026
71049.74May 29, 2026
72040.14May 29, 2026
73030.99May 29, 2026
74023.03May 29, 2026
75015.02May 29, 2026
7608.98May 29, 2026
7704.59May 29, 2026
7801.97May 29, 2026
7900.83May 29, 2026
8000.26May 29, 2026
8100.11May 29, 2026
The exact SQL behind every number
WITH
    pinned AS
    (
        SELECT max(date) AS as_of
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date <= '2026-05-29'
    ),
    chain AS
    (
        SELECT
            date,
            expiration_date,
            strike_price,
            option_close,
            underlying_close,
            volume
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date = (SELECT as_of FROM pinned)
          AND iv_converged = 1
          AND volume > 0
          AND delta > 0.01
          AND delta < 0.99
          AND days_to_expiry BETWEEN 20 AND 45
          AND toDayOfWeek(expiration_date) = 5
    ),
    busiest AS
    (
        SELECT expiration_date
        FROM chain
        GROUP BY expiration_date
        ORDER BY sum(volume) DESC
        LIMIT 1
    )
SELECT
    toUInt32(round(toFloat64(strike_price)))   AS strike,
    round(avg(toFloat64(option_close)), 2)     AS call_price,
    formatDateTime(any(date), '%b %e, %Y')     AS as_of_label
FROM chain
WHERE expiration_date = (SELECT expiration_date FROM busiest)
  AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.08
  AND modulo(toUInt32(round(toFloat64(strike_price))), 10) = 0
GROUP BY strike
ORDER BY strike
Run this yourself

As of May 29, 2026 the ladder holds 12 rungs, the 700 strike at $59.13 and the 810 strike at $0.11. Prices fall as the threshold rises, which is the defining shape of a ladder in either market.

A call price is quoted in dollars rather than in probability, so the differencing step carries one extra division: subtract the next rung's price and divide by the gap between the two strikes. What comes out is the market's probability of finishing above the midpoint of that gap.

QueryDifferencing the rungs: probability above each threshold, and the bucket between them
thresholdprob_above_pctbucket_pct
70593.9-2.1
715964.5
72591.511.9
73579.6-0.5
74580.119.7
75560.416.5
76543.917.7
77526.214.8
78511.45.7
7955.74.2
8051.51.5
The exact SQL behind every number
WITH
    pinned AS
    (
        SELECT max(date) AS as_of
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date <= '2026-05-29'
    ),
    chain AS
    (
        SELECT
            expiration_date,
            strike_price,
            option_close,
            underlying_close,
            volume
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date = (SELECT as_of FROM pinned)
          AND iv_converged = 1
          AND volume > 0
          AND delta > 0.01
          AND delta < 0.99
          AND days_to_expiry BETWEEN 20 AND 45
          AND toDayOfWeek(expiration_date) = 5
    ),
    busiest AS
    (
        SELECT expiration_date
        FROM chain
        GROUP BY expiration_date
        ORDER BY sum(volume) DESC
        LIMIT 1
    ),
    legs AS
    (
        SELECT
            toUInt32(round(toFloat64(strike_price))) AS strike,
            round(avg(toFloat64(option_close)), 4)   AS call_price
        FROM chain
        WHERE expiration_date = (SELECT expiration_date FROM busiest)
          AND abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.08
          AND modulo(toUInt32(round(toFloat64(strike_price))), 10) = 0
        GROUP BY strike
    ),
    ranked AS
    (
        SELECT
            strike,
            call_price,
            row_number() OVER (ORDER BY strike) AS rn
        FROM legs
    ),
    digitals AS
    (
        SELECT
            (a.strike + b.strike) / 2                                                 AS threshold,
            100 * greatest((a.call_price - b.call_price) / (b.strike - a.strike), 0)   AS prob_pct,
            row_number() OVER (ORDER BY a.strike)                                     AS rn
        FROM ranked AS a
        INNER JOIN ranked AS b ON b.rn = a.rn + 1
    )
SELECT
    round(d.threshold, 1)                AS threshold,
    round(d.prob_pct, 1)                 AS prob_above_pct,
    round(d.prob_pct - n.prob_pct, 1)    AS bucket_pct
FROM digitals AS d
LEFT JOIN digitals AS n ON n.rn = d.rn + 1
ORDER BY d.threshold
Run this yourself

The prob_above_pct column is now a ladder in the event contract sense: 93.9% at the 705 threshold, down to 1.5% at 805, across 11 rungs. The bucket_pct column differences that ladder one more time, so each entry is the probability of landing between one threshold and the next, with the final entry holding the open tail above the top rung. Those buckets add back to the first row's 93.9%, the chance of clearing the lowest rung. Divide each bucket by that total and the band is renormalised to 100, which is step 2 of the worked example performed on live prices.

What a ladder that is not monotone tells you

Two situations break the ordering, and both appear as a price that rises with the threshold. One is a stale quote on a thin rung that nobody has updated. The other is a genuine mispricing between two rungs. Either way the differencing step prints a negative bucket, and that negative number is the alarm. The audit below counts the broken pairs rung by rung across every Friday expiration on the pinned session.

QueryLadder audit by expiration: rung count, ordering breaks, and probability inside the 8% band
expiry_dateband_prob_pctleg_countnon_monotone_pairs
2026-06-1296.7100
2026-06-2692.4120
2026-07-1085.5100
2026-07-1782.9120
2026-07-3170.1120
The exact SQL behind every number
WITH
    pinned AS
    (
        SELECT max(date) AS as_of
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date <= '2026-05-29'
    ),
    chain AS
    (
        SELECT
            expiration_date,
            strike_price,
            option_close,
            underlying_close
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date = (SELECT as_of FROM pinned)
          AND iv_converged = 1
          AND volume > 0
          AND delta > 0.01
          AND delta < 0.99
          AND days_to_expiry BETWEEN 10 AND 75
          AND toDayOfWeek(expiration_date) = 5
    ),
    legs AS
    (
        SELECT
            expiration_date                          AS expiry,
            toUInt32(round(toFloat64(strike_price))) AS strike,
            round(avg(toFloat64(option_close)), 4)   AS call_price
        FROM chain
        WHERE abs(toFloat64(strike_price) / toFloat64(underlying_close) - 1) < 0.08
          AND modulo(toUInt32(round(toFloat64(strike_price))), 10) = 0
        GROUP BY expiry, strike
    ),
    ranked AS
    (
        SELECT
            expiry,
            strike,
            call_price,
            row_number() OVER (PARTITION BY expiry ORDER BY strike) AS rn
        FROM legs
    ),
    pairs AS
    (
        SELECT
            a.expiry                                                                AS expiry,
            a.rn                                                                    AS rn,
            greatest((a.call_price - b.call_price) / (b.strike - a.strike), 0)       AS p,
            if(b.call_price > a.call_price, 1, 0)                                    AS bad
        FROM ranked AS a
        INNER JOIN ranked AS b ON b.expiry = a.expiry AND b.rn = a.rn + 1
    )
SELECT
    toString(expiry)                                  AS expiry_date,
    round(100 * (argMin(p, rn) - argMax(p, rn)), 1)   AS band_prob_pct,
    toUInt32(count() + 1)                             AS leg_count,
    toUInt32(sum(bad))                                AS non_monotone_pairs
FROM pairs
GROUP BY expiry
HAVING count() >= 4
ORDER BY expiry
Run this yourself

The audit covers 5 expirations. The nearest one lists 10 rungs inside the 8% band and 0 adjacent pairs that break the ordering. The band_prob_pct column measures the identical 8% window at every horizon: 96.7% of the probability for the nearest expiration and 70.1% for the furthest. A band that covers less of the distribution is the ladder saying the plausible range has widened, which is the same information step 4 pulled out of the cumulative column.

How far off does a rung have to be before the arithmetic survives a fill

Two costs sit between a number on paper and a filled order. The first is the bid and ask on the rung. Suppose your normalised ladder values a rung at 0.30 while the quote shows 0.26 bid and 0.29 asked. Lifting the offer at 0.29 leaves one cent of edge. The second cost is the per contract fee, charged on entry and often again at settlement; a fee of a cent on the round turn erases that penny outright. So a rung has to be mispriced by more than half the spread plus the round trip fee before any of this matters, and the widest quotes sit on the thin end rungs where apparent mispricings cluster.

The collateral a yes leg ties up completes the picture. A 0.30 leg holds 30 cents of cash per contract until settlement, so one cent of edge is one cent earned on 30 cents posted for the full life of the contract.

Reading the same ladder every session

A ladder read once is a snapshot. The same rung read every session is a forecast track record. The trace below differences a fixed pair of legs on one expiration, session after session, into the probability of finishing above a single threshold.

QueryOne threshold, re-read every session: implied probability above a fixed strike
28 rows (showing 20)
dateprob_above_pctthreshold_label
2026-05-1528.6760
2026-05-1831.8760
2026-05-1925.2760
2026-05-2033.4760
2026-05-2133.3760
2026-05-2238.4760
2026-05-2643.6760
2026-05-2743.4760
2026-05-2851760
2026-05-2952.2760
2026-06-0156.7760
2026-06-0258.1760
2026-06-0347.7760
2026-06-0452.5760
2026-06-0519.7760
2026-06-0821.7760
2026-06-0919.2760
2026-06-1010760
2026-06-1120.7760
2026-06-1220.8760
The exact SQL behind every number
WITH
    pinned AS
    (
        SELECT max(date) AS as_of
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date <= '2026-05-29'
    ),
    target AS
    (
        SELECT
            expiration_date                                             AS expiry,
            toUInt32(round(avg(toFloat64(underlying_close)) / 10) * 10)  AS k
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND date = (SELECT as_of FROM pinned)
          AND iv_converged = 1
          AND volume > 0
          AND delta > 0.01
          AND delta < 0.99
          AND days_to_expiry BETWEEN 20 AND 45
          AND toDayOfWeek(expiration_date) = 5
        GROUP BY expiration_date
        ORDER BY sum(volume) DESC
        LIMIT 1
    ),
    daily AS
    (
        SELECT
            date,
            toUInt32(round(toFloat64(strike_price))) AS strike,
            avg(toFloat64(option_close))             AS call_price
        FROM global_markets.options_greeks
        WHERE underlying_symbol = 'SPY'
          AND expiration_date = (SELECT expiry FROM target)
          AND date >= (SELECT as_of FROM pinned) - 14
          AND date <= (SELECT expiry FROM target)
          AND iv_converged = 1
          AND volume > 0
          AND delta > 0
          AND abs(toInt32(round(toFloat64(strike_price))) - toInt32((SELECT k FROM target))) = 10
        GROUP BY date, strike
    )
SELECT
    toString(a.date)                                                 AS date,
    round(100 * greatest((a.call_price - b.call_price) / 20, 0), 1)   AS prob_above_pct,
    toString((SELECT k FROM target))                                  AS threshold_label
FROM daily AS a
INNER JOIN daily AS b ON b.date = a.date AND b.strike = a.strike + 20
ORDER BY a.date
Run this yourself

That is 28 sessions of the same two rungs differenced the same way, for the 760 threshold: 28.6% on 2026-05-15 and 0% on 2026-06-25, the last session in the window. Each reading is a dated probability attached to an outcome that eventually prints as 0 or 1, which is exactly the input the Brier score grades: square the gap between the probability and what happened, average across readings, lower is better. One rung's reading is an opinion. A hundred of them scored is a record.

The rate path version works the same way, with the thresholds named as policy rate levels instead of payroll counts, and the buckets reading as meeting by meeting odds. That walkthrough lives in how markets price Fed rate odds.

How these panels are bounded

Every panel pins one session, the last one on or before May 29, 2026, so the stored numbers never refresh. Rungs are limited to strikes that are multiples of ten inside 8% of that session's close, which keeps the ladders short enough to read and the gaps even. Contracts are included only with traded volume on the session and a converged volatility solve. The differencing clips negative buckets to zero rather than hiding them, and the audit panel counts those clipped pairs separately in non_monotone_pairs. Probability inside the band is measured between the lowest and highest midpoints on each ladder, so it excludes both open tails by construction: a figure of 90% means the remaining 10% sits outside the band entirely.

FAQ

What is an event contract ladder?

It is the full set of yes or no contracts on the same release at different thresholds, for example payrolls above 50,000 and above 100,000 quoted side by side. Each rung prices one cutoff, and the whole ladder together describes a distribution over the published number.

How do you turn ladder prices into probabilities for each outcome range?

Subtract each rung's price from the rung below it. The difference is the probability of landing between those two thresholds. Clip any negative result to zero, divide every bucket by the new total, and the column becomes a usable set of probabilities summing to 100%.

What does it mean when a ladder is not monotone?

A higher threshold should never cost more than a lower one, so a price that rises with the threshold is either a stale quote on a thin rung or a real mispricing between the two. Differencing exposes it immediately as a negative bucket.

Can you get a forecast range out of an event contract ladder?

Yes. Add the bucket probabilities from the bottom to build a cumulative column, then find the thresholds where that running total passes 10% and 90%. Those two thresholds bracket an 80% range, with interpolation inside whichever bucket the mark falls in.

Why does the bid and ask matter when reading a ladder?

The arithmetic values a rung at a single price, while the market offers two. Half the spread plus the per contract fee on the round turn is the minimum gap between your value and the quote before the difference is reachable at all.


Every panel on this page ships the exact SQL underneath it, so the differencing is auditable rung by rung. To build the same ladder on another expiration or another session, ask for it in plain English on the Strasmore terminal.