---
title: "Session OI Levels"
description: "Where open interest was built and unwound in each day, as a profile anchored to the day's own time span. Each bar's change in open interest is spread over the…"
order: 80
section: "cookbook"
---

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

# Session OI Levels

![Open-interest profiles anchored to each day's span with the bar change below](/wrun/images/session-oi-levels.png)

Where open interest was built and unwound in each day, as a profile anchored to the day's own time span. Each bar's change in open interest is spread over the price rows its range covered, so a row grows where positions were opened or closed while price traded there: green where they were opened on balance, red where they were closed, slate where nothing moved. Every day gets its own profile, its rows scaled to that day's biggest and drawn from the day's first bar, and hovering a row reads the open interest moved there. Under the chart, the bar's open-interest change as a histogram. The trader sees the prices the positioning was decided at, day by day.

The parts are an `oi.close` feed input beside the chart's candles ([Data sources](../core-concepts/data-sources.md)), a frame of time spans feeding `plot.levels` with `span: "time"` ([Drawing primitives](../presentation/cards-frames-panels.md)), one histogram output under the chart ([Plotting](../presentation/plotting.md)), and a label handle for the one sentence shown on a market without open interest ([Drawing objects](../presentation/drawing-objects.md)). This is also the `session-oi-levels` template: the **Session OI Levels** card under **On price** in the editor's starter list, and it compiles as written.

## The wrun indicator

```typescript sample=session-oi-levels
// Session OI Levels: where open interest was built and unwound in each day, as a profile anchored to the day's own
// time span. Each bar's change in open interest is spread over the price rows its range covered, so a row grows where
// positions were opened (green) or closed (red) while price traded there; every day gets its own profile, its rows
// scaled to that day's biggest row and drawn by the chart from the day's first bar. The bar's open-interest change
// rides along as a histogram under the chart.

section("Sessions"); // the dialog's one section
param.int("sessions", 10, { min: 2, max: 30, label: "Days shown", description: "Days drawn, the newest last" });
param.int("rows", 32, { min: 8, max: 64, label: "Rows per day", description: "Price rows per day" });
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid
input("oi", oi.close, { missing: "nan" }); // open interest at the bar's close; a bar without a reading, or a market without open interest, reads NaN
output("oi_change", histogram, lower, { color_by: "oi_tone", colors: ["#f87171", "#34d399"], description: "Open interest change on the bar" }); // green where positions were opened, red where they were closed
output("oi_tone", none, overlay, { description: "1 while open interest rose on the bar, 0 while it fell" }); // data-only: the histogram's colour index
const oi_spans = frame("oi_spans", { max_bytes: 65536 }); // one span per day: its start and end, a price per row, the open interest moved on the row, a colour per row
plot.levels({ name: "oi_by_price", frame: oi_spans, dock: "left", span: "time", width_frac: 0.5, labels: false, color: "#38bdf8", opacity: 0.85, format: "si", hover: true }); // each day's profile grows from the day's first bar; hover a row for the open interest moved there
string("note", { max_bytes: 40 }); // the one sentence shown when the market has no open interest
handles.label({ anchor: "top_right", align: "right", color: "#94a3b8", size: 12, style: "plain", safe_area: true }); // that sentence: plain slate words at the pane's top right, clear of the chart's buttons

const MAX_BARS = 8192; const MAX_ROWS = 64; const MAX_DAYS = 30; // the bar ring (8192 bars is 28 days of 5m bars) and the finished days kept, for the largest settings
const barT = new StaticArray<f64>(MAX_BARS); const barLo = new StaticArray<f64>(MAX_BARS); const barHi = new StaticArray<f64>(MAX_BARS); const barOi = new StaticArray<f64>(MAX_BARS); // each ring bar's open time, price span and open-interest change
const dayStart = new StaticArray<f64>(MAX_DAYS + 1); const dayLo = new StaticArray<f64>(MAX_DAYS + 1); const dayStep = new StaticArray<f64>(MAX_DAYS + 1); // each folded day's span start (NaN: it draws nothing), bottom and row height; slot MAX_DAYS is the open day
const dayNet = new StaticArray<f64>((MAX_DAYS + 1) * MAX_ROWS); const dayMoved = new StaticArray<f64>((MAX_DAYS + 1) * MAX_ROWS); // per day and row: the net open-interest change, and the open interest moved, built or unwound
const note = draw.label(0); // the one slate sentence; a handle allocates once at module load
let sessions = 10; let rows = 32; // the params, read in onStart()
let seq = -1; let openSeq = 0; let openDay: i64 = -1; let doneHead = -1; let doneCount = 0; // the bars counted (bar s sits in ring slot s % MAX_BARS), the open day and its first bar, the finished days' ring: its newest slot and the days in it
let prevOi: f64 = NaN; // the last open-interest reading
let t: f64 = NaN; // this bar's open time

function dayOf(time: f64): i64 { return i64(Math.floor(time / 86400.0)); } // the UTC day number of an open time
function priceDecimals(step: f64): i32 { // enough decimals that the rounded row prices stay evenly spaced (the docked grid is contiguous only within 0.1% of its step)
  const d = i32(Math.ceil(Math.log(2000.0 / step) / Math.LN10)); return d < 2 ? 2 : d > 9 ? 9 : d;
}

// ── One day's profile: its bars folded into `rows` price rows across the day's span, kept in a day slot ──
function spread(a: f64, b: f64, amount: f64, gridLo: f64, width: f64, base: i32): void { // [a, b]'s open-interest change over the rows it overlaps, in proportion
  let first = i32(Math.floor((a - gridLo) / width)); let last = i32(Math.floor((b - gridLo) / width));
  if (first < 0) first = 0; if (last > rows - 1) last = rows - 1; if (first > rows - 1) first = rows - 1; if (last < 0) last = 0;
  if (first >= last || b - a <= 0.0) { dayNet[base + first] += amount; dayMoved[base + first] += Math.abs(amount); return; } // one row, or a bar thinner than the grid
  for (let k = first; k <= last; k += 1) {
    const cellLo = gridLo + f64(k) * width; const cellHi = cellLo + width;
    const overlap = Math.min(b, cellHi) - Math.max(a, cellLo);
    if (overlap > 0.0) { const share = (amount * overlap) / (b - a); dayNet[base + k] += share; dayMoved[base + k] += Math.abs(share); }
  }
}
function foldDay(from: i32, to: i32, slot: i32): void { // the bars from..to (counted, oldest first) are one day: its rows into day slot `slot`
  if (to - from >= MAX_BARS) from = to - MAX_BARS + 1; // bars older than the ring are gone
  let lo = Infinity; let hi = -Infinity;
  for (let s = from; s <= to; s += 1) { const i = s % MAX_BARS; if (barLo[i] < lo) lo = barLo[i]; if (barHi[i] > hi) hi = barHi[i]; }
  dayStart[slot] = NaN;
  if (!(hi > lo)) return; // a day with no price span
  const rowHeight = (hi - lo) / f64(rows); const base = slot * MAX_ROWS;
  for (let r = 0; r < rows; r += 1) { dayNet[base + r] = 0.0; dayMoved[base + r] = 0.0; }
  let moved = 0.0;
  for (let s = from; s <= to; s += 1) {
    const i = s % MAX_BARS; const change = barOi[i];
    if (isNaN(change) || change == 0.0) continue;
    spread(barLo[i], barHi[i], change, lo, rowHeight, base); moved += Math.abs(change);
  }
  if (moved <= 0.0) return; // a day with no open-interest change
  dayStart[slot] = f64(dayOf(barT[from % MAX_BARS])) * 86400.0; dayLo[slot] = lo; dayStep[slot] = rowHeight; // the span: the UTC day the bars belong to, in epoch seconds
}
function writeDay(slot: i32, first: bool): bool { // one folded day appended to the frame as a span; false when the day draws nothing
  const start = dayStart[slot]; if (isNaN(start)) return false;
  const lo = dayLo[slot]; const rowHeight = dayStep[slot]; const base = slot * MAX_ROWS; const decimals = priceDecimals(rowHeight);
  if (!first) fb_text(",");
  fb_text("{\"start\":"); fb_num(start); fb_text(",\"end\":"); fb_num(start + 86400.0);
  fb_text(",\"prices\":["); // row centres, strictly increasing
  for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); fb_f64(lo + (f64(r) + 0.5) * rowHeight, decimals); }
  fb_text("],\"values\":["); // the open interest moved on the row (0 where nothing moved, so the grid stays whole)
  for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); fb_f64(dayMoved[base + r], 0); }
  fb_text("],\"colors\":["); // green where positions were opened on balance, red where they were closed, slate where nothing moved
  for (let r = 0; r < rows; r += 1) { if (r > 0) fb_text(","); const net = dayNet[base + r]; fb_text(net > 0.0 ? "\"#34d399\"" : net < 0.0 ? "\"#f87171\"" : "\"#64748b\""); }
  fb_text("]}");
  return true;
}
function writeSpans(): void { // the newest `sessions` days, oldest first, as one spans frame: the finished days as folded when they ended, the open day folded now
  foldDay(openSeq, seq, MAX_DAYS); // only the open day changes on a new bar or a live tick, so it is the one day folded again
  fb_clear(); fb_text("{\"spans\":[");
  let written = 0; const kept = doneCount < sessions - 1 ? doneCount : sessions - 1;
  for (let k = kept - 1; k >= 0; k -= 1) if (writeDay((doneHead + MAX_DAYS - k) % MAX_DAYS, written == 0)) written += 1;
  if (writeDay(MAX_DAYS, written == 0)) written += 1;
  fb_text("]}");
  if (written > 0) writeFrameBuffer(oi_spans); // an unwritten frame leaves the profiles absent; an empty spans list would be refused
}

// onStart() runs once before the first bar: read the params.
function onStart(): void { sessions = i32(p_sessions()); rows = i32(p_rows()); }
// onBar() runs once per bar: the bar's open-interest change against the last reading, the bar into the ring, a finished
// day folded once where the UTC day changes, the change as a number on every bar, and the day profiles on the live bar only.
function onBar(): void {
  t = bar.time(); const oiNow = in_oi(); const lo = bar.low(); const hi = bar.high();
  const change = isNaN(oiNow) || isNaN(prevOi) ? NaN : oiNow - prevOi; // unknown until two readings exist
  if (!isNaN(oiNow)) prevOi = oiNow;
  if (bar.isLast() && isNaN(prevOi)) { sb_clear(); sb_text("No open interest on this market"); note.set(16.0, 12.0).text(str_note_sb); } // no reading on any bar (spot, FX): one sentence, not an empty pane
  if (isNaN(t) || !(hi >= lo)) return; // a bar with no span draws nothing and is not counted
  const day = dayOf(t);
  if (day != openDay) { // a new UTC day: the day before is finished, folded once into the finished ring and kept as it ended
    if (openDay >= 0) { doneHead = (doneHead + 1) % MAX_DAYS; if (doneCount < MAX_DAYS) doneCount += 1; foldDay(openSeq, seq, doneHead); }
    openDay = day; openSeq = seq + 1;
  }
  seq += 1; const slot = seq % MAX_BARS; barT[slot] = t; barLo[slot] = lo; barHi[slot] = hi; barOi[slot] = change;
  out_oi_change(change);
  out_oi_tone(isNaN(change) ? NaN : change >= 0.0 ? 1.0 : 0.0);
  if (bar.isLast()) writeSpans();
}
```

## How it works

**The change, not the level.** `input("oi", oi.close, { missing: "nan" })` reads open interest at each bar's close; the bar's change is the difference from the last reading (NaN until two readings exist, and on a market without open interest). `oi_change` is that number as a histogram under the chart, green while open interest rose and red while it fell.

**Rows per day.** Every bar lands in a ring with its open time, its high and low and its change. Where the UTC day changes, `foldDay` folds the day that just ended once: it finds the day's price span, splits it into `rows` (32) rows, and spreads each bar's change over the rows its range overlapped, in proportion; a row keeps the open interest moved on it (built or unwound, always positive: the bar's length) and the net change (its colour), and the folded day waits in a ring of 30 finished days. On the live bar only the open day is folded again, so a live tick costs one day's bars, never every day's. A day with no span or no change is left out.

**One span per day.** `frame("oi_spans")` carries `{ "spans": [{ "start", "end", "prices", "values", "colors" }, ...] }`, one entry per day with its bounds in epoch seconds (the newest `sessions` (10) days: the finished ones as they were folded, then the open day), and feeds `plot.levels({ span: "time" })`: the chart draws every span's rows from the day's first bar, growing right across `width_frac` (0.5) of the day's width, each day scaled to its own biggest row. `hover: true` with `format: "si"` reads a row's open interest as `1.2M` in the hover card. The frame is built with the generated `fb_*` writer, so the live bar allocates nothing.

## Where it runs

Perpetual futures with an open-interest feed: Binance Futures, Hyperliquid and the other venues that serve `oi` ([Data sources](../core-concepts/data-sources.md) lists them). A day is folded from the ring of the newest 8192 bars when it ends, and the ring holds a whole day down to 11-second bars, so a 1m chart shows its `sessions` days whole, as far back as the chart has loaded, like a 5m one.

## When data is missing

Spot, stocks, FX and prediction markets carry no open interest: every bar's change reads NaN, the histogram stays empty and no profile is drawn (nothing moved), and one line of words shows at the top right of the chart, `No open interest on this market`, in plain slate: the `note` label handle, written on the live bar while no bar has brought a reading, pinned to the pane's top right corner, right-aligned and `safe_area: true`, so it clears the chart's own buttons. A bar without a reading keeps the last one, so the next reading's change covers both bars. The first bar of a run has no change.

## Customize it

- **More days, finer rows.** `sessions` up to 30 and `rows` up to 64 (64 rows across 30 days is 1920 rows, inside the frame's 65536 bytes).
- **Grow from the right.** `dock: "right"` grows each day's rows from the day's last bar instead.
- **Rows with labels.** `labels: true` prints each row's value beside it (`format: "si"` keeps them short); `thickness_px` caps the rows on a coarse grid.

## Run it

1. In the editor's Explorer, press the **Templates** icon ("Browse starter templates") and pick **Session OI Levels** under **On price**.
2. Press **Run** on a perpetual chart such as BTCUSDT on Binance Futures at 15m: a profile grows inside each of the last ten days and the open-interest change appears under the chart.
3. Hover a row for the open interest moved there; at the editor's Console prompt, type `oi_change` to read the newest bar's change.

## Concepts used

- [Data sources](../core-concepts/data-sources.md) for the `oi` feed and the `missing: "nan"` policy
- [Drawing primitives](../presentation/cards-frames-panels.md) for frames, `plot.levels` and the `span: "time"` frame shape
- [Plotting](../presentation/plotting.md) for the histogram output and its colour index
