Stats, history and lists

View as MarkdownOpen the editor

The ./sdk/stats module is the window arithmetic under the TA library and the extra indicators.

It holds a stats namespace of allocation-free functions over a StaticArray<f64> you fill yourself, a History that keeps the last n values of a series so Pine's close[1] becomes history.ago(1), a fixed-capacity List for Pine's array.* idiom, a HandleRing that keeps the newest N drawing ids so the oldest line can be deleted, roundTo for math.round(x, n), roundToTick for math.round_to_mintick(x) and a seeded Random for math.random(min, max, seed). Everything here composes the library's own primitives, so a window mean is the library's Sma to the last bit, and the library's rules hold: NaN until a class is warm, NaN while a non-finite entry sits in its window, and no allocation after the constructor.

Which one

NeedReach for
The previous bar's value of a series you compute, Pine's x[n]History: push() once per bar, read ago(n)
Entries that come and go on their own schedule, by index, Pine's array.*List, with its capacity declared in onStart()
Several readings over one window you fill yourselfthe stats.* functions over a StaticArray<f64> and a count
The ids of the newest N drawings, so the oldest can be deletedHandleRing
math.round(x, n), math.round_to_mintick(x), math.random(min, max, seed)roundTo, roundToTick, Random

Window arithmetic: stats

stats is a namespace of functions over the first n entries of a StaticArray<f64> you own. Nothing here allocates: you size the array in onStart(), fill it as bars arrive, and hand the count you filled. A count above the array's length reads the whole array; a count below 1 reads nothing. A non-finite entry among the first n makes the result NaN, except in min, max, argmin and argmax, which skip it.

FunctionReturns
stats.sum(a, n)the entries added index 0 first; 0 when n < 1
stats.mean(a, n)sum / n; NaN when n < 1
stats.variance(a, n)the population variance (divide by n, the library's Variance)
stats.stdev(a, n)sqrt(variance)
stats.min(a, n)the smallest finite entry; NaN when none is finite
stats.max(a, n)the largest finite entry; NaN when none is finite
stats.argmin(a, n)the index of the smallest finite entry (the lowest index on a tie); -1 when none
stats.argmax(a, n)the index of the largest finite entry (the lowest index on a tie); -1 when none
stats.slope(a, n)the least-squares slope against the index 0..n-1, in value per entry; NaN when n < 2
stats.covariance(a, b, n)the population covariance of the two arrays' first n entries
stats.correlation(a, b, n)the Pearson correlation, -1..1; NaN when either side has no variance
stats.zscore(x, a, n)(x - mean) / stdev; 0 when the standard deviation is 0 (the library's Zscore)
stats.sortAscending(a, n)sorts the first n entries in place, ascending, NaN entries last
stats.median(a, n, scratch)the middle of the sorted entries (the mean of the two middle ones on an even n); copies into scratch and sorts there, so a keeps its order
stats.percentile(a, n, pct, scratch)the nearest-rank percentile, pct in 0..100: rank ceil(pct / 100 * n), entry rank - 1 of the sorted copy (the library's Percentile)
stats.percentileLinearInterpolation(a, n, pct, scratch)the percentile by linear interpolation between the two nearest ranks, pct in 0..100: TradingView's position k = pct * n / 100 - 0.5 over the sorted copy, sorted[floor(k)] plus the fraction of the way to sorted[floor(k) + 1], the smallest entry at or below k = 0 and the largest at or above k = n - 1 (so array.from(3, 1, 8, 5) reads 1, 1, 2, 3.2, 4, 5, 6.5, 8, 8 at 0, 10, 25, 40, 50, 62.5, 75, 90, 100, the values captured on TradingView); the result need not be an entry (Pine's array.percentile_linear_interpolation and ta.percentile_linear_interpolation)

scratch is a second array of at least n you allocate once beside the first; median and the two percentiles copy into it and sort it, and return NaN when it is too small. The fence keeps a sliding window of the last period closes and volumes, oldest first, and reads it every bar.

param("period", 50, { min: 2, max: 400, description: "Bars in the window" });
output("mean", line, lower, { color: "#2563eb", description: "Mean close" });
output("stdev", line, lower, { color: "#7c3aed", description: "Standard deviation" });
output("slope", line, lower, { color: "#16a34a", description: "Least-squares slope, price per bar" });
output("correlation", line, lower, { color: "#ea580c", description: "Close-volume correlation" });
output("zscore", line, lower, { color: "#dc2626", description: "Z-score of this close" });
output("median", line, lower, { color: "#0891b2", description: "Median" });
output("p90", line, lower, { color: "#4b5563", description: "90th percentile of the close" });

let period: i32 = 50;
let closes = new StaticArray<f64>(50);
let volumes = new StaticArray<f64>(50);
let scratch = new StaticArray<f64>(50);
let filled: i32 = 0;

function onStart(): void {
  period = i32(p_period());
  closes = new StaticArray<f64>(period);
  volumes = new StaticArray<f64>(period);
  scratch = new StaticArray<f64>(period);
}

function onBar(): void {
  const close = bar.close();
  const volume = bar.volume();
  // Slide the window one bar: drop the oldest, append the newest.
  if (filled < period) filled += 1;
  else {
    for (let k = 1; k < period; k++) {
      unchecked((closes[k - 1] = closes[k]));
      unchecked((volumes[k - 1] = volumes[k]));
    }
  }
  unchecked((closes[filled - 1] = close));
  unchecked((volumes[filled - 1] = volume));
  if (filled < period) return;
  out_mean(stats.mean(closes, filled));
  out_stdev(stats.stdev(closes, filled));
  out_slope(stats.slope(closes, filled));
  out_correlation(stats.correlation(closes, volumes, filled));
  out_zscore(stats.zscore(close, closes, filled));
  out_median(stats.median(closes, filled, scratch));
  out_p90(stats.percentile(closes, filled, 90.0, scratch));
}

The library's Sma, Stdev, Correlation, Median and Percentile give the same numbers with their own rings; reach for stats when one window feeds several readings or is not a fixed number of bars (a session, a swing, the bars since a signal).

History: x[n] from Pine

History keeps the last size values of one series. Construct it in onStart(), push() once per bar, and read ago(n): ago(0) is the value just pushed, ago(1) the previous bar's.

MemberMeaning
new History(size)keep the last size values (a size below 1 keeps one)
push(v)record this bar's value; the oldest one falls out once full
ago(n)the value pushed n bars ago; NaN before n + 1 pushes or outside 0..size - 1
latest()ago(0)
count()how many values it holds, at most size
size()the size it was constructed with
max(), min(), mean(), sum()over the values held; NaN while empty or when one of them is not finite
reset()empty it

Two Pine idioms port directly: close[1] is history.ago(1), and ta.highest(close, 20) is max() over a History(20) of the close (the library's Highest(20) gives the same number and the offset of the high as well).

output("prev_close", line, overlay, { color: "#4b5563", description: "close[1]: the previous bar's close" });
output("highest", line, overlay, { color: "#16a34a", description: "Highest close of the last 20 bars" });
output("change", line, lower, { color: "#2563eb", description: "close - close[1]" });

let history = new History(20);
let highest = new Highest(20);

function onStart(): void {
  history = new History(20);
  highest = new Highest(20);
}

function onBar(): void {
  const close = bar.close();
  history.push(close);
  const prevClose = history.ago(1);
  const highestClose = history.max();
  // The library class over the same 20 bars reads the same high.
  const check = highest.update(close);
  if (!isNaN(check) && check != highestClose) return;
  if (isNaN(prevClose)) return;
  out_prev_close(prevClose);
  out_highest(highestClose);
  out_change(close - prevClose);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

history.max() reads the values held so far: on bar 5 it is the high of six bars while Highest(20) is still NaN, and from bar 19 on the two agree (the fence checks it). Reach for History to look back on a series you compute yourself, an RSI or a spread.

A percent rank of volume

Where does this bar's volume sit against the last 100 bars? PercentRank (Extra indicators) answers it directly, and a History of the volumes hands the same window to stats.percentile for the threshold the rank crossed. The mark draws on bars whose volume ranks in the top decile.

param("period", 100, { min: 2, max: 400, description: "Bars in the window" });
input("volume", ohlcv.volume);
output("rank", line, lower, { color: "#2563eb", description: "Percent rank of the volume" });
output("p90", line, lower, { color: "#4b5563", description: "90th percentile of volume" });
output("heavy", none, overlay, { description: "1 when the rank is 90 or above" });
output("heavy_mark", shape, overlay, { color: "#ea580c", shape_where: "heavy", description: "The close on a heavy-volume bar" });

let rank = new PercentRank(100);
let history = new History(100);
let window = new StaticArray<f64>(100);
let scratch = new StaticArray<f64>(100);

function onStart(): void {
  const period = i32(p_period());
  rank = new PercentRank(period);
  history = new History(period);
  window = new StaticArray<f64>(period);
  scratch = new StaticArray<f64>(period);
}

function onBar(): void {
  const close = bar.close();
  const volume = bar.volume();
  const rankValue = rank.update(volume);
  history.push(volume);
  // Copy the held volumes into the window, oldest first, and read it.
  const held = history.count();
  for (let k = 0; k < held; k++) unchecked((window[k] = history.ago(held - 1 - k)));
  const p90Value = stats.percentile(window, held, 90.0, scratch);
  const heavy = !isNaN(rankValue) && rankValue >= 90.0 ? 1.0 : 0.0;
  if (isNaN(rankValue)) return;
  out_rank(rankValue);
  out_p90(p90Value);
  out_heavy(heavy);
  out_heavy_mark(close);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Lists: List

List is a fixed-capacity list of f64 values, Pine's array.* idiom with the one difference that the module declares its size up front. Construct it in onStart() with the most entries it will hold (a capacity below 1 keeps one; the buffer is allocated once), then push, insert, remove, read and sort it on any bar: no method allocates after the constructor, so a list never grows the module's memory per bar. Index 0 is the oldest entry and a negative index counts from the end, so get(-1) is the last entry. Three conventions cover every miss: a read outside the list is NaN, a change that does not fit (the list is full, or the index is out of range) returns false and changes nothing, and pushEvict() is the one way a full list takes a new value: it drops the oldest entry and hands it back.

MemberMeaning
new List(capacity)hold at most capacity entries (below 1 keeps one); construct in onStart()
capacity(), size()the limit, and how many entries are held now
isEmpty(), isFull()size() == 0, size() == capacity()
clear()empty the list; the capacity is unchanged
push(v)append v as the last entry; false and no change when full
pushEvict(v)append v; when full, first remove and return the oldest entry (index 0), else return NaN
unshift(v)insert v at index 0; false when full
pop(), shift()remove and return the last, or the first, entry; NaN when empty
get(i)the entry at i; a negative i counts from the end (-1 is the last); NaN outside the list
set(i, v)overwrite the entry at i, the same indexing; false outside the list
insert(i, v)insert v before index i, i in 0..size() (size() appends); false when full or out of range
remove(i)remove and return the entry at i, the same indexing as get; NaN outside the list
first(), last()the entries at 0 and size() - 1; NaN when empty
indexOf(v), includes(v)the lowest index whose entry equals v, or -1; NaN never matches
sort(ascending = true)sort in place; NaN entries sit last in either direction
sum(), mean(), min(), max(), stdev()the stats.* definitions over the entries held (population stdev); NaN while empty or when an entry is not finite, the History rule
values()the backing StaticArray<f64>: entries 0..size() - 1 are the list, so stats.median(list.values(), list.size(), scratch) reads it

The members below give the rest of Pine's array.* calls their own name. A Pine call that makes a second array (array.copy, slice, abs, standardize, sort_indices) writes here into a destination you constructed in onStart(): a second List with enough capacity (it may be the list itself where that makes sense), or a StaticArray<i32> of at least size() for the indexes. median and the two percentiles find their entry by counting, so they need no scratch array and leave the list's order alone, and they read a NaN (or infinite) entry as Pine's na: median skips it, the percentiles count it in size() and sort it last, as TradingView does. Nothing below allocates; List.from is the one exception and belongs in onStart().

MemberMeaning
List.from([v0, v1, ...], capacity = 0)a list holding the values in order, with room for capacity entries or for exactly the values given when capacity is below their count (List.from([1.0, 2.0]) is full: size the capacity when the list will grow); construct in onStart()
copy(into)replace the entries of into with this list's; false and no change when into cannot hold size() entries
slice(into, from, to = size())write the entries from index from up to but not including to into into; a negative bound counts from the end, both are clamped to the list, a range that ends at or before it starts leaves into empty; false when into cannot hold the range; a copy, not Pine's live view
fill(v, from = 0, to = size())set every entry in the same range to v, in place; the size is unchanged
reverse()reverse the entries in place
abs(into)write the absolute value of every entry into into (into may be the list itself); false when it cannot hold them
standardize(into)write every entry's (x - mean()) / stdev() into into (into may be the list itself); every result NaN when the list is empty, holds a non-finite entry or has no spread; false when into cannot hold them
every(), some()read the list as a Pine array<bool> held as 1.0 and 0.0: an entry is true when it is neither 0 nor NaN; both are false for an empty list, as on TradingView (array.every(array.new_bool(0)) is false there)
sortIndices(into, ascending = true)write into the StaticArray<i32> the indexes 0..size() - 1 ordered by their entries, so get(into[0]) is the smallest (or, with false, the largest) entry; ascending is stable (equal entries keep their index order) with the indexes of NaN entries last, and descending is that order reversed as on TradingView, so ties come in reverse index order and the NaN indexes first ([2, 1, 2, 1, 2] reads 1, 3, 0, 2, 4 and 4, 2, 0, 3, 1; [3, NaN, 1, 2] reads 2, 3, 0, 1 and 1, 0, 3, 2); the list is untouched; false when into is shorter than size()
median()the middle entry of the finite entries sorted, the mean of the two middle ones when their count is even; a NaN entry is skipped as TradingView skips na ([3, NaN, 1, 8, 5] reads 4); NaN while no entry is finite
percentileNearestRank(pct)the nearest-rank percentile, pct in 0..100: rank ceil(pct / 100 * size()), the entry at max(0, rank - 1) of the sorted list with the NaN entries last, so a rank that lands on one reads NaN; NaN while empty or when pct is not finite
percentileLinearInterpolation(pct)the percentile by linear interpolation between the two nearest ranks: TradingView's position k = pct * size() / 100 - 0.5 over the sorted list (NaN entries last), the entry at floor(k) plus the fraction of the way to the next one, the smallest entry at or below k = 0 and the largest at or above k = size() - 1, so 50 is the median of an even count and the result need not be an entry; with a NaN entry held a whole position reads the entry there and a fractional one reads NaN, as on TradingView ([3, NaN, 1, 8, 5] reads 1 at 10, NaN at 25, 5 at 50); NaN while empty or when pct is not finite

The list below keeps impulse closes as levels: a close more than step_pct above the previous one goes in through pushEvict(), so the newest keep of them are held, and a level the close has risen above is removed. Walking remove() from the end keeps the lower indexes valid. The nearest level above price is the list's min(), and the mean level is rounded with roundTo for the legend.

param("keep", 8, { min: 1, max: 50, description: "Impulse closes kept" });
param("step_pct", 1, { min: 0.1, max: 20, description: "Rise from the previous close that marks an impulse, in percent" });
output("nearest_above", line, overlay, { color: "#16a34a", description: "The lowest kept level above the close" });
output("mean_level", line, overlay, { color: "#94a3b8", description: "Mean of the kept levels, two decimals" });
output("count", none, lower, { description: "Levels kept on this bar" });

let levels = new List(8);
let step: f64 = 0.01;
let prevClose: f64 = NaN;

function onStart(): void {
  // Construct in onStart(): the list's buffer is allocated once, sized by the param.
  levels = new List(i32(p_keep()));
  step = p_step_pct() / 100.0;
}

function onBar(): void {
  const close = bar.close();
  // An impulse close joins the list; once the list is full the oldest level leaves.
  if (!isNaN(prevClose) && close > prevClose * (1.0 + step)) levels.pushEvict(close);
  prevClose = close;
  // A level the close has risen above is spent: remove it, walking from the end.
  for (let i = levels.size() - 1; i >= 0; i--) {
    if (close > levels.get(i)) levels.remove(i);
  }
  // No level yet: the outputs stay unwritten and draw nothing on this bar.
  if (levels.isEmpty()) return;
  out_nearest_above(levels.min());
  out_mean_level(roundTo(levels.mean(), 2));
  out_count(f64(levels.size()));
}

function onReset(): void {
  levels.clear();
  prevClose = NaN;
}

Only a List hands its entries to the stats functions, through values() and size(); a History is read with ago(n) and its own aggregates, or copied into a window as the volume-rank sample above does.

Percentiles of a list

The close against its own recent spread: the 10th and 90th percentiles of the last closes, their median, and how long ago the highest of them closed, each level rounded to the market's tick.

// The close against its own recent spread: the 10th and 90th percentiles of the last closes and their median, on the market's tick.
param.int("keep", 50, { min: 5, max: 500, label: "Closes kept" });
market.tick_size();
market.price_precision();
output("p90", line, overlay, { color: "#16a34a", description: "90th percentile of the kept closes" });
output("median", line, overlay, { color: "#94a3b8", line_style: "dashed", description: "Median of the kept closes" });
output("p10", line, overlay, { color: "#dc2626", description: "10th percentile of the kept closes" });
output("top_age", none, lower, { description: "Bars since the highest kept close" });

let closes = new List(50);
let order = new StaticArray<i32>(50);
let tick: f64 = 0;
let decimals: i32 = 2;

function onStart(): void {
  const keep = i32(p_keep());
  closes = new List(keep);
  order = new StaticArray<i32>(keep);
  tick = p_market_tick_size();
  decimals = i32(p_market_price_precision());
}

// A level on the market's tick where it publishes one, else at its price decimals.
function snap(x: f64): f64 {
  return tick > 0 ? roundToTick(x, tick) : roundTo(x, decimals);
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  // Keep the newest closes: once the list is full, the oldest leaves.
  closes.pushEvict(close);
  if (!closes.isFull()) return;
  out_p90(snap(closes.percentileLinearInterpolation(90)));
  out_median(snap(closes.median()));
  out_p10(snap(closes.percentileLinearInterpolation(10)));
  // The indexes ordered from the highest close down: order[0] is where the highest sits, 0 the oldest.
  closes.sortIndices(order, false);
  out_top_age(f64(closes.size() - 1 - order[0]));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine
  • The newest closes. pushEvict() appends the close and, once the list is full, drops the oldest, so the list always holds the last keep closes.
  • Percentiles and the median. percentileLinearInterpolation(90) is Pine's array.percentile_linear_interpolation(a, 90), and median() is array.median(a). Both count their way to the answer, so the list keeps its order.
  • An order without sorting. sortIndices(order, false) writes the indexes from the highest close down into order, a StaticArray<i32> made in onStart(). Ties come newest first, as on TradingView.
  • On the tick. roundToTick(x, tick) puts each level on the market's tick. Where the tick reads 0 (only CME markets publish one today), roundTo(x, decimals) rounds to the chart's price decimals instead.

Keep the last N drawings: HandleRing

Lines, boxes, labels and polylines share one id space, and a drawing stays on the chart until its id is deleted (Drawing objects). "Keep only the newest five lines" therefore means remembering the ids you created and deleting the oldest when the sixth arrives. HandleRing is that memory: push(id) records an id and, when the ring is already full, hands back the oldest id for you to delete:

const old = ring.push(id); if (old >= 0) lines[old].delete();

MemberMeaning
new HandleRing(capacity)keep the newest capacity ids (below 1 keeps one); construct in onStart()
push(id)record id as the newest; when full, first drop and return the oldest id (delete that drawing), else return -1; a negative id is not recorded and returns -1
size(), capacity()how many ids are held, and the limit
get(i)the id at i, 0 the oldest and size() - 1 the newest; -1 outside the ring
oldest(), newest()get(0) and get(size() - 1); -1 when empty
remove(id)forget an id you deleted early, so it is not handed back later; false when the ring does not hold it
clear()forget every id; the drawings themselves stay

Two rules from the drawing page shape the idiom. A handle object allocates when it is made, so make them once in onStart() rather than calling draw.line(old) on every eviction: let the ids cycle through one more value than the ring keeps (KEEP + 1), make that many handle objects, and the id you set next is never one still on the chart. And the chart caps live handles at 500 per kind and 1500 in all, so a ring's capacity sits well under those. The sample draws a dashed ray at every new period-bar high and keeps the newest five:

param("period", 20, { min: 2, max: 400, description: "Bars a high must top" });
output("highest", line, overlay, { color: "#f5a623", description: "Highest high of the window" });
output("lines", none, lower, { description: "Rays on the chart" });
handles.line({ color: "#f5a623", width: 1, lineStyle: "dashed", extend: "right" });

// Five rays stay on the chart. Ids cycle through six, one more than the ring keeps,
// so the id set next is never one still drawn; the six handle objects are made once.
const KEEP = 5;
const IDS = KEEP + 1;
const lines = new Array<LineHandle>();
let ring = new HandleRing(KEEP);
let highest = new Highest(20);
let nextId: i32 = 0;
let prevT: f64 = NaN;

function onStart(): void {
  highest = new Highest(i32(p_period()));
  ring = new HandleRing(KEEP);
  for (let id = 0; id < IDS; id++) lines.push(draw.line(id));
}

function onBar(): void {
  const t = bar.time();
  const high = bar.high();
  const top = highest.update(high);
  const width = isNaN(prevT) ? 60.0 : t - prevT;
  prevT = t;
  if (isNaN(top)) return;
  // bars == 0: this bar set the window's high.
  if (highest.bars == 0.0) {
    const id = nextId;
    nextId = (nextId + 1) % IDS;
    // Record the id; when the ring is full the oldest comes back and its ray goes.
    const old = ring.push(id);
    if (old >= 0) lines[old].delete();
    lines[id].set(t, high, t + width, high);
  }
  out_highest(top);
  out_lines(f64(ring.size()));
}

function onReset(): void {
  highest.reset();
  ring.clear();
  nextId = 0;
  prevT = NaN;
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

lines reads 1, 2, 3, 4 on the first four highs and 5 from then on: the sixth high evicts the first ray's id, delete() removes it, and that id is the one set on the very next high. A ray you delete for another reason (a level broken, say) is also handed to ring.remove(id), so the ring never returns an id that is already free.

Rounding: roundTo

roundTo(x, decimals) rounds x to decimals places with halves away from zero, Pine's math.round(x, n). decimals is clamped to 0..15; NaN and the infinities pass through unchanged. Math.round follows JavaScript, where a negative half goes up (Math.round(-2.5) is -2) and there is no decimals argument; roundTo(-2.5, 0) is -3.

CallResult
roundTo(2.5, 0)3
roundTo(-2.5, 0)-3
roundTo(7.125, 2)7.13
roundTo(1.23456, 3)1.235
roundTo(x, -1)the same as roundTo(x, 0)
roundTo(x, 40)the same as roundTo(x, 15)
roundTo(NaN, 2)NaN

The decision reads the fractional part of abs(x) * 10^decimals in f64 (a fraction of 0.5 or more rounds up, nothing is added first), the result is that integer divided back with the sign restored, and x comes back unchanged when it already sits at that precision or when abs(x) is 2^52 or more, where every f64 is an integer. A value whose binary form sits just under a half, 1.005 at two places, rounds down the way its f64 does. Use roundTo for values you compute with, plot or compare; to print a value at a fixed number of decimals use the text builder's f64(x, decimals) (Strings and text), which formats without rounding the number you keep.

Rounding to a tick: roundToTick

roundToTick(x, tick) rounds x to the nearest multiple of tick, ties away from zero as roundTo does: Pine's math.round_to_mintick(x) with the tick given. Pine words the ties of math.round and math.round_to_mintick the same way ("ties rounding up"), and on TradingView math.round(-2.5) reads -3 (captured), so the tick form follows roundTo on a negative tie; a negative tie has not been captured for math.round_to_mintick itself, since prices are positive, so that one case rests on the shared wording. On a chart whose market publishes a tick size the call is roundToTick(x, p_market_tick_size()); where the tick size reads 0 (the market has only its price decimals) use roundTo(x, i32(p_market_price_precision())) instead. x comes back unchanged when tick is not finite or not above 0, when x is NaN or infinite, and when x already sits on a tick.

CallResult
roundToTick(1.125, 0.25)1.25 (a tie, away from zero)
roundToTick(-1.125, 0.25)-1.25
roundToTick(1.1, 0.25)1
roundToTick(6.25, 2.5)7.5
roundToTick(100.08, 0.05)100.1
roundToTick(x, 0.01)the same as roundTo(x, 2), to the bit
roundToTick(x, 0)x
roundToTick(NaN, 0.5)NaN

A tick that is a unit fraction (0.01, 0.25, 0.0001, 0.5, 1: one over an integer k) is handled the way roundTo handles decimals, the decision on the fractional part of abs(x) * k in f64 and the result that integer divided by k, so the answer is the nearest f64 to the decimal a trader would write (100.1, not 100.10000000000001). Any other tick (2.5, 5, 0.3) rounds abs(x) / tick and multiplies back. The levels in Percentiles of a list are rounded this way, with roundTo where the tick reads 0.

Repeatable random numbers: Random

Random is a seeded generator for Pine's math.random(min, max, seed): the same seed gives the same sequence on every host and every run, and reseed(seed) in onReset() replays it. Construct it in onStart(); next() and between() never allocate. Pine's unseeded math.random() differs on every run and there is no clock here to draw a seed from, so pick one. The generator is TradingView's: a seeded math.random there draws java.util.Random's nextDouble() sequence for the seed, and so does Random, draw for draw (seed 42 starts 0.7275636800328681, 0.6832234717598454, 0.30871945533265976; seed 1 starts 0.7308781907032909, 0.41008081149220166, the values captured on TradingView). TradingView keeps one sequence per math.random call site, so construct one Random per call a Pine script makes.

MemberMeaning
new Random(seed)a generator at seed (an integer); construct in onStart()
next()the next value in [0, 1), a multiple of 2^-53, so 1.0 never comes out; Pine's math.random() with the seed
between(min, max)min + (max - min) * next(), a value in [min, max); Pine's math.random(min, max)
reseed(seed)restart the sequence from seed: the values that follow are those a new Random(seed) gives

A random walk from the first close, the same path on every run: a baseline to test a signal against.

// A random walk from the first close, up to 1% a bar either way: the same path on every run for one seed, a baseline to test a signal against.
param.int("seed", 42, { min: 1, max: 1000000, label: "Seed" });
output("walk", line, overlay, { color: "#a855f7", description: "A seeded random walk from the first close" });

let random = new Random(42);
let seed: i64 = 42;
let walk: f64 = NaN;

function onStart(): void {
  seed = i64(p_seed());
  random = new Random(seed);
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  walk = isNaN(walk) ? close : walk * (1.0 + random.between(-0.01, 0.01));
  out_walk(walk);
}

// A reset replays the same path from the seed.
function onReset(): void {
  random.reseed(seed);
  walk = NaN;
}
  • One seed, one path. between(-0.01, 0.01) draws one step per bar, and seed 42 draws the same steps on every run and every host: the walk's first step is 0.7275636800328681 of the way through the range, the value TradingView's math.random gives for that seed.
  • A reset replays it. reseed(seed) in onReset() restarts the sequence, so a reset chart draws the same walk again.
  • A baseline. A signal that scores as well on the walk as on the price has found nothing.

From Pine

One to one: the call returns Pine's value on the same bars. The second table holds the three List calls that differ from Pine's array.*.

PineHere
array.sum, array.avg, array.variance, array.stdev, array.min, array.max, array.indexof(array.min(a)), array.covariance, array.sort, array.median, array.percentile_nearest_rank, array.percentile_linear_interpolationstats.sum, stats.mean, stats.variance, stats.stdev, stats.min, stats.max, stats.argmin (and argmax), stats.covariance, stats.sortAscending, stats.median, stats.percentile, stats.percentileLinearInterpolation over a StaticArray<f64> and a count
x[n]new History(size), .push(x) once per bar, .ago(n)
array.new<float>(), array.push, array.pop, array.shift, array.unshift, array.get, array.set, array.insert, array.remove, array.first, array.last, array.indexof, array.includes, array.size, array.clearnew List(capacity) in onStart(), then .push, .pop, .shift, .unshift, .get, .set, .insert, .remove, .first, .last, .indexOf, .includes, .size, .clear
array.sort(a), array.sort(a, order.descending)list.sort(), list.sort(false)
array.sum, array.avg, array.min, array.max, array.stdev on an array you keeplist.sum(), .mean(), .min(), .max(), .stdev()
array.from(v0, v1, ...)List.from([v0, v1, ...], capacity) in onStart()
array.copy(a), array.slice(a, from, to), array.abs(a), array.standardize(a)list.copy(into), .slice(into, from, to), .abs(into), .standardize(into) with into a second List made in onStart()
array.fill(a, v, from, to), array.reverse(a)list.fill(v, from, to), .reverse()
array.every(a), array.some(a)list.every(), .some() over entries held as 1.0 and 0.0
array.sort_indices(a, order)list.sortIndices(into) / .sortIndices(into, false) with into a StaticArray<i32> made in onStart() (TradingView's orders: stable ascending with na last, that order reversed descending)
array.median(a), array.percentile_nearest_rank(a, pct), array.percentile_linear_interpolation(a, pct)list.median(), .percentileNearestRank(pct), .percentileLinearInterpolation(pct)
if array.size(lines) > N then line.delete(array.shift(lines))new HandleRing(N) in onStart(); const old = ring.push(id); if (old >= 0) lines[old].delete();
math.round(x, n)roundTo(x, n)
math.round_to_mintick(x)roundToTick(x, p_market_tick_size())
math.random(min, max, seed)new Random(seed) in onStart(), then .between(min, max) (.next() for math.random())
PineHereThe difference
array.push(a, v) on an array that grows without boundlist.push(v)a List has the capacity it was given in onStart(): push returns false when full, and pushEvict drops the oldest entry instead, the keep-the-last-N shape most scripts want
array.get(a, i)list.get(i)a negative i counts from the end here (get(-1) is the last entry) and an index outside the list reads NaN instead of stopping the script
array.slice(a, from, to)list.slice(into, from, to)a copy into into, where Pine's slice is a live view of the original; a bound outside the list is clamped instead of stopping the script