Options at price

View as MarkdownOpen the editor

The options traded on the chart's underlying over the last 1 to 30 sessions, by the price the stock stood at when they traded, docked on the price axis as a profile.

Sentiment, the default, splits each row into bullish (calls bought at the ask and puts sold at the bid), neutral (the prints at the mid or with no quote) and bearish (puts bought at the ask and calls sold at the bid); Calls and puts splits it into the two. The Measure setting picks contracts or premium. The rows span the window's lowest to highest price, and the busiest row is marked: the price where the window's options traded most.

The parts are sixteen scalar options_flow inputs, the contracts and the premium by side for calls and puts (Data sources), the chart's own high, low and close, the trade date and session from the time source (Data sources), and a plot.levels profile docked on the price axis with stacked series and a poc_price in its frame (Docked profiles). This is also the options-at-price template: the Options at Price card under Beyond the time axis in the editor's starter list, and it compiles as written.

The wrun indicator

// Options at Price: the options traded on the chart's underlying over the last 1 to 30 sessions, by the price the
// stock stood at when they traded, docked on the price axis as a profile. Each bar's option flow lands on the row
// holding the bar's typical price, (high + low + close) / 3, so a finer chart places it more exactly. Sentiment (the
// default) splits each row into bullish (calls bought at the ask and puts sold at the bid), neutral (the prints at
// the mid or with no quote) and bearish (puts bought at the ask and calls sold at the bid); Calls and puts splits it
// into the two. Contracts or premium, by the Measure setting. The rows span the window's lowest to highest typical
// price, and the busiest row is marked. The rows come from options_flow, one per bar: 1-minute rows reach back 7
// days and 5-minute rows 30 days, so a window longer than the tier's reach is a floor; a bar without a row adds
// nothing, and the regular-session bars that had none are counted.

section("Profile");
param.int("sessions", 5, { min: 1, max: 30, label: "Sessions", description: "The trade dates the profile sums, the newest first" });
param.int("rows", 48, { min: 8, max: 128, label: "Rows", description: "Price rows between the window's lowest and highest typical price" });
param.choice("measure", ["contracts", "premium"], "contracts", { label: "Measure", description: "contracts, or premium in USD" });
param.choice("split", ["sentiment", "calls_puts"], "sentiment", { label: "Split", description: "sentiment: bullish, neutral and bearish; calls_puts: calls and puts" });
input("close", ohlcv.close); // the chart's own candles: the bar grid and the typical price
input("high", ohlcv.high);
input("low", ohlcv.low);
input("trade_date", time.trade_date); // the bar's trade date: the window counts them
input("session", time.session); // 1 in the regular session: the bars a missing row is counted on
input("call_ask", options_flow.call_contracts_ask, { missing: "nan", description: "Call contracts bought at the ask in the bar, the chart's underlying" }); // NaN on a bar the class serves no row for
input("call_bid", options_flow.call_contracts_bid, { missing: "nan", description: "Call contracts sold at the bid" });
input("call_mid", options_flow.call_contracts_mid, { missing: "nan", description: "Call contracts printed at the mid" });
input("call_none", options_flow.call_contracts_none, { missing: "nan", description: "Call contracts printed with no quote" });
input("put_ask", options_flow.put_contracts_ask, { missing: "nan", description: "Put contracts bought at the ask" });
input("put_bid", options_flow.put_contracts_bid, { missing: "nan", description: "Put contracts sold at the bid" });
input("put_mid", options_flow.put_contracts_mid, { missing: "nan", description: "Put contracts printed at the mid" });
input("put_none", options_flow.put_contracts_none, { missing: "nan", description: "Put contracts printed with no quote" });
input("call_prem_ask", options_flow.call_prem_ask, { missing: "nan", description: "Call premium bought at the ask, USD" });
input("call_prem_bid", options_flow.call_prem_bid, { missing: "nan", description: "Call premium sold at the bid, USD" });
input("call_prem_mid", options_flow.call_prem_mid, { missing: "nan", description: "Call premium at the mid, USD" });
input("call_prem_none", options_flow.call_prem_none, { missing: "nan", description: "Call premium with no quote, USD" });
input("put_prem_ask", options_flow.put_prem_ask, { missing: "nan", description: "Put premium bought at the ask, USD" });
input("put_prem_bid", options_flow.put_prem_bid, { missing: "nan", description: "Put premium sold at the bid, USD" });
input("put_prem_mid", options_flow.put_prem_mid, { missing: "nan", description: "Put premium at the mid, USD" });
input("put_prem_none", options_flow.put_prem_none, { missing: "nan", description: "Put premium with no quote, USD" });
// The readings, data-only, on the live bar: the Console reads them.
output("poc_price", none, overlay, { description: "The middle of the busiest row, the price the window's options traded most at; live bar only" });
output("window_total", none, overlay, { description: "Everything the profile holds, in the Measure setting's unit; live bar only" });
output("bull_share", none, overlay, { description: "Bullish over bullish plus bearish, percent: the sided prints only; live bar only" });
output("sessions_read", none, overlay, { description: "The trade dates the profile holds, at most the Sessions setting; live bar only" });
output("missing_bars", none, overlay, { description: "Regular-session bars inside the window that carried no flow row; live bar only" });
string("note", { max_bytes: 72 }); // the one line while the profile is a floor or empty
// The two profiles, one per split; only the one the setting picks is written, so the other stays absent.
const sentiment_rows = frame("sentiment_rows", { max_bytes: 32768 });
const side_rows = frame("side_rows", { max_bytes: 24576 });
plot.levels({ name: "by_sentiment", frame: sentiment_rows, dock: "right", width_frac: 0.18, labels: false, hover: true, format: "si", poc: true, poc_label: true, series: [{ name: "Bullish", color: "#22c55e" }, { name: "Neutral", color: "#94a3b8" }, { name: "Bearish", color: "#ef4444" }] });
plot.levels({ name: "by_side", frame: side_rows, dock: "right", width_frac: 0.18, labels: false, hover: true, format: "si", poc: true, poc_label: true, series: [{ name: "Calls", color: "#22c55e" }, { name: "Puts", color: "#ef4444" }] });
render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] });

const MAX_BARS = 16384; // flow bars kept, the newest: 30 sessions of 1-minute regular-session bars fit
const MAX_ROWS = 128;
const barPrice = new StaticArray<f64>(MAX_BARS); // each kept bar's typical price, trade date and flow, a ring
const barDate = new StaticArray<f64>(MAX_BARS);
const barBull = new StaticArray<f64>(MAX_BARS);
const barNeutral = new StaticArray<f64>(MAX_BARS);
const barBear = new StaticArray<f64>(MAX_BARS);
const barCalls = new StaticArray<f64>(MAX_BARS);
const barPuts = new StaticArray<f64>(MAX_BARS);
const rowBull = new StaticArray<f64>(MAX_ROWS); // the profile, folded on the live bar
const rowNeutral = new StaticArray<f64>(MAX_ROWS);
const rowBear = new StaticArray<f64>(MAX_ROWS);
const rowCalls = new StaticArray<f64>(MAX_ROWS);
const rowPuts = new StaticArray<f64>(MAX_ROWS);
const DAYS = 32; // the newest regular-session trade dates, each with its bars that carried no flow row
const dayDate = new StaticArray<f64>(DAYS);
const dayMissing = new StaticArray<i32>(DAYS);

let sessions = 5; // settings, read in onStart()
let rowCount = 48;
let premium = false;
let bySide = false;
let head = 0; // the ring's next slot
let kept = 0; // bars in the ring
let dayHead = 0; // the dates' ring
let days = 0;

function onStart(): void {
  sessions = i32(p_sessions());
  rowCount = i32(p_rows());
  premium = p_measure() == 1.0;
  bySide = p_split() == 1.0;
}

function noteSession(date: f64, missed: bool): void { // a regular-session bar of this trade date, with or without a row
  const last = (dayHead + DAYS - 1) % DAYS;
  if (days == 0 || dayDate[last] != date) {
    dayDate[dayHead] = date;
    dayMissing[dayHead] = 0;
    dayHead = (dayHead + 1) % DAYS;
    if (days < DAYS) days += 1;
  }
  if (missed) dayMissing[(dayHead + DAYS - 1) % DAYS] += 1;
}

function writeProfile(lo: f64, step: f64, first: i32, last: i32, pocPrice: f64): void {
  fb_clear();
  fb_text("{\"poc_price\":");
  fb_num(pocPrice);
  fb_text(",\"prices\":[");
  for (let r = first; r <= last; r += 1) {
    if (r > first) fb_text(",");
    fb_num(lo + step * (f64(r) + 0.5));
  }
  fb_text("],\"values\":[");
  for (let r = first; r <= last; r += 1) {
    if (r > first) fb_text(",");
    fb_num(bySide ? rowCalls[r] + rowPuts[r] : rowBull[r] + rowNeutral[r] + rowBear[r]);
  }
  fb_text("],\"series\":[");
  if (bySide) {
    fb_text("[");
    for (let r = first; r <= last; r += 1) {
      if (r > first) fb_text(",");
      fb_num(rowCalls[r]);
    }
    fb_text("],[");
    for (let r = first; r <= last; r += 1) {
      if (r > first) fb_text(",");
      fb_num(rowPuts[r]);
    }
    fb_text("]]}");
    writeFrameBuffer(side_rows);
    return;
  }
  fb_text("[");
  for (let r = first; r <= last; r += 1) {
    if (r > first) fb_text(",");
    fb_num(rowBull[r]);
  }
  fb_text("],[");
  for (let r = first; r <= last; r += 1) {
    if (r > first) fb_text(",");
    fb_num(rowNeutral[r]);
  }
  fb_text("],[");
  for (let r = first; r <= last; r += 1) {
    if (r > first) fb_text(",");
    fb_num(rowBear[r]);
  }
  fb_text("]]}");
  writeFrameBuffer(sentiment_rows);
}

// The live bar: the window is the newest Sessions regular-session trade dates; walk back through the ring over the
// bars inside it, span the rows over their typical prices, fold every bar onto its row, write the profile and the
// readings.
function measure(): void {
  const inWindow = days < sessions ? days : sessions; // trade dates in the window
  let oldest: f64 = Infinity; // the window's first trade date
  let missingInWindow = 0;
  for (let k = 0; k < inWindow; k += 1) {
    const at = (dayHead - 1 - k + DAYS) % DAYS;
    oldest = dayDate[at];
    missingInWindow += dayMissing[at];
  }
  let count = 0; // ring entries inside the window, newest first
  let flowDates = 0; // trade dates inside the window that carried a flow row
  let lastDate: f64 = NaN;
  for (let k = 0; k < kept; k += 1) {
    const at = (head - 1 - k + MAX_BARS) % MAX_BARS;
    if (!(barDate[at] >= oldest)) break;
    if (barDate[at] != lastDate) {
      flowDates += 1;
      lastDate = barDate[at];
    }
    count += 1;
  }
  out_sessions_read(f64(flowDates));
  out_missing_bars(f64(missingInWindow));
  if (count == 0) {
    out_poc_price(NaN);
    out_window_total(NaN);
    out_bull_share(NaN);
    str_note(kept == 0 ? "No options flow on this chart yet" : "No options flow in the window");
    return;
  }
  let lo: f64 = Infinity;
  let hi: f64 = -Infinity;
  for (let k = 0; k < count; k += 1) {
    const at = (head - 1 - k + MAX_BARS) % MAX_BARS;
    if (barPrice[at] < lo) lo = barPrice[at];
    if (barPrice[at] > hi) hi = barPrice[at];
  }
  const rowsUsed = hi > lo ? rowCount : 1;
  const step = hi > lo ? (hi - lo) / f64(rowCount) : Math.max(Math.abs(lo) * 0.001, 0.01); // one row around a single price
  const base = hi > lo ? lo : lo - step * 0.5;
  for (let r = 0; r < rowsUsed; r += 1) {
    rowBull[r] = 0.0;
    rowNeutral[r] = 0.0;
    rowBear[r] = 0.0;
    rowCalls[r] = 0.0;
    rowPuts[r] = 0.0;
  }
  let bull = 0.0;
  let bear = 0.0;
  let total = 0.0;
  for (let k = 0; k < count; k += 1) {
    const at = (head - 1 - k + MAX_BARS) % MAX_BARS;
    let r = i32(Math.floor((barPrice[at] - base) / step));
    if (r >= rowsUsed) r = rowsUsed - 1; // the highest price sits on the top row's upper edge
    if (r < 0) r = 0;
    rowBull[r] += barBull[at];
    rowNeutral[r] += barNeutral[at];
    rowBear[r] += barBear[at];
    rowCalls[r] += barCalls[at];
    rowPuts[r] += barPuts[at];
    bull += barBull[at];
    bear += barBear[at];
    total += barCalls[at] + barPuts[at];
  }
  let poc = 0;
  let best: f64 = -1.0;
  for (let r = 0; r < rowsUsed; r += 1) {
    const row = rowCalls[r] + rowPuts[r];
    if (row > best) {
      best = row;
      poc = r;
    }
  }
  const pocPrice = base + step * (f64(poc) + 0.5);
  writeProfile(base, step, 0, rowsUsed - 1, pocPrice); // the frame names the busiest row, so the chart marks the same one
  out_poc_price(pocPrice);
  out_window_total(total);
  out_bull_share(bull + bear > 0.0 ? (100.0 * bull) / (bull + bear) : NaN);
  sb_clear();
  if (inWindow < sessions) {
    sb_text("The chart holds ");
    sb_int(inWindow);
    sb_text(" of ");
    sb_int(sessions);
    sb_text(" sessions");
  } else if (flowDates < inWindow) {
    sb_text("Options flow on ");
    sb_int(flowDates);
    sb_text(" of ");
    sb_int(inWindow);
    sb_text(" sessions");
  } else if (missingInWindow > 0) {
    sb_text("A floor: ");
    sb_int(missingInWindow);
    sb_text(missingInWindow == 1 ? " session bar had no flow row" : " session bars had no flow row");
  }
  str_note_sb(); // an empty text draws no label
}

// onBar() runs once per bar: a bar with a flow row joins the ring at its typical price, in the Measure setting's
// unit; a regular-session bar without one is counted on its trade date; the live bar measures the window.
function onBar(): void {
  const close = in_close();
  const high = in_high();
  const low = in_low();
  const date = in_trade_date();
  const callAsk = premium ? in_call_prem_ask() : in_call_ask();
  const callBid = premium ? in_call_prem_bid() : in_call_bid();
  const callOther = premium ? in_call_prem_mid() + in_call_prem_none() : in_call_mid() + in_call_none();
  const putAsk = premium ? in_put_prem_ask() : in_put_ask();
  const putBid = premium ? in_put_prem_bid() : in_put_bid();
  const putOther = premium ? in_put_prem_mid() + in_put_prem_none() : in_put_mid() + in_put_none();
  const served = !isNaN(callAsk) && !isNaN(callBid) && !isNaN(callOther) && !isNaN(putAsk) && !isNaN(putBid) && !isNaN(putOther);
  const price = (high + low + close) / 3.0;
  if (in_session() == 1.0 && !isNaN(date)) noteSession(date, !served);
  if (served && !isNaN(price) && !isNaN(date)) {
    barPrice[head] = price;
    barDate[head] = date;
    barBull[head] = callAsk + putBid;
    barNeutral[head] = callOther + putOther;
    barBear[head] = putAsk + callBid;
    barCalls[head] = callAsk + callBid + callOther;
    barPuts[head] = putAsk + putBid + putOther;
    head = (head + 1) % MAX_BARS;
    if (kept < MAX_BARS) kept += 1;
  }
  if (bar.isLast()) measure();
  else {
    out_poc_price(NaN);
    out_window_total(NaN);
    out_bull_share(NaN);
    out_sessions_read(NaN);
    out_missing_bars(NaN);
  }
}

How it works

A bar's flow at a price. The flow rows carry no underlying price, so each bar's options land on the row holding the bar's typical price, the high plus the low plus the close over three: the average price the stock traded at while those prints went through. A finer chart places the flow more exactly; on a five-minute chart a row is never off by more than the bar's own range. Every bar with a flow row joins a ring, kept with its typical price, its trade date and its five sums in the Measure setting's unit.

The window. On the live bar the window is the newest Sessions regular-session trade dates (time.session names them, so a pre-market bar never opens one). The rows are Rows equal steps from the window's lowest to its highest typical price, and each kept bar adds to the one row holding its price. poc_price is the middle of the busiest row, and the frame names it so the chart marks the same row.

The split. Bullish is the calls bought at the ask plus the puts sold at the bid, bearish the puts bought at the ask plus the calls sold at the bid, and neutral the prints at the mid or with no quote, whose side is unknown. Calls and puts drops the sides and splits each row into the two. Only the profile the Split setting picks is written, so the other stays absent.

Where it runs

US stock and ETF charts, through OpenMarket's official package once the chart serves options_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. One-minute rows reach back 7 days and five-minute rows 30 days, so 30 sessions want a five-minute chart or coarser.

When data is missing

A label at the top of the chart says what the profile lacks: "The chart holds 3 of 5 sessions" when the loaded bars hold fewer sessions than the window asks, "Options flow on 4 of 5 sessions" when a session in the window carried no row (a chart finer than the tier keeps), and "A floor: 2 session bars had no flow row" when single bars did. A profile with any of these is a floor, never an estimate of what is missing. "No options flow on this chart yet" means no bar of the run carried a row.

Customize it

  • A longer memory. Raise Sessions to 30 on a fifteen-minute chart to see the month's options positioned by price.
  • Finer rows. Raise Rows when the window spans a wide range; lower it on a quiet stock so each row holds enough flow to read.
  • Premium. Switch Measure to premium to weigh each row by the money spent rather than the contracts.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Options at Price under Beyond the time axis.
  2. Press Run on a US stock chart: the official package docks the profile on the price axis.
  3. At the editor's Console prompt, type poc_price for the busiest price and bull_share for the window's bullish share of the sided flow.

Concepts used