Day type

View as MarkdownOpen the editor

What kind of session the chart's underlying is trading, and how often that kind of session has held, from the 10-minute options statistics (the underlying's price, its 1-day at-the-money IV and the dealer gamma levels every ten minutes, on the underlyings the data plane samples that often).

Each sample names a state from the last Window samples of the session: Volatile when the window's high-low range is at least Volatile range times the move the 1-day IV expects over that time; Trending when the price kept going one way (its net move over the path it took is at least Trend efficiency) over a range of at least Pinned range expected moves; Pinned when the range is under that; Unsettled otherwise. The state is inferred from price and IV by these rules; it is not a direction.

The odds that a state holds are counted, not modelled: every earlier sample in the loaded history whose session reached 30, 60, 90 and 120 minutes further is an outcome, and the odds are the share of those outcomes still in the same state, with the typical move (the mean absolute move, in percent) over the same horizons. The forward range is the price plus and minus the typical move at the longest horizon with odds, boxed ahead of the live bar. The call wall, the put wall and the gamma flip ride the price as step lines, a ribbon under the chart colours each sample by its state, and a card at the bottom right reads the state, how firmly it holds (strong at 75% over the next hour, moderate at 50%), the odds, the moves and the distance to each level.

The parts are scalar option_stats_10m inputs declared with missing: "nan", so each sample lands on the bar holding it (Data sources), a box handle drawn on the live bar (Drawing objects), step lines and a histogram coloured per bar by state, and a render.hud card. This is also the day-type template: the Day Type card under HUDs in the editor's starter list, and it compiles as written.

The wrun indicator

// Day Type: what kind of session the chart's underlying is trading, and how often that kind of session has held, from
// the 10-minute options statistics (option_stats_10m: the underlying's price, its 1-day at-the-money IV and the dealer
// gamma levels every ten minutes, on the underlyings the data plane samples that often). Each sample names a state from
// the last Window samples of the session: Volatile when the window's high-low range is at least Volatile range times
// the move the 1-day IV expects over that time, Trending when the price kept going one way (its net move over the path
// it took, at least Trend efficiency) over a range of at least Pinned range times the expected move, Pinned when the
// range is under that, Unsettled otherwise. The odds that a state holds are counted, not modelled: every earlier sample
// in the loaded history whose session reached 30, 60, 90 and 120 minutes further is an outcome, and the odds are the
// share of those outcomes still in the same state, with the typical move (the mean absolute move, percent) over the
// same horizons. The forward range is the price plus and minus the typical move at the longest horizon with odds,
// boxed ahead of the live bar. The call wall, the put wall and the gamma flip ride the price as step lines, a ribbon
// under the chart colours each sample by its state, and a card at the bottom right reads the state, how firmly it holds
// (strong at 75% over the next hour, moderate at 50%), the odds, the moves and the distance to each level. The state is
// inferred from price and IV with the rules above; it is not a direction. The history is short today (the 10-minute
// statistics start when the data plane switches the job on): odds need Minimum outcomes (20) per state and horizon,
// and until then the card says how many there are. Charts of 10 minutes or finer on the sampled underlyings; any other
// underlying is refused by name.

param.int("window_samples", 6, { min: 3, max: 18, label: "Window, samples", description: "10-minute samples a state reads: 6 is the last hour" });
param.number("volatile_ratio", 1.5, { min: 0.5, max: 5, step: 0.1, label: "Volatile range", description: "A window's range at least this many expected moves is Volatile" });
param.number("pinned_ratio", 0.6, { min: 0.1, max: 2, step: 0.05, label: "Pinned range", description: "A window's range under this many expected moves is Pinned" });
param.number("trend_efficiency", 0.6, { min: 0.1, max: 1, step: 0.05, label: "Trend efficiency", description: "Net move over the path taken at least this, over a range not Pinned, is Trending" });
param.int("min_outcomes", 20, { min: 5, max: 500, label: "Minimum outcomes", description: "Outcomes a state needs at a horizon before its odds show" });
param.bool("show_levels", true, { label: "Levels", description: "Draw the call wall, the put wall and the gamma flip on price" });
input("close", ohlcv.close); // the chart's own close: the grid
input("spot", option_stats_10m.underlying_price, { missing: "nan", description: "The underlying's price at each 10-minute sample (the 10-minute options statistics)" }); // lands on the bar holding the sample, NaN elsewhere
input("iv1d", option_stats_10m.atm_iv_1d, { missing: "nan", description: "The 1-day at-the-money IV, percent: the move a window expects" });
input("gex", option_stats_10m.net_gex, { missing: "nan", description: "Net gamma exposure, USD per 1% move" });
input("flip", option_stats_10m.gamma_flip, { missing: "nan", description: "The gamma flip, a price" });
input("call_wall", option_stats_10m.call_wall, { missing: "nan", description: "The call wall, a price" });
input("put_wall", option_stats_10m.put_wall, { missing: "nan", description: "The put wall, a price" });
output("call_wall_line", line, overlay, { color: "#22c55e", width: 1, step: true, line_style: "dashed", label: "Call wall", format: "price", description: "The call wall, held until the next sample" });
output("put_wall_line", line, overlay, { color: "#ef4444", width: 1, step: true, line_style: "dashed", label: "Put wall", format: "price", description: "The put wall" });
output("flip_line", line, overlay, { color: "#f59e0b", width: 1, step: true, label: "Gamma flip", format: "price", description: "The gamma flip" });
output("ribbon", histogram, lower, { color_by: "state", colors: ["#eab308", "#22c55e", "#ef4444", "#a855f7"], label: "State", format: "0", description: "1 on every bar with a state, coloured by it: yellow Pinned, green Trending, red Volatile, violet Unsettled" });
output("state", none, lower, { description: "0 Pinned, 1 Trending, 2 Volatile, 3 Unsettled; NaN while the session's window fills" });
output("strength", none, lower, { description: "How firmly the state holds over the next hour: 0 weak, 1 moderate (50%), 2 strong (75%); NaN without odds" });
output("odds_30", none, lower, { format: "%", description: "The share of earlier outcomes of this state still in it 30 minutes on, in percent" });
output("odds_60", none, lower, { format: "%", description: "The same 60 minutes on" });
output("odds_90", none, lower, { format: "%", description: "The same 90 minutes on" });
output("odds_120", none, lower, { format: "%", description: "The same 120 minutes on" });
output("move_30", none, lower, { description: "The mean absolute move 30 minutes on from this state, percent" });
output("move_60", none, lower, { description: "The same 60 minutes on, percent" });
output("move_90", none, lower, { description: "The same 90 minutes on, percent" });
output("move_120", none, lower, { description: "The same 120 minutes on, percent" });
output("range_ratio", none, lower, { description: "The window's range over the move the 1-day IV expects" });
output("efficiency", none, lower, { description: "The window's net move over the path it took, 0 to 1" });
output("gex", none, lower, { format: "usd", description: "Net gamma exposure at the sample, USD per 1% move" });
output("dist_call", none, lower, { description: "The call wall over the price, minus 1, percent" });
output("dist_put", none, lower, { description: "The put wall over the price, minus 1, percent" });
output("dist_flip", none, lower, { description: "The gamma flip over the price, minus 1, percent" });
output("outcomes", none, lower, { description: "Outcomes the state has at 60 minutes" });
output("status", none, lower, { description: "0 waiting (samples or outcomes), 1 the odds read, 2 a chart coarser than 10 minutes" });
string("read", { max_bytes: 64 }); // the headline: the state and how firmly it holds
string("odds_text", { max_bytes: 64 }); // the odds, or why there are none
string("title", { max_bytes: 64 });
handles.box({ opacity: 0.18, borderWidth: 1 }); // the forward range, boxed ahead of the live bar

render.hud("day_type_card", {
  position: "bottom_right",
  look: "terminal",
  title: "{{title}}",
  columns: 2,
  tiles: [
    tile.pill("State", "read", { headline: true, color_by: "state", colors: ["#eab308", "#22c55e", "#ef4444", "#a855f7"] }),
    tile.pill("Holds", "odds_text", { color_by: "strength", colors: ["#ef4444", "#94a3b8", "#22c55e"] }),
    tile.rows([
      ["1) Holds 30 min", "odds_30", "%"],
      ["2) Holds 1 hour", "odds_60", "%"],
      ["3) Holds 90 min", "odds_90", "%"],
      ["4) Holds 2 hours", "odds_120", "%"],
      ["5) Move 1 hour, %", "move_60", "0.00"],
      ["6) Move 2 hours, %", "move_120", "0.00"],
      ["7) Call wall, %", "dist_call", "0.00"],
      ["8) Put wall, %", "dist_put", "0.00"],
      ["9) Gamma flip, %", "dist_flip", "0.00"],
      ["10) Net gamma", "gex", "usd"],
    ]),
  ],
});

const DAY = 86400.0;
const SAMPLE_SEC = 600.0; // the statistics' cadence
const TRADING_MINUTES_YEAR = 98280.0; // 252 sessions of 390 minutes: the year the 1-day IV is scaled over
const MAX_SESSION = 72; // samples a session can hold (09:30 to 16:15 is 41)
const STATES = 4;
const HORIZONS = 4;
const HORIZON_MIN: StaticArray<f64> = [30.0, 60.0, 90.0, 120.0];
const STATE_WORDS: String[] = ["Pinned", "Trending", "Volatile", "Unsettled"];
const MONTHS: String[] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const sesT = new StaticArray<f64>(MAX_SESSION); // the session's samples: time, price, state
const sesP = new StaticArray<f64>(MAX_SESSION);
const sesS = new StaticArray<f64>(MAX_SESSION);
const outcomeN = new StaticArray<f64>(STATES * HORIZONS); // per state and horizon: outcomes, held, the moves summed
const outcomeHeld = new StaticArray<f64>(STATES * HORIZONS);
const outcomeMove = new StaticArray<f64>(STATES * HORIZONS);
const rangeBox = draw.box(0);
const dateClock = new Clock("UTC");

let windowN = 6; // settings, read in onStart()
let volatileRatio = 1.5;
let pinnedRatio = 0.6;
let trendEfficiency = 0.6;
let minOutcomes = 20;
let showLevels = true;
let prevT: f64 = NaN; // the bar spacing
let barSec: f64 = NaN;
let sesDay: f64 = NaN; // the session the arrays hold
let sesN = 0;
let stateNow: f64 = NaN; // the newest sample's readings, held
let ratioNow: f64 = NaN;
let effNow: f64 = NaN;
let lastT: f64 = NaN;
let lastP: f64 = NaN;
let gex: f64 = NaN;
let flip: f64 = NaN;
let callWall: f64 = NaN;
let putWall: f64 = NaN;

function onStart(): void {
  windowN = i32(p_window_samples());
  volatileRatio = p_volatile_ratio();
  pinnedRatio = p_pinned_ratio();
  trendEfficiency = p_trend_efficiency();
  minOutcomes = i32(p_min_outcomes());
  showLevels = p_show_levels() > 0.5;
}

/** The state of the window ending at the newest sample (the rules in the header); NaN while it fills. */
function classify(iv: f64): f64 {
  ratioNow = NaN;
  effNow = NaN;
  if (sesN < windowN || !(iv > 0.0)) return NaN;
  const first = sesN - windowN;
  let lo = sesP[first];
  let hi = lo;
  let path = 0.0;
  for (let k = first + 1; k < sesN; k += 1) {
    const p = sesP[k];
    if (p < lo) lo = p;
    if (p > hi) hi = p;
    path += Math.abs(p - sesP[k - 1]);
  }
  const last = sesP[sesN - 1];
  const minutes = (sesT[sesN - 1] - sesT[first]) / 60.0;
  const expected = last * (iv / 100.0) * Math.sqrt(minutes / TRADING_MINUTES_YEAR);
  if (!(expected > 0.0)) return NaN;
  ratioNow = (hi - lo) / expected;
  effNow = path > 0.0 ? Math.abs(last - sesP[first]) / path : 0.0;
  if (ratioNow >= volatileRatio) return 2.0;
  if (ratioNow < pinnedRatio) return 0.0;
  if (effNow >= trendEfficiency) return 1.0;
  return 3.0;
}

/** Each earlier sample of the session exactly a horizon before the newest is an outcome of its state. */
function recordOutcomes(): void {
  const t = sesT[sesN - 1];
  const p = sesP[sesN - 1];
  const s = sesS[sesN - 1];
  for (let k = sesN - 2; k >= 0; k -= 1) {
    const ago = (t - sesT[k]) / 60.0;
    if (ago > HORIZON_MIN[HORIZONS - 1]) break;
    const was = sesS[k];
    if (isNaN(was) || isNaN(s)) continue;
    for (let h = 0; h < HORIZONS; h += 1) {
      if (Math.abs(ago - HORIZON_MIN[h]) > 0.5) continue;
      const at = i32(was) * HORIZONS + h;
      outcomeN[at] += 1.0;
      if (was == s) outcomeHeld[at] += 1.0;
      outcomeMove[at] += Math.abs(p / sesP[k] - 1.0) * 100.0;
    }
  }
}

function addSample(t: f64, p: f64, iv: f64): void {
  const day = Math.floor(t / DAY);
  if (day != sesDay) {
    sesDay = day;
    sesN = 0;
  }
  if (sesN >= MAX_SESSION) return;
  if (sesN > 0 && t <= sesT[sesN - 1]) return; // a sample the session already holds
  sesT[sesN] = t;
  sesP[sesN] = p;
  sesN += 1;
  const s = classify(iv);
  sesS[sesN - 1] = s;
  stateNow = s;
  lastT = t;
  lastP = p;
  recordOutcomes();
}

function odds(h: i32): f64 {
  if (isNaN(stateNow)) return NaN;
  const at = i32(stateNow) * HORIZONS + h;
  return outcomeN[at] >= f64(minOutcomes) ? outcomeHeld[at] / outcomeN[at] : NaN;
}

function typicalMove(h: i32): f64 {
  if (isNaN(stateNow)) return NaN;
  const at = i32(stateNow) * HORIZONS + h;
  return outcomeN[at] >= f64(minOutcomes) ? outcomeMove[at] / outcomeN[at] : NaN;
}

function strengthOf(): f64 {
  const o = odds(1);
  if (isNaN(o)) return NaN;
  return o >= 0.75 ? 2.0 : o >= 0.5 ? 1.0 : 0.0;
}

function distance(level: f64): f64 {
  return lastP > 0.0 && level > 0.0 ? (level / lastP - 1.0) * 100.0 : NaN;
}

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 sbPct(v: f64): void { // "71%"
  sb_f64(v * 100.0, 0);
  sb_text("%");
}

function writeWords(coarse: bool): void {
  sb_clear();
  const strength = strengthOf();
  if (coarse) sb_text("Needs a 10-minute or finer chart");
  else if (isNaN(lastT)) sb_text("Waiting for the 10-minute options statistics");
  else if (isNaN(stateNow)) {
    sb_text("Window filling: ");
    sb_int(sesN);
    sb_text(" of ");
    sb_int(windowN);
    sb_text(" samples this session");
  } else {
    sb_text(STATE_WORDS[i32(stateNow)]);
    if (!isNaN(strength)) sb_text(strength == 2.0 ? ", holds strongly" : strength == 1.0 ? ", holds moderately" : ", holds weakly");
  }
  str_read_sb();
  sb_clear();
  const n60 = isNaN(stateNow) ? 0.0 : outcomeN[i32(stateNow) * HORIZONS + 1];
  if (coarse || isNaN(stateNow)) sb_text("No odds");
  else if (isNaN(odds(1))) {
    sb_text("Not enough history yet: ");
    sb_int(i64(n60));
    sb_text(" of ");
    sb_int(minOutcomes);
    sb_text(" outcomes");
  } else {
    sb_text("+30m ");
    if (isNaN(odds(0))) sb_text("-");
    else sbPct(odds(0));
    sb_text(", +1h ");
    sbPct(odds(1));
    sb_text(", +2h ");
    if (isNaN(odds(3))) sb_text("-");
    else sbPct(odds(3));
  }
  str_odds_text_sb();
  sb_clear();
  sb_text("Day type");
  if (!coarse && !isNaN(lastT)) {
    sb_text(", ");
    sbDay(Math.floor(lastT / DAY));
    dateClock.update(lastT);
    sb_text(" ");
    if (dateClock.hour() < 10) sb_text("0");
    sb_int(dateClock.hour());
    sb_text(":");
    if (dateClock.minute() < 10) sb_text("0");
    sb_int(dateClock.minute());
    sb_text(" UTC");
  }
  str_title_sb();
}

/** The forward range: the price plus and minus the typical move at the longest horizon with odds, from the live bar
 *  to that horizon ahead; deleted without one. */
function drawRange(t: f64): void {
  let h = HORIZONS - 1;
  while (h >= 0 && isNaN(typicalMove(h))) h -= 1;
  if (h < 0 || !(lastP > 0.0)) {
    rangeBox.delete(); // every coordinate must be finite: no range, no box
    return;
  }
  const m = typicalMove(h) / 100.0;
  const ink = stateNow == 2.0 ? rgba(239, 68, 68, 255) : stateNow == 1.0 ? rgba(34, 197, 94, 255) : stateNow == 0.0 ? rgba(234, 179, 8, 255) : rgba(168, 85, 247, 255);
  rangeBox.set(t, lastP * (1.0 + m), t + HORIZON_MIN[h] * 60.0, lastP * (1.0 - m)).fill(ink).color(ink).opacity(0.18).border(1.0);
}

// onBar() runs once per bar: the spacing, then the 10-minute sample when it lands (its state, the outcomes it settles,
// the levels); every bar writes the held readings, and the live bar boxes the forward range and writes the card.
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 > SAMPLE_SEC + 1.0;
  const p = in_spot();
  if (!coarse && !isNaN(p) && p > 0.0) {
    addSample(t, p, in_iv1d());
    gex = in_gex();
    flip = in_flip();
    callWall = in_call_wall();
    putWall = in_put_wall();
  }
  const levels = showLevels && !coarse;
  out_call_wall_line(levels ? callWall : NaN);
  out_put_wall_line(levels ? putWall : NaN);
  out_flip_line(levels ? flip : NaN);
  const live = !coarse && !isNaN(stateNow) && Math.floor(t / DAY) == sesDay;
  out_ribbon(live ? 1.0 : NaN);
  out_state(live ? stateNow : NaN);
  out_strength(live ? strengthOf() : NaN);
  // The odds are a share; the outputs and the card's rows read them in percent.
  out_odds_30(live ? odds(0) * 100.0 : NaN);
  out_odds_60(live ? odds(1) * 100.0 : NaN);
  out_odds_90(live ? odds(2) * 100.0 : NaN);
  out_odds_120(live ? odds(3) * 100.0 : NaN);
  out_move_30(live ? typicalMove(0) : NaN);
  out_move_60(live ? typicalMove(1) : NaN);
  out_move_90(live ? typicalMove(2) : NaN);
  out_move_120(live ? typicalMove(3) : NaN);
  out_range_ratio(live ? ratioNow : NaN);
  out_efficiency(live ? effNow : NaN);
  out_gex(coarse ? NaN : gex);
  out_dist_call(coarse ? NaN : distance(callWall));
  out_dist_put(coarse ? NaN : distance(putWall));
  out_dist_flip(coarse ? NaN : distance(flip));
  out_outcomes(live ? outcomeN[i32(stateNow) * HORIZONS + 1] : NaN);
  out_status(coarse ? 2.0 : live && !isNaN(odds(1)) ? 1.0 : 0.0);
  if (bar.isLast()) {
    writeWords(coarse);
    if (live) drawRange(t);
    else rangeBox.delete();
  }
}

How it works

The expected move. Over a window of n samples the price moves for (n - 1) times 10 minutes; the 1-day IV expects price x IV x sqrt(minutes / 98,280) of it, 98,280 being 252 sessions of 390 minutes, the hours the market trades.

Outcomes settle on the later sample. When a sample arrives, each sample of the same session exactly 30, 60, 90 or 120 minutes before it becomes an outcome of that sample's state: held when the two states match, with the absolute move between them. So the odds on any bar use only what had happened by then.

Strength. The share of outcomes that held an hour on: strong at 75% and over, moderate at 50%, weak under.

Where it runs

US stock charts of 10 minutes or finer, through OpenMarket's official package, on the underlyings the data plane samples every ten minutes; any other underlying is refused by name (wrun_not_in_10m_universe). 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. The statistics job samples the delayed chain, so the newest 10-minute sample trails the market by about 15 minutes.

When data is missing

The 10-minute statistics start the day their job is switched on, so the odds need history: each state needs Minimum outcomes (20) at a horizon before its odds show, and until then the pill reads "Not enough history yet: 7 of 20 outcomes". Early in each session the window fills first ("Window filling: 3 of 6 samples this session"). Before the first sample the card reads "Waiting for the 10-minute options statistics"; a chart coarser than 10 minutes reads "Needs a 10-minute or finer chart".

Customize it

  • A longer or shorter look. window_samples of 3 reads the last half hour, 12 the last two hours.
  • Your own edges. Volatile range, Pinned range and Trend efficiency move the rules.
  • Odds sooner. Lower Minimum outcomes, knowing the counts are thinner.

Run it

  1. In the editor's Explorer, press the Templates icon and pick Day Type under HUDs.
  2. Press Run on SPY's 5-minute chart.
  3. At the editor's Console prompt, type state, odds_60 and move_60.

Concepts used