---
title: "Volatility character"
description: "How the chart market's own price has moved, read from its closed daily candles on any market and any interval, in one pane below the chart that the View…"
order: 143
section: "cookbook"
---

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

# Volatility character

How the chart market's own price has moved, read from its closed daily candles on any market and any interval, in one pane below the chart that the **View** setting picks, beside a card that names the volatility's character. **Historical volatility** draws five lines over the past year: the annualized standard deviation of daily log returns over 10, 20, 30, 60 and 90 days, the caption reading the 30-day value and where it sits in its year ("HV 30d 18.4%, 64th percentile of its year"). **Volatility cone** sets each lookback (10, 30, 60, 90 and 180 days) against its own past year: the 90th, 75th, median, 25th and 10th percentile of that window's readings as five lines across the lookbacks, today's reading as a sixth, so a "Now" line near the top of the cone is volatility high for this market and near the bottom low. **Return structure** draws the 120-day rolling lag-1 autocorrelation of daily returns with a signed fill, teal above 0 where a day tends to follow the day before through, orange below 0 where it tends to reverse it, and a badge with the Hurst exponent of the price over the year ("Hurst 0.46 random"). **Seasonality** shows the average annualized daily move by weekday, by day of the month, or on the monthly options expiry against the session after it, the hottest bucket in orange and the calmest in sky. **Character** lists the readings behind the card in a table, each with a line on what it says. The card at the top left, in the glass look, names the character first (mean-reverting, moderate or persistent: how fast a volatility shock decays), then the half-life on a meter marked at 10 and 30 days, the Hurst exponent of the changes in log realized volatility, how predictable tomorrow's volatility direction is, and the 30-day volatility with its rank in the year.

The parts are the chart's own close, the bar grid ([Data sources](../core-concepts/data-sources.md)); one `candles` stream of the same market at `1d`, the newest 800 closed days on the first bar and each day as it closes ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)); two `param.choice` settings labelled **View** and **Seasonality by** ([Setting kinds](../settings/kinds.md)); five frames feeding two `panel.line` panes over time, one `panel.line` over lookbacks, one `panel.bars` and one `panel.table`, of which the module writes only the picked view's frame ([Cards, frames and panels](../presentation/cards-frames-panels.md)); a `render.hud` card of a headline `tile.pill` coloured by an output, a `tile.meter` and a `tile.rows` with dotted leaders in the glass look ([HUD cards](../presentation/hud-and-hover-cards.md#hud-cards)); and a label for the one sentence while the history is short. It reads candles only, so it runs on crypto, US stocks and ETFs, CME futures, forex and gold alike; implied volatility is the [IV term structure](iv-term-structure.md)'s, which reads the option chain. This is also the `vol-character` template: the **Volatility Character** card under **Beyond the time axis** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=vol-character
// Volatility Character: how this market's own price has moved, read from its closed daily candles on any market and any
// chart interval (crypto, US stocks and ETFs, CME futures, forex and gold alike), in one pane below the chart that the
// View setting picks, beside a card that names the volatility's character. Historical volatility: the annualized
// standard deviation of daily log returns over 10, 20, 30, 60 and 90 days, one line each over the past year. Volatility
// cone: for lookbacks of 10, 30, 60, 90 and 180 days, the past year of that window's realized volatility as percentile
// lines (10th, 25th, median, 75th, 90th) with today's reading across them. Return structure: the 120-day rolling lag-1
// autocorrelation of daily returns (above 0 the days follow through, below 0 they reverse), the Hurst exponent of the
// price on the badge. Seasonality: the average annualized daily move by weekday, by day of the month, or on the monthly
// options expiry against the session after it. Character: the readings behind the card in a table. The card at the top
// left names the character (mean-reverting, moderate or persistent: how fast a volatility shock decays), the half-life
// on a fast-to-sticky meter, the Hurst exponent of the changes in log realized volatility, how predictable tomorrow's
// volatility direction is, and the 30-day volatility against its past year. A year is 252 sessions on a market that
// closes for the weekend and 365 days on one that trades every day; every reading is as of the newest closed day.
// Implied volatility is not read here (this file reads candles only, so it runs everywhere): the IV Term Structure
// template reads the option chain and sets its implied volatility against this realized volatility.

section("View");
param.choice("view", ["Historical volatility", "Volatility cone", "Return structure", "Seasonality", "Character"], "Historical volatility", { label: "View", description: "The pane below the chart: realized volatility by window, the cone, the return structure, the seasonality or the character readings" });
param.choice("season_by", ["Weekday", "Day of month", "OpEx"], "Weekday", { label: "Seasonality by", description: "The Seasonality view's buckets: weekday, day of the month, or the monthly options expiry against the session after it" });
input("close", ohlcv.close); // the chart's own candles: the bar grid the daily stream rides
input("days", candles.cells, { interval: "1d", bars: 800, description: "The newest 800 closed daily candles of the chart's market, then each day as it closes" });
output("hv_10", none, overlay, { format: "%", description: "Historical volatility over 10 days: the annualized standard deviation of daily log returns, percent" }); // data-only: the card, the Console and an alert read them
output("hv_20", none, overlay, { format: "%", description: "Historical volatility over 20 days, percent" });
output("hv_30", none, overlay, { format: "%", description: "Historical volatility over 30 days, percent" });
output("hv_60", none, overlay, { format: "%", description: "Historical volatility over 60 days, percent" });
output("hv_90", none, overlay, { format: "%", description: "Historical volatility over 90 days, percent" });
output("hv30_rank", none, overlay, { description: "Where the 30-day historical volatility sits in its past year: the share of the year's readings below it, 0 to 100" });
output("autocorr_120", none, overlay, { description: "Lag-1 autocorrelation of daily log returns over 120 days: above 0 the days follow through, below 0 they reverse" });
output("hurst_price", none, overlay, { description: "Hurst exponent of the log price over the past year: above 0.5 trending, 0.5 a random walk, below 0.5 mean-reverting" });
output("half_life", none, overlay, { description: "Half-life of a volatility shock in trading days, from an AR(1) fit on log 5-day realized volatility; 250 when it shows no reversion" });
output("hurst_rv", none, overlay, { description: "Hurst exponent of the changes in log 5-day realized volatility, over 5 to 40 days: above 0.5 volatility moves persist" });
output("predictability", none, overlay, { description: "How well today's volatility direction foretells tomorrow's: 1 minus its conditional entropy in bits, 0 a coin flip, 1 certain" });
output("character_code", none, overlay, { description: "0 mean-reverting (half-life under 10 days), 1 moderate (10 to 30 days), 2 persistent (over 30 days)" });
string("character_word", { max_bytes: 24 }); // the card's headline: "Mean-reverting"
string("hv30_text", { max_bytes: 32 }); // the card's last row: "18.4%, 64th pct"
string("notice", { max_bytes: 96 }); // the one sentence while the history is too short
const hvRows = frame("hv_rows", { max_bytes: 32768 }); // one frame per view: the View setting picks the one written
const coneRows = frame("cone_rows", { max_bytes: 4096 });
const acRows = frame("ac_rows", { max_bytes: 16384 });
const seasonRows = frame("season_rows", { max_bytes: 4096 });
const charRows = frame("char_rows", { max_bytes: 4096 });
// The five panes. A frame never written leaves its pane off the chart, so only the view picked draws.
panel.line({ name: "windows", title: "Historical volatility", x: "time", place: "below", frame: hvRows, height_frac: 0.3, chrome: "grid", hover_card: true, legend_style: "chips", legend_latest: true, format: "%", decimals: 1, y_title: "Annualized", series: [
  { name: "10d", color: "#f86800", width: 1 },
  { name: "20d", color: "#f8c000", width: 1 },
  { name: "30d", color: "#2dd4bf", width: 2 },
  { name: "60d", color: "#38bdf8", width: 1.5 },
  { name: "90d", color: "#a78bfa", width: 1.5 },
] });
panel.line({ name: "cone", title: "Volatility cone", x: "number", place: "below", frame: coneRows, height_frac: 0.3, chrome: "grid", hover_card: true, legend_style: "chips", format: "%", decimals: 1, x_title: "Lookback, days", x_format: "auto", x_decimals: 0, x_unit: "d", y_title: "Realized volatility", series: [
  { name: "90th", color: "theme.muted", width: 1, line_style: "dashed" },
  { name: "75th", color: "theme.muted", width: 1 },
  { name: "Median", color: "theme.text", width: 1.5 },
  { name: "25th", color: "theme.muted", width: 1 },
  { name: "10th", color: "theme.muted", width: 1, line_style: "dashed" },
  { name: "Now", color: "#2dd4bf", width: 2.5, points: true },
] });
panel.line({ name: "returns", title: "Return structure", x: "time", place: "below", frame: acRows, height_frac: 0.3, chrome: "grid", hover_card: true, legend_style: "none", y_zero: true, fill_mode: "signed", fill_positive_color: "#2dd4bf", fill_negative_color: "#f86800", fill_fade: true, format: "0.00", badge: { text: "Hurst", color: "#94a3b8" }, series: [{ name: "Autocorrelation, 120d", color: "theme.text", width: 1.5 }] });
panel.bars({ name: "seasonality", title: "Volatility by weekday", x: "category", place: "below", frame: seasonRows, height_frac: 0.3, chrome: "grid", hover_card: true, legend_style: "none", format: "%", decimals: 1, series: [{ name: "Average annualized move", color: "#64748b" }] });
panel.table({ name: "character", title: "Volatility character", x: "category", place: "below", frame: charRows, height_frac: 0.3, header_column: true, grid_lines: "rows", series: [{ name: "Reading" }, { name: "Value", align: "right" }, { name: "What it says" }] });
// The card, in the glass look at the top left under the legend: the character first (teal mean-reverting, amber
// moderate, orange persistent), the half-life on a meter from fast to sticky with marks at 10 and 30 days, then three rows.
render.hud("character_card", {
  position: "top_left",
  safe_area: true,
  look: "glass",
  title: "Volatility character",
  columns: 1,
  width: 250,
  tiles: [
    tile.pill("Character", "character_word", { headline: true, color_by: "character_code", colors: ["#2dd4bf", "#f8c000", "#f86800"] }),
    tile.meter("Half-life, trading days", "half_life", { min: 0, max: 60, marks: [10, 30] }),
    tile.rows([
      ["Hurst, vol changes", "hurst_rv", "0.00"],
      ["Predictability", "predictability", "0.00"],
      ["HV 30d", "hv30_text"],
    ], { leader: "dots" }),
  ],
});
render.label("notice", { position: "top_right", text: "notice", offset: [12, 8], style: "knockout", color: "#94a3b8", size: 12 });

const CAP = 1024; // daily candles held, newest last; a full buffer drops its oldest DROP
const DROP = 256;
const DAY_SEC: f64 = 86400.0;
const WINDOWS: StaticArray<i32> = [10, 20, 30, 60, 90]; // the historical volatility windows, days
const LOOKBACKS: StaticArray<i32> = [10, 30, 60, 90, 180]; // the cone's lookbacks, days
const CONE_PCTS: StaticArray<f64> = [0.9, 0.75, 0.5, 0.25, 0.1]; // the cone's lines, in the panel's series order
const AC_DAYS = 120; // the return structure's rolling window, in returns
const RV_DAYS = 5; // the character's realized volatility window, days
const MIN_OBS = 60; // realized volatility readings the character needs
const MIN_SAMPLES = 20; // readings a percentile needs
const HALF_LIFE_CAP: f64 = 250.0; // the half-life read when volatility shows no reversion
const INV_LN2: f64 = 1.4426950408889634;
const DAY_NAMES: StaticArray<string> = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const CHARACTER_WORDS: StaticArray<string> = ["Mean-reverting", "Moderate", "Persistent"];
const HOT = "#f86800"; // the hottest seasonality bucket
const CALM = "#38bdf8"; // the calmest
const PLAIN = "#64748b";
const dayOpen = new StaticArray<f64>(CAP); // each held day's open, epoch seconds (00:00 UTC of its trade date)
const dayClose = new StaticArray<f64>(CAP);
const ret = new StaticArray<f64>(CAP); // log return into each day, NaN on the first
const sum1 = new StaticArray<f64>(CAP); // prefix sums of the returns and of their squares
const sum2 = new StaticArray<f64>(CAP);
const lnClose = new StaticArray<f64>(CAP);
const lnRv = new StaticArray<f64>(CAP); // log 5-day realized volatility, NaN where it is undefined
const scratch = new StaticArray<f64>(CAP); // a lookback's year of readings, sorted for the cone
const bucketSum = new StaticArray<f64>(31); // the seasonality buckets: summed moves and counts
const bucketCount = new StaticArray<i32>(31);

let view = 0; // settings, read in onStart()
let seasonBy = 0;
let days = 0; // days held
let fresh = false; // a day arrived since the last measure
let annual: f64 = 252.0; // sessions a year: 365 on a market that trades through the weekend
let year = 252; // the past year, in held days
// The newest closed day's readings, written to the outputs on every bar.
let hv10: f64 = NaN; let hv20: f64 = NaN; let hv30: f64 = NaN; let hv60: f64 = NaN; let hv90: f64 = NaN;
let rank30: f64 = NaN; let acNow: f64 = NaN; let hurstPrice: f64 = NaN;
let halfLife: f64 = NaN; let arDay: f64 = NaN; let hurstRv: f64 = NaN; let predict: f64 = NaN; let character: f64 = NaN;
let obs = 0; // realized volatility readings in the character's sample
let civMonth = 0; let civDay = 0; // civil()'s answer

function onStart(): void {
  view = i32(p_view());
  seasonBy = i32(p_season_by());
}

function weekday(sec: f64): i32 { return i32((i64(Math.floor(sec / DAY_SEC)) + 3) % 7); } // 1970-01-01 was a Thursday: 0 is Monday
function civil(sec: f64): void { // the UTC calendar day and month of epoch seconds (days to civil, no Date)
  const z = i64(Math.floor(sec / DAY_SEC)) + 719468;
  const era = (z >= 0 ? z : z - 146096) / 146097;
  const doe = z - era * 146097;
  const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const mp = (5 * doy + 2) / 153;
  civDay = i32(doy - (153 * mp + 2) / 5 + 1);
  civMonth = i32(mp < 10 ? mp + 3 : mp - 9);
}
// The monthly options expiry: the third Friday on a market that closes for the weekend (US listed options), the last
// Friday on one that trades every day (Deribit's monthly).
function isOpex(sec: f64): bool {
  if (weekday(sec) != 4) return false;
  civil(sec);
  if (annual < 300.0) return civDay >= 15 && civDay <= 21;
  const month = civMonth;
  civil(sec + 7.0 * DAY_SEC);
  return civMonth != month;
}

// The stream into the held days: each closed candle once, oldest first; a full buffer drops its oldest DROP days.
function fold(t: f64): void {
  const block = in_days_view();
  const cells = in_days_cells(); // f64 values, six per candle: [offset_ms, open, high, low, close, volume]; -1 or 0 carries none
  const n = cells < block.length ? cells : block.length;
  for (let i = 0; i + 6 <= n; i += 6) {
    const close = block[i + 4];
    if (!(close > 0.0)) continue;
    const open = t + block[i] / 1000.0; // the day's open, epoch seconds
    if (days > 0 && open <= dayOpen[days - 1]) continue;
    if (days == CAP) {
      for (let k = DROP; k < CAP; k += 1) {
        dayOpen[k - DROP] = dayOpen[k];
        dayClose[k - DROP] = dayClose[k];
      }
      days = CAP - DROP;
    }
    dayOpen[days] = open;
    dayClose[days] = close;
    days += 1;
    fresh = true;
  }
}

// Realized volatility over `len` returns ending on day i: the sample standard deviation of the daily log returns,
// annualized by the square root of the sessions a year, in percent; NaN until the window is full.
function hv(len: i32, i: i32): f64 {
  if (i - len < 0 || i >= days) return NaN;
  const s1 = sum1[i] - sum1[i - len];
  const s2 = sum2[i] - sum2[i - len];
  let v = (s2 - (s1 * s1) / f64(len)) / f64(len - 1);
  if (v < 0.0) v = 0.0;
  return Math.sqrt(v * annual) * 100.0;
}
function yearFrom(len: i32): i32 { // the first day of the past year with a full window
  const from = days - year;
  return from < len ? len : from;
}
// The share of the past year's readings of a window below a value, in percent; NaN under MIN_SAMPLES readings.
function rankOf(len: i32, value: f64): f64 {
  let n = 0;
  let below = 0;
  for (let i = yearFrom(len); i < days; i += 1) {
    const v = hv(len, i);
    if (isNaN(v)) continue;
    n += 1;
    if (v < value) below += 1;
  }
  return n >= MIN_SAMPLES && !isNaN(value) ? (100.0 * f64(below)) / f64(n) : NaN;
}
// Lag-1 autocorrelation of the AC_DAYS returns ending on day i, around their own mean.
function autocorr(i: i32): f64 {
  const from = i - AC_DAYS + 1;
  if (from < 1 || i >= days) return NaN;
  let m = 0.0;
  for (let j = from; j <= i; j += 1) m += ret[j];
  m /= f64(AC_DAYS);
  let num = 0.0;
  let den = 0.0;
  for (let j = from; j <= i; j += 1) {
    const a = ret[j] - m;
    den += a * a;
    if (j > from) num += a * (ret[j - 1] - m);
  }
  return den > 0.0 ? num / den : NaN;
}
// The Hurst exponent by the scaling of differences: the slope of ln(standard deviation of x[j] - x[j - k]) on ln k
// over k = k0, 2 k0, 4 k0, ...; 0.5 for a random walk, above it trending, below it mean-reverting.
function hurst(src: StaticArray<f64>, from: i32, to: i32, k0: i32, steps: i32): f64 {
  let sx = 0.0;
  let sy = 0.0;
  let sxx = 0.0;
  let sxy = 0.0;
  for (let s = 0; s < steps; s += 1) {
    const k = k0 << s;
    let n = 0;
    let mean = 0.0;
    for (let j = from + k; j <= to; j += 1) {
      const d = src[j] - src[j - k];
      if (isNaN(d)) continue;
      mean += d;
      n += 1;
    }
    if (n < MIN_SAMPLES) return NaN;
    mean /= f64(n);
    let v = 0.0;
    for (let j = from + k; j <= to; j += 1) {
      const d = src[j] - src[j - k];
      if (isNaN(d)) continue;
      v += (d - mean) * (d - mean);
    }
    v /= f64(n - 1);
    if (!(v > 0.0)) return NaN;
    const x = Math.log(f64(k));
    const y = 0.5 * Math.log(v);
    sx += x;
    sy += y;
    sxx += x * x;
    sxy += x * y;
  }
  const m = f64(steps);
  const den = m * sxx - sx * sx;
  return den > 0.0 ? (m * sxy - sx * sy) / den : NaN;
}
// The AR(1) slope of log realized volatility on itself RV_DAYS days back: the two windows share no return, so the
// overlap of a rolling window never reads as persistence.
function arLag(from: i32, to: i32): f64 {
  let n = 0;
  let mx = 0.0;
  let my = 0.0;
  for (let j = from + RV_DAYS; j <= to; j += 1) {
    const x = lnRv[j - RV_DAYS];
    const y = lnRv[j];
    if (isNaN(x) || isNaN(y)) continue;
    mx += x;
    my += y;
    n += 1;
  }
  if (n < MIN_SAMPLES) return NaN;
  mx /= f64(n);
  my /= f64(n);
  let sxy = 0.0;
  let sxx = 0.0;
  for (let j = from + RV_DAYS; j <= to; j += 1) {
    const x = lnRv[j - RV_DAYS];
    const y = lnRv[j];
    if (isNaN(x) || isNaN(y)) continue;
    sxy += (x - mx) * (y - my);
    sxx += (x - mx) * (x - mx);
  }
  return sxx > 0.0 ? sxy / sxx : NaN;
}
function bits(a: i32, b: i32): f64 { // the entropy of a two-way split, in bits
  const n = a + b;
  if (n == 0) return 0.0;
  let h = 0.0;
  if (a > 0) h -= (f64(a) / f64(n)) * Math.log(f64(a) / f64(n)) * INV_LN2;
  if (b > 0) h -= (f64(b) / f64(n)) * Math.log(f64(b) / f64(n)) * INV_LN2;
  return h;
}
// 1 minus the entropy of tomorrow's volatility direction given today's (up or down day over day), in bits.
function predictability(from: i32, to: i32): f64 {
  let uu = 0;
  let ud = 0;
  let du = 0;
  let dd = 0;
  let prev = -1;
  for (let j = from + 1; j <= to; j += 1) {
    const a = lnRv[j - 1];
    const b = lnRv[j];
    let dir = -1;
    if (!isNaN(a) && !isNaN(b)) dir = b > a ? 1 : b < a ? 0 : -1;
    if (prev == 1 && dir == 1) uu += 1;
    else if (prev == 1 && dir == 0) ud += 1;
    else if (prev == 0 && dir == 1) du += 1;
    else if (prev == 0 && dir == 0) dd += 1;
    prev = dir;
  }
  const n = uu + ud + du + dd;
  if (n < MIN_SAMPLES) return NaN;
  return 1.0 - (bits(uu, ud) * f64(uu + ud) + bits(du, dd) * f64(du + dd)) / f64(n);
}

// Every reading as of the newest closed day: run once each time the stream brings a day.
function measure(): void {
  let weekend = 0;
  for (let i = 0; i < days; i += 1) {
    if (weekday(dayOpen[i]) >= 5) weekend += 1;
    lnClose[i] = Math.log(dayClose[i]);
    if (i == 0) {
      ret[0] = NaN;
      sum1[0] = 0.0;
      sum2[0] = 0.0;
      continue;
    }
    const r = Math.log(dayClose[i] / dayClose[i - 1]);
    ret[i] = r;
    sum1[i] = sum1[i - 1] + r;
    sum2[i] = sum2[i - 1] + r * r;
  }
  annual = weekend * 10 > days ? 365.0 : 252.0;
  year = i32(annual);
  const d = days - 1;
  hv10 = hv(10, d);
  hv20 = hv(20, d);
  hv30 = hv(30, d);
  hv60 = hv(60, d);
  hv90 = hv(90, d);
  rank30 = rankOf(30, hv30);
  acNow = autocorr(d);
  const priceFrom = d - year < 0 ? 0 : d - year;
  hurstPrice = d - priceFrom >= MIN_OBS ? hurst(lnClose, priceFrom, d, 1, 5) : NaN;
  for (let i = 0; i < days; i += 1) { // log 5-day realized volatility: the root mean square of five returns, annualized
    const s = i >= RV_DAYS ? sum2[i] - sum2[i - RV_DAYS] : 0.0;
    lnRv[i] = s > 0.0 ? Math.log(Math.sqrt((annual * s) / f64(RV_DAYS)) * 100.0) : NaN;
  }
  const from = yearFrom(RV_DAYS);
  obs = 0;
  for (let i = from; i <= d; i += 1) if (!isNaN(lnRv[i])) obs += 1;
  halfLife = NaN;
  arDay = NaN;
  hurstRv = NaN;
  predict = NaN;
  character = NaN;
  if (obs < MIN_OBS) return;
  const b = arLag(from, d);
  if (!isNaN(b)) {
    const kept = b > 0.001 ? b : 0.001;
    halfLife = b >= 1.0 ? HALF_LIFE_CAP : (f64(RV_DAYS) * Math.log(0.5)) / Math.log(kept);
    if (halfLife > HALF_LIFE_CAP) halfLife = HALF_LIFE_CAP;
    arDay = b > 0.0 ? Math.exp(Math.log(b < 1.0 ? b : 1.0) / f64(RV_DAYS)) : 0.0;
    character = halfLife < 10.0 ? 0.0 : halfLife <= 30.0 ? 1.0 : 2.0;
  }
  hurstRv = hurst(lnRv, from, d, RV_DAYS, 4);
  predict = predictability(from, d);
}

// ── The frames, written on the live bar for the view picked ──
function fbPct(v: f64): void { fb_f64(v, 2); } // a percent, or null (JSON's gap)
function fbOrdinal(n: i32): void {
  fb_int(n);
  fb_text(suffix(n));
}
function writeWindows(): void {
  fb_clear();
  fb_text("{\"caption\":\"");
  if (isNaN(hv30)) fb_text("Needs 31 closed days");
  else {
    fb_text("HV 30d ");
    fb_f64(hv30, 1);
    fb_text("%");
    if (!isNaN(rank30)) {
      fb_text(", ");
      fbOrdinal(i32(Math.round(rank30)));
      fb_text(" percentile of its year");
    }
  }
  fb_text("\",\"rows\":[");
  const first = days - year < 1 ? 1 : days - year;
  for (let i = first; i < days; i += 1) {
    if (i > first) fb_text(",");
    fb_text("[");
    fb_int(<i64>dayOpen[i]);
    for (let w = 0; w < WINDOWS.length; w += 1) {
      fb_text(",");
      fbPct(hv(WINDOWS[w], i));
    }
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_HV_ROWS);
}
function sortScratch(n: i32): void { // insertion sort: a year of readings at most
  for (let i = 1; i < n; i += 1) {
    const v = scratch[i];
    let j = i - 1;
    while (j >= 0 && scratch[j] > v) {
      scratch[j + 1] = scratch[j];
      j -= 1;
    }
    scratch[j + 1] = v;
  }
}
function percentile(n: i32, p: f64): f64 { // linear between the two nearest order statistics
  const pos = p * f64(n - 1);
  const lo = i32(Math.floor(pos));
  const hi = lo + 1 < n ? lo + 1 : lo;
  return scratch[lo] + (scratch[hi] - scratch[lo]) * (pos - f64(lo));
}
function writeCone(): void {
  fb_clear();
  fb_text("{\"caption\":\"");
  if (isNaN(rank30)) fb_text("The cone needs a year of closed days");
  else {
    fb_text("30-day volatility now at the ");
    fbOrdinal(i32(Math.round(rank30)));
    fb_text(" percentile of its year");
  }
  fb_text("\",\"rows\":[");
  for (let l = 0; l < LOOKBACKS.length; l += 1) {
    const len = LOOKBACKS[l];
    let n = 0;
    for (let i = yearFrom(len); i < days; i += 1) {
      const v = hv(len, i);
      if (isNaN(v)) continue;
      scratch[n] = v;
      n += 1;
    }
    sortScratch(n);
    if (l > 0) fb_text(",");
    fb_text("[");
    fb_int(len);
    for (let p = 0; p < CONE_PCTS.length; p += 1) {
      fb_text(",");
      fbPct(n >= MIN_SAMPLES ? percentile(n, CONE_PCTS[p]) : NaN);
    }
    fb_text(",");
    fbPct(n >= MIN_SAMPLES ? hv(len, days - 1) : NaN);
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_CONE_ROWS);
}
function writeReturns(): void {
  const first = days - year < AC_DAYS ? AC_DAYS : days - year;
  let top = 0.05; // the value axis, symmetric around 0 so the zero line sits in the middle
  for (let i = first; i < days; i += 1) {
    const v = Math.abs(autocorr(i));
    if (v > top) top = v;
  }
  fb_clear();
  fb_text("{\"caption\":\"Above 0 the days follow through, below 0 they reverse\",\"y_min\":");
  fb_f64(-top * 1.2, 3);
  fb_text(",\"y_max\":");
  fb_f64(top * 1.2, 3);
  fb_text(",\"badge\":{\"text\":\"");
  if (isNaN(hurstPrice)) fb_text("Hurst n/a\",\"color\":\"#94a3b8\"}");
  else {
    fb_text("Hurst ");
    fb_f64(hurstPrice, 2);
    fb_text(hurstPrice > 0.55 ? " trending\",\"color\":\"#2dd4bf\"}" : hurstPrice < 0.45 ? " reverting\",\"color\":\"#f86800\"}" : " random\",\"color\":\"#94a3b8\"}");
  }
  fb_text(",\"rows\":[");
  for (let i = first; i < days; i += 1) {
    if (i > first) fb_text(",");
    fb_text("[");
    fb_int(<i64>dayOpen[i]);
    fb_text(",");
    fb_f64(autocorr(i), 4);
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_AC_ROWS);
}
function fbBucketKey(b: i32): void { // a seasonality bucket's category key: "Thu", "15", "OpEx"
  fb_text("\"");
  if (seasonBy == 0) fb_text(DAY_NAMES[b]);
  else if (seasonBy == 1) fb_int(b + 1);
  else fb_text(b == 0 ? "OpEx" : "Day after");
  fb_text("\"");
}
function fbBucketWord(b: i32): void { // the same bucket inside the caption: "Thu", "15th"
  if (seasonBy == 0) fb_text(DAY_NAMES[b]);
  else fbOrdinal(b + 1);
}
function fbBucketAvg(b: i32): void { // a bucket's average move inside the caption, or n/a
  if (bucketCount[b] == 0) fb_text("n/a");
  else {
    fb_f64(bucketSum[b] / f64(bucketCount[b]), 1);
    fb_text("%");
  }
}
// The average annualized move (|daily log return| x the root of the sessions a year) by bucket, over the past 365
// calendar days.
function writeSeasonality(): void {
  for (let b = 0; b < 31; b += 1) {
    bucketSum[b] = 0.0;
    bucketCount[b] = 0;
  }
  const scale = Math.sqrt(annual) * 100.0;
  const since = dayOpen[days - 1] - 365.0 * DAY_SEC;
  let all = 0.0;
  let allCount = 0;
  for (let j = 1; j < days; j += 1) {
    if (dayOpen[j] <= since) continue;
    const move = Math.abs(ret[j]) * scale;
    all += move;
    allCount += 1;
    let b = -1;
    if (seasonBy == 0) b = weekday(dayOpen[j]);
    else if (seasonBy == 1) {
      civil(dayOpen[j]);
      b = civDay - 1;
    } else if (isOpex(dayOpen[j])) b = 0;
    else if (isOpex(dayOpen[j - 1])) b = 1;
    if (b < 0) continue;
    bucketSum[b] += move;
    bucketCount[b] += 1;
  }
  const buckets = seasonBy == 0 ? 7 : seasonBy == 1 ? 31 : 2;
  let hot = -1;
  let calm = -1;
  for (let b = 0; b < buckets; b += 1) {
    if (bucketCount[b] == 0) continue;
    const avg = bucketSum[b] / f64(bucketCount[b]);
    if (hot < 0 || avg > bucketSum[hot] / f64(bucketCount[hot])) hot = b;
    if (calm < 0 || avg < bucketSum[calm] / f64(bucketCount[calm])) calm = b;
  }
  fb_clear();
  fb_text(seasonBy == 0 ? "{\"title\":\"Volatility by weekday\"" : seasonBy == 1 ? "{\"title\":\"Volatility by day of month\"" : "{\"title\":\"Volatility at the monthly expiry\"");
  fb_text(",\"caption\":\"");
  if (hot < 0) fb_text("No closed day in the past year");
  else {
    if (seasonBy == 2) { // "OpEx 21.2%, the day after 9.1%, all days 9.9%"
      fb_text("OpEx ");
      fbBucketAvg(0);
      fb_text(", the day after ");
      fbBucketAvg(1);
    } else { // "Hottest Thu 10.8%, calmest Tue 9.1%, all days 9.9%"
      fb_text("Hottest ");
      fbBucketWord(hot);
      fb_text(" ");
      fbBucketAvg(hot);
      fb_text(", calmest ");
      fbBucketWord(calm);
      fb_text(" ");
      fbBucketAvg(calm);
    }
    fb_text(", all days ");
    fb_f64(all / f64(allCount), 1);
    fb_text("%");
  }
  fb_text("\",\"rows\":[");
  let written = 0;
  for (let b = 0; b < buckets; b += 1) {
    if (bucketCount[b] == 0) continue;
    if (written > 0) fb_text(",");
    fb_text("[");
    fbBucketKey(b);
    fb_text(",{\"value\":");
    fb_f64(bucketSum[b] / f64(bucketCount[b]), 2);
    fb_text(",\"color\":\"");
    fb_text(b == hot ? HOT : b == calm ? CALM : PLAIN);
    fb_text("\"}]");
    written += 1;
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_SEASON_ROWS);
}
function fbRow(label: string): void { // a table row's first cell, then the value cell opens
  fb_text("[");
  fb_str(label);
  fb_text(",\"");
}
function fbRowEnd(meaning: string): void { // the value cell closes, then the meaning
  fb_text("\",");
  fb_str(meaning);
  fb_text("]");
}
function fbReading(v: f64, decimals: i32, unit: string): void { // inside a quoted cell: the number and its unit, or n/a
  if (isNaN(v)) fb_text("n/a");
  else {
    fb_f64(v, decimals);
    fb_text(unit);
  }
}
function writeCharacter(): void {
  fb_clear();
  fb_text("{\"caption\":\"");
  if (obs < MIN_OBS) {
    fb_text("Needs ");
    fb_int(MIN_OBS);
    fb_text(" days of 5-day realized volatility, ");
    fb_int(obs);
    fb_text(" so far");
  } else fb_text("Log 5-day realized volatility over the past year");
  fb_text("\",\"rows\":[");
  fbRow("Character");
  fb_text(isNaN(character) ? "n/a" : CHARACTER_WORDS[i32(character)]);
  fbRowEnd("How fast a volatility shock decays");
  fb_text(",");
  fbRow("Half-life");
  if (halfLife >= HALF_LIFE_CAP) fb_text("no reversion");
  else fbReading(halfLife, 1, " days");
  fbRowEnd("Trading days for a shock to decay halfway back");
  fb_text(",");
  fbRow("AR(1), per day");
  fbReading(arDay, 2, "");
  fbRowEnd("Day-to-day persistence of log realized volatility");
  fb_text(",");
  fbRow("Hurst, vol changes");
  fbReading(hurstRv, 2, "");
  fbRowEnd("Above 0.5 volatility moves persist, below 0.5 they reverse");
  fb_text(",");
  fbRow("Predictability");
  fbReading(predict, 2, "");
  fbRowEnd("0 is a coin flip for tomorrow's direction, 1 is certain");
  fb_text(",");
  fbRow("Observations");
  fb_int(obs);
  fbRowEnd("Days of 5-day realized volatility in the sample");
  fb_text("]}");
  writeFrameBuffer(FRAME_CHAR_ROWS);
}
function suffix(n: i32): string { // 1st, 2nd, 3rd, 4th, 11th, 21st
  const tens = n % 100;
  const ones = n % 10;
  if (tens >= 11 && tens <= 13) return "th";
  return ones == 1 ? "st" : ones == 2 ? "nd" : ones == 3 ? "rd" : "th";
}
function sbOrdinal(n: i32): void {
  sb_int(n);
  sb_text(suffix(n));
}

// onBar() runs once per bar: fold the closed days this bar carries (the first bar carries the backlog), measure when a
// day arrived, write the newest day's readings, and on the live bar the card's words and the picked view's frame.
function onBar(): void {
  fold(bar.time());
  if (fresh) {
    measure();
    fresh = false;
  }
  out_hv_10(hv10);
  out_hv_20(hv20);
  out_hv_30(hv30);
  out_hv_60(hv60);
  out_hv_90(hv90);
  out_hv30_rank(rank30);
  out_autocorr_120(acNow);
  out_hurst_price(hurstPrice);
  out_half_life(halfLife);
  out_hurst_rv(hurstRv);
  out_predictability(predict);
  out_character_code(character);
  if (!bar.isLast()) return;
  sb_clear();
  sb_text(isNaN(character) ? "n/a" : CHARACTER_WORDS[i32(character)]);
  str_character_word_sb();
  sb_clear();
  if (isNaN(hv30)) sb_text("n/a");
  else {
    sb_f64(hv30, 1);
    sb_text("%");
    if (!isNaN(rank30)) {
      sb_text(", ");
      sbOrdinal(i32(Math.round(rank30)));
      sb_text(" pct");
    }
  }
  str_hv30_text_sb();
  sb_clear();
  if (days < 32) { // too short for any view: the one sentence and nothing else
    sb_text("Volatility Character needs 32 closed days, ");
    sb_int(days);
    sb_text(" so far");
    str_notice_sb();
    return;
  }
  str_notice_sb();
  if (view == 0) writeWindows();
  else if (view == 1) writeCone();
  else if (view == 2) writeReturns();
  else if (view == 3) writeSeasonality();
  else writeCharacter();
}
```

## How it works

**One daily stream, any chart.** `input("days", candles.cells, { interval: "1d", bars: 800 })` hands the first bar the newest 800 closed daily candles of the chart's market and every later bar the days that closed since the bar before, so a 5m, a 1h and a 1D chart fold the same days and show the same readings ([Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream)). `fold()` keeps each day's open and close once, oldest first, in a buffer of 1,024 days that drops its oldest 256 when full. A forming day never arrives, so every reading is as of the newest closed day, on history bars and the live bar alike.

**Returns, prefix sums and the calendar.** `measure()` runs once each time a day arrives: it turns the closes into daily log returns and keeps their running sums and the running sums of their squares, so any window's variance is two subtractions. A market whose days include weekends (more than one day in ten falls on a Saturday or a Sunday) is a 365-day market, every other one a 252-session market; that number annualizes every volatility and is the past year every percentile and sample is drawn from.

**Historical volatility.** A window of `len` days is the sample standard deviation of its `len` daily log returns (around their own mean, divided by `len - 1`), times the square root of the sessions a year, in percent: `hv_10` through `hv_90` on every bar. `hv30_rank` is the share of the past year's 30-day readings below today's, the number the caption and the card read.

**The cone.** For each lookback the module gathers that window's reading on every day of the past year, sorts them and reads the 90th, 75th, 50th, 25th and 10th percentile, linear between the two nearest readings; a lookback with fewer than 20 readings stays empty. The rows are `[lookback, p90, p75, median, p25, p10, now]` on a `number` axis of days.

**Return structure.** `autocorr_120` is the lag-1 autocorrelation of the newest 120 daily returns around their own mean: above 0 a day tends to continue the day before, below 0 to reverse it. The pane draws it for each day of the past year. `hurst_price` is the Hurst exponent of the log price over the year, by the scaling of differences: the slope of the log standard deviation of `k`-day price changes on `log k` for `k` of 1, 2, 4, 8 and 16 days. A random walk reads 0.5, a trending price above it, a mean-reverting one below; the badge reads "trending" above 0.55 and "reverting" below 0.45.

**Seasonality.** Each day of the past 365 calendar days is one sample, its move `|log return| x sqrt(sessions a year)` in percent, filed under its weekday, its day of the month, or, by OpEx, the monthly options expiry (the third Friday on a 252-session market, where US listed options settle, the last Friday on a 365-day one, Deribit's monthly) and the session after it. A bucket prints its average; the caption names the hottest and the calmest and the average of all days ("Hottest Thu 10.8%, calmest Tue 9.1%, all days 9.9%").

**Character.** The series behind the card is log 5-day realized volatility: the root mean square of five daily returns, annualized, its log. The half-life fits an AR(1) of that series on itself five days back, so the two windows share no return and a rolling window's overlap never reads as persistence: half-life = `5 x ln 0.5 / ln b`, in trading days, 250 when `b` is 1 or more (no reversion), and `AR(1), per day` is `b` to the power one fifth. `hurst_rv` is the Hurst exponent of the changes in that series over 5, 10, 20 and 40 days, the same difference scaling as the price's. `predictability` is 1 minus the entropy, in bits, of tomorrow's volatility direction (up or down day over day) given today's: 0 a coin flip, 1 certain. The character is mean-reverting under a 10-day half-life, moderate from 10 to 30 days and persistent past 30; it needs 60 readings in the year, and `character_code` carries it as 0, 1 or 2.

**One frame per view.** Each pane is declared with its own frame, and `onBar()` writes only the frame of the view picked, on the live bar; a frame never written leaves its pane off the chart, so switching **View** reruns the indicator and swaps the pane. The frames carry what moves per run: the caption, a seasonality title, the return structure's value axis (symmetric around 0) and its Hurst badge, the seasonality bars' colours (`{ "value", "color" }`) and the table's cells.

**The card reads outputs and slots.** The headline pill reads `character_word` and takes its colour from the `character_code` output through a `colors` ladder (teal, amber, orange); the meter reads `half_life` between 0 and 60 with marks at 10 and 30, the edges of the three characters; the rows read `hurst_rv` and `predictability` to two decimals and the `hv30_text` slot ("18.4%, 64th pct").

## Where it runs

Every market with daily candles, on any interval: crypto perpetuals and spot, US stocks and ETFs, forex and gold, and CME futures through OpenMarket's official package (a copy run from the editor does not read CME data; the official package is not on the registry yet, it will be). It reads only the chart's own market, so no market refuses it. A 252-session market annualizes by 252 and a 365-day market by 365; a market that pauses for holidays simply has no candle on those days.

## When data is missing

Under 32 closed days there is no pane: one sentence at the top right reads "Volatility Character needs 32 closed days, 20 so far", and the card prints n/a. Past that each view draws what the history allows: a window shorter than its own length stays empty on the oldest days, a cone lookback with fewer than 20 readings in the year stays empty, the return structure starts once 120 returns are held, and the character waits for 60 readings of 5-day realized volatility (the table's caption says how many it has). How far back the stream reaches depends on the account's history; the readings use whatever arrives.

## Customize it

- **Another pane.** **View** picks historical volatility, the cone, the return structure, the seasonality or the character table; **Seasonality by** picks weekday, day of month or OpEx for the seasonality pane.
- **Other windows.** `WINDOWS` and `LOOKBACKS` are the historical volatility windows and the cone's lookbacks; the panes' series follow them in order, so edit the series names with them. `bars: 800` on the stream is how many days arrive on the first bar (up to 5000).
- **Other character edges.** The thresholds 10 and 30 days in `measure()` and the meter's marks set where moderate and persistent start; `RV_DAYS` is the realized volatility window and the AR(1) lag.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Volatility Character** under **Beyond the time axis**.
2. Press **Run** on BTCUSDT on Binance Futures at 1h: the historical volatility pane fills below the chart and the card names the character at the top left. Open the settings and pick **Volatility cone**, **Return structure**, **Seasonality** or **Character** for the other panes.
3. At the editor's Console prompt, type `hv_30` for the 30-day volatility on the live bar, or `outputs` for every reading together.

## Concepts used

- [Data sources](../core-concepts/data-sources.md) for `ohlcv.close`, the chart's own candles and the bar grid the stream rides
- [Multi-timeframe](../core-concepts/multi-timeframe.md#history-the-candles-stream) for the `candles` stream: `interval`, `bars`, the six-number candle and the backlog on the first bar
- [Setting kinds](../settings/kinds.md) for the two `param.choice` settings
- [Cards, frames and panels](../presentation/cards-frames-panels.md) for frames, `panel.line` on `time` and `number` axes, `panel.bars`, `panel.table` and the frame buffer
- [HUD cards](../presentation/hud-and-hover-cards.md#hud-cards) and [Blocks and tiles](../presentation/hud-and-hover-cards.md#blocks-and-tiles) for `render.hud`, `tile.pill` with `color_by`, `tile.meter` and `tile.rows`
- [Strings and text](../functions/text-formatting.md) for the `sb_*` builders and string slots
