Gamma map
The chart coin's live options chain turned into gamma exposure by strike, docked on the price axis as a profile.
Each row is one strike's net GEX, calls minus puts, in USD per 1% move of spot: sky where calls carry the strike, violet where puts do. Three lines run across the chart with a price tag at the axis: the call wall (sky), the put wall (violet) and the gamma flip (amber, dashed). A card at the bottom of the pane, in the terminal look (black, monospaced, its title on an amber bar), answers one question first, as the headline: are dealers long or short gamma where spot trades? Long gamma, in green, means spot is at or above the gamma flip, where the dealers' hedging leans against moves; short gamma, in red, means spot is under the flip, where their hedging chases them. Under the answer the card reads the chain's net GEX, how far spot sits from the flip, and four numbered rows of prices: 1) the flip, 2) the call wall, 3) the put wall and 4) max pain; its title bar counts the expiries.
The parts are a celled options_chain.cells input read on the live bar (Data sources), a frame feeding plot.levels docked on the price axis (Cards, frames and panels), line and label handles for the walls, the flip and their tags (Drawing objects), and a render.hud card with look: "terminal" of a headline pill, two values and a rows tile over data-only outputs and two text slots (HUD cards, Looks, Blocks and tiles). This is also the gamma-map template: the Gamma Map card under HUDs in the editor's starter list, and it compiles as written.
The wrun indicator
// Gamma Map: the chart coin's live options chain as gamma exposure by strike, docked on the price axis. Each row is
// one strike's net GEX (calls minus puts, in USD per 1% move of spot): sky where calls carry the strike, violet where
// puts do. The call wall (the strike holding the most call open interest), the put wall (most put open interest) and
// the gamma flip (where cumulative net GEX crosses zero, the crossing nearest spot when the far wings' few dollars flip
// the sum back and forth) run across the chart as lines with price tags at the axis.
// A card at the bottom of the pane, in the terminal look, answers one question first: are dealers long or short
// gamma where spot trades?
// Long gamma at or above the flip (their hedging leans against moves), short gamma under it (their hedging chases
// moves). Under the answer, the chain's net GEX (every strike summed), how far spot sits from the flip, and the four
// levels. The chain is live only: history bars draw nothing and cost nothing; everything is measured on the last bar.
// The frame channel, declared here so the profile is written from a static byte buffer: the generated writeFrame
// takes a string, and a string built per bar allocates, which the sandbox refuses once the bars start.
@external("wrun", "wrun_output_frame")
declare function wrun_output_frame(slot: i32, ptr: i32, len: i32): void;
section("Strikes and expiries");
param.number("window_pct", 6, { min: 1, max: 50, label: "Strike window, percent", description: "Strikes in the docked profile and the walls: percent around spot, each side" });
param.int("nearest_expiries", 0, { min: 0, max: 24, label: "Expiries counted", description: "Expiries counted: 0 = every listed expiry, N = the N nearest" });
input("close", ohlcv.close); // the chart's own close: the grid, and spot for the strike window
input("chain", options_chain.cells, { max_cells: 4000, venue: "auto", description: "The live options chain of the chart's coin (venue auto: the chart's own market when it lists options, else the coin's Deribit chain)" }); // [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] per contract; a BTC chain is about 1,550 contracts
// The readings, data-only: the card's tiles read them and the Console can too. Each is NaN on history bars.
output("net_gex", none, overlay, { description: "Net gamma exposure of the counted chain, USD per 1% move of spot; live bar only" });
output("gamma_flip", none, overlay, { description: "Gamma flip: the strike where cumulative net GEX crosses zero, the crossing nearest spot when it crosses more than once" });
output("call_wall", none, overlay, { color: "#38bdf8", description: "Call wall: the strike at or above spot, inside the window, holding the most call open interest" }); // the colour is the dot beside its row on the card, the line's own sky
output("put_wall", none, overlay, { color: "#a78bfa", description: "Put wall: the strike at or below spot, inside the window, holding the most put open interest" });
output("max_pain", none, overlay, { description: "Max pain: the settlement price on the strike grid that minimises the chain's intrinsic payout" });
output("gex_tone", none, overlay, { description: "1 while the chain's net GEX is positive, 0 while negative" });
output("gamma_regime", none, overlay, { description: "Dealer gamma at spot, the first tile's colour: 0 short gamma (spot under the flip), 2 long gamma (spot at or above it), 1 no reading (no flip in range, no gamma, no chain); live bar only" });
output("spot_vs_flip", none, overlay, { description: "Spot against the gamma flip, percent: above 0 while spot trades above the flip, below 0 under it" });
string("tag", { max_bytes: 40 }); // one bounded slot the three price tags are written through, one at a time
string("dealer_gamma", { max_bytes: 32 }); // the card's first tile: Long gamma, Short gamma, or why there is no reading
string("expiries", { max_bytes: 32 }); // the card's title: which expiries were counted
handles.line({ width: 1, extend: "both" }); // the walls and the flip, across the whole chart
handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // price tags: x in pixels from the right edge, y in price
const gex_rows = frame("gex_rows", { max_bytes: 16384 }); // the docked profile: one row per grid price
plot.levels({ name: "gex_by_strike", frame: gex_rows, dock: "right", width_frac: 0.12, labels: false, color: "#38bdf8", thickness_px: 12 }); // bars at most 12 px tall, so a zoomed-in chart reads as a ladder, never as slabs
// The card, in the terminal look (an amber title bar, monospace type, numbered rows): a HUD pinned at the bottom
// centre of the pane, clear of the docked profile and the tags on the right, the legend at the top left and the
// floating button at the bottom left. Every tile reads the newest row, the live bar's. The regime leads as the
// headline, coloured by the regime at spot (bear red for short gamma, slate for no reading, bull green for long
// gamma); net GEX prints in dollars and the levels follow as numbered rows, prices with thousands separators; the title
// carries the expiries counted ({{expiries}} reads the slot of that name).
render.hud("gamma", {
position: "bottom_center",
look: "terminal",
title: "Gamma, {{expiries}}",
columns: 2,
tiles: [
tile.pill("Dealer gamma", "dealer_gamma", { headline: true, color_by: "gamma_regime", colors: ["#ff003c", "#94a3b8", "#10b981"] }),
tile.value("Net GEX per 1% move", "net_gex", { format: "usd" }),
tile.value("Spot vs the flip", "spot_vs_flip", { format: "%" }),
tile.rows([
["1) Gamma flip", "gamma_flip", "auto"],
["2) Call wall", "call_wall", "auto"],
["3) Put wall", "put_wall", "auto"],
["4) Max pain", "max_pain", "auto"],
]),
],
});
const MAX_STRIKES = 512; // distinct strikes the chain may hold across the counted expiries
const MAX_EXPIRIES = 64; // distinct live expiries
const MAX_ROWS = 200; // grid rows in the docked profile
const TUPLE = 10; // f64s per contract: strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega
const strikePrice = new StaticArray<f64>(MAX_STRIKES); // the strike table, sorted ascending, one row per strike
const callOi = new StaticArray<f64>(MAX_STRIKES);
const putOi = new StaticArray<f64>(MAX_STRIKES);
const callGex = new StaticArray<f64>(MAX_STRIKES); // USD per 1% move, the dealer-naive sign convention: calls positive, puts negative
const putGex = new StaticArray<f64>(MAX_STRIKES);
const expiryMs = new StaticArray<f64>(MAX_EXPIRIES); // the live expiries, sorted ascending
const rowGex = new StaticArray<f64>(MAX_ROWS); // the profile grid: signed net GEX per row
const jsonBytes = new StaticArray<u8>(16384); // the frame JSON, appended as UTF-8 bytes
let jsonUsed = 0;
const SKY = rgba(56, 189, 248, 255);
const VIOLET = rgba(167, 139, 250, 255);
const AMBER = rgba(248, 192, 0, 255);
const PILL = rgba(15, 23, 42, 235); // the tags' backdrop
const callWallLine: LineHandle = draw.line(0); // handle objects allocate once; ids are one space across kinds
const putWallLine: LineHandle = draw.line(1);
const flipLine: LineHandle = draw.line(2);
const tags: LabelHandle[] = [draw.label(3), draw.label(4), draw.label(5)]; // call wall, put wall, flip
let windowPct = 0.1; // settings, read in onStart()
let nearestExpiries = 0;
let close: f64 = NaN; // this bar
let t: f64 = NaN;
let firstT: f64 = NaN; // the first bar's open time: where the lines start
let strikeCount = 0; // the strike table in use
let expiryCount = 0;
let netGex: f64 = NaN; // the chain's readings, measured on the live bar
let flip: f64 = NaN;
let callWall: f64 = NaN;
let putWall: f64 = NaN;
let maxPain: f64 = NaN;
// ── The strike table: sorted insertion, one row per strike ──
function strikeSlot(strike: f64): i32 { // the row of this strike, inserted when new; -1 when the table is full
let lo = 0;
let hi = strikeCount;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (strikePrice[mid] < strike) lo = mid + 1;
else hi = mid;
}
if (lo < strikeCount && strikePrice[lo] == strike) return lo;
if (strikeCount >= MAX_STRIKES) return -1;
for (let i = strikeCount; i > lo; i -= 1) {
strikePrice[i] = strikePrice[i - 1];
callOi[i] = callOi[i - 1];
putOi[i] = putOi[i - 1];
callGex[i] = callGex[i - 1];
putGex[i] = putGex[i - 1];
}
strikePrice[lo] = strike;
callOi[lo] = 0.0;
putOi[lo] = 0.0;
callGex[lo] = 0.0;
putGex[lo] = 0.0;
strikeCount += 1;
return lo;
}
function noteExpiry(ms: f64): void { // sorted insertion of a distinct live expiry
let lo = 0;
let hi = expiryCount;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (expiryMs[mid] < ms) lo = mid + 1;
else hi = mid;
}
if (lo < expiryCount && expiryMs[lo] == ms) return;
if (expiryCount >= MAX_EXPIRIES) return;
for (let i = expiryCount; i > lo; i -= 1) expiryMs[i] = expiryMs[i - 1];
expiryMs[lo] = ms;
expiryCount += 1;
}
// ── The chain, measured on the live bar ──
function measureChain(n: i32): void { // n = f64 cells in the live block
const cells = in_chain_view(); // the live chain in place: the first n values of the build's own buffer
const nowMs = t * 1000.0;
strikeCount = 0;
expiryCount = 0;
for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) if (cells[i + 1] > nowMs) noteExpiry(cells[i + 1]); // the live expiries, for the nearest-N filter
if (expiryCount == 0) return;
const cutoff = nearestExpiries > 0 && nearestExpiries < expiryCount ? expiryMs[nearestExpiries - 1] : expiryMs[expiryCount - 1];
for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
const strike = cells[i];
const expiry = cells[i + 1];
const oi = cells[i + 3];
if (!(strike > 0.0) || !(oi > 0.0) || expiry <= nowMs || expiry > cutoff) continue;
const slot = strikeSlot(strike);
if (slot < 0) continue;
const multiplier = cells[i + 8] > 0.0 ? cells[i + 8] : 1.0; // Deribit open interest is in coin units (multiplier 1); CME open interest is contracts times the point value
const spot = cells[i + 7] > 0.0 ? cells[i + 7] : close;
const gexUsd = cells[i + 4] * oi * multiplier * spot * spot * 0.01; // the USD delta change of the open interest for a 1% move of spot
if (cells[i + 2] > 0.0) {
callOi[slot] += oi;
callGex[slot] += gexUsd;
} else {
putOi[slot] += oi;
putGex[slot] += gexUsd;
}
}
// Net GEX, max pain and the flip over every counted strike; the walls among the strikes inside the window, the
// ones the profile shows: the call wall is the heaviest call strike at or above spot, the put wall the heaviest
// put strike at or below it (a wall three windows away is not what a chart of this range is trading against).
netGex = 0.0;
let bestCall = 0.0;
let bestPut = 0.0;
callWall = NaN;
putWall = NaN;
const lo = close * (1.0 - windowPct);
const hi = close * (1.0 + windowPct);
for (let k = 0; k < strikeCount; k += 1) {
netGex += callGex[k] - putGex[k];
const strike = strikePrice[k];
if (strike < lo || strike > hi) continue;
if (strike >= close && callOi[k] > bestCall) {
bestCall = callOi[k];
callWall = strike;
}
if (strike <= close && putOi[k] > bestPut) {
bestPut = putOi[k];
putWall = strike;
}
}
maxPain = NaN;
let leastPain = Infinity;
for (let s = 0; s < strikeCount; s += 1) { // the settlement price on the strike grid that pays the least intrinsic value
const settle = strikePrice[s];
let pain = 0.0;
for (let k = 0; k < strikeCount; k += 1) {
if (settle > strikePrice[k]) pain += (settle - strikePrice[k]) * callOi[k];
else if (settle < strikePrice[k]) pain += (strikePrice[k] - settle) * putOi[k];
}
if (pain < leastPain) {
leastPain = pain;
maxPain = settle;
}
}
// The flip: the interpolated zero crossing of cumulative net GEX, walking strikes upward. The far wings carry a few
// dollars, and a chain narrowed to its nearest expiries can flip their running sum back and forth there; the
// crossing that matters is the one where spot trades, so the flip is the crossing nearest spot. A sum that lands
// exactly on zero crosses at that strike and starts a new segment, so no later crossing is interpolated across it.
flip = NaN;
let cum = 0.0;
let prevCum = 0.0;
let prevStrike: f64 = NaN;
for (let k = 0; k < strikeCount; k += 1) {
cum += callGex[k] - putGex[k];
let cross: f64 = NaN;
if (!isNaN(prevStrike) && prevCum != 0.0) {
if (cum == 0.0) cross = strikePrice[k];
else if ((cum > 0.0) != (prevCum > 0.0)) {
const share = Math.abs(prevCum) / (Math.abs(prevCum) + Math.abs(cum));
cross = prevStrike + (strikePrice[k] - prevStrike) * share;
}
}
if (!isNaN(cross) && (isNaN(flip) || Math.abs(cross - close) < Math.abs(flip - close))) flip = cross;
if (cum == 0.0) prevStrike = NaN; // a zero ends the segment: the next one starts at the next nonzero sum
else {
prevCum = cum;
prevStrike = strikePrice[k];
}
}
}
// ── JSON bytes: append text and numbers without allocating ──
function jbByte(b: u32): void {
if (jsonUsed < jsonBytes.length) {
jsonBytes[jsonUsed] = <u8>b;
jsonUsed += 1;
}
}
function jbText(s: String): void {
for (let i = 0; i < s.length; i += 1) jbByte(<u32>s.charCodeAt(i)); // ASCII only here
}
function jbUint(v: u64): void {
if (v >= 10) jbUint(v / 10);
jbByte(0x30 + <u32>(v % 10));
}
function jbNum(v: f64, decimals: i32): void { // a fixed-point number, or 0 when not finite
if (!isFinite(v)) {
jbByte(0x30);
return;
}
let x = v;
if (x < 0.0) {
jbByte(0x2d);
x = -x;
}
let scale = 1.0;
for (let i = 0; i < decimals; i += 1) scale *= 10.0;
const scaled = Math.round(x * scale);
const whole = Math.floor(scaled / scale);
jbUint(<u64>whole);
if (decimals > 0) {
jbByte(0x2e);
const frac = <u64>(scaled - whole * scale);
let digits = 1;
let probe = frac;
while (probe >= 10) {
probe /= 10;
digits += 1;
}
for (let i = digits; i < decimals; i += 1) jbByte(0x30);
jbUint(frac);
}
}
function jbFlush(slot: i32): void {
wrun_output_frame(slot, i32(changetype<usize>(jsonBytes)), jsonUsed);
jsonUsed = 0;
}
function priceDecimals(step: f64): i32 { // enough decimals that the grid's row prices stay evenly spaced when parsed (the docked grid is contiguous only within 0.1% of its step)
const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10));
return d < 2 ? 2 : d > 9 ? 9 : d;
}
// ── The docked profile: strikes inside the window on a half-step grid, so each bar fills half its band ──
function commonGap(lo: f64, hi: f64): f64 { // the gap most neighbouring strikes inside the window sit apart (ties: the smaller); NaN with fewer than two strikes
let best: f64 = NaN;
let bestCount = 0;
let prev: f64 = NaN;
for (let k = 0; k < strikeCount; k += 1) {
const s = strikePrice[k];
if (s < lo || s > hi) continue;
if (!isNaN(prev)) {
const gap = s - prev;
let count = 0;
let q: f64 = NaN;
for (let j = 0; j < strikeCount; j += 1) { // how many windowed neighbours sit this far apart
const u = strikePrice[j];
if (u < lo || u > hi) continue;
if (!isNaN(q) && Math.abs(u - q - gap) <= gap * 1e-6) count += 1;
q = u;
}
if (count > bestCount || (count == bestCount && gap < best)) {
bestCount = count;
best = gap;
}
}
prev = s;
}
return best;
}
function writeProfile(): void {
const lo = close * (1.0 - windowPct);
const hi = close * (1.0 + windowPct);
let step = commonGap(lo, hi); // the ladder's usual gap: the odd finer strike near the money joins its nearest row instead of shrinking every bar
if (isNaN(step) || step <= 0.0) { // fewer than two strikes in the window: the chain's own smallest gap, or a percent of spot
step = Infinity;
for (let k = 1; k < strikeCount; k += 1) if (strikePrice[k] - strikePrice[k - 1] < step) step = strikePrice[k] - strikePrice[k - 1];
if (!isFinite(step) || step <= 0.0) step = close * 0.01;
}
let half = step * 0.5;
let gridLo = Math.floor(lo / half) * half;
let rows = i32(Math.ceil((hi - gridLo) / half)) + 1;
while (rows > MAX_ROWS) { // a very wide window on a fine ladder: coarsen the grid until it fits
half *= 2.0;
gridLo = Math.floor(lo / half) * half;
rows = i32(Math.ceil((hi - gridLo) / half)) + 1;
}
if (rows < 3) return; // the engine needs three prices for a grid
for (let r = 0; r < rows; r += 1) rowGex[r] = 0.0;
for (let k = 0; k < strikeCount; k += 1) {
const s = strikePrice[k];
if (s < lo || s > hi) continue;
const r = i32(Math.round((s - gridLo) / half));
if (r >= 0 && r < rows) rowGex[r] += callGex[k] - putGex[k];
}
const decimals = priceDecimals(half);
jbText("{\"prices\":[");
for (let r = 0; r < rows; r += 1) {
if (r > 0) jbByte(0x2c);
jbNum(gridLo + f64(r) * half, decimals);
}
jbText("],\"values\":[");
for (let r = 0; r < rows; r += 1) {
if (r > 0) jbByte(0x2c);
jbNum(Math.abs(rowGex[r]), 0); // bar length: the size of the strike's net exposure
}
jbText("],\"colors\":[");
for (let r = 0; r < rows; r += 1) {
if (r > 0) jbByte(0x2c);
jbText(rowGex[r] > 0.0 ? "\"#38bdf8\"" : rowGex[r] < 0.0 ? "\"#a78bfa\"" : "\"#94a3b8\""); // calls carry the strike: sky; puts: violet; an empty row draws nothing (opaque: the docked renderer ignores an alpha in a row colour and falls back to the plot colour)
}
jbText("]}");
jbFlush(gex_rows);
}
// ── Text helpers ──
function sbGrouped(n: i64): void { // 84210 -> 84,210
if (n >= 1000) {
sbGrouped(n / 1000);
sb_text(",");
const r = n % 1000;
if (r < 100) sb_text("0");
if (r < 10) sb_text("0");
sb_int(r);
} else sb_int(n);
}
function sbPrice(v: f64): void { // decimals follow the price's size: 84,210 / 2,431.55 / 12.345 / 0.13797
if (v >= 1000.0) sbGrouped(i64(Math.round(v)));
else if (v >= 100.0) sb_f64(v, 2);
else if (v >= 1.0) sb_f64(v, 3);
else sb_f64(v, 5);
}
function drawLevel(which: i32, name: String, price: f64, ink: i32, dashed: bool, nudge: bool): void { // a line across the chart at the strike and its price tag at the axis
const lineHandle = which == 0 ? callWallLine : which == 1 ? putWallLine : flipLine;
if (isNaN(price)) {
lineHandle.delete(); // no-ops when never drawn
tags[which].delete();
return;
}
lineHandle.set(firstT, price, t, price).color(ink).width(dashed ? 1.0 : 1.5).style(dashed ? LineStyle.Dashed : LineStyle.Solid).extend(Extend.Both);
sb_clear();
sb_text(name);
sb_text(" ");
sbPrice(price);
tags[which].set(nudge ? 168.0 : 56.0, price).text(str_tag_sb).color(ink).fill(PILL); // 56 px in from the right edge clears the axis tags (High, Low, last price reach about 46 px into the pane); a wall sitting on the flip steps further in so the two tags never overlap
}
function near(a: f64, b: f64): bool { // two levels within 0.3% of each other share a tag row
return !isNaN(a) && !isNaN(b) && Math.abs(a - b) <= Math.abs(b) * 0.003;
}
function onStart(): void {
windowPct = p_window_pct() / 100.0;
nearestExpiries = i32(p_nearest_expiries());
}
// onBar() runs once per bar: history rows carry an empty block and cost one read; the live bar measures the chain. Then the
// readings as numbers (NaN on history rows); the profile, the lines, the tags and the card's words on the live bar only.
function onBar(): void {
close = bar.close();
t = bar.time();
if (isNaN(firstT) && !isNaN(t)) firstT = t;
let chainRead = false; // the live bar carried a chain and it was measured
let chainCells = -1; // f64 values the live bar carried; -1 on history bars
if (bar.isLast()) {
chainCells = in_chain_cells();
if (chainCells >= TUPLE) {
measureChain(chainCells);
chainRead = strikeCount > 0;
}
}
if (isNaN(close)) return;
out_net_gex(chainRead ? netGex : NaN);
out_gamma_flip(chainRead ? flip : NaN);
out_call_wall(chainRead ? callWall : NaN);
out_put_wall(chainRead ? putWall : NaN);
out_max_pain(chainRead ? maxPain : NaN);
out_gex_tone(chainRead ? (netGex >= 0.0 ? 1.0 : 0.0) : NaN);
// The regime where spot trades: net GEX sums every strike, but the dealers' hedging turns at the flip, so the word
// asks which side of it spot is on. No flip (the running sum never changes sign) or no gamma leaves no word to name.
const regimeKnown = chainRead && !isNaN(netGex) && !isNaN(flip);
const longGamma = regimeKnown && close >= flip;
out_gamma_regime(regimeKnown ? (longGamma ? 2.0 : 0.0) : bar.isLast() ? 1.0 : NaN);
out_spot_vs_flip(chainRead && flip > 0.0 ? (close / flip - 1.0) * 100.0 : NaN); // NaN while the flip is: a sum that never changes sign has none
if (chainRead) {
writeProfile();
drawLevel(2, "Flip", flip, AMBER, true, false);
drawLevel(0, "Call wall", callWall, SKY, false, near(callWall, flip));
drawLevel(1, "Put wall", putWall, VIOLET, false, near(putWall, flip) || near(putWall, callWall));
// The card's words: the regime at spot leads, then the expiries counted for the title.
sb_clear();
if (isNaN(netGex)) sb_text("No gamma in the chain");
else if (isNaN(flip)) sb_text("No flip in range");
else sb_text(longGamma ? "Long gamma" : "Short gamma");
str_dealer_gamma_sb();
sb_clear();
if (nearestExpiries > 0 && nearestExpiries < expiryCount) {
sb_text("nearest ");
sb_int(nearestExpiries);
sb_text(" of ");
sb_int(expiryCount);
} else {
sb_text("all ");
sb_int(expiryCount);
}
sb_text(" expiries");
str_expiries_sb();
} else if (bar.isLast()) {
// The live bar carried no chain to measure: the first tile says why, so the card is never a row of blanks.
sb_clear();
sb_text(chainCells >= TUPLE ? "No live expiry in the chain" : "Waiting for the options chain");
str_dealer_gamma_sb();
sb_clear();
sb_text("no chain read");
str_expiries_sb();
}
}How it works
The chain is live only. input("chain", options_chain.cells, { max_cells: 4000, venue: "auto" }) delivers the chart coin's listed contracts as [strike, expiry_ms, side, oi, gamma, delta, mark_iv, underlying, multiplier, vega] tuples on the live row (a BTC chain is about 1,550); history bars carry an empty block, so the map is measured on the last bar and refreshed as the chain moves. venue: "auto" reads the chart's own market when it lists options, else the coin's Deribit chain.
GEX per contract. Gamma times open interest times the multiplier times spot squared times 0.01 (Deribit open interest is in coin units with multiplier 1; CME open interest is contracts times the point value); net is calls minus puts per strike, the flip the interpolated zero crossing of cumulative net GEX walking strikes upward. The far wings carry a few dollars each, and a chain narrowed to its nearest expiries can flip their running sum back and forth there, so when the sum crosses zero more than once the flip is the crossing nearest spot: with nearest_expiries at 2 the BTC chain's sum dithers around zero near 74,000, and the flip still reads about 86,100, where spot trades. A sum that lands exactly on zero crosses at that strike and the walk starts afresh after it, so no crossing is interpolated across the zero. nearest_expiries (0) counts every listed expiry or the N nearest. net_gex, gamma_flip, call_wall, put_wall, max_pain, gex_tone, gamma_regime and spot_vs_flip are data-only outputs, live bar only.
The window is the view. window_pct (6) sets the strikes shown in the docked profile and searched for the walls, percent around spot each side; the flip, max pain and net GEX always count the whole chain. frame("gex_rows") carries one row per grid price and feeds plot.levels, whose thickness_px: 12 keeps every bar at most 12 px tall, so a 1m chart reads the profile as a ladder instead of slabs over the axis; its text is appended to a static byte buffer and sent through the frame channel the file declares itself, so the live bar allocates nothing. The walls and the flip are line handles extended across the chart, each with a price tag written through the one bounded tag slot.
The word follows spot against the flip. The first tile names the regime where spot trades: "Long gamma" while the close is at or above the flip, "Short gamma" while it is under it, "No flip in range" when the running sum never changes sign. Net GEX is a different number: it sums every strike of the chain, so the card can read "Short gamma" beside a positive net GEX. Both are true at once: the chain as a whole is net long gamma, and spot trades under the strike where that running sum turns, the side where the dealers' hedging chases moves. gamma_regime carries the word as a number (0 short, 1 no reading, 2 long); gex_tone stays the sign of net GEX.
The card reads the outputs. render.hud("gamma", { position: "bottom_center", look: "terminal", columns: 2, tiles }) pins the card. The first tile is a headline tile.pill over the dealer_gamma slot, coloured by the gamma_regime ladder: bear red at 0 (short gamma), slate at 1 (no reading), bull green at 2 (long gamma). Two tile.value tiles read net_gex in usd ($291.2M) and spot_vs_flip in %: the value is already a percent, printed with two decimals, a minus sign while spot sits under the flip and no sign above it. A tile.rows reads the flip, the walls and max pain in auto (thousands separators, a whole strike without decimals: 90,000), numbered 1) to 4) in their labels; a wall's row carries a dot in its line's colour, because a row's dot is the colour declared on its output. The title, "Gamma, {{expiries}}", reads the expiries slot, so it says which expiries were counted. Every tile reads the newest row, the live bar's, and the two slots are written there only.
The look draws the tiles. look: "terminal" sets a black card with square corners and monospaced type, the title and the labels in capitals, the labels in amber over white numbers, and the title on a full-width amber bar in black ink; the headline prints as its word alone in its ladder colour. It keeps its colours on a dark and a light chart alike (Looks).
Where the card sits. At the bottom centre: the legend holds the top left, the action bar and the High and Low tags the top right, the docked profile and the level tags the right edge, and the app's floating button the bottom left. The card covers older volume bars, never the newest candles.
Where it runs
Charts of a coin Deribit lists options for (BTC, ETH, SOL and the rest of its list), on any venue: Binance Futures BTCUSDT, Binance spot ETHUSDT and so on. The chart serves the newest chain on the live bar only, every history bar an empty block, and refreshes it with a snapshot about every 30 seconds. A CME futures chart has its own chain, but the editor's Run is paused on CME markets and a community indicator cannot read CME data; OpenMarket's official wrun indicators can.
When data is missing
Other markets are refused by name before any fetch, so the card does not draw there: the run stops, the legend marks the indicator, and the Console names the input and the way out: Input 'chain' reads options_chain cells, but the chart market HYPERLIQUID_FUTURES/PURR has no option chain (wrun_options_chain_unavailable): coin 'PURR' is not listed on Deribit and the market is not an options venue; open a CME futures chart or a chart of a coin Deribit lists (BTC, ETH, SOL, ...). Gold refuses the same way. On a chain it cannot use, the first tile says why instead of the card going blank: "No live expiry in the chain" when every listed expiry has passed, "No gamma in the chain" when the venue serves no gamma, "Waiting for the options chain" while the live bar carries none. A flip that never crosses zero turns the first tile into "No flip in range" in slate, leaves "Spot vs the flip" and the flip's row reading "-", and draws no line; a side with no strike inside the window leaves its wall's row reading "-" and draws no wall. The levels can also sit outside the price range on screen: the lines are there, the chart's scale does not stretch to them, and the card's rows still read them.
Customize it
- A narrower map. Lower
window_pct; the walls are searched inside the same window. - Front expiries only. Set
nearest_expiriesto 1 or 2 for the gamma that moves this week; the title bar then reads "Gamma, nearest 2 of 13 expiries", in capitals. - Pin the venue. Change
venue: "auto"to"deribit"and Run again to read the coin's Deribit chain even where the chart's market lists options of its own."cme"reads a CME futures chart's chain and is refused on any other chart;"binance","okx","bybit","bullish"and"derive"read that venue's chain and are refused on a coin the venue does not list. - Another place for the card. Change
positionto another of the nine anchors (middle_leftworks too); keep the right edge free for the profile and the tags. - Change the look.
look:takes any of the twelve shipped looks,defaulttoclassic(Looks), and a word declared beside it still wins; the Look row on the indicator's Style page switches it without code (The Style page).
Run it
- In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Gamma Map under HUDs.
- Press Run on a BTC or ETH chart: the profile docks on the price axis once the chain arrives, the walls and the flip draw across the chart, and the terminal card appears at the bottom centre.
- At the editor's Console prompt, type
net_gexandspot_vs_flipto read the chain's net GEX and spot's distance from the flip on the live bar.
Concepts used
- Data sources for the
options_chaincelled class, its tuple and thevenueword - Cards, frames and panels for frames and
plot.levels - Drawing objects for line handles extended across the chart and right-anchored tags
- HUD cards and Blocks and tiles for the card, its tiles, the headline, the ladder colour and the slot in the title
- Looks for
look: "terminal", its title bar and what it draws for each tile kind - The Style page for the Look row that switches the look without code