OpenScriptv0.5.0Documentation
GitHub

Tables

Pin a grid of readings to a corner of the chart with table() and cell(). Declare it once, write it on the newest bar, and align and colour it so it reads at a glance.

On this page
  1. A complete dashboard
  2. Declaring a grid
  3. Writing cells
  4. Write on the newest bar
  5. Alignment and numbers that line up
  6. Colour in a table
  7. Headers and merged cells
  8. Clearing and switching a table off
  9. A higher timeframe grid
  10. What a table is not for
  11. Common mistakes

Almost everything a study produces is a value per bar, and a value per bar is a plot. A table is for the rest: readings that describe the current moment rather than a history. The latest RSI (relative strength index, a 0 to 100 momentum reading) and ATR (average true range, the typical size of one bar's move), the trend on three timeframes, where price sits in today's range. None of these has a shape on a time axis; each is read at a glance.

A table is also the one output that stays where you put it. It is pinned to a corner of the pane, so unlike a stack of labels it does not move when price moves. This page covers declaring a grid with table(), writing it with cell(), when to write it, and how to align and colour it.

A complete dashboard

version 1
study("Dashboard", overlay = true)

rsiLen   = input(14, "RSI length", min = 2, max = 200)
atrLen   = input(14, "ATR length", min = 1, max = 200)
lookback = input(20, "Range lookback", min = 2, max = 500)
corner   = input("topRight", "Corner",
                 options = ["topLeft", "topRight", "bottomLeft", "bottomRight"])

// Declared once, at the top level, before the first bar.
panel = table("Readings", 7, 2, position = corner,
              textColor = silver, bgColor = fade(black, 25))

// Every reading is computed on every bar, outside the if below.
oscillator  = rsi(close, rsiLen)
atrPercent  = atr(atrLen) / close * 100
top         = highest(high, lookback)
bottom      = lowest(low, lookback)
rangePct    = top > bottom ? (close - bottom) / (top - bottom) * 100 : none
volumeRatio = volume / sma(volume, lookback)
trend       = ema(close, 20) > ema(close, 50)

fn show(value, decimals) => isNone(value) ? "warming up" : text(value, decimals)

zone = isNone(oscillator) ? "warming up" :
       (oscillator > 70 ? "overbought" : (oscillator < 30 ? "oversold" : "neutral"))
zoneColor = isNone(oscillator) ? silver :
            (oscillator > 70 ? red : (oscillator < 30 ? lime : silver))

// The panel shows one state, the current one, so it is written on the newest bar.
if bar.isLast
    cell(panel, 0, 0, chart.symbol, textColor = white)
    cell(panel, 0, 1, chart.interval, textColor = white, align = "right")

    cell(panel, 1, 0, "Trend")
    cell(panel, 1, 1, isNone(trend) ? "warming up" : (trend ? "up" : "down"),
         textColor = isNone(trend) ? silver : (trend ? lime : red), align = "right")

    cell(panel, 2, 0, "RSI")
    cell(panel, 2, 1, show(oscillator, 1), textColor = zoneColor, align = "right")

    cell(panel, 3, 0, "Zone")
    cell(panel, 3, 1, zone, textColor = zoneColor, align = "right")

    cell(panel, 4, 0, "ATR, percent of price")
    cell(panel, 4, 1, show(atrPercent, 2), align = "right")

    cell(panel, 5, 0, "Position in " + text(lookback, 0) + " bar range")
    cell(panel, 5, 1, isNone(rangePct) ? "warming up" : text(rangePct, 0) + " percent",
         align = "right")

    cell(panel, 6, 0, "Volume against average")
    cell(panel, 6, 1, show(volumeRatio, 2), align = "right",
         textColor = volumeRatio > 2 ? orange : silver)
A table dashboard fixed to a corner of the chart
A table fixed to a corner of the chart, updated on every bar.

The screenshot shows a panel like this one on a 15 minute NSE chart. "Trend" is whether the 20 bar EMA (exponential moving average) is above the 50 bar one, "Position in 20 bar range" is where the close sits between the lowest low and the highest high of the last 20 bars (0 at the low, 100 at the high), and the last row compares this bar's volume with its 20 bar average.

Three habits in that script make a good panel, and the rest of this page explains each:

  1. The grid is declared at the top level, once, with a fixed size.
  2. The readings are computed at the top level and only the writing sits inside the if. A stateful call such as rsi() advances only on the bars where it runs, so one moved inside if bar.isLast would see a single bar and return nothing. The compiler warns about that with OS8001.
  3. The cells are written on the newest bar only, through one show helper that says "warming up" rather than leaving a blank or inventing a zero.

The first row reads chart.symbol and chart.interval, which the /trading chart supplies. It does not yet supply chart.exchange or chart.lotSize, so there both are absent: a cell built from either is blank, and show would say "warming up" for ever.

Declaring a grid

table(title, rows, cols) declares a grid and returns the table you write into. The optional arguments set where it sits and how it looks:

ArgumentTakesDefault
titlestring, the grid's name. It is not drawn on the chartRequired
rowsA whole number of rowsRequired
colsA whole number of columnsRequired
position"topLeft", "topRight", "bottomLeft" or "bottomRight""topRight"
textColorThe default text colour for every cellnone, the chart's default
bgColorThe background behind the whole gridnone
borderWidthBorder thickness, 0 for none0

table() must be at the top level. Like plot(), fill() and level(), a table is part of the study's fixed shape: the pane has to know what room to reserve before the first bar runs. A table() inside an if, a loop or a function is OS3006:

if bar.isLast
    panel = table("Readings", 2, 2)

You never hide a table by wrapping it in a branch. You hide it by writing no cells, as switching a table off shows.

For the same reason, every argument of table() is settled before the first bar. Each must be a literal, arithmetic over literals, or an input(); a value that depends on bar data is OS3003:

rows  = bar.index > 100 ? 4 : 2
panel = table("Readings", rows, 2)

Make position an input, as the dashboard does. It costs one line, and which corner is free depends on the reader's chart, not on your study. An input works for the grid's colours too: bgColor = input(black, "Panel colour").

table() returns a table object, the same object on every bar. It can be named, kept and passed to a function, and it is never deleted: the grid lives as long as the study does.

One grid per study

A study may declare several grids and the compiler accepts it, but a chart pane has room for one: the chart draws the first grid a study declares. A second table() compiles and its cells are written, yet nothing appears for it, and no diagnostic says so yet. Declare one grid and give it the rows you need. If you want two panels, write two studies and put them in different corners.

Writing cells

cell(t, row, col, text) writes one cell of the grid t on this bar. Its optional arguments style that one cell:

ArgumentTakesDefault
textColorThe cell's text colournone, the grid's textColor
bgColorThe cell's backgroundnone, the grid's bgColor
align"left", "center" or "right""left"

Unlike table(), cell may appear anywhere: inside an if, a loop or a function. All of its arguments, colours and alignment included, are read on every bar, so a cell can change colour with the reading it shows.

Rows and columns count from zero, so a grid declared with 4 rows and 2 columns has rows 0 to 3 and columns 0 and 1. Writing outside the grid stops the script with OS4004, so keep the declared size and the rows you write in step. A second write to the same cell on the same bar replaces the first.

A helper function keeps a long panel short, because a table object can be passed to a function like any other value:

fn row(t, r, name, value) =>
    cell(t, r, 0, name)
    cell(t, r, 1, value, align = "right")

panel = table("Levels", 3, 2, position = "bottomRight")

if bar.isLast
    row(panel, 0, "High", text(high, 2))
    row(panel, 1, "Low", text(low, 2))
    row(panel, 2, "Close", text(close, 2))

Numbers in cells

The text of a cell is a string, and OpenScript never converts a number to text on its own, so passing a number is OS3011:

panel = table("Readings", 2, 2)
cell(panel, 0, 0, close)

Convert with text(): text(value, decimals) writes a fixed number of decimals. How it handles an absent value decides what a warming-up panel shows:

You writeWhen the value is absent
text(value, 2)The result is absent, and the cell is blank
text(value)The result is the string "none"
show(value, 2) with the helperThe cell says warming up

A blank cell hides that the study has not started, and a zero would invent a number. Saying so is the only honest option, and one helper makes it consistent across the whole panel:

fn show(value, decimals) => isNone(value) ? "warming up" : text(value, decimals)

Write on the newest bar

Cells do not carry over from one bar to the next. At the start of every run of a bar, the engine empties every grid, and the chart shows what the newest bar wrote. Three consequences follow:

  • Write inside if bar.isLast. Writing the panel on every bar of a fifty thousand bar chart is fifty thousand writes to show the last one. bar.isLast is true only on the newest bar.
  • A cell written only under some condition disappears on the bars where the condition is false. If the condition is false on the newest bar, the panel is empty. Write every cell you want to see on the newest bar.
  • Nothing is ever left over. A panel that writes five rows on one bar and three on the next shows three.

The same rule makes a table safe while the newest bar is still forming. That bar runs again on every update, the grid is emptied each time, and the panel is rewritten from scratch rather than piling up.

Alignment and numbers that line up

The rule that makes a panel readable: labels left, numbers right. A right-aligned column puts the last digit of every number at the same edge, so with the same number of decimals the units line up and a longer number reads as a bigger one. In a left-aligned column 9.50 and 11.25 start at the same place and their decimal points do not line up. align defaults to "left", so pass align = "right" on every value cell.

Inside one cell, str.repeat() turns a number into a bar drawn with characters, which is often quicker to read than the number itself:

version 1
study("Strength meter", overlay = true)

barLen = input(10, "Meter width", min = 4, max = 40)

meter = table("Strength", 2, 2, position = "bottomRight", textColor = silver)

oscillator = rsi(close, 14)
strength   = isNone(oscillator) ? none : floor(oscillator / 100 * barLen)

if bar.isLast
    cell(meter, 0, 0, "RSI")
    cell(meter, 0, 1, isNone(strength) ? "" :
                      str.repeat("|", strength) + str.repeat(".", barLen - strength),
         textColor = oscillator > 70 ? red : (oscillator < 30 ? lime : aqua))

    cell(meter, 1, 0, "Value")
    cell(meter, 1, 1, isNone(oscillator) ? "warming up" : text(oscillator, 1),
         align = "right")

With the RSI at 64 and a width of 10, the meter reads ||||||..... floor() is not decoration: str.repeat needs a whole number of copies, and given 6.4 it stops the script with OS4003.

str.padLeft() and str.padRight() pad text to a number of characters. They do not line numbers up in a cell: the chart draws cells in a proportional font, where a space is narrower than a digit, and align is what lines a column up. Padding is for text that must have a fixed number of characters, such as a zero-padded hour: str.padLeft("9", 2, "0") is "09".

Colour in a table

Colour is set at three levels, each overriding the one above it:

LevelSet byApplies toCan change per bar
The gridtable(..., textColor = ..., bgColor = ...)Every cell that sets nothing itselfNo, fixed before the first bar
The cellcell(..., textColor = ..., bgColor = ...)That cellYes
NeitherLeaving the arguments outThe chart's own defaultNot applicable

So a grid's colours are constants or inputs, and a colour that follows the data, such as red for overbought, goes on the cell.

Give the grid a translucent background rather than a solid one. bgColor = fade(black, 25) keeps the candles behind the panel faintly visible, which matters because the panel sits over the price pane; the same colour at full strength punches a rectangular hole in the chart.

Colour in a cell is information, so spend it on the cell that carries the reading and not on the label beside it. A panel where every cell is coloured is a panel where nothing stands out. Colors covers colours that read well on both light and dark charts.

Headers and merged cells

There is no cell span in version 1: every cell occupies exactly one row and one column. For a header across a row, put the text in the first column, leave the rest blank, and give the whole row one background so it reads as a block:

panel = table("Bias", 4, 3)

if bar.isLast
    cell(panel, 0, 0, "Higher timeframes", textColor = white, bgColor = fade(navy, 40))
    cell(panel, 0, 1, "", bgColor = fade(navy, 40))
    cell(panel, 0, 2, "", bgColor = fade(navy, 40))

For a value that needs the width of two columns, design the grid with fewer, wider columns. A grid has a fixed shape, so the clean fix for a value that does not fit is usually a different shape. In return, every cell has exactly one address, and cell(panel, r, c, ...) means the same thing on every bar.

Clearing and switching a table off

clear() empties every cell of a grid that has been written so far on this bar. Since the grid starts every bar empty anyway, you need it only to discard what the script has already written on the current bar and write something else instead, such as replacing a full panel with one message:

panel = table("Readings", 3, 2)

if bar.isLast
    cell(panel, 0, 0, "Close")
    cell(panel, 0, 1, text(close, 2), align = "right")
    cell(panel, 1, 0, "Volume")
    cell(panel, 1, 1, text(volume, 0), align = "right")

    if not chart.isIntraday
        clear(panel)
        cell(panel, 0, 0, "Intraday charts only")

Switching a whole table off works the same way as hiding it: write no cells. The table() call cannot sit in an if, but the writes can.

One detail decides whether that leaves the corner clean. The chart draws the grid at its declared size whether or not anything was written, so a grid declared with a bgColor or a borderWidth still shows as an empty block when it holds no text. For a panel that can be switched off, leave the grid's own background at none and give the background to the cells you write:

panel      = table("Readings", 1, 2)
showPanel  = input(true, "Show the panel")
shade      = fade(black, 25)
oscillator = rsi(close, 14)

// With showPanel off, nothing is written and nothing is drawn.
if bar.isLast and showPanel
    cell(panel, 0, 0, "RSI", bgColor = shade)
    cell(panel, 0, 1, text(oscillator, 1), bgColor = shade, align = "right")

A higher timeframe grid

The case a table is best at: several timeframes, one row each, each row saying the same thing about a different interval. This panel shows the EMA trend on three intervals the reader chooses:

version 1
study("Timeframe bias", overlay = true)

fastLen = input(20, "Fast length", min = 1, max = 500)
slowLen = input(50, "Slow length", min = 1, max = 500)
tfA     = input("15", "Timeframe 1", kind = "interval")
tfB     = input("60", "Timeframe 2", kind = "interval")
tfC     = input("1D", "Timeframe 3", kind = "interval")

grid = table("Bias", 4, 2, position = "topRight", bgColor = fade(black, 20))

// Each read waits for that timeframe's bar to close, so it never repaints.
biasA = req.timeframe(tfA, ema(close, fastLen) > ema(close, slowLen))
biasB = req.timeframe(tfB, ema(close, fastLen) > ema(close, slowLen))
biasC = req.timeframe(tfC, ema(close, fastLen) > ema(close, slowLen))

fn word(b) => isNone(b) ? "warming up" : (b ? "up" : "down")
fn tint(b) => isNone(b) ? silver : (b ? lime : red)

if bar.isLast
    cell(grid, 0, 0, "Timeframe", textColor = white)
    cell(grid, 0, 1, "Bias", textColor = white, align = "right")

    cell(grid, 1, 0, tfA)
    cell(grid, 1, 1, word(biasA), textColor = tint(biasA), align = "right")

    cell(grid, 2, 0, tfB)
    cell(grid, 2, 1, word(biasB), textColor = tint(biasB), align = "right")

    cell(grid, 3, 0, tfC)
    cell(grid, 3, 1, word(biasC), textColor = tint(biasC), align = "right")

word and tint both test isNone() first, and that is not caution for its own sake. An absent condition takes the false branch, so b ? "up" : "down" would print "down" on every row until the first higher timeframe bar closed. A panel that says "down" when it means "not known yet" is worse than no panel. Higher timeframes explains req.timeframe() and why its default mode never repaints.

What a table is not for

You wantUseBecause
A value per barplot()A table shows one state, not a history
An event on a barsignal()A marker is attached to the bar it happened on
A running log of valuesprint()The log takes a value per bar and draws nothing
A caption on a shapedraw.label() or a box's own textIt belongs with the thing it describes
Fifty rows of historyA backtest report or the logA chart corner is too small to read it

The last row is the one people push against. A table can be declared with fifty rows and filled with the last fifty bars, and it will work, and it will be unreadable at the size a chart corner allows. Charts are for shapes over time; a list belongs in the print log or a backtest report.

Common mistakes

SymptomCauseFix
OS3006 on the table() lineDeclared inside an if, a loop or a functionDeclare at the top level and guard the cell writes instead
OS3003 on the table() lineA size, corner or colour that depends on bar dataUse a literal or an input; put per-bar colour on the cell
OS3011 on a cell callA number passed where text is expectedtext(value, decimals)
OS4004 on a cell callA row or column outside the declared gridDeclare enough rows, or check the index
The panel is emptyCells written under a condition that is false on the newest barWrite every cell inside if bar.isLast
A reading is blanktext(value, decimals) of an absent valueUse a show helper that says "warming up"
Every row says "down" on a fresh chartAn absent condition took the false branchTest isNone first and say so
The chart is slow with a table on itCells written on every bar of historyWrite inside if bar.isLast
The panel hides the candles under itA solid bgColorfade(black, 25) or similar
A second panel never appearsThe chart draws only the first grid a study declaresDeclare one grid, or split the study in two
Numbers do not line upCells are left aligned by defaultalign = "right" on the value column
An empty block stays in the corner with the panel switched offThe grid has a bgColor or a border, which is drawn at the declared sizePut the background on the cells you write instead
A cell is blank, or says "warming up" for ever, on the /trading chartIt reads chart.exchange or chart.lotSize, which the chart does not supply yetLeave those readings out of a panel meant for the chart
OS4003 on a str.repeat meterA count that is not a whole numberRound it down with floor first

Related. Labels and shapes for the stack of labels a table replaces, Lines and boxes for output that belongs on the chart rather than in a corner, Colors for cell colours, Bar colouring and backgrounds for stating a regime in colour, and the reference entries table() and cell().