Levels kit

View as MarkdownOpen the editor

Levels are the prices a trader draws before the session starts: today's open, yesterday's high and low, last week's close, the session's range, the pivot point with its supports and resistances, the round numbers near price, and the swing levels price keeps returning to.

A wrun indicator sees one bar at a time (Execution model), so the ./sdk/levels module keeps the state for you. PeriodLevels and SessionLevels roll over on the clock from the clock and sessions kit, PivotPoints computes the standard, Fibonacci, Camarilla and Woodie sets from a previous range, roundLevels fills a grid of round numbers around a price, and SupportResistance clusters confirmed pivots into levels and counts how often each was touched. Everything is a class or a function you construct in onStart(), feed once per bar and read on the same bar; no call allocates after construction, so the module never grows memory per bar.

ExportSignatureDefinition
PeriodLevelsnew PeriodLevels(period, zone = "UTC"); .update(t, open, high, low, close); .isNew(), .open(), .high(), .low(), .prevOpen(), .prevHigh(), .prevLow(), .prevClose(), .reset()The running open, high and low of the current "day", "week", "month", "quarter" or "year" in a time zone (or the period as Pine spells the timeframe: "D" / "1D", "W" / "1W", "M" / "1M", "3M", "12M"), and the open, high, low and close of the last completed one (NaN until one completes)
SessionLevelsnew SessionLevels(spec, zone = "UTC", days = "0123456"); .update(t, open, high, low, close); .isIn(), .isNew(), .open(), .high(), .low(), .prevOpen(), .prevHigh(), .prevLow(), .prevClose(), .reset()The same over an "HHMM-HHMM" session on the listed weekdays (0 = Sunday): the running levels while the bar is inside a session instance, the last completed instance as prev*
PivotPointsnew PivotPoints(kind = "standard"); .compute(high, low, close, open = NaN); .pp(), .r1(), .r2(), .r3(), .s1(), .s2(), .s3()The seven floor-trader levels from one range in the "standard", "fibonacci", "camarilla" or "woodie" form; every compute() overwrites them
roundLevelsroundLevels(price, step, out): i32Fills out (a StaticArray<f64>) with the multiples of step around price, the first half at or below it and the rest above, nearest first; returns the count written (0 for a step of 0 or below, a NaN step or a NaN price)
SupportResistancenew SupportResistance(left, right, tolerancePct, maxLevels); .update(high, low, close); .count(), .level(i), .touches(i), .kind(i), .nearestAbove(price), .nearestBelow(price), .reset()Every confirmed PivotHigh / PivotLow merges into the nearest level within tolerancePct of its price or starts one; at most maxLevels are kept, the least touched dropped first

The first argument of update() on the two calendar classes is the bar's open time in UTC seconds: pass bar.time(). A period, kind, zone or session spec the module does not know aborts in the constructor, which is why every example constructs in onStart(): an abort there shows its message in the Console, one at module start does not.

Previous day, week and month levels

PeriodLevels(period, zone) follows the Clock of the clock and sessions kit: a period is the run of bars sharing the clock's day, week (Monday start), month, quarter (from January, April, July or October 1st) or year key in the zone you name ("UTC" by default; "America/New_York", "Europe/London" and the other names that page lists, or a fixed "+05:30"). period is one of the five words "day", "week", "month", "quarter" and "year", or the timeframe string a Pine script passes to request.security: "D" or "1D", "W" or "1W", "M" or "1M", "3M" for the quarter and "12M" for the year. Anything else (a lowercase "d", an intraday "60", a "2D" the clock has no key for) aborts in the constructor. On each bar:

  • isNew() is true on the first bar of a period, and on the very first bar the object sees.
  • open() is the open of the period's first bar; high() and low() are the running extremes so far.
  • On the bar a new period starts, the period that just ended becomes prevOpen(), prevHigh(), prevLow() and prevClose() (the close of its last bar). They hold for the whole period and never repaint.
  • prev* are NaN until the first period boundary after construction or reset(). The period already running when the history starts counts as a period, however much of it the loaded bars cover, the same rule as the Key levels recipe: on an intraday chart the first prev* may describe a partial day.
  • A non-finite open makes open() NaN for the period; a non-finite high or low makes that extreme NaN for the rest of the period (and the prev* it becomes); prevClose() is the last bar's close.

The previous New York day's high and low as lines, with the running day open dashed and a data-only flag on the first bar of each day:

output("pdh", line, overlay, { color: "#f5a623", width: 1, description: "Previous New York day high" });
output("pdl", line, overlay, { color: "#f5a623", width: 1, description: "Previous New York day low" });
output("day_open", line, overlay, { color: "#94a3b8", width: 1, line_style: "dashed", description: "Open of the running day" });
output("new_day", none, overlay, { description: "1 on the first bar of a New York day" });

let day = new PeriodLevels("day");

function onStart(): void {
  // Construct in onStart(): an unknown period or zone aborts with a readable message here.
  day = new PeriodLevels("day", "America/New_York");
}

function onBar(): void {
  day.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close());
  out_pdh(day.prevHigh());
  out_pdl(day.prevLow());
  out_day_open(day.open());
  out_new_day(day.isNew() ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The two level lines are NaN until the first New York midnight in the loaded history, then step on each midnight, daylight time included: the clock handles the 23-hour and 25-hour days. Four more PeriodLevels objects give the week, the month, the quarter and the year, or the same day in another zone; a yearly open is new PeriodLevels("12M", zone) read with open().

Session levels

SessionLevels(spec, zone, days) follows the Session of the same kit: spec is "HHMM-HHMM" in the zone's local time, half-open ("0930-1600" holds 09:30 and excludes 16:00) and wrapping past midnight when the close is earlier than the open; days lists the weekday digits of the instance's opening day, 0 = Sunday through 6 = Saturday, so "12345" is Monday to Friday. Pine's session day string "23456" (Sunday = 1) is "12345" here.

  • isIn() is true while the bar sits inside an instance; isNew() on its first in-session bar.
  • open(), high() and low() describe the instance in progress and read NaN outside a session.
  • On the first bar after an instance ended (outside the hours, or already the first bar of the next instance when the bars skip the close) the ended instance becomes prevOpen(), prevHigh(), prevLow() and prevClose(); they hold until the next instance ends, so the previous session's range is available all night and through the weekend.
  • prev* are NaN until one instance has ended after construction or reset(); an instance already running when the history starts counts from its first loaded bar. A session no bar ever opens inside (a daily chart against an intraday session) never produces levels.

new SessionLevels("0930-1600", "America/New_York", "12345") is the regular trading session of the US stock exchanges; feed it exactly as the example above feeds PeriodLevels and draw prevHigh() and prevLow() the same way.

Pivot points

PivotPoints(kind).compute(high, low, close) takes one range, normally the previous day's from PeriodLevels, and fills seven levels. With H, L, C and the range R = H - L:

KindPivotResistancesSupports
"standard"PP = (H + L + C) / 3R1 = 2 PP - L, R2 = PP + R, R3 = H + 2 (PP - L)S1 = 2 PP - H, S2 = PP - R, S3 = L - 2 (H - PP)
"fibonacci"PP as standardR1 = PP + 0.382 R, R2 = PP + 0.618 R, R3 = PP + RS1 = PP - 0.382 R, S2 = PP - 0.618 R, S3 = PP - R
"camarilla"PP as standardR1 = C + R * 1.1 / 12, R2 = C + R * 1.1 / 6, R3 = C + R * 1.1 / 4S1 = C - R * 1.1 / 12, S2 = C - R * 1.1 / 6, S3 = C - R * 1.1 / 4
"woodie"PP = (H + L + 2 C) / 4the standard formulas from that PPthe standard formulas from that PP

compute() accepts a fourth open argument for kinds that read it; none of the four does. A non-finite high, low or close makes all seven levels NaN, and there is no reset(): every compute() overwrites every level, so compute(NaN, NaN, NaN) clears them. The kind is chosen at construction; to make it a setting, map a numeric param to the name in onStart():

param("kind", 0, { min: 0, max: 3, description: "0 standard, 1 fibonacci, 2 camarilla, 3 woodie" });
output("pp", line, overlay, { color: "#94a3b8", width: 1, description: "Pivot point from the previous UTC day" });
output("r1", line, overlay, { color: "#dc2626", width: 1, description: "First resistance" });
output("r2", line, overlay, { color: "#dc2626", width: 1, line_style: "dashed", description: "Second resistance" });
output("r3", line, overlay, { color: "#dc2626", width: 1, line_style: "dotted", description: "Third resistance" });
output("s1", line, overlay, { color: "#16a34a", width: 1, description: "First support" });
output("s2", line, overlay, { color: "#16a34a", width: 1, line_style: "dashed", description: "Second support" });
output("s3", line, overlay, { color: "#16a34a", width: 1, line_style: "dotted", description: "Third support" });

function kindName(kind: i32): string {
  if (kind == 1) return "fibonacci";
  if (kind == 2) return "camarilla";
  if (kind == 3) return "woodie";
  return "standard";
}

let day = new PeriodLevels("day");
let pivots = new PivotPoints();

function onStart(): void {
  day = new PeriodLevels("day");
  pivots = new PivotPoints(kindName(i32(p_kind())));
}

function onBar(): void {
  day.update(bar.time(), bar.open(), bar.high(), bar.low(), bar.close());
  // The previous day's range stands for the whole day: recompute once, when the day turns.
  if (day.isNew()) pivots.compute(day.prevHigh(), day.prevLow(), day.prevClose());
  out_pp(pivots.pp());
  out_r1(pivots.r1());
  out_r2(pivots.r2());
  out_r3(pivots.r3());
  out_s1(pivots.s1());
  out_s2(pivots.s2());
  out_s3(pivots.s3());
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Weekly pivots are the same file with new PeriodLevels("week") (or "W", as Pine spells it); a session's pivots feed compute() from SessionLevels on the bar its prev* change.

Round numbers

roundLevels(price, step, out) fills a StaticArray<f64> you allocated once (at module level or in onStart()) with the multiples of step around price: with base the nearest multiple at or below price, the first out.length / 2 slots (integer division) hold base, base - step, base - 2 step, ... and the remaining slots hold base + step, base + 2 step, ..., each half nearest first. It returns how many slots it wrote, out.length on success. A step of 0 or below, a NaN step or a NaN price write NaN into every slot and return 0, so a stale grid never survives a bad bar. The values are floating-point products (3 * 0.1 prints as 0.30000000000000004): format them for display instead of comparing them with ==.

wrun
const grid = new StaticArray<f64>(6); // 3 at or below the close, 3 above

// In onBar(): grid[0] is the nearest round number at or below the close,
// grid[3] the nearest above; count is 6, or 0 when the step was unusable.
// const count = roundLevels(bar.close(), 100.0, grid);

A step of 100 on a five-figure price gives the hundreds; derive it from the price instead (Math.pow(10.0, Math.floor(Math.log10(close)) - 1)) for a grid that scales with the market.

Clustered support and resistance

SupportResistance(left, right, tolerancePct, maxLevels) is new: the shipped recipes take calendar extrema (Key levels) or keep one zone per side (Zone tracker); this class keeps a small ledger of levels built from every confirmed swing and remembers how often each was hit. It runs a PivotHigh(left, right) over the high and a PivotLow(left, right) over the low from ./sdk/ta (Series functions), so a pivot is reported right bars after its bar and never repaints. On each confirmed pivot:

  • It merges into the NEAREST existing level whose price is within tolerancePct percent of the pivot's price: the level's price becomes the touch-weighted mean of everything that merged into it and its touches grow by one. A tolerance of 0 merges exact matches only.
  • Otherwise it starts a new level with one touch. When that would exceed maxLevels, the existing level with the fewest touches is dropped first, the oldest on a tie; a new level always enters.
  • When a high and a low confirm on the same bar, the high merges first.

count() is how many levels are kept, level(i) the price of the i-th by age (0 the oldest kept), touches(i) its count and kind(i) +1 when more pivot highs than lows merged into it (resistance), -1 when more lows (support), the latest pivot's side on a tie; an index outside 0..count() - 1 reads NaN, 0 and 0. nearestAbove(price) is the lowest level strictly above price and nearestBelow(price) the highest strictly below, NaN when there is none. update(high, low, close) takes the close for symmetry with the other kit classes and does not read it. reset() empties the ledger and the pivot windows.

The levels as line handles, one per kept level, coloured by side and extended to the right, with the nearest level above and below the close as ordinary lines:

param("left", 5, { min: 1, max: 50, description: "Bars a swing must dominate on its left" });
param("right", 5, { min: 1, max: 50, description: "Bars that confirm the swing on its right" });
param("tolerance_pct", 0.3, { min: 0, max: 5, description: "Merge band as a percent of the pivot's price" });
param("max_levels", 6, { min: 1, max: 12, description: "Levels kept; the least touched goes first" });
output("nearest_above", line, overlay, { color: "#dc2626", width: 1, description: "The nearest level above the close" });
output("nearest_below", line, overlay, { color: "#16a34a", width: 1, description: "The nearest level below the close" });
output("level_count", none, overlay, { description: "Levels kept on this bar" });
handles.line({ color: "#94a3b8", width: 1, lineStyle: "dashed", extend: "right" });

let sr = new SupportResistance(5, 5, 0.3, 6);
const lines = new Array<LineHandle>();
let firstT: f64 = NaN;

function onStart(): void {
  sr = new SupportResistance(i32(p_left()), i32(p_right()), p_tolerance_pct(), i32(p_max_levels()));
  // One handle per kept level, made once here: ids are one space across the handle kinds.
  for (let i = 0; i < i32(p_max_levels()); i++) lines.push(draw.line(i));
}

function onBar(): void {
  const t = bar.time();
  if (isNaN(firstT)) firstT = t;
  const close = bar.close();
  sr.update(bar.high(), bar.low(), close);
  const count = sr.count();
  for (let i = 0; i < lines.length; i++) {
    if (i < count) {
      // Levels move as pivots merge, so every bar re-sets each line: same id, same line.
      const price = sr.level(i);
      const ink = sr.kind(i) > 0 ? rgba(220, 38, 38, 255) : rgba(22, 163, 74, 255);
      lines[i].set(firstT, price, t, price).color(ink).width(sr.touches(i) > 1 ? 2.0 : 1.0);
    } else {
      // A slot without a level: delete() on an id nobody holds is a no-op.
      lines[i].delete();
    }
  }
  out_nearest_above(sr.nearestAbove(close));
  out_nearest_below(sr.nearestBelow(close));
  out_level_count(f64(count));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Levels with more than one touch draw thicker. Because a level's index is its age, a level that merges keeps its slot and its line; when a level is dropped the younger levels move down one index and the new level takes the last one, so several lines can change price on that bar. Read last 20 level_count at the editor's Console prompt to see the ledger fill, and nearest_above or nearest_below for the level price itself.

From Pine

  • request.security(syminfo.tickerid, "D", high[1]): new PeriodLevels("D") (the same object as "day") fed each bar, then .prevHigh() (and .prevLow(), .prevOpen(), .prevClose() for the other fields).
  • request.security(syminfo.tickerid, "W", close[1]), and the same with "M", "3M" or "12M": new PeriodLevels("W"), ("M"), ("3M"), ("12M") and .prevClose(); the same request without [1] is the running period, .open(), .high() and .low().
  • timeframe.change("D") (or "W", "M", "3M", "12M"): .isNew() on the PeriodLevels built from that word, or clock.isNewDay() and its siblings on a Clock (clock and sessions kit).
  • ta.pivot_point_levels(type, anchor): new PivotPoints(kind) with .compute() called on the bar the anchor period turns, the previous period's high, low and close from PeriodLevels as its arguments.