OpenScriptv0.5.0Documentation
GitHub

Colors

The nineteen named colours, hex literals, rgb and rgba, transparency with fade and withAlpha, blends with mix, a colour per bar, and palettes that read on both light and dark charts.

On this page
  1. A first example
  2. Named colours
  3. Hex literals
  4. Building a colour
  5. Transparency, and its two conventions
  6. Colour that follows a value
  7. A colour per bar
  8. An absent colour
  9. Colours for light and dark charts
  10. Letting the user choose
  11. Common mistakes

Every line, band, marker and shaded bar a script draws takes a colour. This page covers how to name one, build one from channels, make it transparent by the right amount for the surface it lands on, vary it with a value or from bar to bar, and choose a palette that stays readable whether the reader's chart is light or dark. Colour carries meaning on a chart (up or down, calm or stretched), so a few careful choices here make a study far easier to read.

A first example

A relative strength index (RSI) whose line changes colour with its zone, with a faint wash behind the pane while it is overbought:

version 1
study("RSI zones", precision = 2, range = [0, 100])

len = input(14, "Length", min = 2, max = 200)

r = rsi(close, len)

// Three states, and none while the RSI has no value yet.
zoneColor = isNone(r) ? none : (r > 70 ? red : (r < 30 ? lime : purple))

level(70, "Overbought", fade(red, 40))
level(30, "Oversold", fade(lime, 40))

plot(r, "RSI", color = zoneColor, width = 2)
background(r > 70 ? fade(red, 92) : none)

Four colour ideas are in those lines: named colours (red, lime, purple), transparency with fade, a colour that changes per bar, and none meaning "paint nothing here" for the background. The rest of the page takes them one at a time.

Named colours

Nineteen names are built in, written bare with no prefix. Each is fully opaque, and its channels are fixed by the language, so aqua is the same colour on every engine that runs your script. That is the reason to prefer a name over a hex value you half remember.

NameRed, green, blueHex
aqua0, 255, 255#00ffff
black0, 0, 0#000000
blue0, 0, 255#0000ff
brown165, 42, 42#a52a2a
fuchsia255, 0, 255#ff00ff
gray128, 128, 128#808080
green0, 128, 0#008000
lime0, 255, 0#00ff00
maroon128, 0, 0#800000
navy0, 0, 128#000080
olive128, 128, 0#808000
orange255, 165, 0#ffa500
pink255, 192, 203#ffc0cb
purple128, 0, 128#800080
red255, 0, 0#ff0000
silver192, 192, 192#c0c0c0
teal0, 128, 128#008080
white255, 255, 255#ffffff
yellow255, 255, 0#ffff00

The names are ordinary built-in values of type color, not keywords. Assigning to one is OS2002, the same error as redeclaring any other name that already exists:

aqua = #00e5ff

Hex literals

A colour can also be written in hex, with six digits or with eight, where the last two are alpha (how opaque the colour is):

plot(ema(close, 9), "Fast", #ff8800)       // six digits, fully opaque
plot(ema(close, 21), "Slow", #ff880080)    // eight digits: the last two are alpha

A hex literal becomes four numbers when the script compiles: red, green and blue from 0 to 255, and alpha from 0 to 1. The alpha byte is divided by 255, so 80 (128) is an alpha of about 0.5. The three-digit shorthand, such as #f80, is not a colour literal and does not compile.

Two colours are equal when all four channels match. aqua == aqua is true, and a faded colour is not equal to the colour it came from.

Building a colour

CallReturnsFor
rgb() (r, g, b)colorChannels 0 to 255, fully opaque
rgba() (r, g, b, a)colorThe same, with alpha from 0 to 1, where 1 is opaque
fade() (color, percent)colorThe same colour at percent transparency, where 100 is invisible
withAlpha() (color, a)colorThe same colour at a stated alpha, 0 to 1, where 1 is opaque
mix() (a, b, weight)colorA blend of two colours: weight 0 gives a and 1 gives b
alpha() (color)numberRead a colour's alpha back, 0 to 1
plot(sma(close, 20), "Basis", rgb(255, 136, 0), width = 2)
plot(sma(close, 50), "Slow", rgba(0, 150, 255, 0.6))

Two more are named in the language and not available yet: hsl(), for hue, saturation and lightness, and gradient(), for positioning a value between two colours. Calling either is OS2020 in version 0.5.0:

plot(close, "Close", hsl(200, 80, 50))

Until they arrive, mix() with a weight you compute yourself does the work of gradient, as shown in Colour that follows a value.

Channels out of range

A channel written as a number that is out of range, or not a whole number, is refused when the script compiles, with OS3004:

plot(close, "Close", rgb(300, 0, 0))

A channel computed from data is a different case. It is meant to raise runtime error OS4009, because a colour computed from data that lands at 300 is a bug in the computation. In version 0.5.0 nothing raises OS4009 yet: the engine rounds the channel to a whole number, clamps it to 0 to 255, and the bar carries on. Do not rely on that. Where a computed channel can run past its end, clamp it where you compute it with clamp(), so a reader can see the decision:

// How far the close sits above its 20 bar low, in average true ranges,
// turned into a red channel from 0 to 255.
stretch = (close - lowest(low, 20)) / atr(14)
redness = clamp(stretch * 64, 0, 255)

plot(close, "Close", rgb(redness, 80, 80))

Transparency, and its two conventions

This is the one part of colour that catches everybody once.

fade takes transparency. withAlpha takes opacity. They run in opposite directions.

Call0 means100 or 1 means
fade(color, percent)Fully opaqueInvisible
withAlpha(color, a)InvisibleFully opaque

For an opaque colour the two describe the same thing: fade(aqua, 60) and withAlpha(aqua, 0.4) are both aqua at 40 percent strength. Most scripts use fade. withAlpha suits a script that computes an opacity from data, because it takes that number as it is.

plot(sma(close, 20), "Faded 60 percent", fade(aqua, 60))
plot(sma(close, 50), "Alpha 0.4", withAlpha(aqua, 0.4))

The Opacity control in the settings dialog's Style tab runs the way withAlpha does, from 0 (invisible) to 100 (solid), so a user who sets it to 40 sees roughly what fade(c, 60) gives. Keep the two directions straight when you tell a user what to change.

Apply fade once, to an opaque colour such as a named one. Fading a colour that is already transparent gives different answers in version 0.5.0 depending on where the colour is worked out: in a colour fixed before the first bar the outer fade replaces the inner one, and in a colour computed on a bar the two multiply. To give an exact alpha to a colour that already carries some transparency, use withAlpha, which always sets the alpha outright.

The right amount of transparency depends entirely on what the colour lands on:

SurfaceTypicalWhy
A plotted lineOpaque, or fade(c, 20) for a secondary lineA line is thin: transparency mostly costs legibility
A fill between two plotsfade(c, 85) to fade(c, 95)It covers a large area around the candles
A box fillopacity = 0.08 to 0.15 on draw.box (its default is 0.12)The same, and boxes often overlap each other
A pane backgroundfade(c, 90) or moreIt covers the full height of the bar
A table backgroundfade(black, 25)It sits over the chart and should still let it through
A label plateOpaqueThe text has to be readable against it

The rule behind the table: the bigger the area, the more transparent it must be. A fill at 50 percent transparency looks reasonable in a screenshot of twenty bars and turns the chart into a wash at two hundred.

Colour that follows a value

mix() places a colour between two others. The weight is yours to compute, which means it is yours to scale and yours to bound:

version 1
study("Volume heat", overlay = true)

lookback = input(20, "Average over", min = 2, max = 500)
hottest  = input(3.0, "Ratio that counts as hot", min = 1.5, max = 10)

ratio = volume / sma(volume, lookback)

// A weight is a position between two colours, so it is clamped to 0 to 1.
weight = isNone(ratio) ? none : clamp((ratio - 1) / (hottest - 1), 0, 1)

heat = isNone(weight) ? none : mix(fade(aqua, 40), red, weight)

barColor(heat)

Three steps in that script apply to every colour scale you will write:

  1. Scale first. A weight is a fraction of the way between two readings, so divide the raw value by the range you consider meaningful. Here that range is an input, because "hot" is a judgement.
  2. Clamp second. A ratio has no upper bound and a weight does. mix does not refuse a weight outside 0 to 1: it carries on past the far colour until the channels hit their limits, which is rarely the colour you meant.
  3. Handle absence third. barColor(none) leaves the candle its own colour, which is the right picture during warmup: the study says nothing about bars it knows nothing about.

mix blends alpha as well as the three channels, so the blend above runs from aqua at 60 percent strength to solid red. It rounds each channel to a whole number, so every colour a script computes has whole channels, just like a literal.

For a two-sided scale, blend from the middle outwards rather than end to end:

strength = rsi(close, 14)
weight   = isNone(strength) ? none : clamp(abs(strength - 50) / 50, 0, 1)
tint     = isNone(weight) ? silver : mix(silver, strength > 50 ? lime : red, weight)

plot(close, "Close", tint, width = 2)

That reads correctly at 50, where both sides are grey. A blend straight from lime to red would put olive, mix(lime, red, 0.5), at exactly the value the reader cares about most.

A colour per bar

A constant colour and a per-bar colour are the same argument. Pass a fixed colour and it becomes the plot's style colour. Pass an expression that gives a colour each bar and the compiler stores one beside each value:

m = macd(close, 12, 26, 9)
plot(m[2], "Histogram", color = m[2] > 0 ? lime : red, style = "histogram")

One argument covers both because a script that starts with one colour and later wants two should not have to move to a different function. The cost is worth knowing: a constant colour costs nothing per bar, and a computed one costs one more value per bar.

What counts as fixed. A colour is fixed only when it is written in the call itself: a named colour, a hex literal, a colour built from literals such as fade(red, 40), or a colour input passed by its own name. Everything else is treated as a colour per bar, including a name you assigned a constant colour to, and fade() applied to an input.

Not every surface takes a per-bar colour:

SurfaceA fixed colourA colour per bar
plot(value, title, color)The plot's style colour, which the user can change in the Style tabYes. It is drawn as computed, and the Style tab no longer changes it
plotCandles(..., colorUp, colorDown, wickColor, borderColor)The candles' coloursYes
barColor(color)Every bar the sameYes, the usual case
background(color)Every bar the sameYes, the usual case
cell(..., textColor, bgColor)The cell's coloursYes: they are read each time the cell is written
fill(a, b, color, colorUp, colorDown)The band's coloursAccepted, but the /trading chart does not draw it in this release: the band takes its first plot's colour at twelve percent instead
level(price, title, color)The line's colourNo: OS3003
signal(text, color)The marker's colourNo: OS3003
table(..., textColor, bgColor)The grid's coloursNo: OS3003

A colour taken from a name you computed, even from constants, counts as per bar, so it is OS3003 where a fixed one is required:

OVERBOUGHT = fade(red, 40)
level(70, "Overbought", OVERBOUGHT)

Write fade(red, 40) in the level call instead, or declare a colour input with that default.

An absent colour

none is a valid colour, and passing it is never an error. What it does depends on the surface:

SurfaceAn absent colour on a bar
barColorThe candle keeps its own colour
backgroundThe bar's column is not shaded
plotThe bar is drawn in the plot's own colour from the Style tab. To hide a plot on a bar, make its value none, not its colour
fillnone means the band has no colour of its own, so it takes its first plot's colour at twelve percent

For the two paint calls this is how a conditional paint switches itself off, so a script never needs a separate call to clear one:

paint = input(true, "Recolour the candles")
up    = close > open
risky = atr(14) > 2 * atr(100)

barColor(paint ? (up ? lime : red) : none)     // the input switches it off
background(risky ? fade(red, 92) : none)       // only risky bars are shaded

Watch the interaction with absent conditions, because it is where a script tells a lie without meaning to. An absent condition takes the false branch, so trendUp ? lime : red paints every warmup bar red, as if the trend were down. What you want is three states:

basis   = ema(close, 50)
trendUp = close > basis

tint = isNone(basis) ? none : (trendUp ? lime : red)
barColor(tint)

Absent for "I do not know yet", lime for up, red for down. The chart then shows its own candle colours across the warmup, which is the truth. Absent values explains the rules behind this.

Colours for light and dark charts

A script cannot read the chart's theme. Nothing in the chart namespace reports whether the background is light or dark, so the colours you pick have to work on both. A study that only looks right on its author's theme is a study half its readers will restyle or discard.

PurposeA choice that works on bothWhy
A primary lineaqua, orange, purple, fuchsiaMid-tone, saturated hues stand out against both a near-white and a near-black background
A secondary linefade(silver, 50) or grayGrey reads as secondary on both
A line meant to recedeTransparency, not a near-background colourNo single colour disappears into both themes
Up and downlime and red, plus a difference that is not colourColour alone fails a reader who cannot separate red and green
Text on a coloured plateA text colour chosen for the plate: white on red or navy, black on lime or yellowThe plate is the background for that text, and it does not change with the theme
A fill or a backgroundThe colour at 85 to 95 percent transparencyThe theme's own background shows through and does the work

Be careful with white and black. Each is invisible on one of the two themes, so neither belongs on a line, a marker or a drawing where it is the only carrier of meaning. They are fine as the text colour on a plate whose colour you chose, because there the background is the plate rather than the chart.

"A difference that is not colour" means giving two things a second way to tell them apart: a solid line against a dashed one, a triangle up against a triangle down, a marker above the bar against one below it. Then the picture still works for a reader who sees your lime and your red as the same grey.

Letting the user choose

The Style tab of the settings dialog gives every plot its own colour, opacity, thickness, line style and plot style, whether or not the script asks, so a reader can always restyle a fixed-colour plot. Where one colour is central to the study, declare it as an input and pass it through by its own name:

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

len       = input(20,   "Length", min = 2, max = 500)
mult      = input(2.0,  "Width, in deviations", min = 0.5, max = 5)
bandColor = input(aqua, "Band colour")

b = bollinger(close, len, mult)

// Every colour here is the input itself, so one setting restyles the three
// lines and the band together.
plot(b[0], "Basis", bandColor, width = 2)
upper = plot(b[1], "Upper", bandColor)
lower = plot(b[2], "Lower", bandColor)

fill(upper, lower, color = bandColor, opacity = 0.1)

When a plot's colour is a colour input passed by its own name, the plot's colour in the Style tab and the input are one setting, so the user has one value to change rather than two that disagree. A band given the input follows it too, and opacity keeps it faint whatever colour the user picks.

Two things break that link, because each turns the colour into a per-bar colour: copying the input into another name (LINE = bandColor) and fading it in the call (fade(bandColor, 45)). A plot still draws such a colour, but the Style tab no longer changes it; a band in the /trading chart falls back to its first plot's colour; and a level or signal refuses it. When you want a faded version the user can change, declare it as its own input with a faded default, such as input(fade(aqua, 45), "Edge colour").

See Inputs for colour inputs and Settings and style for the rows the settings dialog generates.

Common mistakes

SymptomCauseFix
A fill drowns the candles' coloursTransparency too lowfade(c, 88) or higher for a band
Nothing is drawn at allfade(c, 100)100 is invisible; fade takes transparency, not opacity
A line hidden with a none colour still showsOn a plot, an absent colour falls back to the plot's own colourMake the value none instead
Every warmup bar is painted the "down" colourAn absent condition taking the false branchisNone(x) ? none : (cond ? upColor : downColor)
OS3004 on an rgb callA channel literal outside 0 to 255, or not wholeFix the number
A computed channel gives an unexpected colourThe channel ran past its rangeclamp it where you compute it
OS3003 on a level or signal colourThe colour was computed, or taken from a nameWrite it in the call, or pass an input by its own name
A band ignores the colour you computed for itThe /trading chart does not draw a per-bar band colourGive the band fixed colours
The Style tab does not change a plot's colourThe colour is computed per bar, or held in another namePass the colour input straight to the plot
OS2020 on hsl or gradientBoth are planned, not yet availableUse rgb, or mix with a computed weight
The study is invisible on a dark chartblack used as a line colourgray or silver, or a mid-tone hue
Label text cannot be readText colour picked for the theme rather than the plateChoose the text colour from the plate colour
Two series look identical to some readersColour is the only differenceAdd a line style, a shape or a position difference
A colour scale is muddy in the middleBlending end to end across a neutral midpointBlend from the middle outwards on each side

Related: Visuals overview, Plots, Fills, Bar colouring and backgrounds, Labels and shapes, Tables, Colors reference.