---
title: "Options sentiment"
description: "Where the day's option volume sits against the price, and how the options price upside against downside, blended into one score from -100 to +100. Two inputs…"
order: 128
section: "cookbook"
---

<!-- source: docs/indicators/cookbook/options-sentiment.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Options sentiment

Where the day's option volume sits against the price, and how the options price upside against downside, blended into one score from -100 to +100. Two inputs make it. VWKS, the volume-weighted strike offset, is the "centre of volume mass": the day's volume-weighted mean of each traded contract's strike over the underlying, minus 1, each offset capped at 50% either way; above zero the day traded strikes above the price. AVAR, the asymmetric volatility, is the 25 delta call IV minus the 25 delta put IV at a 30-day constant maturity; above zero the options price the upside richer than the downside. Each is scaled to -1..+1 against a fixed bound, blended 60% VWKS and 40% AVAR, and times 100: bullish at +20 and up, bearish at -20 and down, neutral between. A card at the bottom of the pane, in the terminal look, leads with the reading and the score on a meter; AVAR runs as a line under the chart, bar by bar; the volume-weighted strike is drawn across the chart.

The parts are a celled `option_contract_state.cells` input read on the live bar for the day's volume per contract ([US listed options](../core-concepts/data-sources.md#us-listed-options-opra)) and a celled `option_surface.cells` input read on every bar for the 25 delta wings ([Option surface](../core-concepts/data-sources.md#option-surface)), both read through their `present` bits ([The present mask and partial bars](../core-concepts/data-sources.md#the-present-mask-and-partial-bars)), the options kit's `totalVarianceIv` for the constant maturity ([The at-the-money term](../functions/options-kit.md#the-at-the-money-term)), a split line in the lower pane ([Plotting](../presentation/plotting.md)), a line handle with its tag ([Drawing objects](../presentation/drawing-objects.md#handles)) and a `render.hud` card with a meter ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards), [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles)). This is also the `options-sentiment` template: the **Options Sentiment** card under **HUDs** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=options-sentiment
// Options Sentiment: where the day's option volume sits against the price, and how the options price upside against
// downside, blended into one score. VWKS, the volume-weighted strike offset, is the day's volume-weighted mean of each
// traded contract's strike over the underlying, minus 1, each offset capped at 50% either way (the option_contract_state
// source: the day's volume per contract on the newest bar; the eight nearest expiries, strikes within 20%). AVAR, the
// asymmetric volatility, is the 25 delta call IV minus the 25 delta put IV at a constant maturity (30 days by default),
// each wing interpolated in total variance between the volatility surface's expiries (the option_surface source, on
// every bar). Each is scaled to -1..+1 against a fixed bound (the settings), blended 60% VWKS and 40% AVAR and read as
// a score from -100 to +100: bullish at +20 and up, bearish at -20 and down, neutral between. A day with fewer traded
// contracts or less volume than the minimums reads no score, and neither does a surface without both 25 delta wings.
// AVAR runs as a line under the chart, the volume-weighted strike is drawn across the chart (on the underlying's own
// chart: the close inside the strikes the summary lists), and a card in the terminal look leads with the score.

section("Score");
param.number("vwks_bound_pct", 5, { min: 0.5, max: 50, step: 0.5, label: "VWKS bound, percent", description: "The volume-weighted strike offset that scores +1 or -1" });
param.number("avar_bound", 15, { min: 1, max: 50, step: 0.5, label: "AVAR bound, IV points", description: "The call minus put IV at 25 delta that scores +1 or -1" });
param.int("tenor_days", 30, { min: 7, max: 120, label: "AVAR maturity, days", description: "The constant maturity the two 25 delta wings are read at" });
param.int("min_contracts", 15, { min: 1, max: 10000, label: "Smallest traded contracts", description: "Contracts that must have traded today for a score" });
param.int("min_volume", 500, { min: 1, max: 10000000, label: "Smallest day volume", description: "Contracts of volume the day needs for a score" });
input("close", ohlcv.close); // the chart's own close: the underlying each strike is measured against
input("state", option_contract_state.cells, { max_cells: 24000, expiries: "nearest:8", strike_window_pct: 20, description: "The chart's own option chain summary on the newest bar: the eight nearest expiries, strikes within 20% of the underlying" }); // [root_code, expiry_ms, side_cp, strike, bid, ask, last, prev_close, theta, volume_day, oi, oi_prev, present] per contract
input("surface", option_surface.cells, { max_cells: 128, description: "The chart's own volatility surface on every bar: one tuple per expiry" }); // [expiry_ms, dte_days, forward, atm_iv, iv_p25, iv_c25, iv_p10, iv_c10, present] per expiry
output("avar", line, lower, { width: 2, split: "level", up_color: "theme.up", down_color: "theme.down", format: "0.00", label: "AVAR", description: "The 25 delta call IV minus the 25 delta put IV at the AVAR maturity, IV points, on each bar with a surface" });
// The readings, data-only: the card's tiles read them and the Console can too. VWKS and the score need the newest bar.
output("vwks", none, overlay, { description: "The volume-weighted strike offset, percent: the day's volume-weighted mean of strike over the underlying, minus 1, each capped at 50%; live bar only" });
output("vw_strike", none, overlay, { description: "The day's volume-weighted mean strike, uncapped; live bar only" });
output("score", none, overlay, { description: "60% VWKS and 40% AVAR, each scaled to -1..+1 against its bound, times 100; live bar only" });
output("direction", none, overlay, { description: "The score's reading, the headline's colour: 0 bearish (-20 and down), 1 neutral, 2 bullish (+20 and up)" });
output("avar_now", none, overlay, { description: "The newest AVAR at or before the bar, the one the score blends" });
output("traded", none, overlay, { description: "Contracts that traded today" });
output("day_volume", none, overlay, { description: "The day's volume of the contracts the summary lists" });
output("put_call_volume", none, overlay, { description: "Put volume over call volume" });
output("surface_partial", none, overlay, { description: "1 on a bar whose surface the data plane marked partial (a finer chart than its samples, or older than they are kept), else 0" });
string("read", { max_bytes: 64 }); // the headline: the reading and the score, or why there is none
string("title", { max_bytes: 64 }); // the card's title: the maturity AVAR is read at
string("tag", { max_bytes: 40 }); // the volume-weighted strike's tag
handles.line({ width: 1, extend: "both" }); // the volume-weighted strike, across the whole chart
handles.label({ anchor: "right", align: "right", size: 11, color: "#e2e8f0" }); // its tag: x in pixels from the right edge, y in price

// The card, in the terminal look: the reading leads as the headline, coloured by it (violet bearish, slate neutral, sky
// bullish), the score on a meter from -100 to +100 with marks at -20 and +20, then the two inputs and the day's activity.
render.hud("sentiment", {
  position: "bottom_center",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("Sentiment", "read", { headline: true, color_by: "direction", colors: ["#a78bfa", "#94a3b8", "#38bdf8"] }),
    tile.meter("Score", "score", { min: -100, max: 100, marks: [-20, 20], color_by: "direction", colors: ["#a78bfa", "#94a3b8", "#38bdf8"] }),
    tile.value("VWKS", "vwks", { format: "%" }),
    tile.value("AVAR, IV points", "avar_now", { format: "0.00" }),
    tile.rows([
      ["1) Volume-weighted strike", "vw_strike", "price"],
      ["2) Contracts traded", "traded", "int"],
      ["3) Day volume", "day_volume", "si"],
      ["4) Put/call volume", "put_call_volume", "0.00"],
    ]),
  ],
});

const STATE_TUPLE = 13; // f64s per contract: root_code, expiry_ms, side_cp, strike, bid, ask, last, prev_close, theta, volume_day, oi, oi_prev, present
const SURFACE_TUPLE = 9; // f64s per expiry: expiry_ms, dte_days, forward, atm_iv, iv_p25, iv_c25, iv_p10, iv_c10, present
const BIT_VOLUME = 9; // option_contract_state: volume_day
const BIT_DTE = 1; // option_surface: dte_days, the two 25 delta wings
const BIT_P25 = 4;
const BIT_C25 = 5;
const VWKS_WEIGHT = 0.6; // the blend
const AVAR_WEIGHT = 0.4;
const MAX_OFFSET = 0.5; // each strike offset capped at 50% either way
const READ_WORDS: String[] = ["Bearish", "Neutral", "Bullish"];
const ACCENT = rgba(248, 192, 0, 255); // the volume-weighted strike's ink
const PILL = rgba(15, 23, 42, 235); // its tag's backdrop
const wingDays = new StaticArray<f64>(in_surface_max_cells); // the surface's anchors this bar, ascending by days to expiry
const wingCall = new StaticArray<f64>(in_surface_max_cells); // their 25 delta call IV
const wingPut = new StaticArray<f64>(in_surface_max_cells); // their 25 delta put IV
const level = draw.line(0); // the volume-weighted strike
const tag = draw.label(1); // ids are one space across kinds

let vwksBound = 0.05; // settings, read in onStart()
let avarBound = 15.0;
let tenor = 30.0;
let minContracts = 15.0;
let minVolume = 500.0;
let t: f64 = NaN; // this bar
let firstT: f64 = NaN; // the first bar's open time: where the level starts
let avarNow: f64 = NaN; // the newest AVAR at or before this bar
let traded = 0.0; // the live summary: contracts with volume today
let volume = 0.0; // their volume
let callVolume = 0.0;
let putVolume = 0.0;
let offsetSum = 0.0; // the capped offsets, volume-weighted
let strikeSum = 0.0; // the strikes, volume-weighted
let lowStrike: f64 = NaN; // the strikes the summary lists, lowest and highest
let highStrike: f64 = NaN;

function onStart(): void {
  vwksBound = p_vwks_bound_pct() / 100.0;
  avarBound = p_avar_bound();
  tenor = p_tenor_days();
  minContracts = p_min_contracts();
  minVolume = p_min_volume();
}

// ── AVAR: each 25 delta wing at the maturity, linear in total variance between the anchors around it ──
function wingAt(iv: StaticArray<f64>, count: i32): f64 { // before the first anchor its IV, past the last the last's
  const years = tenor / 365.0;
  for (let k = 0; k < count; k += 1) {
    if (years <= wingDays[k] / 365.0) return k == 0 ? iv[0] : totalVarianceIv(wingDays[k - 1] / 365.0, iv[k - 1], wingDays[k] / 365.0, iv[k], years);
  }
  return iv[count - 1];
}
function avarOf(n: i32): f64 { // this bar's AVAR from its surface block; NaN without an anchor (an expiry past half a day out with both wings)
  const cells = in_surface_view(); // this bar's surface in place: the first n values of the build's own buffer
  let count = 0;
  for (let i = 0; i + SURFACE_TUPLE - 1 < n; i += SURFACE_TUPLE) {
    const present = cells[i + 8];
    if (!hasBit(present, BIT_DTE) || !hasBit(present, BIT_P25) || !hasBit(present, BIT_C25)) continue; // an absent wing reads 0: never an anchor
    const days = cells[i + 1];
    if (!(days > 0.5) || !(cells[i + 4] > 0.0) || !(cells[i + 5] > 0.0)) continue; // an expiry settling within half a day never anchors
    let k = count; // sorted insertion by days to expiry
    while (k > 0 && wingDays[k - 1] > days) {
      wingDays[k] = wingDays[k - 1];
      wingCall[k] = wingCall[k - 1];
      wingPut[k] = wingPut[k - 1];
      k -= 1;
    }
    wingDays[k] = days;
    wingCall[k] = cells[i + 5];
    wingPut[k] = cells[i + 4];
    count += 1;
  }
  if (count == 0) return NaN;
  return wingAt(wingCall, count) - wingAt(wingPut, count);
}
function surfacePartial(n: i32): bool {
  const cells = in_surface_view();
  for (let i = 0; i + SURFACE_TUPLE - 1 < n; i += SURFACE_TUPLE) if (isPartial(cells[i + 8])) return true;
  return false;
}

// ── The day's volume against the price ──
function measureVolume(n: i32, spot: f64): void {
  const cells = in_state_view(); // the live summary in place
  traded = 0.0;
  volume = 0.0;
  callVolume = 0.0;
  putVolume = 0.0;
  offsetSum = 0.0;
  strikeSum = 0.0;
  lowStrike = NaN;
  highStrike = NaN;
  for (let i = 0; i + STATE_TUPLE - 1 < n; i += STATE_TUPLE) {
    const strike = cells[i + 3];
    if (!(strike > 0.0)) continue;
    if (isNaN(lowStrike) || strike < lowStrike) lowStrike = strike;
    if (isNaN(highStrike) || strike > highStrike) highStrike = strike;
    if (!hasBit(cells[i + 12], BIT_VOLUME)) continue;
    const v = cells[i + 9];
    if (!(v > 0.0)) continue;
    traded += 1.0;
    volume += v;
    if (cells[i + 2] > 0.0) callVolume += v;
    else putVolume += v;
    strikeSum += strike * v;
    let offset = strike / spot - 1.0;
    if (offset > MAX_OFFSET) offset = MAX_OFFSET;
    else if (offset < -MAX_OFFSET) offset = -MAX_OFFSET;
    offsetSum += offset * v;
  }
}

// ── The volume-weighted strike across the chart, tagged at the price axis ──
function sbPrice(v: f64): void { // decimals follow the price's size: 6,652 / 660.10 / 48.125
  if (v >= 1000.0) sb_f64(v, 0);
  else if (v >= 100.0) sb_f64(v, 2);
  else sb_f64(v, 3);
}
function drawLevel(price: f64): void {
  if (isNaN(price)) {
    level.delete(); // no-ops when never drawn
    tag.delete();
    return;
  }
  level.set(firstT, price, t, price).color(ACCENT).width(1.0).style(LineStyle.Dashed).extend(Extend.Both);
  sb_clear();
  sb_text("Volume centre ");
  sbPrice(price);
  tag.set(56.0, price).text(str_tag_sb).color(ACCENT).fill(PILL); // 56 px in from the right edge clears the axis tags
}

// onBar() runs once per bar: every bar reads its surface into AVAR; the live bar measures the day's volume, blends the
// score and writes the card's words and the level.
function onBar(): void {
  const close = bar.close();
  t = bar.time();
  if (isNaN(firstT) && !isNaN(t)) firstT = t;
  const m = in_surface_cells();
  const avar = m >= SURFACE_TUPLE ? avarOf(m) : NaN;
  if (!isNaN(avar)) avarNow = avar;
  out_avar(avar);
  out_avar_now(avarNow);
  out_surface_partial(m >= SURFACE_TUPLE ? (surfacePartial(m) ? 1.0 : 0.0) : NaN);
  if (!bar.isLast()) {
    out_vwks(NaN);
    out_vw_strike(NaN);
    out_score(NaN);
    out_direction(NaN);
    out_traded(NaN);
    out_day_volume(NaN);
    out_put_call_volume(NaN);
    return;
  }
  const n = in_state_cells();
  const summary = n >= STATE_TUPLE && close > 0.0;
  if (summary) measureVolume(n, close);
  // The strikes belong to this chart's price only on the underlying's own chart: its close inside the listed strikes.
  const onChart = summary && !isNaN(lowStrike) && close >= lowStrike && close <= highStrike;
  const vwks = onChart && volume > 0.0 ? offsetSum / volume : NaN; // a fraction
  const vwStrike = onChart && volume > 0.0 ? strikeSum / volume : NaN;
  const enough = traded >= minContracts && volume >= minVolume;
  let score: f64 = NaN;
  if (onChart && enough && !isNaN(vwks) && !isNaN(avarNow)) {
    const v = Math.max(-1.0, Math.min(1.0, vwks / vwksBound));
    const a = Math.max(-1.0, Math.min(1.0, avarNow / avarBound));
    score = 100.0 * (VWKS_WEIGHT * v + AVAR_WEIGHT * a);
  }
  const direction = isNaN(score) ? NaN : score >= 20.0 ? 2.0 : score <= -20.0 ? 0.0 : 1.0;
  out_vwks(vwks * 100.0);
  out_vw_strike(vwStrike);
  out_score(score);
  out_direction(direction);
  out_traded(summary ? traded : NaN);
  out_day_volume(summary ? volume : NaN);
  out_put_call_volume(summary && callVolume > 0.0 ? putVolume / callVolume : NaN);
  drawLevel(vwStrike);
  sb_clear();
  if (!summary) sb_text("Waiting for the option chain summary");
  else if (!onChart) sb_text("Read on the underlying's chart: strikes need its price");
  else if (!enough) {
    sb_text("Thin day: ");
    sb_f64(traded, 0);
    sb_text(" contracts, ");
    sb_f64(volume, 0);
    sb_text(" volume");
  } else if (isNaN(avarNow)) sb_text("No 25 delta wings on the surface");
  else {
    sb_text(READ_WORDS[i32(direction)]);
    sb_text(", score ");
    if (score > 0.0) sb_text("+");
    sb_f64(score, 0);
  }
  str_read_sb();
  sb_clear();
  sb_text("Options sentiment, AVAR at ");
  sb_f64(tenor, 0);
  sb_text(" days");
  str_title_sb();
}
```

## How it works

**VWKS.** On the live bar every contract of the summary whose `present` mask carries bit 9 (`volume_day`) and that traded today adds its strike over the chart's close, minus 1, capped at -50% and +50%, weighted by its volume; the sum over the day's volume is VWKS, in percent as `vwks`. The same weights give `vw_strike`, the volume-weighted mean strike itself (uncapped), drawn across the chart as "Volume centre". The summary is the eight nearest expiries with strikes within 20% of the underlying (`expiries: "nearest:8", strike_window_pct: 20`), where the day's volume concentrates. The strikes are measured against the chart's own close, so VWKS reads on the underlying's own chart only, where the close lies inside the strikes the summary lists.

**AVAR.** On every bar the surface lists one tuple per expiry: days to expiry, the forward, the at-the-money IV and the 25 and 10 delta wings, in percent. An expiry anchors when its `present` mask carries bits 1, 4 and 5 (days, the 25 delta put and call IV) and it settles more than half a day out (a same-day expiry's IV swings with the hours left). Each wing is read at **AVAR maturity** (30 days) linearly in total variance between the two anchors around it, IV squared times years (`totalVarianceIv`, the step the kit's at-the-money term takes); before the first anchor it is the first's IV, past the last the last's. AVAR is the call wing minus the put wing in IV points: `avar` on its bar, and `avar_now`, the newest one at or before the bar, which the score blends. A bar the data plane marked partial (a chart finer than the surface's five-minute samples, or older than they are kept) still reads, and `surface_partial` says so.

**The score.** VWKS over **VWKS bound** (5%) and AVAR over **AVAR bound** (15 IV points), each clamped to -1..+1, blend as `100 * (0.6 * vwks + 0.4 * avar)`. The bounds are settings because no published source fixes them: an index whose puts always trade rich reads bearish on a tight AVAR bound, so widen it there. A day needs **Smallest traded contracts** (15) contracts with volume and **Smallest day volume** (500) contracts traded for a score.

**The card.** The headline reads the score (`Bearish, score -32`), coloured by it; the meter runs from -100 to +100 with marks at -20 and +20; then VWKS, AVAR, the volume-weighted strike, the contracts traded, the day's volume and put volume over call volume.

## Where it runs

US stock and ETF charts with US listed options, through OpenMarket's official wrun indicator, under the US options rule ([Limitations](../reference/limitations.md)). The `option_contract_state` source is upcoming: the kit declares it ahead of the chart lane that will serve it, so the package builds, and the chart shows its refusal until that lane ships. The `option_surface` source is served by the chart's browser lane on crypto, CME and US stock charts ([Option surface](../core-concepts/data-sources.md#option-surface)); the score also needs the chain summary, so the indicator runs on US stock and ETF charts. The surface keeps five-minute samples for 30 days and a daily sample after that, so the AVAR line reaches back as far as the daily samples do.

## When data is missing

The headline says why instead of the card going blank: "Waiting for the option chain summary" while the live bar carries none, "Read on the underlying's chart: strikes need its price" on a chart whose close is not among the strikes (an option contract's own chart), "Thin day: 9 contracts, 320 volume" below the minimums, and "No 25 delta wings on the surface" while no expiry carries both wings. A bar with no surface reads no AVAR, and the line breaks there. The fallback some tools use when no 25 delta reading exists (the calls' IV above spot minus the puts' below it, within 7%) needs each contract's IV, which the summary does not carry, so this score reads nothing instead.

## Customize it

- **A shorter maturity.** Set **AVAR maturity** to 7 for the front of the curve.
- **An index.** Raise **AVAR bound** to 25 so the index's standing put skew does not pin the score bearish.
- **A stricter day.** Raise **Smallest day volume** to score only busy days.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Options Sentiment** under **HUDs**.
2. Press **Run** on a US stock or ETF chart: the AVAR line fills bar by bar, the volume centre draws across the chart and the card sits at the bottom centre.
3. At the editor's Console prompt, type `score`, `vwks` and `avar_now` to read them on the live bar.

## Concepts used

- [Data sources](../core-concepts/data-sources.md#us-listed-options-opra) for the `option_contract_state` celled class, its tuple and the present mask
- [Option surface](../core-concepts/data-sources.md#option-surface) for the `option_surface` celled class on crypto, CME and US stock charts, and its tuple
- [Options kit](../functions/options-kit.md#the-at-the-money-term) for the total-variance interpolation
- [Drawing objects](../presentation/drawing-objects.md#handles) for a line handle extended across the chart and its tag
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for the card and its meter
