Options sentiment gauge

View as MarkdownOpen the editor

Fear against greed for the chart's underlying on a 0 to 100 dial, read from its own options and its own price.

Six inputs, each scored 0 (fear) to 100 (greed) by where today sits among the window's earlier sessions, its percentile: the put/call volume ratio and the put/call premium ratio (more puts reads as fear, so each scores 100 minus its percentile), the 25 delta risk reversal (calls dearer reads as greed), the 30-day IV (a high IV reads as fear), the term's slope (the 90-day IV over the 30-day, minus 1: contango reads as calm), and momentum (the close over its 125-day average, from the chart's own daily candles). The gauge is the mean of the inputs that have a score, shown once at least four of the six have one: under 25 extreme fear, under 45 fear, to 55 neutral, to 75 greed, over 75 extreme greed. On an index ETF's chart it reads that market's mood.

The parts are scalar option_daily inputs and a candles stream of the chart's daily candles (Calendar periods and candle lists), one DayHistory per input (Options kit), a step line coloured per bar by band with guides at the band edges, and a render.hud card with a gauge tile (HUD cards). This is also the sentiment-gauge template: the Options Sentiment Gauge card under HUDs in the editor's starter list, and it compiles as written.

The wrun indicator

// Options Sentiment Gauge: fear against greed for the chart's underlying on a 0 to 100 dial, read from its own options
// (the daily options statistics, option_daily) and its own price. Six inputs, each scored 0 (fear) to 100 (greed) by
// where today sits among the window's earlier sessions (its percentile): the put/call volume ratio and the put/call
// premium ratio (more puts reads as fear, so each scores 100 minus its percentile), the 25 delta risk reversal (calls
// dearer reads as greed), the 30-day IV (a high IV reads as fear), the term's slope (the 90-day IV over the 30-day,
// minus 1: contango reads as calm), and momentum (the close over its 125-day average, from the chart's own daily
// candles). The gauge is the mean of the inputs that have a score, shown once at least four of the six have one:
// under 25 extreme fear, under 45 fear, to 55 neutral, to 75 greed, over 75 extreme greed. A pane under the chart draws
// it as a step line coloured by band, with guides at the band edges, and a card at the bottom right reads the dial,
// the band and each input's score. The statistics are short today (they start when the data plane switches the daily
// job on): each options input needs Minimum history (30 sessions) of its own before it scores, and until four inputs
// score the card says how many sessions there are. On an index ETF's chart the dial reads that market's mood. US stock
// charts, daily or finer.

param.int("lookback", 252, { min: 20, max: 520, label: "Window, sessions", description: "Sessions each input's percentile reads: 252 is about a year" });
param.int("min_sessions", 30, { min: 10, max: 520, label: "Minimum history, sessions", description: "Sessions an input needs before it scores" });
param.int("momentum_days", 125, { min: 20, max: 250, label: "Momentum average, days", description: "Daily closes the momentum input's average reads" });
input("close", ohlcv.close); // the chart's own close: the grid
input("call_vol", option_daily.call_volume, { missing: "nan", description: "Call contracts traded in the session (the daily options statistics)" }); // every daily input lands on the first bar of its day, NaN elsewhere
input("put_vol", option_daily.put_volume, { missing: "nan", description: "Put contracts traded in the session" });
input("call_prem", option_daily.call_premium, { missing: "nan", description: "Call premium traded in the session, USD" });
input("put_prem", option_daily.put_premium, { missing: "nan", description: "Put premium traded in the session, USD" });
input("rr25", option_daily.rr25_30d, { missing: "nan", description: "The 30-day 25 delta risk reversal, call IV minus put IV" });
input("iv30", option_daily.atm_iv_30d, { missing: "nan", description: "The 30-day at-the-money IV, percent" });
input("iv90", option_daily.atm_iv_90d, { missing: "nan", description: "The 90-day at-the-money IV, percent" });
input("daily", candles.cells, { interval: "1d", bars: 400, description: "The chart's own closed daily candles: the momentum input" }); // [offset_ms, open, high, low, close, volume] per candle, oldest first
output("gauge", line, lower, { color: "#e2e8f0", width: 2, step: true, color_by: "band", colors: ["#ef4444", "#f97316", "#94a3b8", "#84cc16", "#22c55e"], label: "Sentiment", format: "0", description: "Fear (0) to greed (100): the mean of the inputs that score, once four of the six do" });
output("g75", line, lower, { role: "guide", color: "#22c55e66", line_style: "dashed", label: "75", description: "Over it: extreme greed" });
output("g55", line, lower, { role: "guide", color: "#84cc1666", line_style: "dotted", label: "55", description: "Over it: greed" });
output("g45", line, lower, { role: "guide", color: "#f9731666", line_style: "dotted", label: "45", description: "Under it: fear" });
output("g25", line, lower, { role: "guide", color: "#ef444466", line_style: "dashed", label: "25", description: "Under it: extreme fear" });
output("band", none, lower, { description: "0 extreme fear, 1 fear, 2 neutral, 3 greed, 4 extreme greed; 2 while there is no gauge" });
output("s_pc_volume", none, lower, { description: "The put/call volume input's score, 0 fear to 100 greed" });
output("s_pc_premium", none, lower, { description: "The put/call premium input's score" });
output("s_skew", none, lower, { description: "The risk reversal input's score" });
output("s_iv", none, lower, { description: "The IV level input's score" });
output("s_term", none, lower, { description: "The term slope input's score" });
output("s_momentum", none, lower, { description: "The momentum input's score" });
output("inputs", none, lower, { description: "Inputs that score, 0 to 6" });
output("sessions", none, lower, { description: "Sessions of statistics held" });
output("status", none, lower, { description: "0 fewer than four inputs score, 1 the gauge reads, 2 a chart coarser than a day" });
string("read", { max_bytes: 64 }); // the headline: the band and the number, or why there is none
string("title", { max_bytes: 64 });

render.hud("sentiment_card", {
  position: "bottom_right",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Sentiment", "read", { headline: true, color_by: "band", colors: ["#ef4444", "#f97316", "#94a3b8", "#84cc16", "#22c55e"] }),
    tile.gauge("Fear to greed", "gauge", { min: 0, max: 100, format: "int" }),
    tile.rows([
      ["1) Put/call volume", "s_pc_volume", "0"],
      ["2) Put/call premium", "s_pc_premium", "0"],
      ["3) Risk reversal", "s_skew", "0"],
      ["4) IV level", "s_iv", "0"],
      ["5) Term slope", "s_term", "0"],
      ["6) Momentum", "s_momentum", "0"],
      ["7) Inputs scoring", "inputs", "int"],
    ]),
  ],
});

const DAY = 86400.0;
const CAPACITY = 520;
const MAX_CANDLES = 420;
const BAND_WORDS: String[] = ["Extreme fear", "Fear", "Neutral", "Greed", "Extreme greed"];
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const pcVolumes = new DayHistory(CAPACITY); // one reading per session and input
const pcPremiums = new DayHistory(CAPACITY);
const skews = new DayHistory(CAPACITY);
const ivLevels = new DayHistory(CAPACITY);
const slopes = new DayHistory(CAPACITY);
const momenta = new DayHistory(CAPACITY); // one per daily candle
const sessions = new DayHistory(CAPACITY); // every session that landed
const days = new CandleList(MAX_CANDLES); // the chart's closed daily candles
const dateClock = new Clock("UTC");
const scores = new StaticArray<f64>(6); // the six inputs' scores, blended on every bar

let lookback = 252; // settings, read in onStart()
let need = 30;
let momentumDays = 125;
let prevT: f64 = NaN; // the bar spacing
let barSec: f64 = NaN;
let lastCandleT: f64 = NaN; // the newest candle folded into the momentum history
let sPcVolume: f64 = NaN; // the scores, held
let sPcPremium: f64 = NaN;
let sSkew: f64 = NaN;
let sIv: f64 = NaN;
let sTerm: f64 = NaN;
let sMomentum: f64 = NaN;
let gaugeNow: f64 = NaN;
let scoring = 0;

function onStart(): void {
  lookback = i32(p_lookback());
  need = i32(p_min_sessions());
  if (need > lookback) need = lookback;
  momentumDays = i32(p_momentum_days());
}

/** The input's score: its newest reading's percentile among the window's earlier ones (turned round when a high
 *  reading means fear); NaN while it holds fewer than need readings. */
function scoreOf(h: DayHistory, fearHigh: bool): f64 {
  if (h.span(lookback) < need) return NaN;
  const p = h.percentile(lookback);
  if (isNaN(p)) return NaN;
  return fearHigh ? 100.0 - p : p;
}

function ratio(a: f64, b: f64): f64 {
  return b > 0.0 && !isNaN(a) ? a / b : NaN;
}

// ── Momentum: each closed daily candle's close over the average of the momentumDays closes ending at it ──
function foldMomentum(): void {
  const n = days.count();
  if (n == 0) return;
  const newestT = days.openSec(n - 1);
  if (newestT == lastCandleT) return;
  // Every candle closed since the last fold, oldest first.
  let first = n - 1;
  while (first > 0 && (isNaN(lastCandleT) || days.openSec(first - 1) > lastCandleT)) first -= 1;
  for (let i = first; i < n; i += 1) {
    if (i + 1 < momentumDays) continue;
    let sum = 0.0;
    for (let k = i + 1 - momentumDays; k <= i; k += 1) sum += days.close(k);
    const mean = sum / f64(momentumDays);
    const close = days.close(i);
    if (mean > 0.0 && close > 0.0) momenta.push(Math.floor(days.openSec(i) / DAY), close / mean - 1.0);
  }
  lastCandleT = newestT;
  sMomentum = scoreOf(momenta, false);
}

function land(day: f64): void {
  sessions.push(day, 1.0);
  pcVolumes.push(day, ratio(in_put_vol(), in_call_vol()));
  pcPremiums.push(day, ratio(in_put_prem(), in_call_prem()));
  skews.push(day, in_rr25());
  const iv30 = in_iv30();
  const iv90 = in_iv90();
  ivLevels.push(day, iv30);
  slopes.push(day, iv30 > 0.0 && iv90 > 0.0 ? iv90 / iv30 - 1.0 : NaN);
  sPcVolume = scoreOf(pcVolumes, true);
  sPcPremium = scoreOf(pcPremiums, true);
  sSkew = scoreOf(skews, false);
  sIv = scoreOf(ivLevels, true);
  sTerm = scoreOf(slopes, false);
}

function blend(): void {
  let sum = 0.0;
  scoring = 0;
  scores[0] = sPcVolume;
  scores[1] = sPcPremium;
  scores[2] = sSkew;
  scores[3] = sIv;
  scores[4] = sTerm;
  scores[5] = sMomentum;
  for (let k = 0; k < 6; k += 1) {
    const s = scores[k];
    if (isNaN(s)) continue;
    sum += s;
    scoring += 1;
  }
  gaugeNow = scoring >= 4 ? sum / f64(scoring) : NaN;
}

function bandOf(g: f64): f64 {
  if (isNaN(g)) return 2.0;
  if (g < 25.0) return 0.0;
  if (g < 45.0) return 1.0;
  if (g <= 55.0) return 2.0;
  if (g <= 75.0) return 3.0;
  return 4.0;
}

function sbDay(day: f64): void { // "16OCT"
  dateClock.update(day * DAY);
  if (dateClock.dayOfMonth() < 10) sb_text("0");
  sb_int(dateClock.dayOfMonth());
  sb_text(MONTHS[dateClock.month() - 1]);
}

function writeWords(coarse: bool): void {
  sb_clear();
  if (coarse) sb_text("Needs a daily or finer chart");
  else if (sessions.count() == 0) sb_text("Waiting for the daily options statistics");
  else if (isNaN(gaugeNow) && sessions.count() < need) {
    sb_text("Not enough history yet: ");
    sb_int(sessions.count());
    sb_text(" of ");
    sb_int(need);
    sb_text(" sessions");
  } else if (isNaN(gaugeNow)) {
    sb_text("Too few inputs: ");
    sb_int(scoring);
    sb_text(" of 6 score, 4 needed");
  } else {
    sb_text(BAND_WORDS[i32(bandOf(gaugeNow))]);
    sb_text(", ");
    sb_f64(gaugeNow, 0);
  }
  str_read_sb();
  sb_clear();
  sb_text("Options sentiment");
  if (!coarse && sessions.count() > 0) {
    sb_text(", as of ");
    sbDay(sessions.day(0));
  }
  str_title_sb();
}

// onBar() runs once per bar: the spacing; every closed daily candle into the momentum history; the session's statistics
// when they land; then the blend, held on every bar.
function onBar(): void {
  const t = bar.time();
  if (!isNaN(prevT) && t > prevT && (isNaN(barSec) || t - prevT < barSec)) barSec = t - prevT;
  prevT = t;
  const coarse = !isNaN(barSec) && barSec > 1.5 * DAY;
  days.load(in_daily_view(), in_daily_cells(), t);
  if (!coarse) {
    foldMomentum();
    const day = Math.floor(t / DAY);
    const any = !isNaN(in_call_vol()) || !isNaN(in_put_vol()) || !isNaN(in_iv30()) || !isNaN(in_rr25());
    if (any && (sessions.count() == 0 || day >= sessions.day(0))) land(day);
    blend();
  }
  out_g75(75.0);
  out_g55(55.0);
  out_g45(45.0);
  out_g25(25.0);
  out_gauge(coarse ? NaN : gaugeNow);
  out_band(coarse ? 2.0 : bandOf(gaugeNow));
  out_s_pc_volume(coarse ? NaN : sPcVolume);
  out_s_pc_premium(coarse ? NaN : sPcPremium);
  out_s_skew(coarse ? NaN : sSkew);
  out_s_iv(coarse ? NaN : sIv);
  out_s_term(coarse ? NaN : sTerm);
  out_s_momentum(coarse ? NaN : sMomentum);
  out_inputs(coarse ? NaN : f64(scoring));
  out_sessions(coarse ? NaN : f64(sessions.count()));
  out_status(coarse ? 2.0 : isNaN(gaugeNow) ? 0.0 : 1.0);
  if (bar.isLast()) writeWords(coarse);
}

How it works

A score per input. Each input's reading joins its own DayHistory once per session; its score is percentile() over the window (or 100 minus it where a high reading means fear), once it holds Minimum history sessions. Momentum is measured on every closed daily candle, so it scores from the first bar.

The blend. Equal weights over the inputs that score, shown once four do, so a dial never stands on one or two inputs. A missing input drops out instead of reading as neutral.

Where it runs

US stock charts, through OpenMarket's official package: option_daily is served by the chart's browser lane for the chart's own underlying (Limitations), and the daily candles are the chart's own. The class is declared ahead of its lane, so the template builds today and the chart shows the lane's refusal until the data plane serves the statistics. A daily or finer chart.

When data is missing

The statistics start the day the job is switched on, so each options input needs Minimum history (30 sessions) of its own before it scores. Until four inputs score the dial is empty and the card reads "Not enough history yet: 12 of 30 sessions", or "Too few inputs: 3 of 6 score, 4 needed" when the history is there but inputs are missing. Before the first session the card reads "Waiting for the daily options statistics"; a chart coarser than a day reads "Needs a daily or finer chart".

Customize it

  • Another year. lookback sets the sessions each percentile reads.
  • Score sooner. Lower Minimum history, knowing each score reads fewer sessions.
  • Another momentum. momentum_days of 50 reads a shorter trend.

Run it

  1. In the editor's Explorer, press the Templates icon and pick Options Sentiment Gauge under HUDs.
  2. Press Run on SPY's chart for the market's mood, or any US stock's for its own.
  3. At the editor's Console prompt, type gauge, inputs and s_momentum.

Concepts used