DEV Community

GEX.live
GEX.live

Posted on Originally published at gex.live

How far does SPX travel before the close? An empirical touch-probability surface from 1,082 sessions

Originally published on gex.live/research, where the full write-up, the caveats and the reproduce block live. This is the short version.

Every intraday level — a wall, a gamma flip, yesterday's high, a round number — invites the same question: what are the odds we get there before the close? The honest answer is not a model. It is how often it happened.

For every minute of every archived SPX session the rest of the day is known, so the running maximum and minimum of the forward path tell you, for every candidate distance at once, whether price reached that far. Pool 1,082 sessions (2022-04-14 to 2026-08-17, one-minute index prints, 404,130 minute-observations) and you get a frequency table instead of an assumption.

The surface

Rows: minutes left in the session. Columns: distance from spot in sigma units. Cells: percent of minute-observations whose forward path reached at least that far, up and down pooled. Read: “with 60–120 minutes left, a level 1.12σ away was reached 28.3% of the time.”

minutes left ≥0.12σ ≥0.38σ ≥0.62σ ≥0.88σ ≥1.12σ ≥1.38σ ≥1.75σ ≥2.25σ ≥2.75σ ≥3.5σ ≥5σ
300–391 (open → 10:30) 81.9 57.4 39.0 25.1 15.7 9.9 5.4 2.5 1.3 0.6 0.2
210–300 85.8 64.9 47.5 34.1 24.4 17.0 10.1 5.3 3.2 1.7 0.7
120–210 85.9 67.3 51.3 38.6 28.7 21.3 13.8 8.0 4.7 2.5 1.2
60–120 85.7 67.5 51.4 38.5 28.3 20.6 12.8 6.8 3.8 1.7 0.5
30–60 85.1 67.8 52.1 39.3 28.9 21.1 13.2 7.2 4.2 2.0 0.4
15–30 83.2 68.3 54.3 42.2 32.1 24.2 15.6 9.1 5.3 2.4 0.4
0–15 (last quarter hour) 74.9 62.3 50.7 40.6 31.9 24.5 16.4 9.8 5.5 2.3 0.4

Every row pools 1,073–1,082 sessions; the thinnest row rests on 10,820 observations, the thickest on 97,290. Means are day-clustered — a session's 390 minutes count as one observation, not 390.

How distance is measured

Points are the wrong unit: 20 points is a formality on a wild day and unreachable on a quiet one. Distance is in units of the move the tape was already making — the trailing 30-minute realised move per minute, projected over the minutes remaining: sigma = rv30 * sqrt(minutes_left). Only information available at that minute, so no lookahead; and no option chain needed, so it rebuilds from price alone.

Three things the table says

Beyond 2σ the surface is flat and small. A level 2.25σ away is reached 2.5–9.8% of the time whatever the clock says; at 2.75σ the range is 1.3–5.5%. That is the number any “this level held 9 days in 10” claim has to be compared against — which is exactly what the companion hold-rate study does for dealer-gamma levels.

The opening row is the quietest, not the wildest. For the same σ-distance, reach is lowest with 300+ minutes left. The scaler is the reason: in the first half hour the trailing 30-minute move is the opening volatility, the highest of the day, so “one sigma” at 10:00 is more points than “one sigma” at 13:00 — and the afternoon does not deliver what the open implied.

The last quarter hour under-reaches at short range. With 5–15 minutes left even 0.12σ is reached only 75% of the time, against 85% mid-session. A walk of five steps rarely reaches its own sigma; that is discreteness, not mean reversion.

Caveat

Touches are measured on minute closes, not the per-second path. An intra-minute spike that reached a level and came back is invisible here, so every probability is a lower bound. The bias is the same in every cell, so differences between cells are meaningful even where a single cell is not. And this is the unconditional surface — it knows nothing about dealer positioning. That is deliberate: it is the benchmark a conditional claim must beat, not a claim itself.

Reproduce it

The grid itself is served at https://gex.live/touch.json (t and u are bucket midpoints, grid[t][u] the probability). To rebuild it from price, every finished session's minute series is in https://gex.live/snapshots/YYYY-MM-DD.json (fields minutes, spot); the dates are listed at gex.live/sessions.

import numpy as np, pandas as pd
s = np.array(day["spot"], float)                  # one session, 389 one-minute prints
r = np.diff(np.log(s), prepend=np.nan)
rv = pd.Series(r).rolling(30, min_periods=10).std().values * s   # trailing move, points
left = len(s) - 1 - np.arange(len(s))             # minutes to the close
sig = rv * np.sqrt(np.maximum(left, 1))
fmax = np.maximum.accumulate(s[::-1])[::-1]       # forward extremes from each minute
fmin = np.minimum.accumulate(s[::-1])[::-1]
ok = np.isfinite(sig) & (sig > 1e-9) & (left >= 5)
up, dn = (fmax - s)[ok] / sig[ok], (s - fmin)[ok] / sig[ok]
# per (minutes-left bucket, u): mean over sessions of the session's share of
# minutes with up >= u (and dn >= u), pooled
Enter fullscreen mode Exit fullscreen mode

Nothing here is a signal or investment advice — it is a measurement with its sample attached. Full post, with the companion study on how often dealer-gamma levels hold: gex.live/research.

Top comments (0)