Premium by strike

View as MarkdownOpen the editor

Where the options money on the chart's underlying went, strike by strike, drawn behind the candles as a heat map with one column per bar and one row per strike step, and docked on the price axis as the session's net premium by strike.

A strike's net premium is its bullish premium minus its bearish premium: calls bought at the ask and puts sold at the bid are bullish, puts bought at the ask and calls sold at the bid bearish. A green cell is money leaning up at that strike in that bar, a red cell money leaning down, and the docked bars sum the session.

The parts are a celled option_contract_flow.cells input with strike_window_pct: 10 (Data sources), an out.grid and a plot.heatmap with center: 0 over it (Price canvases), a plot.levels profile with a colour per row (Docked profiles) and isPartial for the bars the data plane cut down (Data sources). This is also the premium-by-strike template: the Premium by Strike card under Order flow in the editor's starter list, and it compiles as written.

The wrun indicator

// Premium by Strike: where the options money on the chart's underlying went, strike by strike, drawn behind the
// candles as a heat map, one column per bar and one row per strike step, and docked on the price axis as the
// session's net premium by strike. A strike's net premium is its bullish premium minus its bearish premium: calls
// bought at the ask and puts sold at the bid are bullish, puts bought at the ask and calls sold at the bid bearish,
// so a green cell is money leaning up at that strike in that bar and a red cell money leaning down. Premium at the
// mid or with no quote has no side and counts in neither. The rows are the chain's strike spacing (read off the
// strikes that traded: the most common gap near the price) unless Row height sets one, 48 of them centred on each
// bar's close; the docked profile sums the session (each trade date starts again) over the strikes within Profile
// window of the close. The cells come from option_contract_flow with the strikes within 10% of the underlying: a
// bar whose block was cut down (the partial bit, 14) gets an amber dot under its candle, its column a floor; a
// tuple whose contract is not whole (a root the symbol codec refuses, no expiry, a side other than call or put, no
// strike) is skipped and counted. 1-minute cells reach back 7 days and 5-minute cells 30 days.

section("Strikes");
param.number("row_step", 0, { min: 0, max: 1000, step: 0.5, label: "Row height", description: "Price per heat row: 0 reads the chain's strike spacing" });
param.number("profile_pct", 8, { min: 1, max: 50, label: "Profile window, percent", description: "Strikes docked on the price axis: percent around the close, each side" });
input("close", ohlcv.close); // the chart's own candles: the bar grid and each column's centre
input("trade_date", time.trade_date); // the bar's trade date: the docked profile starts again on each
input("flow", option_contract_flow.cells, { max_cells: 4000, strike_window_pct: 10, description: "Each contract within 10% of the underlying that traded in the bar: [root_code, expiry_ms, side_cp, strike, vol_ask, vol_bid, vol_mid, vol_none, prem_ask, prem_bid, share_delta_net, vwap, last_price, iv_last, vol_above_ask, vol_below_bid, present]" });
output("partial_mark", shape, overlay, { color: "#f59e0b", description: "A dot under the candle whose contract flow was cut down (bit 14): its column is a floor" }); // first output on price: the package homes on the chart
output("heat_low", none, overlay, { description: "The bottom edge of the heat map's lowest row on this bar" }); // data-only: the heatmap reads it
output("heat_step", none, overlay, { description: "The heat map's row height in price on this bar" });
out.grid("heat", { rows: 48 }); // 48 data-only outputs heat_0..heat_47, one per row, written with out_heat(row, value)
plot.heatmap({ name: "net_heat", grid: "heat", price_low: "heat_low", price_step: "heat_step", center: 0, auto_quantile: 0.98, opacity: 0.7, behind_candles: true, label: "Net premium by strike", tooltip: "{{value:usd}} net at {{price}}" }); // the diverging default: the chart's down colour, its background, its up colour
// The readings, data-only: the Console reads them.
output("session_net", none, overlay, { description: "Bullish minus bearish premium over the session's strikes, USD" });
output("top_bull_strike", none, overlay, { description: "The strike holding the session's largest bullish net premium" });
output("top_bull_net", none, overlay, { description: "Its net premium, USD" });
output("top_bear_strike", none, overlay, { description: "The strike holding the session's largest bearish net premium" });
output("top_bear_net", none, overlay, { description: "Its net premium, USD (below zero)" });
output("skipped", none, overlay, { description: "Tuples of this bar skipped because their contract is not whole" });
string("note", { max_bytes: 64 }); // the one line while there is nothing to draw
const strike_rows = frame("strike_rows", { max_bytes: 24576 });
plot.levels({ name: "session_by_strike", frame: strike_rows, dock: "right", width_frac: 0.14, baseline: "center", labels: false, thickness_px: 8, hover: true, format: "usd" });
render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] });

const TUPLE = 17; // f64s per contract: OPTION_CONTRACT_FLOW_TUPLE
const ROWS = 48; // the heat map's rows, the grid's declared count
const MAX_SEEN = 1024; // distinct strikes kept over the run, the nearest the close: the spacing reads them
const MAX_STRIKES = 512; // the session's strikes, the nearest the close
const MAX_PROFILE = 256; // rows docked on the axis
const STEPS: StaticArray<f64> = [0.5, 1.0, 2.0, 2.5, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0]; // the listed strike spacings
const stepCount = new StaticArray<i32>(10);
const seen = new StaticArray<f64>(MAX_SEEN); // the run's distinct strikes, ascending
const strikeAt = new StaticArray<f64>(MAX_STRIKES); // the session's strikes, ascending, and their net premium
const netAt = new StaticArray<f64>(MAX_STRIKES);
const rowHeat = new StaticArray<f64>(ROWS); // this bar's column
const rowTouched = new StaticArray<bool>(ROWS);

let fixedStep = 0.0; // settings, read in onStart()
let profilePct = 0.08;
let day: f64 = NaN; // the trade date the profile sums
let seenCount = 0;
let strikeCount = 0;
let runCells = 0; // tuples read over the whole run
let close: f64 = NaN; // this bar

function onStart(): void {
  fixedStep = p_row_step();
  profilePct = p_profile_pct() / 100.0;
}

// The symbol codec's rule without decoding: a whole number from 1 to 38^10 - 1 with no zero digit in base 38.
function rootOk(code: f64): bool {
  if (!(code >= 1.0) || code > 6278211847988223.0 || code != Math.floor(code)) return false;
  let rest = code;
  while (rest > 0.0) {
    const digit = rest % 38.0;
    if (digit == 0.0) return false;
    rest = (rest - digit) / 38.0;
  }
  return true;
}

// A tuple counts when its contract is whole and its present mask is a whole number from 0 up.
function contractOk(cells: StaticArray<f64>, i: i32): bool {
  const present = cells[i + 16];
  const side = cells[i + 2];
  return rootOk(cells[i]) && cells[i + 1] > 0.0 && (side == 1.0 || side == -1.0) && cells[i + 3] > 0.0 && present >= 0.0 && present == Math.floor(present);
}

// Where a strike sits in an ascending table: its index when listed, else the index it would take.
function lowerBound(keys: StaticArray<f64>, count: i32, key: f64): i32 {
  let lo = 0;
  let hi = count;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (keys[mid] < key) lo = mid + 1;
    else hi = mid;
  }
  return lo;
}

// Which end of a full table leaves for a new strike: 0 the low end, 1 the high end, -1 the new strike itself (it is
// the farthest from the close).
function evict(keys: StaticArray<f64>, count: i32, strike: f64): i32 {
  const dLow = Math.abs(keys[0] - close);
  const dHigh = Math.abs(keys[count - 1] - close);
  const dNew = Math.abs(strike - close);
  if (dNew >= dLow && dNew >= dHigh) return -1;
  return dLow >= dHigh ? 0 : 1;
}

// One more distinct strike into the run's table, the nearest the close kept.
function noteSeen(strike: f64): void {
  let at = lowerBound(seen, seenCount, strike);
  if (at < seenCount && seen[at] == strike) return;
  if (seenCount >= MAX_SEEN) {
    const end = evict(seen, seenCount, strike);
    if (end < 0) return;
    if (end == 0) {
      for (let k = 1; k < seenCount; k += 1) seen[k - 1] = seen[k];
      at -= 1;
    }
    seenCount -= 1;
    if (at > seenCount) at = seenCount;
  }
  for (let k = seenCount; k > at; k -= 1) seen[k] = seen[k - 1];
  seen[at] = strike;
  seenCount += 1;
}

// A contract's net premium onto its strike for the session, the nearest strikes to the close kept.
function addNet(strike: f64, net: f64): void {
  let at = lowerBound(strikeAt, strikeCount, strike);
  if (at < strikeCount && strikeAt[at] == strike) {
    netAt[at] += net;
    return;
  }
  if (strikeCount >= MAX_STRIKES) {
    const end = evict(strikeAt, strikeCount, strike);
    if (end < 0) return;
    if (end == 0) {
      for (let k = 1; k < strikeCount; k += 1) {
        strikeAt[k - 1] = strikeAt[k];
        netAt[k - 1] = netAt[k];
      }
      at -= 1;
    }
    strikeCount -= 1;
    if (at > strikeCount) at = strikeCount;
  }
  for (let k = strikeCount; k > at; k -= 1) {
    strikeAt[k] = strikeAt[k - 1];
    netAt[k] = netAt[k - 1];
  }
  strikeAt[at] = strike;
  netAt[at] = net;
  strikeCount += 1;
}

// The chain's strike spacing: the most common gap between neighbouring strikes within 10% of the close, matched to
// a listed spacing (the smaller on a tie); the smallest gap when none matches; NaN with fewer than two strikes.
function spacing(): f64 {
  for (let c = 0; c < 10; c += 1) stepCount[c] = 0;
  let smallest: f64 = NaN;
  let prev: f64 = NaN;
  for (let k = 0; k < seenCount; k += 1) {
    const strike = seen[k];
    if (Math.abs(strike - close) > close * 0.1) continue;
    if (!isNaN(prev)) {
      const gap = strike - prev;
      if (gap > 0.0 && (isNaN(smallest) || gap < smallest)) smallest = gap;
      for (let c = 0; c < 10; c += 1) {
        if (Math.abs(gap - STEPS[c]) <= STEPS[c] * 0.01) {
          stepCount[c] += 1;
          break;
        }
      }
    }
    prev = strike;
  }
  let best = -1;
  for (let c = 0; c < 10; c += 1) if (stepCount[c] > 0 && (best < 0 || stepCount[c] > stepCount[best])) best = c;
  return best >= 0 ? STEPS[best] : smallest;
}

function writeProfile(): void {
  const lo = close * (1.0 - profilePct);
  const hi = close * (1.0 + profilePct);
  let first = 0;
  while (first < strikeCount && strikeAt[first] < lo) first += 1;
  let last = first;
  while (last < strikeCount && strikeAt[last] <= hi) last += 1;
  while (last - first > MAX_PROFILE) { // a wide window on a fine ladder: drop the row farther from the close
    if (close - strikeAt[first] >= strikeAt[last - 1] - close) first += 1;
    else last -= 1;
  }
  if (last <= first) return; // no strike inside the window: the profile stays absent
  fb_clear();
  fb_text("{\"prices\":[");
  for (let k = first; k < last; k += 1) {
    if (k > first) fb_text(",");
    fb_num(strikeAt[k]);
  }
  fb_text("],\"values\":[");
  for (let k = first; k < last; k += 1) {
    if (k > first) fb_text(",");
    fb_f64(netAt[k], 0);
  }
  fb_text("],\"colors\":[");
  for (let k = first; k < last; k += 1) {
    if (k > first) fb_text(",");
    fb_text(netAt[k] > 0.0 ? "\"#22c55e\"" : netAt[k] < 0.0 ? "\"#ef4444\"" : "\"#94a3b8\""); // bullish green, bearish red
  }
  fb_text("]}");
  writeFrameBuffer(strike_rows);
}

// onBar() runs once per bar: a new trade date starts the profile again; the bar's block adds each contract's net
// premium to its strike for the session and to its heat row for this bar's column; then the readings, the column
// and, on the live bar, the docked profile.
function onBar(): void {
  close = bar.close();
  const date = in_trade_date();
  if (!isNaN(date) && date != day) {
    day = date;
    strikeCount = 0;
  }
  for (let r = 0; r < ROWS; r += 1) {
    rowHeat[r] = 0.0;
    rowTouched[r] = false;
  }
  const n = in_flow_cells();
  let skipped = 0;
  let partial = false;
  if (n >= TUPLE && !isNaN(close)) {
    const cells = in_flow_view();
    for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) { // the strikes first: the spacing reads them
      if (contractOk(cells, i)) noteSeen(cells[i + 3]);
    }
    const step = fixedStep > 0.0 ? fixedStep : spacing();
    const gridLow = isNaN(step) ? NaN : (Math.round(close / step) - f64(ROWS / 2)) * step - step * 0.5; // rows centred on the spacing's multiples
    for (let i = 0; i + TUPLE - 1 < n; i += TUPLE) {
      if (!contractOk(cells, i)) {
        skipped += 1;
        continue;
      }
      runCells += 1;
      if (isPartial(cells[i + 16])) partial = true;
      const strike = cells[i + 3];
      const call = cells[i + 2] > 0.0;
      const net = call ? cells[i + 8] - cells[i + 9] : cells[i + 9] - cells[i + 8]; // bullish minus bearish
      addNet(strike, net);
      if (isNaN(gridLow)) continue;
      const r = i32(Math.floor((strike - gridLow) / step));
      if (r < 0 || r >= ROWS) continue; // off this bar's column
      rowHeat[r] += net;
      rowTouched[r] = true;
    }
    out_heat_low(gridLow);
    out_heat_step(step);
  } else {
    out_heat_low(NaN);
    out_heat_step(NaN);
  }
  for (let r = 0; r < ROWS; r += 1) out_heat(r, rowTouched[r] ? rowHeat[r] : NaN); // NaN = an empty cell
  out_partial_mark(partial ? bar.low() : NaN);
  out_skipped(f64(skipped));
  let sessionNet = 0.0;
  let bullStrike: f64 = NaN;
  let bullNet: f64 = NaN;
  let bearStrike: f64 = NaN;
  let bearNet: f64 = NaN;
  for (let k = 0; k < strikeCount; k += 1) {
    const net = netAt[k];
    sessionNet += net;
    if (net > 0.0 && (isNaN(bullNet) || net > bullNet)) {
      bullNet = net;
      bullStrike = strikeAt[k];
    }
    if (net < 0.0 && (isNaN(bearNet) || net < bearNet)) {
      bearNet = net;
      bearStrike = strikeAt[k];
    }
  }
  out_session_net(strikeCount > 0 ? sessionNet : NaN);
  out_top_bull_strike(bullStrike);
  out_top_bull_net(bullNet);
  out_top_bear_strike(bearStrike);
  out_top_bear_net(bearNet);
  if (!bar.isLast()) return;
  if (strikeCount > 0 && !isNaN(close)) writeProfile();
  str_note(runCells == 0 ? "No contract flow on this chart yet" : strikeCount == 0 ? "No contract flow yet this session" : ""); // an empty text draws no label
}

How it works

Net premium per contract. Each tuple carries the contract's premium at the ask and at the bid in the bar. A call's net is the ask premium minus the bid premium, a put's the bid premium minus the ask premium, so both read bullish above zero. Premium at the mid or with no quote has no side and counts in neither.

The rows. The heat map's 48 rows sit on the chain's strike spacing: the package keeps every distinct strike that traded, and the spacing is the most common gap between neighbouring strikes within 10% of the close, matched to a listed spacing (0.5, 1, 2, 2.5, 5 and up); Row height sets one by hand. Each bar's rows are centred on its close, one row per spacing step with a strike in the middle of each, so a contract lands on the row holding its strike. The rows of a bar sum its contracts there; a row no contract touched draws nothing.

The session by strike. The same nets add into a table of the session's strikes, which a new trade date empties; on the live bar the strikes within Profile window of the close dock on the price axis, green above zero and red below. top_bull_strike and top_bear_strike name the session's strongest strikes either way.

Where it runs

US stock and ETF charts, and the option contract charts of those underlyings, through OpenMarket's official package once the chart serves option_contract_flow. The class is upcoming: the kit declares it ahead of the chart lane, and a copy you run from the editor follows the US options rule on Limitations. The read keeps the strikes within 10% of the underlying; one-minute cells reach back 7 days and five-minute cells 30 days.

When data is missing

A bar whose block the data plane cut down (the partial bit, 14, past a read's row cap on a busy underlying) gets an amber dot under its candle: its column is a floor. A bar with no block draws an empty column. A tuple whose contract is not whole (a root the symbol codec refuses, no expiry, a side other than call or put, no strike) is skipped and counted in skipped. A label at the top of the chart says "No contract flow on this chart yet" while no bar of the run carried a contract, and "No contract flow yet this session" while the newest session has none.

Customize it

  • Coarser rows. Set Row height to 5 on a stock whose strikes crowd, so each row sums a few strikes.
  • The profile's reach. Widen Profile window to see the far strikes the session's money reached.
  • One expiry. Add expiries: "nearest:1" to the input to paint the front expiry alone.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Premium by Strike under Order flow.
  2. Press Run on a US stock chart: the official package paints the heat map behind the candles and docks the session's profile.
  3. At the editor's Console prompt, type session_net, top_bull_strike and top_bear_strike to read the session.

Concepts used