Row Rings & RingCursor
A ring buffer for Pine Script: a fixed number of equal-width rows in one array. Once full, the next row overwrites the oldest one, and reads still come back in time order.
Why not shift the array
A rolling history usually keeps the latest N entries with array.shift() and array.push(). Once the array is full, every new entry moves all the others one place: keeping the latest 200 events shifts 199 values per event, and the result is no different.
A ring writes the new row over the oldest physical slot and moves nothing. A push costs O(width), or O(1) through pushValue() on a width-one ring. Rings suit pivots, completed trades, sampled features and other fixed event histories.
close[50] and other series references are already O(1) in Pine. A ring is for histories you build yourself: events, rows of features, objects in arrival order.Create a ring
var op.FloatRowRing history = op.floatRowRing(200, 3)
float atr14 = ta.atr(14)
if barstate.isconfirmed
history.push(array.from(close, volume, atr14))
float oldestPrice = history.at(0, 0)
float latestPrice = history.newestAt(0, 0)
// Output: after a confirmed push, these are the oldest and newest retained closes.op.floatRowRing(capacity, width)andop.intRowRing(capacity, width)create a ring of floats or ints. Capacity times width may not exceed 100,000.- Storage is allocated up front by default. With
preallocate = falseit grows on demand instead, trading a smaller start for growth work during the first cycle. push(row)takes exactlywidthvalues.pushValue(value)takes one, without building an array, and needs a width of 1.- A
RingConfigfromop.ringConfig()holds the same settings forop.floatRowRingFrom()andop.intRowRingFrom().
Read in time order
Reads use logical rows, which always run in time order: row 0 is the oldest retained row and rowCount() - 1 the newest. Where it physically sits in the array does not matter.
at(logicalRow, column)reads one value, counting from the oldest.newestAt(offset, column)counts back from the newest: offset 0 is the latest row.- Both return
nafor a row that is not occupied yet. setAt(logicalRow, column, value)writes one value in place and returns whether the row was occupied.rowView(logicalRow)returns one row as a read-only slice of the storage, without copying it.
Copy rows out
Every copy comes back flat and row by row: width values for the first row, then the next.
chronological(output = na)copies every row, oldest to newest, using at most two native contiguous copies. In our tests that ran 90% faster than rebuilding a 512-cell, width-four output row by row.window(startLogicalRow, rowCount, output = na)copies a run of consecutive rows, and raises when the run is outside what the ring holds.gather(logicalRows, output = na)copies chosen rows in the order you ask for them. An unoccupied row contributeswidthvalues ofna.
Pass a var output array to reuse it; without one, each call allocates a new array.
Detect changes
A ring keeps a content revision that advances when its content or membership changes. setAt() with the value already there leaves it alone. Because of that revision, another part of the script can watch the ring in O(1), whatever its size:
var op.Watch historyWatch = op.watch()
var float summary = na
if historyWatch.changed(history)
// Pseudocode: summarize() reads the ring.
summary := summarize(history)This is useful when several producers can change one ring. revision() reads the counter directly, for example for a CadenceGate.
RingCursor for your own arrays
When several arrays you own must share one circular layout, for example a price, a time and a label per event, a RingCursor keeps the layout without holding any data. reserve() advances it and returns the physical slot to write; once full, that is the oldest slot.
var op.RingCursor events = op.ringCursor(100)
var array<float> eventPrices = array.new<float>(100, na)
var array<int> eventTimes = array.new<int>(100, na)
var array<string> eventNames = array.new<string>(100, na)
// Series calls run on every bar, before any condition uses them.
bool crossed = ta.cross(close, ta.sma(close, 50))
if barstate.isconfirmed and crossed
int slot = events.reserve()
eventPrices.set(slot, close)
eventTimes.set(slot, time)
eventNames.set(slot, close > open ? "up" : "down")
// The newest event's price, when there is one.
float lastEventPrice = events.count() > 0 ? eventPrices.get(events.newest()) : naphysical(logicalIndex)andlogical(physicalSlot)translate between time order and storage position.newest(offset)andoldest(offset)locate rows from either end.- Each returns
-1for a position that is not occupied, so checkcount()first. - For a flat array of rows,
op.copyChronologicalFloatRows(),op.copyLogicalFloatWindow(),op.gatherLogicalFloatRows()andop.copyLogicalFloatRow()copy rows out in time order, with Int versions alongside.
and and or lazily, so a ta.* call written after barstate.isconfirmed and would be skipped on some bars and lose its history. That is why the example computes crossed on its own line first.Physical access
For a measured hot path, rings expose their storage directly. These calls are Tier 3:
reserveRowUnchecked()advances the ring and returns the slot of the new newest row without writing anything. Write every column withwriteUnchecked(slot, column, value)before the row is read.writePhysical(slot, row)overwrites a whole row in place without advancing the ring.rawView()is the whole storage, row-major in physical order, read-only.physicalRow(logicalRow)maps a logical row to its slot.- To write the storage directly, open a scope with
editBegin(), write throughrawUnsafe(), and close it witheditFinish(), which advances the revision the raw writes could not.
Calls
| Call | What it does |
|---|---|
op.floatRowRing(capacity, width, preallocate = true, fillValue = 0.0) | Creates a float ring. op.intRowRing() takes an int fill value. |
ring.push(row), ring.pushValue(value) | Append the newest row. Both return the physical slot written. |
ring.at(logicalRow, column), ring.newestAt(offset, column) | Read one value, from the oldest or the newest end. |
ring.setAt(logicalRow, column, value) | Write one value in place. True when the row was occupied. |
ring.chronological(output = na), ring.window(startLogicalRow, rowCount, output = na), ring.gather(logicalRows, output = na) | Copy all rows, a run of rows, or chosen rows. |
ring.rowView(logicalRow), ring.rawView() | Read-only views of one row or the whole storage. |
ring.rowCount(), ring.rowWidth(), ring.rowCapacity(), ring.revision(), ring.clear() | Size, shape, content revision, and emptying. |
op.ringCursor(capacity) | A ring layout for arrays you own. |
cursor.reserve(), cursor.physical(logicalIndex), cursor.logical(physicalSlot) | Advance, and translate between time order and storage. |
cursor.newest(offset = 0), cursor.oldest(offset = 0), cursor.full(), cursor.count(), cursor.limit(), cursor.clear() | Locate rows and read the size. |
Every signature is in the API Reference. Next: StablePool & SlotCache.