Options market stats

View as MarkdownOpen the editor

The whole US listed options market's calls against its puts, through the day. Two lines under the chart read the day's put/call ratios so far: the put contracts over the call contracts, and the put premium over the call premium, with a dashed line at 1.0 where puts match calls.

On a daily chart each bar is that day's ratio, so the lines become the market's put/call history. A table under the chart reads the day for every underlying and, picked in Breakdown, for equities, ETFs and indices alone and for the contracts expiring that day (0DTE): call and put contracts, their ratio, call and put premium, its ratio and the prints. A card at the top right says which side leads by the contract ratio, then the day's sums and the 0DTE share of the contracts.

The parts are scalar options_flow inputs under the market scopes, the breakdown rows behind the options of a multi setting through when and when_bit (Data sources), the bar's trade date and session from the time source (Data sources), a panel.table over a frame written on the live bar (Frames and panels), and a render.hud card in the terminal look over data-only outputs and text slots (HUD cards). This is also the options-market-stats template: the Options Market Stats card under Order flow in the editor's starter list, and it compiles as written.

The wrun indicator

// Options Market Stats: the whole US listed options market's calls against its puts, through the day. Two lines under
// the chart read the day's put/call ratios so far, by contracts and by premium (the put contracts over the call
// contracts, the put premium over the call premium, every bar of the trade date summed), with a dashed line at 1.0
// where puts match calls; on a daily chart each bar is that day's ratio. A table under the chart reads the day for
// every underlying and, picked in Breakdown, for equities, ETFs and indices alone and for the contracts expiring that
// day (0DTE): call and put contracts, their ratio, call and put premium, its ratio and the prints. A card at the top
// right says which side leads by the contract ratio, then the day's sums and the 0DTE share of the contracts. The
// equity and ETF rows need each underlying's kind, which the data plane stamps from a daily reference list: until it
// does they read empty, and the table and a line at the top of the chart say so. The rows come from options_flow's
// market scopes, one per bar: 1-minute rows reach back 7 days, 5-minute rows 30 and daily rows are kept, so older
// intraday bars carry none; a regular-session bar without a row adds nothing and is counted, the sums being a floor
// while that count is above 0.

param.multi("breakdown", ["Equities", "ETFs", "Indices", "0DTE"], ["Equities", "ETFs", "Indices", "0DTE"], { label: "Breakdown", description: "The rows the table adds under every underlying; each is one more read, fetched only while it is picked" });
input("close", ohlcv.close); // the chart's own candles: the bar grid (the stats read the whole market on any US stock chart)
input("trade_date", time.trade_date); // the bar's trade date, the New York date on a US stock: the sums start again on each
input("session", time.session); // 1 in the regular session: the bars a missing row is counted on
// Five fields per row of the table: every underlying always, the breakdown rows only while picked (when_bit is the
// row's place in Breakdown).
input("mkt_calls", options_flow.call_contracts_total, { scope: "market", missing: "nan", description: "Every underlying: call contracts traded in the bar" });
input("mkt_puts", options_flow.put_contracts_total, { scope: "market", missing: "nan", description: "Every underlying: put contracts traded in the bar" });
input("mkt_cprem", options_flow.call_prem_total, { scope: "market", missing: "nan", description: "Every underlying: call premium traded in the bar, USD" });
input("mkt_pprem", options_flow.put_prem_total, { scope: "market", missing: "nan", description: "Every underlying: put premium traded in the bar, USD" });
input("mkt_prints", options_flow.prints, { scope: "market", missing: "nan", description: "Every underlying: option prints in the bar" });
input("eq_calls", options_flow.call_contracts_total, { scope: "equity", when: "breakdown", when_bit: 0, missing: "nan", description: "Equities: call contracts traded in the bar" });
input("eq_puts", options_flow.put_contracts_total, { scope: "equity", when: "breakdown", when_bit: 0, missing: "nan", description: "Equities: put contracts traded in the bar" });
input("eq_cprem", options_flow.call_prem_total, { scope: "equity", when: "breakdown", when_bit: 0, missing: "nan", description: "Equities: call premium traded in the bar, USD" });
input("eq_pprem", options_flow.put_prem_total, { scope: "equity", when: "breakdown", when_bit: 0, missing: "nan", description: "Equities: put premium traded in the bar, USD" });
input("eq_prints", options_flow.prints, { scope: "equity", when: "breakdown", when_bit: 0, missing: "nan", description: "Equities: option prints in the bar" });
input("etf_calls", options_flow.call_contracts_total, { scope: "etf", when: "breakdown", when_bit: 1, missing: "nan", description: "ETFs: call contracts traded in the bar" });
input("etf_puts", options_flow.put_contracts_total, { scope: "etf", when: "breakdown", when_bit: 1, missing: "nan", description: "ETFs: put contracts traded in the bar" });
input("etf_cprem", options_flow.call_prem_total, { scope: "etf", when: "breakdown", when_bit: 1, missing: "nan", description: "ETFs: call premium traded in the bar, USD" });
input("etf_pprem", options_flow.put_prem_total, { scope: "etf", when: "breakdown", when_bit: 1, missing: "nan", description: "ETFs: put premium traded in the bar, USD" });
input("etf_prints", options_flow.prints, { scope: "etf", when: "breakdown", when_bit: 1, missing: "nan", description: "ETFs: option prints in the bar" });
input("idx_calls", options_flow.call_contracts_total, { scope: "index", when: "breakdown", when_bit: 2, missing: "nan", description: "Indices: call contracts traded in the bar" });
input("idx_puts", options_flow.put_contracts_total, { scope: "index", when: "breakdown", when_bit: 2, missing: "nan", description: "Indices: put contracts traded in the bar" });
input("idx_cprem", options_flow.call_prem_total, { scope: "index", when: "breakdown", when_bit: 2, missing: "nan", description: "Indices: call premium traded in the bar, USD" });
input("idx_pprem", options_flow.put_prem_total, { scope: "index", when: "breakdown", when_bit: 2, missing: "nan", description: "Indices: put premium traded in the bar, USD" });
input("idx_prints", options_flow.prints, { scope: "index", when: "breakdown", when_bit: 2, missing: "nan", description: "Indices: option prints in the bar" });
input("zd_calls", options_flow.call_contracts_total, { scope: "market", dte: "0dte", when: "breakdown", when_bit: 3, missing: "nan", description: "0DTE: call contracts traded in the bar" });
input("zd_puts", options_flow.put_contracts_total, { scope: "market", dte: "0dte", when: "breakdown", when_bit: 3, missing: "nan", description: "0DTE: put contracts traded in the bar" });
input("zd_cprem", options_flow.call_prem_total, { scope: "market", dte: "0dte", when: "breakdown", when_bit: 3, missing: "nan", description: "0DTE: call premium traded in the bar, USD" });
input("zd_pprem", options_flow.put_prem_total, { scope: "market", dte: "0dte", when: "breakdown", when_bit: 3, missing: "nan", description: "0DTE: put premium traded in the bar, USD" });
input("zd_prints", options_flow.prints, { scope: "market", dte: "0dte", when: "breakdown", when_bit: 3, missing: "nan", description: "0DTE: option prints in the bar" });
output("pc_volume", line, lower, { color: "#f59e0b", width: 2, label: "Put/call, contracts", format: "0.00", description: "The day's put contracts over its call contracts so far, every underlying; NaN before the day's first row" });
output("pc_premium", line, lower, { color: "#a78bfa", width: 2, label: "Put/call, premium", format: "0.00", description: "The day's put premium over its call premium so far, every underlying" });
output("parity", line, lower, { color: "#64748b", width: 1, line_style: "dashed", legend: false, description: "1.0: as many puts as calls" });
// The readings, data-only: the card reads them and the Console can too.
output("calls_today", none, lower, { format: "si", description: "Call contracts traded today through this bar, every underlying" });
output("puts_today", none, lower, { format: "si", description: "Put contracts traded today through this bar, every underlying" });
output("call_premium_today", none, lower, { format: "usd", description: "Call premium traded today, every underlying, USD" });
output("put_premium_today", none, lower, { format: "usd", description: "Put premium traded today, every underlying, USD" });
output("prints_today", none, lower, { format: "si", description: "Option prints today, every underlying" });
output("zero_dte_share", none, lower, { format: "%", decimals: 1, description: "Contracts expiring the same day over every contract traded today, percent; NaN while 0DTE is not picked" });
output("missing_bars", none, lower, { description: "Regular-session bars of today with no row for every underlying: the sums are a floor while it is above 0" });
output("tone", none, lower, { description: "The headline's colour: 0 puts lead, 1 even or no reading, 2 calls lead" });
string("read", { max_bytes: 48 }); // the headline: which side leads, or why there is no reading
string("title", { max_bytes: 48 }); // the card's title: the day
string("note", { max_bytes: 96 }); // the line at the top while a picked equity or ETF row has read nothing

// The table, rewritten on the live bar: one row per underlying group, the day's sums.
const stat_rows = frame("stat_rows", { max_bytes: 4096 });
panel.table({
  name: "market_stats",
  title: "Options market today",
  x: "category",
  place: "below",
  frame: stat_rows,
  height_frac: 0.18,
  font_size: 10,
  cell_padding: 3,
  valign: "middle",
  header_text_color: "theme.muted",
  grid_color: "theme.grid",
  grid_width: 1,
  grid_lines: "rows",
  border_width: 0,
  series: [
    { name: "Underlyings" },
    { name: "Calls", align: "right", format: "si" },
    { name: "Puts", align: "right", format: "si" },
    { name: "Put/call", align: "right", format: "0.00" },
    { name: "Call premium", align: "right", format: "usd" },
    { name: "Put premium", align: "right", format: "usd" },
    { name: "Put/call, premium", align: "right", format: "0.00" },
    { name: "Prints", align: "right", format: "si" },
  ],
});

// The card, in the terminal look: which side leads by the contract ratio, then the day's sums.
render.hud("market_card", {
  position: "top_right",
  safe_area: true,
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Who leads", "read", { headline: true, color_by: "tone", colors: ["#ef4444", "#94a3b8", "#22c55e"] }),
    tile.value("Call contracts", "calls_today", { format: "si" }),
    tile.value("Put contracts", "puts_today", { format: "si" }),
    tile.rows([
      ["Put/call, contracts", "pc_volume", "0.00"],
      ["Call premium", "call_premium_today", "usd"],
      ["Put premium", "put_premium_today", "usd"],
      ["Put/call, premium", "pc_premium", "0.00"],
      ["Prints", "prints_today", "si"],
      ["0DTE share", "zero_dte_share", "%"],
      ["Bars with no flow row", "missing_bars", "int"],
    ]),
  ],
});
render.label("note_label", { position: "top_center", text: "note", color: "#94a3b8", style: "knockout", offset: [0, 8] });

const GROUPS = 5; // every underlying, then the four Breakdown rows in its order
const FIELDS = 5; // call and put contracts, call and put premium, prints
const EVEN_BAND = 0.1; // a put/call ratio within 10% of 1 reads even
const NAMES: StaticArray<string> = ["Every underlying", "Equities", "ETFs", "Indices", "0DTE"];
// The rows that read each underlying's kind as the data plane stamps it: equities and ETFs.
const KINDED: StaticArray<bool> = [false, true, true, false, false];
const MONTHS: StaticArray<string> = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const sums = new StaticArray<f64>(GROUPS * FIELDS); // today's sums, group by group
const rows = new StaticArray<i32>(GROUPS); // today's bars that carried a row, per group
const runRows = new StaticArray<i32>(GROUPS); // rows over the whole run, per group
const cur = new StaticArray<f64>(FIELDS); // this bar's fields of one group
const dateClock = new Clock("UTC"); // the trade date's day and month

let breakdown = 0.0; // the Breakdown bitmask, read in onStart()
let day: f64 = NaN; // today's trade date, epoch seconds
let missing = 0; // regular-session bars of today without a row for every underlying

function onStart(): void {
  breakdown = p_breakdown();
}

// Whether group g is read: every underlying always, a breakdown row while picked (a host that fetches its lane
// anyway is not read).
function readOn(g: i32): bool {
  return g == 0 || multiHas(breakdown, g - 1);
}

// This bar's five fields of group g into cur.
function readGroup(g: i32): void {
  if (g == 0) { cur[0] = in_mkt_calls(); cur[1] = in_mkt_puts(); cur[2] = in_mkt_cprem(); cur[3] = in_mkt_pprem(); cur[4] = in_mkt_prints(); }
  else if (g == 1) { cur[0] = in_eq_calls(); cur[1] = in_eq_puts(); cur[2] = in_eq_cprem(); cur[3] = in_eq_pprem(); cur[4] = in_eq_prints(); }
  else if (g == 2) { cur[0] = in_etf_calls(); cur[1] = in_etf_puts(); cur[2] = in_etf_cprem(); cur[3] = in_etf_pprem(); cur[4] = in_etf_prints(); }
  else if (g == 3) { cur[0] = in_idx_calls(); cur[1] = in_idx_puts(); cur[2] = in_idx_cprem(); cur[3] = in_idx_pprem(); cur[4] = in_idx_prints(); }
  else if (g == 4) { cur[0] = in_zd_calls(); cur[1] = in_zd_puts(); cur[2] = in_zd_cprem(); cur[3] = in_zd_pprem(); cur[4] = in_zd_prints(); }
}

function ratio(top: f64, bottom: f64): f64 {
  return bottom > 0.0 ? top / bottom : NaN;
}

// A ratio cell: two decimals, or an empty cell when there is no ratio (no call or no call premium).
function fbRatio(value: f64): void {
  if (isNaN(value)) fb_text('""');
  else fb_f64(value, 2);
}

// The table on the live bar: every underlying, then each picked breakdown row. A row without a row today says why in
// its first value cell: the equity and ETF rows wait for the data plane's issue types while they never read one.
function writeTable(): void {
  fb_clear();
  fb_text('{"rows":[');
  let written = 0;
  for (let g = 0; g < GROUPS; g += 1) {
    if (!readOn(g)) continue;
    if (written > 0) fb_text(",");
    written += 1;
    fb_text("[");
    fb_str(NAMES[g]);
    if (rows[g] == 0) {
      fb_text(',{"text":');
      fb_str(KINDED[g] && runRows[g] == 0 ? "waits for issue types" : "no rows today");
      fb_text(',"text_color":"theme.muted"},"","","","","",""]');
      continue;
    }
    const at = g * FIELDS;
    fb_text(",");
    fb_f64(sums[at], 0);
    fb_text(",");
    fb_f64(sums[at + 1], 0);
    fb_text(",");
    fbRatio(ratio(sums[at + 1], sums[at]));
    fb_text(",");
    fb_f64(sums[at + 2], 0);
    fb_text(",");
    fb_f64(sums[at + 3], 0);
    fb_text(",");
    fbRatio(ratio(sums[at + 3], sums[at + 2]));
    fb_text(",");
    fb_f64(sums[at + 4], 0);
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_STAT_ROWS);
}

// The card's words and the line at the top, on the live bar.
function writeWords(pcVolume: f64): void {
  sb_clear();
  if (runRows[0] == 0) sb_text("No options flow on this chart yet");
  else if (rows[0] == 0) sb_text("No options flow yet today");
  else if (isNaN(pcVolume)) sb_text("Puts only: no call traded yet");
  else {
    sb_text(Math.abs(pcVolume - 1.0) <= EVEN_BAND ? "Even: put/call " : pcVolume < 1.0 ? "Calls lead: put/call " : "Puts lead: put/call ");
    sb_f64(pcVolume, 2);
  }
  str_read_sb();
  sb_clear();
  sb_text("Options market");
  if (!isNaN(day)) {
    dateClock.update(day); // the trade date is its date at 00:00 UTC
    sb_text(", ");
    if (dateClock.dayOfMonth() < 10) sb_text("0");
    sb_int(dateClock.dayOfMonth());
    sb_text(MONTHS[dateClock.month() - 1]);
  }
  str_title_sb();
  let waiting = false; // a picked equity or ETF row that read nothing in the whole run
  for (let g = 0; g < GROUPS; g += 1) if (KINDED[g] && readOn(g) && runRows[g] == 0) waiting = true;
  str_note(waiting ? "Equity and ETF rows read empty until the data plane stamps each underlying's kind" : ""); // an empty text draws no label
}

// onBar() runs once per bar: a new trade date starts today's sums again; every read group's row joins its sums (a
// regular-session bar without a row for every underlying counts as missing); then the ratio lines, the readings and,
// on the live bar, the table and the words.
function onBar(): void {
  const date = in_trade_date();
  if (!isNaN(date) && date != day) {
    day = date;
    for (let i = 0; i < GROUPS * FIELDS; i += 1) sums[i] = 0.0;
    for (let g = 0; g < GROUPS; g += 1) rows[g] = 0;
    missing = 0;
  }
  for (let g = 0; g < GROUPS; g += 1) {
    if (!readOn(g)) continue;
    readGroup(g);
    let served = true;
    for (let f = 0; f < FIELDS; f += 1) if (isNaN(cur[f])) served = false;
    if (!served) {
      if (g == 0 && in_session() == 1.0) missing += 1;
      continue;
    }
    for (let f = 0; f < FIELDS; f += 1) sums[g * FIELDS + f] += cur[f];
    rows[g] += 1;
    runRows[g] += 1;
  }
  const reading = rows[0] > 0;
  const calls = sums[0];
  const puts = sums[1];
  const pcVolume = reading ? ratio(puts, calls) : NaN;
  out_pc_volume(pcVolume);
  out_pc_premium(reading ? ratio(sums[3], sums[2]) : NaN);
  out_parity(1.0);
  out_calls_today(reading ? calls : NaN);
  out_puts_today(reading ? puts : NaN);
  out_call_premium_today(reading ? sums[2] : NaN);
  out_put_premium_today(reading ? sums[3] : NaN);
  out_prints_today(reading ? sums[4] : NaN);
  const zeroDte = readOn(4) && reading && rows[4] > 0 ? ratio(sums[4 * FIELDS] + sums[4 * FIELDS + 1], calls + puts) : NaN;
  out_zero_dte_share(isNaN(zeroDte) ? NaN : zeroDte * 100.0);
  out_missing_bars(f64(missing));
  out_tone(!reading ? 1.0 : isNaN(pcVolume) ? 0.0 : Math.abs(pcVolume - 1.0) <= EVEN_BAND ? 1.0 : pcVolume < 1.0 ? 2.0 : 0.0); // no call traded: the puts lead
  if (!bar.isLast()) return;
  writeTable();
  writeWords(pcVolume);
}

How it works

The day's sums. Each bar's row carries the market's call and put contracts, call and put premium and prints (call_contracts_total, put_contracts_total, call_prem_total, put_prem_total, prints). The sums start again on each trade date (time.trade_date) and add every bar of the date, the pre-market included, since the market trades there too. The ratios on each bar are the sums' so far: put contracts over call contracts (pc_volume) and put premium over call premium (pc_premium). A day without a call has no ratio: the line breaks and the card says puts only.

Who leads. The headline reads the contract ratio: within 10% of 1 is even, under it the calls lead and over it the puts lead. The 0DTE share is the same-day contracts (dte: "0dte") over every contract of the day.

Breakdown. breakdown is a multi setting, one option per extra row of the table. Every row's five inputs carry when: "breakdown" and that option's when_bit, so the chart fetches a row's rows only while it is picked; the code reads the same bits (multiHas), so a row that is not picked is neither read nor listed, even on a host that fetched its lane anyway.

The kind of each underlying. The equity, ETF and index rows read scope: "equity", "etf" and "index", which sum the rows of the underlyings of that kind. The data plane stamps each underlying's kind from a daily reference list; until it does, the equity and ETF rows read nothing, so the table says "waits for issue types" in their place and a line at the top of the chart says so. An index root is also stamped by its own chain, so the index row reads rows without the list.

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 options_flow. The stats read the whole market whatever the chart's own underlying is. 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, five-minute rows 30 days and daily rows are kept, so a daily chart reads the ratios back to the first day the data plane kept; an alert and a Protected indicator on OpenMarket's servers refuse the class by name.

When data is missing

The card says why instead of going blank: "No options flow on this chart yet" while no bar of the run carried a row for every underlying, "No options flow yet today" while the newest day has none. Bars with no flow row prints the regular-session bars of the day that carried no row for every underlying: above 0, read the sums as at least what they show. In the table a row without a row today says "no rows today", and the equity and ETF rows say "waits for issue types" until the data plane stamps each underlying's kind. A refused source shows its own chip on the legend, with the reason.

Customize it

  • Out of the money only. Add moneyness: "otm" to every input to read the contracts whose strike is past the underlying; each new set of knobs is one more read.
  • The next seven days. Change the 0DTE row's dte: "0dte" to dte: "week" for the contracts expiring in one to seven days.
  • A leaner chart. Untick every option of Breakdown: the table keeps its every-underlying row and the chart reads one lane.

Run it

  1. In the editor's Explorer, press the Templates icon ("Browse starter templates") and pick Options Market Stats under Order flow.
  2. Press Run on a US stock chart: the official package draws the two ratios under the chart, the table below it and the card at the top right; a copy that cannot read US options says so on the legend.
  3. At the editor's Console prompt, type pc_volume, pc_premium and zero_dte_share to read the day on the live bar, or missing_bars to see how many bars carried no row.

Concepts used